From 0fa4e4c3dd2d4136f6051cf59fa70f80e0e979f9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 30 Apr 2026 21:59:35 -0700 Subject: [PATCH 01/45] Add version control foundation plans - Add phase 1 VCS driver plan - Add phase 2 source control provider plan - Index both plans in `.plans/README.md` --- ...n-control-phase-1-vcs-driver-foundation.md | 216 ++++++++++++++ ...se-2-source-control-provider-foundation.md | 268 ++++++++++++++++++ .plans/README.md | 2 + 3 files changed, 486 insertions(+) create mode 100644 .plans/19-version-control-phase-1-vcs-driver-foundation.md create mode 100644 .plans/20-version-control-phase-2-source-control-provider-foundation.md 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 00000000000..e71c22d0ce3 --- /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 00000000000..ac1186ba5f9 --- /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 7bb69a3b912..379158d4efd 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` From 3364f1f2af1183a01a773c6100b5a64234a6621a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 30 Apr 2026 23:05:37 -0700 Subject: [PATCH 02/45] Introduce pluggable VCS driver for git operations - Replace direct GitCore dependencies with VcsDriver and VcsProcess - Move checkpointing, workspace, and orchestration flows onto the new VCS abstraction - Drop workspace-specific git helpers from GitCore --- .../OrchestrationEngineHarness.integration.ts | 18 +- apps/server/src/checkpointing/Errors.ts | 7 +- .../Layers/CheckpointStore.test.ts | 40 +-- .../checkpointing/Layers/CheckpointStore.ts | 60 ++--- apps/server/src/git/Layers/GitCore.test.ts | 88 ------ apps/server/src/git/Layers/GitCore.ts | 146 ---------- apps/server/src/git/Layers/GitManager.test.ts | 52 ++-- apps/server/src/git/Layers/GitManager.ts | 4 +- apps/server/src/git/Services/GitCore.ts | 25 -- .../Layers/CheckpointReactor.test.ts | 14 +- .../Layers/ProviderCommandReactor.test.ts | 4 +- .../Layers/ProviderCommandReactor.ts | 4 +- apps/server/src/server.test.ts | 61 +++-- apps/server/src/server.ts | 12 +- .../src/vcs/Layers/GitVcsDriver.test.ts | 125 +++++++++ apps/server/src/vcs/Layers/GitVcsDriver.ts | 252 ++++++++++++++++++ apps/server/src/vcs/Layers/VcsProcess.ts | 194 ++++++++++++++ apps/server/src/vcs/Services/VcsDriver.ts | 30 +++ apps/server/src/vcs/Services/VcsProcess.ts | 32 +++ .../workspace/Layers/WorkspaceEntries.test.ts | 16 +- .../src/workspace/Layers/WorkspaceEntries.ts | 38 +-- .../Layers/WorkspaceFileSystem.test.ts | 5 +- apps/server/src/ws.ts | 4 +- packages/contracts/src/index.ts | 1 + packages/contracts/src/vcs.ts | 139 ++++++++++ 25 files changed, 960 insertions(+), 411 deletions(-) create mode 100644 apps/server/src/vcs/Layers/GitVcsDriver.test.ts create mode 100644 apps/server/src/vcs/Layers/GitVcsDriver.ts create mode 100644 apps/server/src/vcs/Layers/VcsProcess.ts create mode 100644 apps/server/src/vcs/Services/VcsDriver.ts create mode 100644 apps/server/src/vcs/Services/VcsProcess.ts create mode 100644 packages/contracts/src/vcs.ts diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index d650f62308e..27c55bbffbd 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -25,8 +25,6 @@ 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 { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts"; @@ -35,6 +33,7 @@ import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/Pr import { ProjectionPendingApprovalRepositoryLive } from "../src/persistence/Layers/ProjectionPendingApprovals.ts"; import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts"; import { makeSqlitePersistenceLive } from "../src/persistence/Layers/Sqlite.ts"; +import { VcsDriver, type VcsDriverShape } from "../src/vcs/Services/VcsDriver.ts"; import { ProjectionCheckpointRepository } from "../src/persistence/Services/ProjectionCheckpoints.ts"; import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts"; import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts"; @@ -77,6 +76,8 @@ 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 { GitVcsDriverLive } from "../src/vcs/Layers/GitVcsDriver.ts"; +import { VcsProcessLive } from "../src/vcs/Layers/VcsProcess.ts"; function runGit(cwd: string, args: ReadonlyArray) { return execFileSync("git", args, { @@ -291,7 +292,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provide(providerEventLoggersLayer), ); - const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive)); + const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitVcsDriverLive)); const projectionSnapshotQueryLayer = OrchestrationProjectionSnapshotQueryLive; const runtimeServicesLayer = Layer.mergeAll( projectionSnapshotQueryLayer, @@ -307,17 +308,17 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(serverSettingsLayer), ); - const gitCoreLayer = Layer.succeed(GitCore, { - renameBranch: (input: Parameters[0]) => + const vcsDriverLayer = Layer.succeed(VcsDriver, { + renameBranch: (input: Parameters[0]) => Effect.succeed({ branch: input.newBranch }), - } as unknown as GitCoreShape); + } as unknown as VcsDriverShape); 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(vcsDriverLayer), Layer.provideMerge(textGenerationLayer), Layer.provideMerge(serverSettingsLayer), ); @@ -342,11 +343,12 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge( WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), - Layer.provideMerge(gitCoreLayer), + Layer.provideMerge(GitVcsDriverLive), Layer.provide(NodeServices.layer), ), ), Layer.provideMerge(WorkspacePathsLive), + Layer.provideMerge(VcsProcessLive), ); const orchestrationReactorLayer = OrchestrationReactorLive.pipe( Layer.provideMerge(runtimeIngestionLayer), diff --git a/apps/server/src/checkpointing/Errors.ts b/apps/server/src/checkpointing/Errors.ts index cb873559c16..c6875e585cf 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; + const result = yield* process.run({ operation: "CheckpointStore.test.git", + command: "git", cwd, args, timeoutMs: 10_000, @@ -64,14 +69,9 @@ function git( function initRepoWithCommit( cwd: string, -): Effect.Effect< - void, - GitCommandError | PlatformError.PlatformError, - GitCore | FileSystem.FileSystem -> { +): Effect.Effect { 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 211877e9b1a..d7b0b8b34c2 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 { VcsDriver } from "../../vcs/Services/VcsDriver.ts"; import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; import { CheckpointRef } from "@t3tools/contracts"; @@ -24,10 +24,10 @@ 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 vcs = yield* VcsDriver; - const resolveHeadCommit = (cwd: string): Effect.Effect => - git + const resolveHeadCommit = (cwd: string) => + vcs .execute({ operation: "CheckpointStore.resolveHeadCommit", cwd, @@ -36,7 +36,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 +44,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 +64,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 +73,7 @@ const makeCheckpointStore = Effect.gen(function* () { ); const isGitRepository: CheckpointStoreShape["isGitRepository"] = (cwd) => - git + vcs .execute({ operation: "CheckpointStore.isGitRepository", cwd, @@ -84,7 +81,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 +105,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 +113,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 +128,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 +146,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 +196,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 +209,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 +234,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 +262,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/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 665c4b138f9..2f2c8bd1301 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -173,13 +173,6 @@ function buildLargeText(lineCount = 20_000): string { .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) => { @@ -223,87 +216,6 @@ it.layer(TestLayer)("git integration", (it) => { ); }); - 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", () => { diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 3e9df316f1e..2fac4b9adf1 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -46,14 +46,6 @@ 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); @@ -126,47 +118,6 @@ function parseNumstatEntries( 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(); @@ -1634,100 +1585,6 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { 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())), @@ -2185,9 +2042,6 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { pullCurrentBranch, readRangeContext, readConfigValue, - isInsideWorkTree, - listWorkspaceFiles, - filterIgnoredPaths, listBranches, createWorktree, fetchPullRequestBranch, diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index b8eeb541892..b94a771dfbd 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -21,8 +21,9 @@ import { 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 { GitVcsDriverLive } from "../../vcs/Layers/GitVcsDriver.ts"; +import { VcsProcessLive } from "../../vcs/Layers/VcsProcess.ts"; +import { VcsDriver } from "../../vcs/Services/VcsDriver.ts"; import { makeGitManager } from "./GitManager.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; @@ -211,16 +212,33 @@ function runGit( ): Effect.Effect< { readonly code: number; readonly stdout: string; readonly stderr: string }, GitCommandError, - GitCore + VcsDriver > { return Effect.gen(function* () { - const gitCore = yield* GitCore; - return yield* gitCore.execute({ - operation: "GitManager.test.runGit", - cwd, - args, - allowNonZeroExit, - }); + const vcs = yield* VcsDriver; + const result = yield* vcs + .execute({ + operation: "GitManager.test.runGit", + cwd, + args, + allowNonZeroExit, + }) + .pipe( + Effect.mapError( + (error) => + new GitCommandError({ + operation: "GitManager.test.runGit", + command: `git ${args.join(" ")}`, + cwd, + detail: error.message, + }), + ), + ); + return { + code: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + }; }); } @@ -229,7 +247,7 @@ function initRepo( ): Effect.Effect< void, PlatformError.PlatformError | GitCommandError, - FileSystem.FileSystem | Scope.Scope | GitCore + FileSystem.FileSystem | Scope.Scope | VcsDriver > { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -245,7 +263,7 @@ function initRepo( function createBareRemote(): Effect.Effect< string, PlatformError.PlatformError | GitCommandError, - FileSystem.FileSystem | Scope.Scope | GitCore + FileSystem.FileSystem | Scope.Scope | VcsDriver > { return Effect.gen(function* () { const remoteDir = yield* makeTempDir("t3code-git-remote-"); @@ -259,7 +277,7 @@ function configureRemote( remoteName: string, remotePath: string, fetchNamespace: string, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { yield* runGit(cwd, ["config", `remote.${remoteName}.url`, remotePath]); yield* runGit(cwd, [ @@ -639,7 +657,8 @@ function makeManager(input?: { const serverSettingsLayer = ServerSettingsService.layerTest(); - const gitCoreLayer = GitCoreLive.pipe( + const vcsDriverLayer = GitVcsDriverLive.pipe( + Layer.provideMerge(VcsProcessLive), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(ServerConfigLayer), ); @@ -653,7 +672,7 @@ function makeManager(input?: { runForThread: () => Effect.succeed({ status: "no-script" as const }), }, ), - gitCoreLayer, + vcsDriverLayer, serverSettingsLayer, ).pipe(Layer.provideMerge(NodeServices.layer)); @@ -665,8 +684,9 @@ function makeManager(input?: { const asThreadId = (threadId: string) => threadId as ThreadId; -const GitManagerTestLayer = GitCoreLive.pipe( +const GitManagerTestLayer = GitVcsDriverLive.pipe( Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })), + Layer.provideMerge(VcsProcessLive), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 21f3411d1e5..349a19a59e9 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -38,7 +38,6 @@ import { 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"; @@ -50,6 +49,7 @@ import { decodeGitHubPullRequestListJson, formatGitHubJsonDecodeError, } from "../githubPullRequests.ts"; +import { VcsDriver } from "../../vcs/Services/VcsDriver.ts"; const COMMIT_TIMEOUT_MS = 10 * 60_000; const MAX_PROGRESS_TEXT_LENGTH = 500; @@ -491,7 +491,7 @@ function toPullRequestHeadRemoteInfo(pr: { } export const makeGitManager = Effect.fn("makeGitManager")(function* () { - const gitCore = yield* GitCore; + const gitCore = yield* VcsDriver; const gitHubCli = yield* GitHubCli; const textGeneration = yield* TextGeneration; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index 9f3bc0b9b91..232cbe29d71 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -98,11 +98,6 @@ export interface GitRangeContext { diffPatch: string; } -export interface GitListWorkspaceFilesResult { - readonly paths: ReadonlyArray; - readonly truncated: boolean; -} - export interface GitRenameBranchInput { cwd: string; oldBranch: string; @@ -205,26 +200,6 @@ export interface GitCoreShape { 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. */ diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 5603ce63252..bc19e8c9025 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 { GitVcsDriverLive } from "../../vcs/Layers/GitVcsDriver.ts"; +import { VcsProcessLive } from "../../vcs/Layers/VcsProcess.ts"; import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { CheckpointReactorLive } from "./CheckpointReactor.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; @@ -306,10 +307,15 @@ describe("CheckpointReactor", () => { 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(CheckpointStoreLive.pipe(Layer.provide(GitVcsDriverLive))), + Layer.provideMerge( + WorkspaceEntriesLive.pipe( + Layer.provide(WorkspacePathsLive), + Layer.provideMerge(GitVcsDriverLive), + ), + ), Layer.provideMerge(WorkspacePathsLive), - Layer.provideMerge(GitCoreLive), + Layer.provideMerge(VcsProcessLive), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index c44f291504a..0d16d3ebddb 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -33,7 +33,6 @@ import { ProviderService, type ProviderServiceShape, } from "../../provider/Services/ProviderService.ts"; -import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.ts"; import { GitStatusBroadcaster, type GitStatusBroadcasterShape, @@ -52,6 +51,7 @@ 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 { VcsDriver, type VcsDriverShape } from "../../vcs/Services/VcsDriver.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value); @@ -324,7 +324,7 @@ 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(VcsDriver, { renameBranch } as unknown as VcsDriverShape)), Layer.provideMerge( Layer.succeed(GitStatusBroadcaster, { getStatus: () => Effect.die("getStatus should not be called in this test"), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 9a9f3d71b08..ea955553ed3 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -16,7 +16,6 @@ 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"; @@ -29,6 +28,7 @@ import { type ProviderCommandReactorShape, } from "../Services/ProviderCommandReactor.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { VcsDriver } from "../../vcs/Services/VcsDriver.ts"; type ProviderIntentEvent = Extract< OrchestrationEvent, @@ -168,7 +168,7 @@ function buildGeneratedWorktreeBranchName(raw: string): string { const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const providerService = yield* ProviderService; - const git = yield* GitCore; + const git = yield* VcsDriver; const gitStatusBroadcaster = yield* GitStatusBroadcaster; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index e699ad8339c..7d0066d4c61 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -58,7 +58,6 @@ 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 { @@ -105,6 +104,7 @@ import { import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; +import { VcsDriver, type VcsDriverShape } from "./vcs/Services/VcsDriver.ts"; import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; @@ -195,16 +195,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,7 +314,7 @@ const buildAppUnderTest = (options?: { providerRegistry?: Partial; serverSettings?: Partial; open?: Partial; - gitCore?: Partial; + vcsDriver?: Partial; gitManager?: Partial; gitStatusBroadcaster?: Partial; projectSetupScriptRunner?: Partial; @@ -372,22 +362,34 @@ const buildAppUnderTest = (options?: { ...options?.config, }; const layerConfig = Layer.succeed(ServerConfig, config); - const gitCoreLayer = Layer.mock(GitCore)({ + const vcsDriverLayer = Layer.mock(VcsDriver)({ + capabilities: { + kind: "git", + supportsWorktrees: true, + supportsBookmarks: false, + supportsAtomicSnapshot: false, + supportsPushDefaultRemote: true, + }, + detectRepository: () => Effect.succeed(null), isInsideWorkTree: () => Effect.succeed(false), listWorkspaceFiles: () => Effect.succeed({ paths: [], truncated: false, + freshness: { + source: "live-local", + observedAt: new Date(0).toISOString(), + }, }), filterIgnoredPaths: (_cwd, relativePaths) => Effect.succeed(relativePaths), - ...options?.layers?.gitCore, + ...options?.layers?.vcsDriver, }); const gitManagerLayer = Layer.mock(GitManager)({ ...options?.layers?.gitManager, }); const workspaceEntriesLayer = WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), - Layer.provideMerge(gitCoreLayer), + Layer.provideMerge(vcsDriverLayer), ); const workspaceAndProjectServicesLayer = Layer.mergeAll( WorkspacePathsLive, @@ -441,7 +443,6 @@ const buildAppUnderTest = (options?: { ...options?.layers?.open, }), ), - Layer.provide(gitCoreLayer), Layer.provide(gitManagerLayer), Layer.provideMerge(gitStatusBroadcasterLayer), Layer.provide( @@ -2068,12 +2069,16 @@ 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: new Date(0).toISOString(), + }, }), filterIgnoredPaths: (_cwd, relativePaths) => Effect.succeed( @@ -2372,7 +2377,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { worktreePath: null, }), }, - gitCore: { + vcsDriver: { pullCurrentBranch: () => Effect.succeed({ status: "pulled", @@ -2526,7 +2531,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { let statusCalls = 0; yield* buildAppUnderTest({ layers: { - gitCore: { + vcsDriver: { pullCurrentBranch: () => Effect.fail(gitError), }, gitManager: { @@ -2680,7 +2685,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest({ layers: { - gitCore: { + vcsDriver: { pullCurrentBranch: () => Effect.succeed({ status: "pulled" as const, @@ -3494,7 +3499,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { pr: null, }), ); - const createWorktree = vi.fn((_: Parameters[0]) => + const createWorktree = vi.fn((_: Parameters[0]) => Effect.succeed({ worktree: { branch: "t3code/bootstrap-branch", @@ -3515,7 +3520,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { - gitCore: { + vcsDriver: { createWorktree, }, gitStatusBroadcaster: { @@ -3619,7 +3624,7 @@ 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]) => + const createWorktree = vi.fn((_: Parameters[0]) => Effect.succeed({ worktree: { branch: "t3code/bootstrap-branch", @@ -3634,7 +3639,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { - gitCore: { + vcsDriver: { createWorktree, }, orchestrationEngine: { @@ -3712,7 +3717,7 @@ 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]) => + const createWorktree = vi.fn((_: Parameters[0]) => Effect.succeed({ worktree: { branch: "t3code/bootstrap-branch", @@ -3734,7 +3739,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { - gitCore: { + vcsDriver: { createWorktree, }, orchestrationEngine: { @@ -3830,13 +3835,13 @@ 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]) => + const createWorktree = vi.fn((_: Parameters[0]) => Effect.die(new Error("worktree exploded")), ); yield* buildAppUnderTest({ layers: { - gitCore: { + vcsDriver: { createWorktree, }, orchestrationEngine: { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 85f2d84ad28..66bbd68acc3 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -25,7 +25,6 @@ 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"; @@ -47,6 +46,8 @@ 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 { GitVcsDriverLive } from "./vcs/Layers/GitVcsDriver.ts"; +import { VcsProcessLive } from "./vcs/Layers/VcsProcess.ts"; import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts"; import { ObservabilityLive } from "./observability/Layers/Observability.ts"; import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts"; @@ -135,7 +136,7 @@ const ReactorLayerLive = Layer.empty.pipe( const CheckpointingLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointDiffQueryLive), - Layer.provideMerge(CheckpointStoreLive), + Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(GitVcsDriverLive))), ); const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( @@ -157,7 +158,7 @@ const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersisten const GitManagerLayerLive = GitManagerLive.pipe( Layer.provideMerge(ProjectSetupScriptRunnerLive), - Layer.provideMerge(GitCoreLive), + Layer.provideMerge(GitVcsDriverLive), Layer.provideMerge(GitHubCliLive), Layer.provideMerge(TextGenerationLive), ); @@ -165,14 +166,14 @@ const GitManagerLayerLive = GitManagerLive.pipe( const GitLayerLive = Layer.empty.pipe( Layer.provideMerge(GitManagerLayerLive), Layer.provideMerge(GitStatusBroadcasterLive.pipe(Layer.provide(GitManagerLayerLive))), - Layer.provideMerge(GitCoreLive), + Layer.provideMerge(GitVcsDriverLive), ); const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), - Layer.provideMerge(GitCoreLive), + Layer.provideMerge(GitVcsDriverLive), ); const WorkspaceFileSystemLayerLive = WorkspaceFileSystemLive.pipe( @@ -310,6 +311,7 @@ export const makeServerLayer = Layer.unwrap( Layer.provideMerge(HttpServerLive), Layer.provide(ObservabilityLive), Layer.provideMerge(FetchHttpClient.layer), + Layer.provideMerge(VcsProcessLive), Layer.provideMerge(PlatformServicesLive), ); }), diff --git a/apps/server/src/vcs/Layers/GitVcsDriver.test.ts b/apps/server/src/vcs/Layers/GitVcsDriver.test.ts new file mode 100644 index 00000000000..454ba6bd0ab --- /dev/null +++ b/apps/server/src/vcs/Layers/GitVcsDriver.test.ts @@ -0,0 +1,125 @@ +import { it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Effect, Layer } from "effect"; +import { describe, expect } from "vitest"; + +import { ServerConfig } from "../../config.ts"; +import { VcsProcess } from "../Services/VcsProcess.ts"; +import { VcsDriver } from "../Services/VcsDriver.ts"; +import { GitVcsDriverLive } from "./GitVcsDriver.ts"; + +const splitNullSeparatedPaths = (input: string): string[] => + input + .split("\0") + .map((value) => value.trim()) + .filter((value) => value.length > 0); + +const GitVcsDriverTestDependencies = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-git-vcs-driver-test-", +}).pipe(Layer.provide(NodeServices.layer)); + +it.layer(Layer.empty)("GitVcsDriverLive", (it) => { + 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 layer = GitVcsDriverLive.pipe( + Layer.provideMerge(GitVcsDriverTestDependencies), + Layer.provideMerge(NodeServices.layer), + Layer.provide( + Layer.succeed(VcsProcess, { + run: (input) => { + expect(input.command).toBe("git"); + expect(input.args).toEqual([ + "-c", + "core.fsmonitor=false", + "-c", + "core.untrackedCache=false", + "check-ignore", + "--no-index", + "-z", + "--stdin", + ]); + + const chunkPaths = splitNullSeparatedPaths(input.stdin ?? ""); + seenChunks.push(chunkPaths); + const ignoredPaths = chunkPaths.filter((relativePath) => + relativePath.startsWith("ignored/"), + ); + + return Effect.succeed({ + exitCode: ignoredPaths.length > 0 ? 0 : 1, + stdout: ignoredPaths.length > 0 ? `${ignoredPaths.join("\0")}\0` : "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }); + }, + }), + ), + ); + + const result = yield* Effect.gen(function* () { + const vcs = yield* VcsDriver; + return yield* vcs.filterIgnoredPaths(cwd, relativePaths); + }).pipe(Effect.provide(layer)); + + 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 layer = GitVcsDriverLive.pipe( + Layer.provideMerge(GitVcsDriverTestDependencies), + Layer.provideMerge(NodeServices.layer), + Layer.provide( + Layer.succeed(VcsProcess, { + run: (input) => { + expect(input.command).toBe("git"); + expect(input.args).toEqual([ + "-c", + "core.fsmonitor=false", + "-c", + "core.untrackedCache=false", + "ls-files", + "--cached", + "--others", + "--exclude-standard", + "-z", + ]); + return Effect.succeed({ + exitCode: 0, + stdout: "src/index.ts\0README.md\0", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }); + }, + }), + ), + ); + + const result = yield* Effect.gen(function* () { + const vcs = yield* VcsDriver; + return yield* vcs.listWorkspaceFiles("/virtual/repo"); + }).pipe(Effect.provide(layer)); + + expect(result.paths).toEqual(["src/index.ts", "README.md"]); + expect(result.truncated).toBe(false); + expect(result.freshness.source).toBe("live-local"); + }), + ); + }); +}); diff --git a/apps/server/src/vcs/Layers/GitVcsDriver.ts b/apps/server/src/vcs/Layers/GitVcsDriver.ts new file mode 100644 index 00000000000..be547a4c457 --- /dev/null +++ b/apps/server/src/vcs/Layers/GitVcsDriver.ts @@ -0,0 +1,252 @@ +import { Effect, Layer } from "effect"; + +import { VcsProcessExitError } from "@t3tools/contracts"; +import { makeGitCore } from "../../git/Layers/GitCore.ts"; +import { VcsDriver, type VcsDriverShape } from "../Services/VcsDriver.ts"; +import { VcsProcess, type VcsProcessShape } from "../Services/VcsProcess.ts"; + +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; + +function nowFreshness() { + return { + source: "live-local" as const, + observedAt: new Date().toISOString(), + }; +} + +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; +} + +const gitCommand = ( + process: VcsProcessShape, + operation: string, + cwd: string, + args: ReadonlyArray, + options?: { + readonly stdin?: string; + 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?.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 makeGitVcsDriver = Effect.fn("makeGitVcsDriver")(function* () { + const process = yield* VcsProcess; + const legacyGit = yield* makeGitCore(); + const capabilities = { + kind: "git" as const, + supportsWorktrees: true, + supportsBookmarks: false, + supportsAtomicSnapshot: false, + supportsPushDefaultRemote: true, + }; + + 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.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: 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.succeed({ + paths: splitNullSeparatedPaths(result.stdout, result.stdoutTruncated), + truncated: result.stdoutTruncated, + freshness: nowFreshness(), + }) + : Effect.fail( + new VcsProcessExitError({ + operation: "GitVcsDriver.listWorkspaceFiles", + command: "git ls-files", + cwd, + exitCode: result.exitCode, + detail: result.stderr.trim() || "git ls-files failed", + }), + ), + ), + ); + + 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)); + }, + ); + + return VcsDriver.of({ + ...legacyGit, + capabilities, + execute, + detectRepository, + isInsideWorkTree, + listWorkspaceFiles, + filterIgnoredPaths, + }); +}); + +export const GitVcsDriverLive = Layer.effect(VcsDriver, makeGitVcsDriver()); diff --git a/apps/server/src/vcs/Layers/VcsProcess.ts b/apps/server/src/vcs/Layers/VcsProcess.ts new file mode 100644 index 00000000000..c406f0bdca3 --- /dev/null +++ b/apps/server/src/vcs/Layers/VcsProcess.ts @@ -0,0 +1,194 @@ +import { Duration, Effect, Layer, Option, PlatformError, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + VcsOutputDecodeError, + VcsProcessExitError, + VcsProcessSpawnError, + VcsProcessTimeoutError, +} from "@t3tools/contracts"; +import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "../Services/VcsProcess.ts"; + +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(" "); +} + +const collectOutput = Effect.fn("VcsProcess.collectOutput")(function* ( + input: VcsProcessInput, + stream: Stream.Stream, + maxOutputBytes: number, + truncateOutputAtMaxBytes: boolean, +) { + const decoder = new TextDecoder(); + let text = ""; + let bytes = 0; + let truncated = false; + + yield* Stream.runForEach(stream, (chunk) => + Effect.sync(() => { + if (truncated) return; + + const remainingBytes = maxOutputBytes - bytes; + if (remainingBytes <= 0) { + truncated = true; + if (truncateOutputAtMaxBytes) { + text += OUTPUT_TRUNCATED_MARKER; + return; + } + 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; + } + } + }), + ).pipe( + Effect.mapError( + (cause) => + new VcsOutputDecodeError({ + operation: input.operation, + command: commandLabel(input.command, input.args), + cwd: input.cwd, + detail: "failed to collect process output", + cause, + }), + ), + ); + + if (!truncated) { + text += decoder.decode(); + } + + return { text, truncated }; +}); + +export const makeVcsProcess = Effect.fn("makeVcsProcess")(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + 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 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, + }), + ), + ); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectOutput( + input, + child.stdout, + maxOutputBytes, + input.truncateOutputAtMaxBytes ?? false, + ), + collectOutput( + input, + child.stderr, + maxOutputBytes, + input.truncateOutputAtMaxBytes ?? false, + ), + child.exitCode.pipe( + Effect.map((value) => Number(value)), + Effect.mapError( + (cause) => + new VcsOutputDecodeError({ + operation: input.operation, + command: label, + cwd: input.cwd, + detail: "failed to read process exit code", + cause, + }), + ), + ), + input.stdin === undefined + ? Effect.void + : Stream.run(Stream.encodeText(Stream.make(input.stdin)), child.stdin).pipe( + Effect.mapError( + (cause) => + new VcsOutputDecodeError({ + operation: input.operation, + command: label, + cwd: input.cwd, + detail: "failed to write process stdin", + cause, + }), + ), + ), + ], + { 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({ run }); +}); + +export const VcsProcessLive = Layer.effect(VcsProcess, makeVcsProcess()); diff --git a/apps/server/src/vcs/Services/VcsDriver.ts b/apps/server/src/vcs/Services/VcsDriver.ts new file mode 100644 index 00000000000..1cc7c3f8044 --- /dev/null +++ b/apps/server/src/vcs/Services/VcsDriver.ts @@ -0,0 +1,30 @@ +import { Context, type Effect } from "effect"; + +import type { + VcsDriverCapabilities, + VcsError, + VcsListWorkspaceFilesResult, + VcsRepositoryIdentity, +} from "@t3tools/contracts"; +import type { GitCoreShape } from "../../git/Services/GitCore.ts"; +import type { VcsProcessInput, VcsProcessOutput } from "./VcsProcess.ts"; + +export interface VcsDriverShape extends Omit { + 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 filterIgnoredPaths: ( + cwd: string, + relativePaths: ReadonlyArray, + ) => Effect.Effect, VcsError>; +} + +export class VcsDriver extends Context.Service()( + "t3/vcs/Services/VcsDriver", +) {} diff --git a/apps/server/src/vcs/Services/VcsProcess.ts b/apps/server/src/vcs/Services/VcsProcess.ts new file mode 100644 index 00000000000..ff29f197844 --- /dev/null +++ b/apps/server/src/vcs/Services/VcsProcess.ts @@ -0,0 +1,32 @@ +import { Context, type Effect } from "effect"; + +import type { VcsError } 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: number; + readonly stdout: string; + readonly stderr: string; + readonly stdoutTruncated: boolean; + readonly stderrTruncated: boolean; +} + +export interface VcsProcessShape { + readonly run: (input: VcsProcessInput) => Effect.Effect; +} + +export class VcsProcess extends Context.Service()( + "t3/vcs/Services/VcsProcess", +) {} diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts index 85b43ab37f6..61f2b9240c3 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts @@ -5,8 +5,9 @@ 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 { GitVcsDriverLive } from "../../vcs/Layers/GitVcsDriver.ts"; +import { VcsProcessLive } from "../../vcs/Layers/VcsProcess.ts"; +import { VcsProcess } from "../../vcs/Services/VcsProcess.ts"; import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; import { WorkspacePathsLive } from "./WorkspacePaths.ts"; @@ -14,7 +15,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(VcsProcessLive), + Layer.provideMerge(GitVcsDriverLive.pipe(Layer.provide(VcsProcessLive))), Layer.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-workspace-entries-test-", @@ -25,12 +27,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 +52,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; + 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 7cb16b652be..7bb143347fd 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.ts @@ -13,7 +13,7 @@ import { type RankedSearchResult, } from "@t3tools/shared/searchRanking"; -import { GitCore } from "../../git/Services/GitCore.ts"; +import { VcsDriver } from "../../vcs/Services/VcsDriver.ts"; import { WorkspaceEntries, WorkspaceEntriesBrowseError, @@ -174,38 +174,38 @@ const resolveBrowseTarget = ( export const makeWorkspaceEntries = Effect.gen(function* () { const path = yield* Path.Path; - const gitOption = yield* Effect.serviceOption(GitCore); + const vcsOption = yield* Effect.serviceOption(VcsDriver); const workspacePaths = yield* WorkspacePaths; - const isInsideGitWorkTree = (cwd: string): Effect.Effect => - Option.match(gitOption, { - onSome: (git) => git.isInsideWorkTree(cwd).pipe(Effect.catch(() => Effect.succeed(false))), + const isInsideVcsWorkTree = (cwd: string): Effect.Effect => + Option.match(vcsOption, { + onSome: (vcs) => vcs.isInsideWorkTree(cwd).pipe(Effect.catch(() => Effect.succeed(false))), onNone: () => Effect.succeed(false), }); - const filterGitIgnoredPaths = ( + const filterVcsIgnoredPaths = ( cwd: string, relativePaths: string[], ): Effect.Effect => - Option.match(gitOption, { - onSome: (git) => - git.filterIgnoredPaths(cwd, relativePaths).pipe( + Option.match(vcsOption, { + onSome: (vcs) => + vcs.filterIgnoredPaths(cwd, relativePaths).pipe( Effect.map((paths) => [...paths]), Effect.catch(() => Effect.succeed(relativePaths)), ), onNone: () => Effect.succeed(relativePaths), }); - const buildWorkspaceIndexFromGit = Effect.fn("WorkspaceEntries.buildWorkspaceIndexFromGit")( + const buildWorkspaceIndexFromVcs = Effect.fn("WorkspaceEntries.buildWorkspaceIndexFromVcs")( function* (cwd: string) { - if (Option.isNone(gitOption)) { + if (Option.isNone(vcsOption)) { return null; } - if (!(yield* isInsideGitWorkTree(cwd))) { + if (!(yield* isInsideVcsWorkTree(cwd))) { return null; } - const listedFiles = yield* gitOption.value + const listedFiles = yield* vcsOption.value .listWorkspaceFiles(cwd) .pipe(Effect.catch(() => Effect.succeed(null))); @@ -216,7 +216,7 @@ 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* filterVcsIgnoredPaths(cwd, listedPaths); const directorySet = new Set(); for (const filePath of filePaths) { @@ -288,7 +288,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 +336,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) { @@ -378,9 +378,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 fcfd13c912e..71829708c91 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 { GitVcsDriverLive } from "../../vcs/Layers/GitVcsDriver.ts"; +import { VcsProcessLive } from "../../vcs/Layers/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(GitVcsDriverLive.pipe(Layer.provide(VcsProcessLive))), 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 fd256b32dfa..da0bfad5986 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -29,7 +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"; @@ -50,6 +49,7 @@ 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 { VcsDriver } from "./vcs/Services/VcsDriver.ts"; import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner.ts"; import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver.ts"; import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; @@ -137,7 +137,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const keybindings = yield* Keybindings; const open = yield* Open; const gitManager = yield* GitManager; - const git = yield* GitCore; + const git = yield* VcsDriver; const gitStatusBroadcaster = yield* GitStatusBroadcaster; const terminalManager = yield* TerminalManager; const providerRegistry = yield* ProviderRegistry; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index ad905717405..6e7f6b7e62b 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -11,6 +11,7 @@ export * from "./keybindings.ts"; export * from "./server.ts"; export * from "./settings.ts"; export * from "./git.ts"; +export * from "./vcs.ts"; export * from "./orchestration.ts"; export * from "./editor.ts"; export * from "./project.ts"; diff --git a/packages/contracts/src/vcs.ts b/packages/contracts/src/vcs.ts new file mode 100644 index 00000000000..3bef29a479e --- /dev/null +++ b/packages/contracts/src/vcs.ts @@ -0,0 +1,139 @@ +import { Schema } from "effect"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; + +export const VcsDriverKind = Schema.Literals(["git", "jj", "sapling", "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: TrimmedNonEmptyString, + expiresAt: Schema.optional(TrimmedNonEmptyString), +}); +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, +}); +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 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; From 69b07777e2a56ac7806c0ededc6c89db627b9c5c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 30 Apr 2026 23:30:11 -0700 Subject: [PATCH 03/45] Refactor VCS layers into top-level modules - Move VcsDriver and VcsProcess out of nested service/layer paths - Update server, workspace, orchestration, and tests to use new exports - Rename layer exports to `layer` for consistency --- .../OrchestrationEngineHarness.integration.ts | 12 +++--- .../Layers/CheckpointStore.test.ts | 10 ++--- .../checkpointing/Layers/CheckpointStore.ts | 2 +- apps/server/src/git/Layers/GitManager.test.ts | 14 +++---- apps/server/src/git/Layers/GitManager.ts | 2 +- .../Layers/CheckpointReactor.test.ts | 10 ++--- .../Layers/ProviderCommandReactor.test.ts | 2 +- .../Layers/ProviderCommandReactor.ts | 2 +- apps/server/src/server.test.ts | 2 +- apps/server/src/server.ts | 14 +++---- .../src/vcs/{Layers => }/GitVcsDriver.test.ts | 14 +++---- .../src/vcs/{Layers => }/GitVcsDriver.ts | 10 ++--- apps/server/src/vcs/Services/VcsProcess.ts | 32 ---------------- .../src/vcs/{Services => }/VcsDriver.ts | 6 +-- .../server/src/vcs/{Layers => }/VcsProcess.ts | 38 ++++++++++++++++--- .../workspace/Layers/WorkspaceEntries.test.ts | 10 ++--- .../src/workspace/Layers/WorkspaceEntries.ts | 2 +- .../Layers/WorkspaceFileSystem.test.ts | 6 +-- apps/server/src/ws.ts | 2 +- 19 files changed, 92 insertions(+), 98 deletions(-) rename apps/server/src/vcs/{Layers => }/GitVcsDriver.test.ts (92%) rename apps/server/src/vcs/{Layers => }/GitVcsDriver.ts (95%) delete mode 100644 apps/server/src/vcs/Services/VcsProcess.ts rename apps/server/src/vcs/{Services => }/VcsDriver.ts (89%) rename apps/server/src/vcs/{Layers => }/VcsProcess.ts (84%) diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 27c55bbffbd..0792b5c19dd 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -33,7 +33,7 @@ import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/Pr import { ProjectionPendingApprovalRepositoryLive } from "../src/persistence/Layers/ProjectionPendingApprovals.ts"; import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts"; import { makeSqlitePersistenceLive } from "../src/persistence/Layers/Sqlite.ts"; -import { VcsDriver, type VcsDriverShape } from "../src/vcs/Services/VcsDriver.ts"; +import { VcsDriver, type VcsDriverShape } from "../src/vcs/VcsDriver.ts"; import { ProjectionCheckpointRepository } from "../src/persistence/Services/ProjectionCheckpoints.ts"; import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts"; import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts"; @@ -76,8 +76,8 @@ 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 { GitVcsDriverLive } from "../src/vcs/Layers/GitVcsDriver.ts"; -import { VcsProcessLive } from "../src/vcs/Layers/VcsProcess.ts"; +import { layer as GitVcsDriverLayer } from "../src/vcs/GitVcsDriver.ts"; +import { layer as VcsProcessLayer } from "../src/vcs/VcsProcess.ts"; function runGit(cwd: string, args: ReadonlyArray) { return execFileSync("git", args, { @@ -292,7 +292,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provide(providerEventLoggersLayer), ); - const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitVcsDriverLive)); + const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitVcsDriverLayer)); const projectionSnapshotQueryLayer = OrchestrationProjectionSnapshotQueryLive; const runtimeServicesLayer = Layer.mergeAll( projectionSnapshotQueryLayer, @@ -343,12 +343,12 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge( WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), - Layer.provideMerge(GitVcsDriverLive), + Layer.provideMerge(GitVcsDriverLayer), Layer.provide(NodeServices.layer), ), ), Layer.provideMerge(WorkspacePathsLive), - Layer.provideMerge(VcsProcessLive), + Layer.provideMerge(VcsProcessLayer), ); const orchestrationReactorLayer = OrchestrationReactorLive.pipe( Layer.provideMerge(runtimeIngestionLayer), diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts index 0bdff6fd823..7a710c7aeae 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts @@ -8,9 +8,9 @@ import { describe, expect } from "vitest"; import { checkpointRefForThreadTurn } from "../Utils.ts"; import { CheckpointStoreLive } from "./CheckpointStore.ts"; import { CheckpointStore } from "../Services/CheckpointStore.ts"; -import { GitVcsDriverLive } from "../../vcs/Layers/GitVcsDriver.ts"; -import { VcsProcessLive } from "../../vcs/Layers/VcsProcess.ts"; -import { VcsProcess } from "../../vcs/Services/VcsProcess.ts"; +import { layer as GitVcsDriverLayer } from "../../vcs/GitVcsDriver.ts"; +import { layer as VcsProcessLayer } from "../../vcs/VcsProcess.ts"; +import { VcsProcess } from "../../vcs/VcsProcess.ts"; import type { VcsError } from "@t3tools/contracts"; import { ServerConfig } from "../../config.ts"; import { ThreadId } from "@t3tools/contracts"; @@ -18,8 +18,8 @@ import { ThreadId } from "@t3tools/contracts"; const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-checkpoint-store-test-", }); -const VcsProcessTestLayer = VcsProcessLive.pipe(Layer.provide(NodeServices.layer)); -const VcsDriverTestLayer = GitVcsDriverLive.pipe(Layer.provide(VcsProcessTestLayer)); +const VcsProcessTestLayer = VcsProcessLayer.pipe(Layer.provide(NodeServices.layer)); +const VcsDriverTestLayer = GitVcsDriverLayer.pipe(Layer.provide(VcsProcessTestLayer)); const CheckpointStoreTestLayer = CheckpointStoreLive.pipe( Layer.provideMerge(VcsDriverTestLayer), Layer.provideMerge(NodeServices.layer), diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.ts index d7b0b8b34c2..41ca7785c8f 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.ts @@ -15,7 +15,7 @@ import { Effect, Layer, FileSystem, Path } from "effect"; import { CheckpointInvariantError } from "../Errors.ts"; import { VcsProcessExitError } from "@t3tools/contracts"; -import { VcsDriver } from "../../vcs/Services/VcsDriver.ts"; +import { VcsDriver } from "../../vcs/VcsDriver.ts"; import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; import { CheckpointRef } from "@t3tools/contracts"; diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index b94a771dfbd..0638a9f22c0 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -21,9 +21,9 @@ import { GitHubCli, } from "../Services/GitHubCli.ts"; import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; -import { GitVcsDriverLive } from "../../vcs/Layers/GitVcsDriver.ts"; -import { VcsProcessLive } from "../../vcs/Layers/VcsProcess.ts"; -import { VcsDriver } from "../../vcs/Services/VcsDriver.ts"; +import { layer as GitVcsDriverLayer } from "../../vcs/GitVcsDriver.ts"; +import { layer as VcsProcessLayer } from "../../vcs/VcsProcess.ts"; +import { VcsDriver } from "../../vcs/VcsDriver.ts"; import { makeGitManager } from "./GitManager.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; @@ -657,8 +657,8 @@ function makeManager(input?: { const serverSettingsLayer = ServerSettingsService.layerTest(); - const vcsDriverLayer = GitVcsDriverLive.pipe( - Layer.provideMerge(VcsProcessLive), + const vcsDriverLayer = GitVcsDriverLayer.pipe( + Layer.provideMerge(VcsProcessLayer), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(ServerConfigLayer), ); @@ -684,9 +684,9 @@ function makeManager(input?: { const asThreadId = (threadId: string) => threadId as ThreadId; -const GitManagerTestLayer = GitVcsDriverLive.pipe( +const GitManagerTestLayer = GitVcsDriverLayer.pipe( Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })), - Layer.provideMerge(VcsProcessLive), + Layer.provideMerge(VcsProcessLayer), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 349a19a59e9..b6b87f8f9ce 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -49,7 +49,7 @@ import { decodeGitHubPullRequestListJson, formatGitHubJsonDecodeError, } from "../githubPullRequests.ts"; -import { VcsDriver } from "../../vcs/Services/VcsDriver.ts"; +import { VcsDriver } from "../../vcs/VcsDriver.ts"; const COMMIT_TIMEOUT_MS = 10 * 60_000; const MAX_PROGRESS_TEXT_LENGTH = 500; diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index bc19e8c9025..500f3debd39 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -25,8 +25,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts"; -import { GitVcsDriverLive } from "../../vcs/Layers/GitVcsDriver.ts"; -import { VcsProcessLive } from "../../vcs/Layers/VcsProcess.ts"; +import { layer as GitVcsDriverLayer } from "../../vcs/GitVcsDriver.ts"; +import { layer as VcsProcessLayer } from "../../vcs/VcsProcess.ts"; import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { CheckpointReactorLive } from "./CheckpointReactor.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; @@ -307,15 +307,15 @@ describe("CheckpointReactor", () => { Layer.provideMerge(RuntimeReceiptBusLive), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), Layer.provideMerge(gitStatusBroadcasterLayer), - Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(GitVcsDriverLive))), + Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(GitVcsDriverLayer))), Layer.provideMerge( WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), - Layer.provideMerge(GitVcsDriverLive), + Layer.provideMerge(GitVcsDriverLayer), ), ), Layer.provideMerge(WorkspacePathsLive), - Layer.provideMerge(VcsProcessLive), + Layer.provideMerge(VcsProcessLayer), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 0d16d3ebddb..293fa0e6000 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -51,7 +51,7 @@ 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 { VcsDriver, type VcsDriverShape } from "../../vcs/Services/VcsDriver.ts"; +import { VcsDriver, type VcsDriverShape } from "../../vcs/VcsDriver.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index ea955553ed3..89c3c9cdec8 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -28,7 +28,7 @@ import { type ProviderCommandReactorShape, } from "../Services/ProviderCommandReactor.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -import { VcsDriver } from "../../vcs/Services/VcsDriver.ts"; +import { VcsDriver } from "../../vcs/VcsDriver.ts"; type ProviderIntentEvent = Extract< OrchestrationEvent, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 7d0066d4c61..a9d5a46c8ce 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -104,7 +104,7 @@ import { import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; -import { VcsDriver, type VcsDriverShape } from "./vcs/Services/VcsDriver.ts"; +import { VcsDriver, type VcsDriverShape } from "./vcs/VcsDriver.ts"; import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 66bbd68acc3..114354fccda 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -46,8 +46,8 @@ 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 { GitVcsDriverLive } from "./vcs/Layers/GitVcsDriver.ts"; -import { VcsProcessLive } from "./vcs/Layers/VcsProcess.ts"; +import { layer as GitVcsDriverLayer } from "./vcs/GitVcsDriver.ts"; +import { layer as VcsProcessLayer } from "./vcs/VcsProcess.ts"; import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts"; import { ObservabilityLive } from "./observability/Layers/Observability.ts"; import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts"; @@ -136,7 +136,7 @@ const ReactorLayerLive = Layer.empty.pipe( const CheckpointingLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointDiffQueryLive), - Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(GitVcsDriverLive))), + Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(GitVcsDriverLayer))), ); const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( @@ -158,7 +158,7 @@ const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersisten const GitManagerLayerLive = GitManagerLive.pipe( Layer.provideMerge(ProjectSetupScriptRunnerLive), - Layer.provideMerge(GitVcsDriverLive), + Layer.provideMerge(GitVcsDriverLayer), Layer.provideMerge(GitHubCliLive), Layer.provideMerge(TextGenerationLive), ); @@ -166,14 +166,14 @@ const GitManagerLayerLive = GitManagerLive.pipe( const GitLayerLive = Layer.empty.pipe( Layer.provideMerge(GitManagerLayerLive), Layer.provideMerge(GitStatusBroadcasterLive.pipe(Layer.provide(GitManagerLayerLive))), - Layer.provideMerge(GitVcsDriverLive), + Layer.provideMerge(GitVcsDriverLayer), ); const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), - Layer.provideMerge(GitVcsDriverLive), + Layer.provideMerge(GitVcsDriverLayer), ); const WorkspaceFileSystemLayerLive = WorkspaceFileSystemLive.pipe( @@ -311,7 +311,7 @@ export const makeServerLayer = Layer.unwrap( Layer.provideMerge(HttpServerLive), Layer.provide(ObservabilityLive), Layer.provideMerge(FetchHttpClient.layer), - Layer.provideMerge(VcsProcessLive), + Layer.provideMerge(VcsProcessLayer), Layer.provideMerge(PlatformServicesLive), ); }), diff --git a/apps/server/src/vcs/Layers/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts similarity index 92% rename from apps/server/src/vcs/Layers/GitVcsDriver.test.ts rename to apps/server/src/vcs/GitVcsDriver.test.ts index 454ba6bd0ab..92910b21e87 100644 --- a/apps/server/src/vcs/Layers/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -3,10 +3,10 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { Effect, Layer } from "effect"; import { describe, expect } from "vitest"; -import { ServerConfig } from "../../config.ts"; -import { VcsProcess } from "../Services/VcsProcess.ts"; -import { VcsDriver } from "../Services/VcsDriver.ts"; -import { GitVcsDriverLive } from "./GitVcsDriver.ts"; +import { ServerConfig } from "../config.ts"; +import { VcsProcess } from "./VcsProcess.ts"; +import { VcsDriver } from "./VcsDriver.ts"; +import { layer as GitVcsDriverLayer } from "./GitVcsDriver.ts"; const splitNullSeparatedPaths = (input: string): string[] => input @@ -18,7 +18,7 @@ const GitVcsDriverTestDependencies = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-vcs-driver-test-", }).pipe(Layer.provide(NodeServices.layer)); -it.layer(Layer.empty)("GitVcsDriverLive", (it) => { +it.layer(Layer.empty)("GitVcsDriverLayer", (it) => { describe("workspace helpers", () => { it.effect("filterIgnoredPaths chunks large path lists and preserves kept paths", () => Effect.gen(function* () { @@ -32,7 +32,7 @@ it.layer(Layer.empty)("GitVcsDriverLive", (it) => { ); const seenChunks: string[][] = []; - const layer = GitVcsDriverLive.pipe( + const layer = GitVcsDriverLayer.pipe( Layer.provideMerge(GitVcsDriverTestDependencies), Layer.provideMerge(NodeServices.layer), Layer.provide( @@ -81,7 +81,7 @@ it.layer(Layer.empty)("GitVcsDriverLive", (it) => { it.effect("listWorkspaceFiles disables fsmonitor and untracked cache helpers", () => Effect.gen(function* () { - const layer = GitVcsDriverLive.pipe( + const layer = GitVcsDriverLayer.pipe( Layer.provideMerge(GitVcsDriverTestDependencies), Layer.provideMerge(NodeServices.layer), Layer.provide( diff --git a/apps/server/src/vcs/Layers/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts similarity index 95% rename from apps/server/src/vcs/Layers/GitVcsDriver.ts rename to apps/server/src/vcs/GitVcsDriver.ts index be547a4c457..b1a0728c18e 100644 --- a/apps/server/src/vcs/Layers/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -1,9 +1,9 @@ import { Effect, Layer } from "effect"; import { VcsProcessExitError } from "@t3tools/contracts"; -import { makeGitCore } from "../../git/Layers/GitCore.ts"; -import { VcsDriver, type VcsDriverShape } from "../Services/VcsDriver.ts"; -import { VcsProcess, type VcsProcessShape } from "../Services/VcsProcess.ts"; +import { makeGitCore } from "../git/Layers/GitCore.ts"; +import { VcsDriver, type VcsDriverShape } from "./VcsDriver.ts"; +import { VcsProcess, type VcsProcessShape } from "./VcsProcess.ts"; const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; @@ -91,7 +91,7 @@ const gitCommand = ( : {}), }); -export const makeGitVcsDriver = Effect.fn("makeGitVcsDriver")(function* () { +export const make = Effect.fn("makeGitVcsDriver")(function* () { const process = yield* VcsProcess; const legacyGit = yield* makeGitCore(); const capabilities = { @@ -249,4 +249,4 @@ export const makeGitVcsDriver = Effect.fn("makeGitVcsDriver")(function* () { }); }); -export const GitVcsDriverLive = Layer.effect(VcsDriver, makeGitVcsDriver()); +export const layer = Layer.effect(VcsDriver, make()); diff --git a/apps/server/src/vcs/Services/VcsProcess.ts b/apps/server/src/vcs/Services/VcsProcess.ts deleted file mode 100644 index ff29f197844..00000000000 --- a/apps/server/src/vcs/Services/VcsProcess.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Context, type Effect } from "effect"; - -import type { VcsError } 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: number; - readonly stdout: string; - readonly stderr: string; - readonly stdoutTruncated: boolean; - readonly stderrTruncated: boolean; -} - -export interface VcsProcessShape { - readonly run: (input: VcsProcessInput) => Effect.Effect; -} - -export class VcsProcess extends Context.Service()( - "t3/vcs/Services/VcsProcess", -) {} diff --git a/apps/server/src/vcs/Services/VcsDriver.ts b/apps/server/src/vcs/VcsDriver.ts similarity index 89% rename from apps/server/src/vcs/Services/VcsDriver.ts rename to apps/server/src/vcs/VcsDriver.ts index 1cc7c3f8044..d94e0a53f65 100644 --- a/apps/server/src/vcs/Services/VcsDriver.ts +++ b/apps/server/src/vcs/VcsDriver.ts @@ -6,7 +6,7 @@ import type { VcsListWorkspaceFilesResult, VcsRepositoryIdentity, } from "@t3tools/contracts"; -import type { GitCoreShape } from "../../git/Services/GitCore.ts"; +import type { GitCoreShape } from "../git/Services/GitCore.ts"; import type { VcsProcessInput, VcsProcessOutput } from "./VcsProcess.ts"; export interface VcsDriverShape extends Omit { @@ -25,6 +25,4 @@ export interface VcsDriverShape extends Omit { ) => Effect.Effect, VcsError>; } -export class VcsDriver extends Context.Service()( - "t3/vcs/Services/VcsDriver", -) {} +export class VcsDriver extends Context.Service()("t3/vcs/VcsDriver") {} diff --git a/apps/server/src/vcs/Layers/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts similarity index 84% rename from apps/server/src/vcs/Layers/VcsProcess.ts rename to apps/server/src/vcs/VcsProcess.ts index c406f0bdca3..5196a870cd5 100644 --- a/apps/server/src/vcs/Layers/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -1,13 +1,42 @@ -import { Duration, Effect, Layer, Option, PlatformError, Stream } from "effect"; +import { Duration, Context, Effect, Layer, Option, PlatformError, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { VcsOutputDecodeError, + type VcsError, VcsProcessExitError, VcsProcessSpawnError, VcsProcessTimeoutError, } from "@t3tools/contracts"; -import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "../Services/VcsProcess.ts"; + +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: number; + readonly stdout: string; + readonly stderr: string; + readonly stdoutTruncated: boolean; + readonly stderrTruncated: boolean; +} + +export interface VcsProcessShape { + 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; @@ -37,7 +66,6 @@ const collectOutput = Effect.fn("VcsProcess.collectOutput")(function* ( truncated = true; if (truncateOutputAtMaxBytes) { text += OUTPUT_TRUNCATED_MARKER; - return; } return; } @@ -73,7 +101,7 @@ const collectOutput = Effect.fn("VcsProcess.collectOutput")(function* ( return { text, truncated }; }); -export const makeVcsProcess = Effect.fn("makeVcsProcess")(function* () { +export const make = Effect.fn("makeVcsProcess")(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const run = Effect.fn("VcsProcess.run")(function* (input: VcsProcessInput) { @@ -191,4 +219,4 @@ export const makeVcsProcess = Effect.fn("makeVcsProcess")(function* () { return VcsProcess.of({ run }); }); -export const VcsProcessLive = Layer.effect(VcsProcess, makeVcsProcess()); +export const layer = Layer.effect(VcsProcess, make()); diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts index 61f2b9240c3..87ca014a95c 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts @@ -5,9 +5,9 @@ import { it, afterEach, describe, expect, vi } from "@effect/vitest"; import { Effect, FileSystem, Layer, Path, PlatformError } from "effect"; import { ServerConfig } from "../../config.ts"; -import { GitVcsDriverLive } from "../../vcs/Layers/GitVcsDriver.ts"; -import { VcsProcessLive } from "../../vcs/Layers/VcsProcess.ts"; -import { VcsProcess } from "../../vcs/Services/VcsProcess.ts"; +import { layer as GitVcsDriverLayer } from "../../vcs/GitVcsDriver.ts"; +import { layer as VcsProcessLayer } from "../../vcs/VcsProcess.ts"; +import { VcsProcess } from "../../vcs/VcsProcess.ts"; import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; import { WorkspacePathsLive } from "./WorkspacePaths.ts"; @@ -15,8 +15,8 @@ import { WorkspacePathsLive } from "./WorkspacePaths.ts"; const TestLayer = Layer.empty.pipe( Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), Layer.provideMerge(WorkspacePathsLive), - Layer.provideMerge(VcsProcessLive), - Layer.provideMerge(GitVcsDriverLive.pipe(Layer.provide(VcsProcessLive))), + Layer.provideMerge(VcsProcessLayer), + Layer.provideMerge(GitVcsDriverLayer.pipe(Layer.provide(VcsProcessLayer))), Layer.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-workspace-entries-test-", diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.ts index 7bb143347fd..ff15c8cfc4c 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.ts @@ -13,7 +13,7 @@ import { type RankedSearchResult, } from "@t3tools/shared/searchRanking"; -import { VcsDriver } from "../../vcs/Services/VcsDriver.ts"; +import { VcsDriver } from "../../vcs/VcsDriver.ts"; import { WorkspaceEntries, WorkspaceEntriesBrowseError, diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts index 71829708c91..fd8daf6a3e1 100644 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts @@ -3,8 +3,8 @@ import { it, describe, expect } from "@effect/vitest"; import { Effect, FileSystem, Layer, Path } from "effect"; import { ServerConfig } from "../../config.ts"; -import { GitVcsDriverLive } from "../../vcs/Layers/GitVcsDriver.ts"; -import { VcsProcessLive } from "../../vcs/Layers/VcsProcess.ts"; +import { layer as GitVcsDriverLayer } from "../../vcs/GitVcsDriver.ts"; +import { layer as VcsProcessLayer } from "../../vcs/VcsProcess.ts"; import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; import { WorkspaceFileSystem } from "../Services/WorkspaceFileSystem.ts"; import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; @@ -20,7 +20,7 @@ const TestLayer = Layer.empty.pipe( Layer.provideMerge(ProjectLayer), Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), Layer.provideMerge(WorkspacePathsLive), - Layer.provideMerge(GitVcsDriverLive.pipe(Layer.provide(VcsProcessLive))), + Layer.provideMerge(GitVcsDriverLayer.pipe(Layer.provide(VcsProcessLayer))), 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 da0bfad5986..065706ca3f9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -49,7 +49,7 @@ 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 { VcsDriver } from "./vcs/Services/VcsDriver.ts"; +import { VcsDriver } from "./vcs/VcsDriver.ts"; import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner.ts"; import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver.ts"; import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; From f337848d47355cbc2e1b58b9c0ee0927b80df844 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 30 Apr 2026 23:49:43 -0700 Subject: [PATCH 04/45] Standardize VCS layer imports across server tests - Switch GitVcsDriver and VcsProcess usages to namespace imports - Update server and integration wiring to use `.layer` consistently - Keep test type annotations aligned with the exported service types --- .../OrchestrationEngineHarness.integration.ts | 10 +++++----- .../Layers/CheckpointStore.test.ts | 20 +++++++++++-------- apps/server/src/git/Layers/GitManager.test.ts | 12 +++++------ .../Layers/CheckpointReactor.test.ts | 10 +++++----- apps/server/src/server.ts | 14 ++++++------- apps/server/src/vcs/GitVcsDriver.test.ts | 8 ++++---- .../workspace/Layers/WorkspaceEntries.test.ts | 12 +++++------ .../Layers/WorkspaceFileSystem.test.ts | 6 +++--- 8 files changed, 48 insertions(+), 44 deletions(-) diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 0792b5c19dd..d4c61e8aa94 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -76,8 +76,8 @@ 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 { layer as GitVcsDriverLayer } from "../src/vcs/GitVcsDriver.ts"; -import { layer as VcsProcessLayer } from "../src/vcs/VcsProcess.ts"; +import * as GitVcsDriver from "../src/vcs/GitVcsDriver.ts"; +import * as VcsProcess from "../src/vcs/VcsProcess.ts"; function runGit(cwd: string, args: ReadonlyArray) { return execFileSync("git", args, { @@ -292,7 +292,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provide(providerEventLoggersLayer), ); - const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitVcsDriverLayer)); + const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitVcsDriver.layer)); const projectionSnapshotQueryLayer = OrchestrationProjectionSnapshotQueryLive; const runtimeServicesLayer = Layer.mergeAll( projectionSnapshotQueryLayer, @@ -343,12 +343,12 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge( WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), - Layer.provideMerge(GitVcsDriverLayer), + Layer.provideMerge(GitVcsDriver.layer), Layer.provide(NodeServices.layer), ), ), Layer.provideMerge(WorkspacePathsLive), - Layer.provideMerge(VcsProcessLayer), + Layer.provideMerge(VcsProcess.layer), ); const orchestrationReactorLayer = OrchestrationReactorLive.pipe( Layer.provideMerge(runtimeIngestionLayer), diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts index 7a710c7aeae..e32249977f6 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts @@ -8,9 +8,9 @@ import { describe, expect } from "vitest"; import { checkpointRefForThreadTurn } from "../Utils.ts"; import { CheckpointStoreLive } from "./CheckpointStore.ts"; import { CheckpointStore } from "../Services/CheckpointStore.ts"; -import { layer as GitVcsDriverLayer } from "../../vcs/GitVcsDriver.ts"; -import { layer as VcsProcessLayer } from "../../vcs/VcsProcess.ts"; -import { VcsProcess } from "../../vcs/VcsProcess.ts"; +import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; +import { VcsProcess as VcsProcessService } from "../../vcs/VcsProcess.ts"; import type { VcsError } from "@t3tools/contracts"; import { ServerConfig } from "../../config.ts"; import { ThreadId } from "@t3tools/contracts"; @@ -18,8 +18,8 @@ import { ThreadId } from "@t3tools/contracts"; const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-checkpoint-store-test-", }); -const VcsProcessTestLayer = VcsProcessLayer.pipe(Layer.provide(NodeServices.layer)); -const VcsDriverTestLayer = GitVcsDriverLayer.pipe(Layer.provide(VcsProcessTestLayer)); +const VcsProcessTestLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); +const VcsDriverTestLayer = GitVcsDriver.layer.pipe(Layer.provide(VcsProcessTestLayer)); const CheckpointStoreTestLayer = CheckpointStoreLive.pipe( Layer.provideMerge(VcsDriverTestLayer), Layer.provideMerge(NodeServices.layer), @@ -53,9 +53,9 @@ function writeTextFile( function git( cwd: string, args: ReadonlyArray, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { - const process = yield* VcsProcess; + const process = yield* VcsProcessService; const result = yield* process.run({ operation: "CheckpointStore.test.git", command: "git", @@ -69,7 +69,11 @@ function git( function initRepoWithCommit( cwd: string, -): Effect.Effect { +): Effect.Effect< + void, + VcsError | PlatformError.PlatformError, + VcsProcessService | FileSystem.FileSystem +> { return Effect.gen(function* () { yield* git(cwd, ["init"]); yield* git(cwd, ["config", "user.email", "test@test.com"]); diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 0638a9f22c0..fb5c2ee2d81 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -21,8 +21,8 @@ import { GitHubCli, } from "../Services/GitHubCli.ts"; import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; -import { layer as GitVcsDriverLayer } from "../../vcs/GitVcsDriver.ts"; -import { layer as VcsProcessLayer } from "../../vcs/VcsProcess.ts"; +import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; import { VcsDriver } from "../../vcs/VcsDriver.ts"; import { makeGitManager } from "./GitManager.ts"; import { ServerConfig } from "../../config.ts"; @@ -657,8 +657,8 @@ function makeManager(input?: { const serverSettingsLayer = ServerSettingsService.layerTest(); - const vcsDriverLayer = GitVcsDriverLayer.pipe( - Layer.provideMerge(VcsProcessLayer), + const vcsDriverLayer = GitVcsDriver.layer.pipe( + Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(ServerConfigLayer), ); @@ -684,9 +684,9 @@ function makeManager(input?: { const asThreadId = (threadId: string) => threadId as ThreadId; -const GitManagerTestLayer = GitVcsDriverLayer.pipe( +const GitManagerTestLayer = GitVcsDriver.layer.pipe( Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })), - Layer.provideMerge(VcsProcessLayer), + Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 500f3debd39..ff267b11abe 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -25,8 +25,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts"; -import { layer as GitVcsDriverLayer } from "../../vcs/GitVcsDriver.ts"; -import { layer as VcsProcessLayer } from "../../vcs/VcsProcess.ts"; +import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { CheckpointReactorLive } from "./CheckpointReactor.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; @@ -307,15 +307,15 @@ describe("CheckpointReactor", () => { Layer.provideMerge(RuntimeReceiptBusLive), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), Layer.provideMerge(gitStatusBroadcasterLayer), - Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(GitVcsDriverLayer))), + Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(GitVcsDriver.layer))), Layer.provideMerge( WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), - Layer.provideMerge(GitVcsDriverLayer), + Layer.provideMerge(GitVcsDriver.layer), ), ), Layer.provideMerge(WorkspacePathsLive), - Layer.provideMerge(VcsProcessLayer), + Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 114354fccda..8632e46921c 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -46,8 +46,8 @@ 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 { layer as GitVcsDriverLayer } from "./vcs/GitVcsDriver.ts"; -import { layer as VcsProcessLayer } from "./vcs/VcsProcess.ts"; +import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; +import * as VcsProcess from "./vcs/VcsProcess.ts"; import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts"; import { ObservabilityLive } from "./observability/Layers/Observability.ts"; import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts"; @@ -136,7 +136,7 @@ const ReactorLayerLive = Layer.empty.pipe( const CheckpointingLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointDiffQueryLive), - Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(GitVcsDriverLayer))), + Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(GitVcsDriver.layer))), ); const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( @@ -158,7 +158,7 @@ const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersisten const GitManagerLayerLive = GitManagerLive.pipe( Layer.provideMerge(ProjectSetupScriptRunnerLive), - Layer.provideMerge(GitVcsDriverLayer), + Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge(GitHubCliLive), Layer.provideMerge(TextGenerationLive), ); @@ -166,14 +166,14 @@ const GitManagerLayerLive = GitManagerLive.pipe( const GitLayerLive = Layer.empty.pipe( Layer.provideMerge(GitManagerLayerLive), Layer.provideMerge(GitStatusBroadcasterLive.pipe(Layer.provide(GitManagerLayerLive))), - Layer.provideMerge(GitVcsDriverLayer), + Layer.provideMerge(GitVcsDriver.layer), ); const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), - Layer.provideMerge(GitVcsDriverLayer), + Layer.provideMerge(GitVcsDriver.layer), ); const WorkspaceFileSystemLayerLive = WorkspaceFileSystemLive.pipe( @@ -311,7 +311,7 @@ export const makeServerLayer = Layer.unwrap( Layer.provideMerge(HttpServerLive), Layer.provide(ObservabilityLive), Layer.provideMerge(FetchHttpClient.layer), - Layer.provideMerge(VcsProcessLayer), + Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(PlatformServicesLive), ); }), diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 92910b21e87..63e04702f6d 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -6,7 +6,7 @@ import { describe, expect } from "vitest"; import { ServerConfig } from "../config.ts"; import { VcsProcess } from "./VcsProcess.ts"; import { VcsDriver } from "./VcsDriver.ts"; -import { layer as GitVcsDriverLayer } from "./GitVcsDriver.ts"; +import * as GitVcsDriver from "./GitVcsDriver.ts"; const splitNullSeparatedPaths = (input: string): string[] => input @@ -18,7 +18,7 @@ const GitVcsDriverTestDependencies = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-vcs-driver-test-", }).pipe(Layer.provide(NodeServices.layer)); -it.layer(Layer.empty)("GitVcsDriverLayer", (it) => { +it.layer(Layer.empty)("GitVcsDriver.layer", (it) => { describe("workspace helpers", () => { it.effect("filterIgnoredPaths chunks large path lists and preserves kept paths", () => Effect.gen(function* () { @@ -32,7 +32,7 @@ it.layer(Layer.empty)("GitVcsDriverLayer", (it) => { ); const seenChunks: string[][] = []; - const layer = GitVcsDriverLayer.pipe( + const layer = GitVcsDriver.layer.pipe( Layer.provideMerge(GitVcsDriverTestDependencies), Layer.provideMerge(NodeServices.layer), Layer.provide( @@ -81,7 +81,7 @@ it.layer(Layer.empty)("GitVcsDriverLayer", (it) => { it.effect("listWorkspaceFiles disables fsmonitor and untracked cache helpers", () => Effect.gen(function* () { - const layer = GitVcsDriverLayer.pipe( + const layer = GitVcsDriver.layer.pipe( Layer.provideMerge(GitVcsDriverTestDependencies), Layer.provideMerge(NodeServices.layer), Layer.provide( diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts index 87ca014a95c..f7261ff3f99 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts @@ -5,9 +5,9 @@ import { it, afterEach, describe, expect, vi } from "@effect/vitest"; import { Effect, FileSystem, Layer, Path, PlatformError } from "effect"; import { ServerConfig } from "../../config.ts"; -import { layer as GitVcsDriverLayer } from "../../vcs/GitVcsDriver.ts"; -import { layer as VcsProcessLayer } from "../../vcs/VcsProcess.ts"; -import { VcsProcess } from "../../vcs/VcsProcess.ts"; +import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; +import { VcsProcess as VcsProcessService } from "../../vcs/VcsProcess.ts"; import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; import { WorkspacePathsLive } from "./WorkspacePaths.ts"; @@ -15,8 +15,8 @@ import { WorkspacePathsLive } from "./WorkspacePaths.ts"; const TestLayer = Layer.empty.pipe( Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), Layer.provideMerge(WorkspacePathsLive), - Layer.provideMerge(VcsProcessLayer), - Layer.provideMerge(GitVcsDriverLayer.pipe(Layer.provide(VcsProcessLayer))), + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(GitVcsDriver.layer.pipe(Layer.provide(VcsProcess.layer))), Layer.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-workspace-entries-test-", @@ -52,7 +52,7 @@ function writeTextFile( const git = (cwd: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) => Effect.gen(function* () { - const process = yield* VcsProcess; + const process = yield* VcsProcessService; const result = yield* process.run({ operation: "WorkspaceEntries.test.git", command: "git", diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts index fd8daf6a3e1..fffc401a8cf 100644 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts @@ -3,8 +3,8 @@ import { it, describe, expect } from "@effect/vitest"; import { Effect, FileSystem, Layer, Path } from "effect"; import { ServerConfig } from "../../config.ts"; -import { layer as GitVcsDriverLayer } from "../../vcs/GitVcsDriver.ts"; -import { layer as VcsProcessLayer } from "../../vcs/VcsProcess.ts"; +import * as GitVcsDriver from "../../vcs/GitVcsDriver.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"; @@ -20,7 +20,7 @@ const TestLayer = Layer.empty.pipe( Layer.provideMerge(ProjectLayer), Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), Layer.provideMerge(WorkspacePathsLive), - Layer.provideMerge(GitVcsDriverLayer.pipe(Layer.provide(VcsProcessLayer))), + Layer.provideMerge(GitVcsDriver.layer.pipe(Layer.provide(VcsProcess.layer))), Layer.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-workspace-files-test-", From 98f873fe0aac56977df71c969191210e4f99605c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 30 Apr 2026 23:59:55 -0700 Subject: [PATCH 05/45] Use VcsProcess module directly in tests - Remove the aliased service import - Reference `VcsProcess.VcsProcess` explicitly in checkpoint and workspace tests --- .../src/checkpointing/Layers/CheckpointStore.test.ts | 7 +++---- apps/server/src/workspace/Layers/WorkspaceEntries.test.ts | 3 +-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts index e32249977f6..ce9d6b305e9 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts @@ -10,7 +10,6 @@ import { CheckpointStoreLive } from "./CheckpointStore.ts"; import { CheckpointStore } from "../Services/CheckpointStore.ts"; import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../../vcs/VcsProcess.ts"; -import { VcsProcess as VcsProcessService } from "../../vcs/VcsProcess.ts"; import type { VcsError } from "@t3tools/contracts"; import { ServerConfig } from "../../config.ts"; import { ThreadId } from "@t3tools/contracts"; @@ -53,9 +52,9 @@ function writeTextFile( function git( cwd: string, args: ReadonlyArray, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { - const process = yield* VcsProcessService; + const process = yield* VcsProcess.VcsProcess; const result = yield* process.run({ operation: "CheckpointStore.test.git", command: "git", @@ -72,7 +71,7 @@ function initRepoWithCommit( ): Effect.Effect< void, VcsError | PlatformError.PlatformError, - VcsProcessService | FileSystem.FileSystem + VcsProcess.VcsProcess | FileSystem.FileSystem > { return Effect.gen(function* () { yield* git(cwd, ["init"]); diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts index f7261ff3f99..3c39526ff29 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts @@ -7,7 +7,6 @@ import { Effect, FileSystem, Layer, Path, PlatformError } from "effect"; import { ServerConfig } from "../../config.ts"; import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../../vcs/VcsProcess.ts"; -import { VcsProcess as VcsProcessService } from "../../vcs/VcsProcess.ts"; import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; import { WorkspacePathsLive } from "./WorkspacePaths.ts"; @@ -52,7 +51,7 @@ function writeTextFile( const git = (cwd: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) => Effect.gen(function* () { - const process = yield* VcsProcessService; + const process = yield* VcsProcess.VcsProcess; const result = yield* process.run({ operation: "WorkspaceEntries.test.git", command: "git", From 2bf08b86561522ec014597af096834f4e14d6229 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 1 May 2026 00:01:07 -0700 Subject: [PATCH 06/45] Fix GitVcsDriver test dependency import - Import `VcsProcess` as a namespace in the test - Update layer injection to target `VcsProcess.VcsProcess` --- apps/server/src/vcs/GitVcsDriver.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 63e04702f6d..e8fb4d97eff 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -4,9 +4,9 @@ import { Effect, Layer } from "effect"; import { describe, expect } from "vitest"; import { ServerConfig } from "../config.ts"; -import { VcsProcess } from "./VcsProcess.ts"; import { VcsDriver } from "./VcsDriver.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; +import * as VcsProcess from "./VcsProcess.ts"; const splitNullSeparatedPaths = (input: string): string[] => input @@ -36,7 +36,7 @@ it.layer(Layer.empty)("GitVcsDriver.layer", (it) => { Layer.provideMerge(GitVcsDriverTestDependencies), Layer.provideMerge(NodeServices.layer), Layer.provide( - Layer.succeed(VcsProcess, { + Layer.succeed(VcsProcess.VcsProcess, { run: (input) => { expect(input.command).toBe("git"); expect(input.args).toEqual([ @@ -85,7 +85,7 @@ it.layer(Layer.empty)("GitVcsDriver.layer", (it) => { Layer.provideMerge(GitVcsDriverTestDependencies), Layer.provideMerge(NodeServices.layer), Layer.provide( - Layer.succeed(VcsProcess, { + Layer.succeed(VcsProcess.VcsProcess, { run: (input) => { expect(input.command).toBe("git"); expect(input.args).toEqual([ From 2bf290532f775fb95b0251519deed1c4a3a6d696 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 1 May 2026 00:12:30 -0700 Subject: [PATCH 07/45] Use Effect time APIs for VCS and workspace scans - Switch freshness and scan timestamps to `DateTime.now` from Effect - Keep timestamps consistent within each async operation --- apps/server/src/vcs/GitVcsDriver.ts | 22 +++++++++++-------- .../src/workspace/Layers/WorkspaceEntries.ts | 8 ++++--- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index b1a0728c18e..7ccc5c9334b 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -1,4 +1,4 @@ -import { Effect, Layer } from "effect"; +import { DateTime, Effect, Layer } from "effect"; import { VcsProcessExitError } from "@t3tools/contracts"; import { makeGitCore } from "../git/Layers/GitCore.ts"; @@ -14,12 +14,13 @@ const WORKSPACE_GIT_HARDENED_CONFIG_ARGS = [ "core.untrackedCache=false", ] as const; -function nowFreshness() { +const nowFreshness = Effect.fn("GitVcsDriver.nowFreshness")(function* () { + const now = yield* DateTime.now; return { source: "live-local" as const, - observedAt: new Date().toISOString(), + observedAt: DateTime.formatIso(now), }; -} +}); function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { const parts = input.split("\0"); @@ -147,7 +148,7 @@ export const make = Effect.fn("makeGitVcsDriver")(function* () { kind: "git" as const, rootPath: root.stdout.trim(), metadataPath: gitCommonDir?.stdout.trim() || null, - freshness: nowFreshness(), + freshness: yield* nowFreshness(), }; }, ); @@ -174,10 +175,13 @@ export const make = Effect.fn("makeGitVcsDriver")(function* () { ).pipe( Effect.flatMap((result) => result.exitCode === 0 - ? Effect.succeed({ - paths: splitNullSeparatedPaths(result.stdout, result.stdoutTruncated), - truncated: result.stdoutTruncated, - freshness: nowFreshness(), + ? Effect.gen(function* () { + const freshness = yield* nowFreshness(); + return { + paths: splitNullSeparatedPaths(result.stdout, result.stdoutTruncated), + truncated: result.stdoutTruncated, + freshness, + }; }) : Effect.fail( new VcsProcessExitError({ diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.ts index ff15c8cfc4c..4406ebedd13 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, Option, Path } from "effect"; import { type FilesystemBrowseInput, type ProjectEntry } from "@t3tools/contracts"; import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; @@ -248,9 +248,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, }; @@ -368,8 +369,9 @@ export const makeWorkspaceEntries = Effect.gen(function* () { } } + const now = yield* DateTime.now; return { - scannedAt: Date.now(), + scannedAt: now.epochMilliseconds, entries, truncated, }; From 131870ba8c0e0bacca7d6f0dc1cb6e897bd76d72 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 1 May 2026 01:38:52 -0700 Subject: [PATCH 08/45] Split git VCS driver from core service - Introduce a dedicated `GitVcsDriver` service and `vcsLayer` - Update server, orchestration, and workspace wiring to use the new layer - Adjust tests and mocks for the new git-specific dependency --- .../OrchestrationEngineHarness.integration.ts | 13 ++-- .../Layers/CheckpointStore.test.ts | 2 +- apps/server/src/git/Layers/GitManager.test.ts | 37 ++++------ apps/server/src/git/Layers/GitManager.ts | 4 +- .../Layers/CheckpointReactor.test.ts | 4 +- .../Layers/ProviderCommandReactor.test.ts | 8 ++- .../Layers/ProviderCommandReactor.ts | 4 +- apps/server/src/server.test.ts | 70 +++++++++++-------- apps/server/src/server.ts | 4 +- apps/server/src/vcs/GitVcsDriver.test.ts | 6 +- apps/server/src/vcs/GitVcsDriver.ts | 21 ++++-- apps/server/src/vcs/VcsDriver.ts | 3 +- .../workspace/Layers/WorkspaceEntries.test.ts | 2 +- .../Layers/WorkspaceFileSystem.test.ts | 2 +- apps/server/src/ws.ts | 4 +- 15 files changed, 97 insertions(+), 87 deletions(-) diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index d4c61e8aa94..d2ff44a55a7 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -33,7 +33,6 @@ import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/Pr import { ProjectionPendingApprovalRepositoryLive } from "../src/persistence/Layers/ProjectionPendingApprovals.ts"; import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts"; import { makeSqlitePersistenceLive } from "../src/persistence/Layers/Sqlite.ts"; -import { VcsDriver, type VcsDriverShape } from "../src/vcs/VcsDriver.ts"; import { ProjectionCheckpointRepository } from "../src/persistence/Services/ProjectionCheckpoints.ts"; import { ProjectionPendingApprovalRepository } from "../src/persistence/Services/ProjectionPendingApprovals.ts"; import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapterRegistryMock.ts"; @@ -292,7 +291,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provide(providerEventLoggersLayer), ); - const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitVcsDriver.layer)); + const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitVcsDriver.vcsLayer)); const projectionSnapshotQueryLayer = OrchestrationProjectionSnapshotQueryLive; const runtimeServicesLayer = Layer.mergeAll( projectionSnapshotQueryLayer, @@ -308,17 +307,17 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(serverSettingsLayer), ); - const vcsDriverLayer = Layer.succeed(VcsDriver, { - renameBranch: (input: Parameters[0]) => + const gitVcsDriverLayer = Layer.succeed(GitVcsDriver.GitVcsDriver, { + renameBranch: (input: Parameters[0]) => Effect.succeed({ branch: input.newBranch }), - } as unknown as VcsDriverShape); + } as unknown as GitVcsDriver.GitVcsDriverShape); 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(vcsDriverLayer), + Layer.provideMerge(gitVcsDriverLayer), Layer.provideMerge(textGenerationLayer), Layer.provideMerge(serverSettingsLayer), ); @@ -343,7 +342,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge( WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), - Layer.provideMerge(GitVcsDriver.layer), + Layer.provideMerge(GitVcsDriver.vcsLayer), Layer.provide(NodeServices.layer), ), ), diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts index ce9d6b305e9..363e5ba6fc0 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts @@ -18,7 +18,7 @@ const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-checkpoint-store-test-", }); const VcsProcessTestLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); -const VcsDriverTestLayer = GitVcsDriver.layer.pipe(Layer.provide(VcsProcessTestLayer)); +const VcsDriverTestLayer = GitVcsDriver.vcsLayer.pipe(Layer.provide(VcsProcessTestLayer)); const CheckpointStoreTestLayer = CheckpointStoreLive.pipe( Layer.provideMerge(VcsDriverTestLayer), Layer.provideMerge(NodeServices.layer), diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index fb5c2ee2d81..4213f6336ec 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -23,7 +23,6 @@ import { import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../../vcs/VcsProcess.ts"; -import { VcsDriver } from "../../vcs/VcsDriver.ts"; import { makeGitManager } from "./GitManager.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; @@ -212,30 +211,18 @@ function runGit( ): Effect.Effect< { readonly code: number; readonly stdout: string; readonly stderr: string }, GitCommandError, - VcsDriver + GitVcsDriver.GitVcsDriver > { return Effect.gen(function* () { - const vcs = yield* VcsDriver; - const result = yield* vcs - .execute({ - operation: "GitManager.test.runGit", - cwd, - args, - allowNonZeroExit, - }) - .pipe( - Effect.mapError( - (error) => - new GitCommandError({ - operation: "GitManager.test.runGit", - command: `git ${args.join(" ")}`, - cwd, - detail: error.message, - }), - ), - ); + const git = yield* GitVcsDriver.GitVcsDriver; + const result = yield* git.execute({ + operation: "GitManager.test.runGit", + cwd, + args, + allowNonZeroExit, + }); return { - code: result.exitCode, + code: result.code, stdout: result.stdout, stderr: result.stderr, }; @@ -247,7 +234,7 @@ function initRepo( ): Effect.Effect< void, PlatformError.PlatformError | GitCommandError, - FileSystem.FileSystem | Scope.Scope | VcsDriver + FileSystem.FileSystem | Scope.Scope | GitVcsDriver.GitVcsDriver > { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -263,7 +250,7 @@ function initRepo( function createBareRemote(): Effect.Effect< string, PlatformError.PlatformError | GitCommandError, - FileSystem.FileSystem | Scope.Scope | VcsDriver + FileSystem.FileSystem | Scope.Scope | GitVcsDriver.GitVcsDriver > { return Effect.gen(function* () { const remoteDir = yield* makeTempDir("t3code-git-remote-"); @@ -277,7 +264,7 @@ function configureRemote( remoteName: string, remotePath: string, fetchNamespace: string, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { yield* runGit(cwd, ["config", `remote.${remoteName}.url`, remotePath]); yield* runGit(cwd, [ diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index b6b87f8f9ce..a25cb6856bf 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -49,7 +49,7 @@ import { decodeGitHubPullRequestListJson, formatGitHubJsonDecodeError, } from "../githubPullRequests.ts"; -import { VcsDriver } from "../../vcs/VcsDriver.ts"; +import { GitVcsDriver } from "../../vcs/GitVcsDriver.ts"; const COMMIT_TIMEOUT_MS = 10 * 60_000; const MAX_PROGRESS_TEXT_LENGTH = 500; @@ -491,7 +491,7 @@ function toPullRequestHeadRemoteInfo(pr: { } export const makeGitManager = Effect.fn("makeGitManager")(function* () { - const gitCore = yield* VcsDriver; + const gitCore = yield* GitVcsDriver; const gitHubCli = yield* GitHubCli; const textGeneration = yield* TextGeneration; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index ff267b11abe..4ccf68f925a 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -307,11 +307,11 @@ describe("CheckpointReactor", () => { Layer.provideMerge(RuntimeReceiptBusLive), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), Layer.provideMerge(gitStatusBroadcasterLayer), - Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(GitVcsDriver.layer))), + Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(GitVcsDriver.vcsLayer))), Layer.provideMerge( WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), - Layer.provideMerge(GitVcsDriver.layer), + Layer.provideMerge(GitVcsDriver.vcsLayer), ), ), Layer.provideMerge(WorkspacePathsLive), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 293fa0e6000..d5100418350 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -51,7 +51,7 @@ 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 { VcsDriver, type VcsDriverShape } from "../../vcs/VcsDriver.ts"; +import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value); @@ -324,7 +324,11 @@ describe("ProviderCommandReactor", () => { const layer = ProviderCommandReactorLive.pipe( Layer.provideMerge(orchestrationLayer), Layer.provideMerge(Layer.succeed(ProviderService, service)), - Layer.provideMerge(Layer.succeed(VcsDriver, { renameBranch } as unknown as VcsDriverShape)), + Layer.provideMerge( + Layer.succeed(GitVcsDriver.GitVcsDriver, { + renameBranch, + } as unknown as GitVcsDriver.GitVcsDriverShape), + ), Layer.provideMerge( Layer.succeed(GitStatusBroadcaster, { getStatus: () => Effect.die("getStatus should not be called in this test"), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 89c3c9cdec8..d3793c7622c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -28,7 +28,7 @@ import { type ProviderCommandReactorShape, } from "../Services/ProviderCommandReactor.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -import { VcsDriver } from "../../vcs/VcsDriver.ts"; +import { GitVcsDriver } from "../../vcs/GitVcsDriver.ts"; type ProviderIntentEvent = Extract< OrchestrationEvent, @@ -168,7 +168,7 @@ function buildGeneratedWorktreeBranchName(raw: string): string { const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const providerService = yield* ProviderService; - const git = yield* VcsDriver; + const git = yield* GitVcsDriver; const gitStatusBroadcaster = yield* GitStatusBroadcaster; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index a9d5a46c8ce..27cc34ba856 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -104,6 +104,7 @@ 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 { VcsDriver, type VcsDriverShape } from "./vcs/VcsDriver.ts"; import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; @@ -315,6 +316,7 @@ const buildAppUnderTest = (options?: { serverSettings?: Partial; open?: Partial; vcsDriver?: Partial; + gitVcsDriver?: Partial; gitManager?: Partial; gitStatusBroadcaster?: Partial; projectSetupScriptRunner?: Partial; @@ -384,6 +386,9 @@ const buildAppUnderTest = (options?: { filterIgnoredPaths: (_cwd, relativePaths) => Effect.succeed(relativePaths), ...options?.layers?.vcsDriver, }); + const gitVcsDriverLayer = Layer.mock(GitVcsDriver.GitVcsDriver)({ + ...options?.layers?.gitVcsDriver, + }); const gitManagerLayer = Layer.mock(GitManager)({ ...options?.layers?.gitManager, }); @@ -444,6 +449,7 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide(gitManagerLayer), + Layer.provide(gitVcsDriverLayer), Layer.provideMerge(gitStatusBroadcasterLayer), Layer.provide( Layer.mock(ProjectSetupScriptRunner)({ @@ -2377,7 +2383,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { worktreePath: null, }), }, - vcsDriver: { + gitVcsDriver: { pullCurrentBranch: () => Effect.succeed({ status: "pulled", @@ -2531,7 +2537,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { let statusCalls = 0; yield* buildAppUnderTest({ layers: { - vcsDriver: { + gitVcsDriver: { pullCurrentBranch: () => Effect.fail(gitError), }, gitManager: { @@ -2685,7 +2691,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest({ layers: { - vcsDriver: { + gitVcsDriver: { pullCurrentBranch: () => Effect.succeed({ status: "pulled" as const, @@ -3499,13 +3505,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: { + branch: "t3code/bootstrap-branch", + path: "/tmp/bootstrap-worktree", + }, + }), ); const runForThread = vi.fn( (_: Parameters[0]) => @@ -3520,7 +3527,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { - vcsDriver: { + gitVcsDriver: { createWorktree, }, gitStatusBroadcaster: { @@ -3624,13 +3631,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: { + branch: "t3code/bootstrap-branch", + path: "/tmp/bootstrap-worktree", + }, + }), ); const runForThread = vi.fn( (_: Parameters[0]) => @@ -3639,7 +3647,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { - vcsDriver: { + gitVcsDriver: { createWorktree, }, orchestrationEngine: { @@ -3717,13 +3725,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: { + branch: "t3code/bootstrap-branch", + path: "/tmp/bootstrap-worktree", + }, + }), ); const runForThread = vi.fn( (_: Parameters[0]) => @@ -3739,7 +3748,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { - vcsDriver: { + gitVcsDriver: { createWorktree, }, orchestrationEngine: { @@ -3835,13 +3844,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: { - vcsDriver: { + gitVcsDriver: { createWorktree, }, orchestrationEngine: { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 8632e46921c..a478be135a1 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -136,7 +136,7 @@ const ReactorLayerLive = Layer.empty.pipe( const CheckpointingLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointDiffQueryLive), - Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(GitVcsDriver.layer))), + Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(GitVcsDriver.vcsLayer))), ); const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( @@ -173,7 +173,7 @@ const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive) const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), - Layer.provideMerge(GitVcsDriver.layer), + Layer.provideMerge(GitVcsDriver.vcsLayer), ); const WorkspaceFileSystemLayerLive = WorkspaceFileSystemLive.pipe( diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index e8fb4d97eff..7e981cd991d 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -18,7 +18,7 @@ const GitVcsDriverTestDependencies = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-vcs-driver-test-", }).pipe(Layer.provide(NodeServices.layer)); -it.layer(Layer.empty)("GitVcsDriver.layer", (it) => { +it.layer(Layer.empty)("GitVcsDriver.vcsLayer", (it) => { describe("workspace helpers", () => { it.effect("filterIgnoredPaths chunks large path lists and preserves kept paths", () => Effect.gen(function* () { @@ -32,7 +32,7 @@ it.layer(Layer.empty)("GitVcsDriver.layer", (it) => { ); const seenChunks: string[][] = []; - const layer = GitVcsDriver.layer.pipe( + const layer = GitVcsDriver.vcsLayer.pipe( Layer.provideMerge(GitVcsDriverTestDependencies), Layer.provideMerge(NodeServices.layer), Layer.provide( @@ -81,7 +81,7 @@ it.layer(Layer.empty)("GitVcsDriver.layer", (it) => { it.effect("listWorkspaceFiles disables fsmonitor and untracked cache helpers", () => Effect.gen(function* () { - const layer = GitVcsDriver.layer.pipe( + const layer = GitVcsDriver.vcsLayer.pipe( Layer.provideMerge(GitVcsDriverTestDependencies), Layer.provideMerge(NodeServices.layer), Layer.provide( diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 7ccc5c9334b..7df9c135816 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -1,10 +1,17 @@ -import { DateTime, Effect, Layer } from "effect"; +import { Context, DateTime, Effect, Layer } from "effect"; import { VcsProcessExitError } from "@t3tools/contracts"; import { makeGitCore } from "../git/Layers/GitCore.ts"; +import type { GitCoreShape } from "../git/Services/GitCore.ts"; import { VcsDriver, type VcsDriverShape } from "./VcsDriver.ts"; import { VcsProcess, type VcsProcessShape } from "./VcsProcess.ts"; +export interface GitVcsDriverShape extends GitCoreShape {} + +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 = [ @@ -92,9 +99,8 @@ const gitCommand = ( : {}), }); -export const make = Effect.fn("makeGitVcsDriver")(function* () { +export const makeVcsDriver = Effect.fn("makeGitVcsDriver")(function* () { const process = yield* VcsProcess; - const legacyGit = yield* makeGitCore(); const capabilities = { kind: "git" as const, supportsWorktrees: true, @@ -243,7 +249,6 @@ export const make = Effect.fn("makeGitVcsDriver")(function* () { ); return VcsDriver.of({ - ...legacyGit, capabilities, execute, detectRepository, @@ -253,4 +258,10 @@ export const make = Effect.fn("makeGitVcsDriver")(function* () { }); }); -export const layer = Layer.effect(VcsDriver, make()); +export const make = Effect.fn("makeGitVcsDriverService")(function* () { + const legacyGit = yield* makeGitCore(); + return GitVcsDriver.of(legacyGit); +}); + +export const vcsLayer = Layer.effect(VcsDriver, makeVcsDriver()); +export const layer = Layer.effect(GitVcsDriver, make()); diff --git a/apps/server/src/vcs/VcsDriver.ts b/apps/server/src/vcs/VcsDriver.ts index d94e0a53f65..7dfa3b707af 100644 --- a/apps/server/src/vcs/VcsDriver.ts +++ b/apps/server/src/vcs/VcsDriver.ts @@ -6,10 +6,9 @@ import type { VcsListWorkspaceFilesResult, VcsRepositoryIdentity, } from "@t3tools/contracts"; -import type { GitCoreShape } from "../git/Services/GitCore.ts"; import type { VcsProcessInput, VcsProcessOutput } from "./VcsProcess.ts"; -export interface VcsDriverShape extends Omit { +export interface VcsDriverShape { readonly capabilities: VcsDriverCapabilities; readonly execute: ( input: Omit, diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts index 3c39526ff29..a0ec468a3e7 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts @@ -15,7 +15,7 @@ const TestLayer = Layer.empty.pipe( Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), Layer.provideMerge(WorkspacePathsLive), Layer.provideMerge(VcsProcess.layer), - Layer.provideMerge(GitVcsDriver.layer.pipe(Layer.provide(VcsProcess.layer))), + Layer.provideMerge(GitVcsDriver.vcsLayer.pipe(Layer.provide(VcsProcess.layer))), Layer.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-workspace-entries-test-", diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts index fffc401a8cf..b19b810dd1b 100644 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts @@ -20,7 +20,7 @@ const TestLayer = Layer.empty.pipe( Layer.provideMerge(ProjectLayer), Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), Layer.provideMerge(WorkspacePathsLive), - Layer.provideMerge(GitVcsDriver.layer.pipe(Layer.provide(VcsProcess.layer))), + Layer.provideMerge(GitVcsDriver.vcsLayer.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 065706ca3f9..38a34884d6e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -49,7 +49,7 @@ 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 { VcsDriver } from "./vcs/VcsDriver.ts"; +import { GitVcsDriver } from "./vcs/GitVcsDriver.ts"; import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner.ts"; import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver.ts"; import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; @@ -137,7 +137,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const keybindings = yield* Keybindings; const open = yield* Open; const gitManager = yield* GitManager; - const git = yield* VcsDriver; + const git = yield* GitVcsDriver; const gitStatusBroadcaster = yield* GitStatusBroadcaster; const terminalManager = yield* TerminalManager; const providerRegistry = yield* ProviderRegistry; From c3300acd4d13d9abdf8cb044580cee0380dca04f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 1 May 2026 11:28:33 -0700 Subject: [PATCH 09/45] Refactor git core into pluggable VCS driver - Move low-level git service into `GitVcsDriverCore` - Update server wiring and tests to use the new driver --- apps/server/src/git/Layers/GitManager.ts | 3 +- apps/server/src/git/Services/GitCore.ts | 288 ------- apps/server/src/vcs/GitVcsDriver.ts | 191 ++++- .../GitVcsDriverCore.test.ts} | 633 ++++++++------- .../GitCore.ts => vcs/GitVcsDriverCore.ts} | 718 +++++++++--------- 5 files changed, 929 insertions(+), 904 deletions(-) delete mode 100644 apps/server/src/git/Services/GitCore.ts rename apps/server/src/{git/Layers/GitCore.test.ts => vcs/GitVcsDriverCore.test.ts} (77%) rename apps/server/src/{git/Layers/GitCore.ts => vcs/GitVcsDriverCore.ts} (76%) diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index a25cb6856bf..d8b0f77760e 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -38,7 +38,6 @@ import { type GitManagerShape, type GitRunStackedActionOptions, } from "../Services/GitManager.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"; @@ -49,7 +48,7 @@ import { decodeGitHubPullRequestListJson, formatGitHubJsonDecodeError, } from "../githubPullRequests.ts"; -import { GitVcsDriver } from "../../vcs/GitVcsDriver.ts"; +import { GitVcsDriver, type GitStatusDetails } from "../../vcs/GitVcsDriver.ts"; const COMMIT_TIMEOUT_MS = 10 * 60_000; const MAX_PROGRESS_TEXT_LENGTH = 500; diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts deleted file mode 100644 index 232cbe29d71..00000000000 --- a/apps/server/src/git/Services/GitCore.ts +++ /dev/null @@ -1,288 +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 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; - - /** - * 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/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 7df9c135816..9c692780a15 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -1,12 +1,191 @@ import { Context, DateTime, Effect, Layer } from "effect"; -import { VcsProcessExitError } from "@t3tools/contracts"; -import { makeGitCore } from "../git/Layers/GitCore.ts"; -import type { GitCoreShape } from "../git/Services/GitCore.ts"; +import { + GitCommandError, + VcsProcessExitError, + type GitCheckoutInput, + type GitCheckoutResult, + type GitCreateBranchInput, + type GitCreateBranchResult, + type GitCreateWorktreeInput, + type GitCreateWorktreeResult, + type GitInitInput, + type GitListBranchesInput, + type GitListBranchesResult, + type GitPullResult, + type GitRemoveWorktreeInput, + type GitStatusInput, + type GitStatusResult, +} from "@t3tools/contracts"; +import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts"; import { VcsDriver, type VcsDriverShape } from "./VcsDriver.ts"; import { VcsProcess, type VcsProcessShape } from "./VcsProcess.ts"; -export interface GitVcsDriverShape extends GitCoreShape {} +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 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: GitStatusInput) => 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, + baseBranch: string, + ) => Effect.Effect; + readonly readConfigValue: ( + cwd: string, + key: string, + ) => Effect.Effect; + readonly listBranches: ( + input: GitListBranchesInput, + ) => Effect.Effect; + readonly pullCurrentBranch: (cwd: string) => Effect.Effect; + readonly createWorktree: ( + input: GitCreateWorktreeInput, + ) => 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: GitRemoveWorktreeInput) => Effect.Effect; + readonly renameBranch: ( + input: GitRenameBranchInput, + ) => Effect.Effect; + readonly createBranch: ( + input: GitCreateBranchInput, + ) => Effect.Effect; + readonly checkoutBranch: ( + input: GitCheckoutInput, + ) => Effect.Effect; + readonly initRepo: (input: GitInitInput) => Effect.Effect; + readonly listLocalBranchNames: (cwd: string) => Effect.Effect; +} export class GitVcsDriver extends Context.Service()( "t3/vcs/GitVcsDriver", @@ -259,8 +438,8 @@ export const makeVcsDriver = Effect.fn("makeGitVcsDriver")(function* () { }); export const make = Effect.fn("makeGitVcsDriverService")(function* () { - const legacyGit = yield* makeGitCore(); - return GitVcsDriver.of(legacyGit); + const git = yield* makeGitVcsDriverCore(); + return GitVcsDriver.of(git); }); export const vcsLayer = Layer.effect(VcsDriver, makeVcsDriver()); diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts similarity index 77% rename from apps/server/src/git/Layers/GitCore.test.ts rename to apps/server/src/vcs/GitVcsDriverCore.test.ts index 2f2c8bd1301..044a8dba0f9 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -6,20 +6,20 @@ 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 { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts"; +import * as GitVcsDriver from "./GitVcsDriver.ts"; import { GitCommandError } from "@t3tools/contracts"; -import { type ProcessRunResult, runProcess } from "../../processRunner.ts"; -import { ServerConfig } from "../../config.ts"; +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( +const GitVcsDriverTestLayer = GitVcsDriver.layer.pipe( Layer.provide(ServerConfigLayer), Layer.provide(NodeServices.layer), ); -const TestLayer = Layer.mergeAll(NodeServices.layer, GitCoreTestLayer); +const TestLayer = Layer.mergeAll(NodeServices.layer, GitVcsDriverTestLayer); function makeTmpDir( prefix = "git-test-", @@ -63,11 +63,11 @@ function git( cwd: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { - const gitCore = yield* GitCore; - const result = yield* gitCore.execute({ - operation: "GitCore.test.git", + const gitVcsDriver = yield* GitVcsDriver.GitVcsDriver; + const result = yield* gitVcsDriver.execute({ + operation: "GitVcsDriver.test.git", cwd, args, ...(env ? { env } : {}), @@ -82,7 +82,7 @@ function configureRemote( remoteName: string, remotePath: string, fetchNamespace: string, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { yield* git(cwd, ["config", `remote.${remoteName}.url`, remotePath]); return yield* git(cwd, [ @@ -119,8 +119,8 @@ function runShellCommand(input: { }); } -const makeIsolatedGitCore = (executeOverride: GitCoreShape["execute"]) => - makeGitCore({ executeOverride }).pipe( +const makeIsolatedGitVcsDriver = (executeOverride: GitVcsDriver.GitVcsDriverShape["execute"]) => + makeGitVcsDriverCore({ executeOverride }).pipe( Effect.provide(Layer.provideMerge(ServerConfigLayer, NodeServices.layer)), ); @@ -130,10 +130,10 @@ function initRepoWithCommit( ): Effect.Effect< { initialBranch: string }, GitCommandError | PlatformError.PlatformError, - GitCore | FileSystem.FileSystem + GitVcsDriver.GitVcsDriver | FileSystem.FileSystem > { return Effect.gen(function* () { - const core = yield* GitCore; + const core = yield* GitVcsDriver.GitVcsDriver; yield* core.initRepo({ cwd }); yield* git(cwd, ["config", "user.email", "test@test.com"]); yield* git(cwd, ["config", "user.name", "Test"]); @@ -154,7 +154,7 @@ function commitWithDate( ): Effect.Effect< void, GitCommandError | PlatformError.PlatformError, - GitCore | FileSystem.FileSystem + GitVcsDriver.GitVcsDriver | FileSystem.FileSystem > { return Effect.gen(function* () { yield* writeTextFile(path.join(cwd, fileName), fileContents); @@ -199,7 +199,7 @@ it.layer(TestLayer)("git integration", (it) => { it.effect("creates a valid git repo", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); - yield* (yield* GitCore).initRepo({ cwd: tmp }); + yield* (yield* GitVcsDriver.GitVcsDriver).initRepo({ cwd: tmp }); expect(existsSync(path.join(tmp, ".git"))).toBe(true); }), ); @@ -208,7 +208,7 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); + const result = yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ cwd: tmp }); expect(result.isRepo).toBe(true); expect(result.hasOriginRemote).toBe(false); expect(result.branches.length).toBeGreaterThanOrEqual(1); @@ -222,7 +222,7 @@ it.layer(TestLayer)("git integration", (it) => { it.effect("returns isRepo: false for non-git directory", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); + const result = yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ cwd: tmp }); expect(result.isRepo).toBe(false); expect(result.hasOriginRemote).toBe(false); expect(result.branches).toEqual([]); @@ -236,7 +236,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* makeDirectory(deletedDir); yield* removePath(deletedDir); - const result = yield* (yield* GitCore).listBranches({ cwd: deletedDir }); + const result = yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ cwd: deletedDir }); expect(result.isRepo).toBe(false); expect(result.hasOriginRemote).toBe(false); @@ -248,7 +248,7 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); + const result = yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ cwd: tmp }); const current = result.branches.find((b) => b.current); expect(current).toBeDefined(); expect(current!.current).toBe(true); @@ -261,7 +261,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* initRepoWithCommit(tmp); yield* git(tmp, ["checkout", "--detach", "HEAD"]); - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); + const result = yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ cwd: tmp }); expect(result.branches.some((branch) => branch.name.startsWith("("))).toBe(false); expect(result.branches.some((branch) => branch.current)).toBe(false); }), @@ -271,12 +271,18 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (branch) => branch.current, - )!.name; + const initialBranch = (yield* (yield* GitVcsDriver.GitVcsDriver).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* (yield* GitVcsDriver.GitVcsDriver).createBranch({ + cwd: tmp, + branch: "older-branch", + }); + yield* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ + cwd: tmp, + branch: "older-branch", + }); yield* commitWithDate( tmp, "older.txt", @@ -285,9 +291,18 @@ it.layer(TestLayer)("git integration", (it) => { "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* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ + cwd: tmp, + branch: initialBranch, + }); + yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ + cwd: tmp, + branch: "newer-branch", + }); + yield* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ + cwd: tmp, + branch: "newer-branch", + }); yield* commitWithDate( tmp, "newer.txt", @@ -297,9 +312,12 @@ it.layer(TestLayer)("git integration", (it) => { ); // Switch away to show current branch is pinned, then remaining branches are recency-sorted. - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "older-branch" }); + yield* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ + cwd: tmp, + branch: "older-branch", + }); - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); + const result = yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ cwd: tmp }); expect(result.branches[0]!.name).toBe("older-branch"); expect(result.branches[1]!.name).toBe("newer-branch"); }), @@ -310,17 +328,23 @@ it.layer(TestLayer)("git integration", (it) => { 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; + const defaultBranch = (yield* (yield* GitVcsDriver.GitVcsDriver).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* (yield* GitVcsDriver.GitVcsDriver).createBranch({ + cwd: tmp, + branch: "current-branch", + }); + yield* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ + cwd: tmp, + branch: "current-branch", + }); yield* commitWithDate( tmp, "current.txt", @@ -329,9 +353,18 @@ it.layer(TestLayer)("git integration", (it) => { "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* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ + cwd: tmp, + branch: defaultBranch, + }); + yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ + cwd: tmp, + branch: "newer-branch", + }); + yield* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ + cwd: tmp, + branch: "newer-branch", + }); yield* commitWithDate( tmp, "newer.txt", @@ -340,9 +373,12 @@ it.layer(TestLayer)("git integration", (it) => { "newer change", ); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "current-branch" }); + yield* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ + cwd: tmp, + branch: "current-branch", + }); - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); + const result = yield* (yield* GitVcsDriver.GitVcsDriver).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"); @@ -353,10 +389,10 @@ it.layer(TestLayer)("git integration", (it) => { 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" }); + yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ cwd: tmp, branch: "feature-a" }); + yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ cwd: tmp, branch: "feature-b" }); - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); + const result = yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ cwd: tmp }); const names = result.branches.map((b) => b.name); expect(names).toContain("feature-a"); expect(names).toContain("feature-b"); @@ -367,11 +403,14 @@ it.layer(TestLayer)("git integration", (it) => { 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" }); + yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ cwd: tmp, branch: "feature-a" }); + yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ cwd: tmp, branch: "feature-b" }); + yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ cwd: tmp, branch: "feature-c" }); - const firstPage = yield* (yield* GitCore).listBranches({ cwd: tmp, limit: 2 }); + const firstPage = yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ + cwd: tmp, + limit: 2, + }); expect(firstPage.totalCount).toBe(4); expect(firstPage.nextCursor).toBe(2); expect(firstPage.branches.map((branch) => branch.name)).toEqual([ @@ -379,7 +418,7 @@ it.layer(TestLayer)("git integration", (it) => { "feature-a", ]); - const secondPage = yield* (yield* GitCore).listBranches({ + const secondPage = yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ cwd: tmp, cursor: firstPage.nextCursor ?? 0, limit: 2, @@ -403,7 +442,7 @@ it.layer(TestLayer)("git integration", (it) => { "copilot/rewrite-cli-in-rust", ] as const; for (const branchName of createdBranchNames) { - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: branchName }); + yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ cwd: tmp, branch: branchName }); } yield* git(tmp, ["config", "column.ui", "always"]); @@ -420,9 +459,9 @@ it.layer(TestLayer)("git integration", (it) => { ), ).toBe(true); - const realGitCore = yield* GitCore; - const core = yield* makeIsolatedGitCore((input) => - realGitCore.execute( + const realGitVcsDriver = yield* GitVcsDriver.GitVcsDriver; + const core = yield* makeIsolatedGitVcsDriver((input) => + realGitVcsDriver.execute( input.args[0] === "branch" ? { ...input, @@ -455,7 +494,7 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); + const result = yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ cwd: tmp }); expect(result.branches.every((b) => b.isDefault === false)).toBe(true); }), ); @@ -467,23 +506,29 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(tmp); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (branch) => branch.current, - )!.name; + const defaultBranch = (yield* (yield* GitVcsDriver.GitVcsDriver).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" }); + yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ + cwd: tmp, + branch: "feature/local-only", + }); const remoteOnlyBranch = "feature/remote-only"; - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch }); + yield* (yield* GitVcsDriver.GitVcsDriver).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 result = yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ cwd: tmp }); const firstRemoteIndex = result.branches.findIndex((branch) => branch.isRemote); expect(result.hasOriginRemote).toBe(true); @@ -515,9 +560,9 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(tmp); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (branch) => branch.current, - )!.name; + const defaultBranch = (yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ + cwd: tmp, + })).branches.find((branch) => branch.current)!.name; yield* git(tmp, ["remote", "add", remoteName, remote]); yield* git(tmp, ["push", "-u", remoteName, defaultBranch]); @@ -528,7 +573,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(tmp, ["checkout", defaultBranch]); yield* git(tmp, ["branch", "-D", remoteOnlyBranch]); - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); + const result = yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ cwd: tmp }); const remoteBranch = result.branches.find( (branch) => branch.name === `${remoteName}/${remoteOnlyBranch}`, ); @@ -551,7 +596,10 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(tmp, ["remote", "add", "origin", remote]); yield* git(tmp, ["push", "-u", "origin", initialBranch]); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/demo" }); + yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ + cwd: tmp, + branch: "feature/demo", + }); yield* git(tmp, ["push", "-u", "origin", "feature/demo"]); yield* git(tmp, ["checkout", "-b", "feature/remote-only"]); @@ -559,7 +607,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(tmp, ["checkout", initialBranch]); yield* git(tmp, ["branch", "-D", "feature/remote-only"]); - const result = yield* (yield* GitCore).listBranches({ + const result = yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ cwd: tmp, query: "feature/", limit: 10, @@ -582,11 +630,11 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature" }); + yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ cwd: tmp, branch: "feature" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature" }); + yield* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ cwd: tmp, branch: "feature" }); - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); + const result = yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ cwd: tmp }); const current = result.branches.find((b) => b.current); expect(current!.name).toBe("feature"); }), @@ -603,20 +651,29 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(source); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( - (branch) => branch.current, - )!.name; + const defaultBranch = (yield* (yield* GitVcsDriver.GitVcsDriver).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* (yield* GitVcsDriver.GitVcsDriver).createBranch({ + cwd: source, + branch: featureBranch, + }); + yield* (yield* GitVcsDriver.GitVcsDriver).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* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ + cwd: source, + branch: defaultBranch, + }); yield* git(clone, ["clone", remote, "."]); yield* git(clone, ["config", "user.email", "test@test.com"]); @@ -627,8 +684,11 @@ it.layer(TestLayer)("git integration", (it) => { 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* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ + cwd: source, + branch: featureBranch, + }); + const core = yield* GitVcsDriver.GitVcsDriver; yield* Effect.promise(() => vi.waitFor( async () => { @@ -653,9 +713,9 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(source); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( - (branch) => branch.current, - )!.name; + const defaultBranch = (yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ + cwd: source, + })).branches.find((branch) => branch.current)!.name; yield* git(source, ["remote", "add", "origin", remote]); yield* git(source, ["push", "-u", "origin", defaultBranch]); @@ -668,9 +728,9 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(source, ["push", "-u", "origin", featureBranch]); yield* git(source, ["checkout", defaultBranch]); - const realGitCore = yield* GitCore; + const realGitVcsDriver = yield* GitVcsDriver.GitVcsDriver; let refreshFetchAttempts = 0; - const core = yield* makeIsolatedGitCore((input) => { + const core = yield* makeIsolatedGitVcsDriver((input) => { if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { refreshFetchAttempts += 1; return Effect.fail( @@ -682,7 +742,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); } - return realGitCore.execute(input); + return realGitVcsDriver.execute(input); }); yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); const status = yield* core.statusDetails(source); @@ -700,9 +760,9 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(source); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( - (branch) => branch.current, - )!.name; + const defaultBranch = (yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ + cwd: source, + })).branches.find((branch) => branch.current)!.name; yield* git(source, ["remote", "add", "origin", remote]); yield* git(source, ["push", "-u", "origin", defaultBranch]); @@ -714,9 +774,9 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(source, ["push", "-u", "origin", featureBranch]); yield* git(source, ["checkout", defaultBranch]); - const realGitCore = yield* GitCore; + const realGitVcsDriver = yield* GitVcsDriver.GitVcsDriver; let refreshFetchAttempts = 0; - const core = yield* makeIsolatedGitCore((input) => { + const core = yield* makeIsolatedGitVcsDriver((input) => { if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { refreshFetchAttempts += 1; return Effect.succeed({ @@ -727,7 +787,7 @@ it.layer(TestLayer)("git integration", (it) => { stderrTruncated: false, }); } - return realGitCore.execute(input); + return realGitVcsDriver.execute(input); }); yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 50))); @@ -750,7 +810,7 @@ it.layer(TestLayer)("git integration", (it) => { }); let fetchCount = 0; - const core = yield* makeIsolatedGitCore((input) => { + const core = yield* makeIsolatedGitVcsDriver((input) => { if ( input.args[0] === "rev-parse" && input.args[1] === "--abbrev-ref" && @@ -780,7 +840,7 @@ it.layer(TestLayer)("git integration", (it) => { ]); return ok(); } - if (input.operation === "GitCore.statusDetails.status") { + if (input.operation === "GitVcsDriver.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" @@ -788,12 +848,12 @@ it.layer(TestLayer)("git integration", (it) => { ); } if ( - input.operation === "GitCore.statusDetails.unstagedNumstat" || - input.operation === "GitCore.statusDetails.stagedNumstat" + input.operation === "GitVcsDriver.statusDetails.unstagedNumstat" || + input.operation === "GitVcsDriver.statusDetails.stagedNumstat" ) { return ok(); } - if (input.operation === "GitCore.statusDetails.defaultRef") { + if (input.operation === "GitVcsDriver.statusDetails.defaultRef") { return ok("refs/remotes/origin/main\n"); } return Effect.fail( @@ -826,7 +886,7 @@ it.layer(TestLayer)("git integration", (it) => { }); let fetchCount = 0; - const core = yield* makeIsolatedGitCore((input) => { + const core = yield* makeIsolatedGitVcsDriver((input) => { if ( input.args[0] === "rev-parse" && input.args[1] === "--abbrev-ref" && @@ -856,7 +916,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); } - if (input.operation === "GitCore.statusDetails.status") { + if (input.operation === "GitVcsDriver.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" @@ -864,12 +924,12 @@ it.layer(TestLayer)("git integration", (it) => { ); } if ( - input.operation === "GitCore.statusDetails.unstagedNumstat" || - input.operation === "GitCore.statusDetails.stagedNumstat" + input.operation === "GitVcsDriver.statusDetails.unstagedNumstat" || + input.operation === "GitVcsDriver.statusDetails.stagedNumstat" ) { return ok(); } - if (input.operation === "GitCore.statusDetails.defaultRef") { + if (input.operation === "GitVcsDriver.statusDetails.defaultRef") { return ok("refs/remotes/origin/main\n"); } return Effect.fail( @@ -893,7 +953,7 @@ it.layer(TestLayer)("git integration", (it) => { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); const result = yield* Effect.result( - (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "nonexistent" }), + (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ cwd: tmp, branch: "nonexistent" }), ); expect(result._tag).toBe("Failure"); }), @@ -906,16 +966,19 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(source); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( - (branch) => branch.current, - )!.name; + const defaultBranch = (yield* (yield* GitVcsDriver.GitVcsDriver).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" }); + yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ cwd: source, branch: "feature" }); const checkoutResult = yield* Effect.result( - (yield* GitCore).checkoutBranch({ cwd: source, branch: "origin/feature" }), + (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ + cwd: source, + branch: "origin/feature", + }), ); expect(checkoutResult._tag).toBe("Failure"); expect(yield* git(source, ["branch", "--show-current"])).toBe(defaultBranch); @@ -935,9 +998,9 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(prefixRemote, ["init", "--bare"]); yield* initRepoWithCommit(source); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( - (branch) => branch.current, - )!.name; + const defaultBranch = (yield* (yield* GitVcsDriver.GitVcsDriver).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]); @@ -950,16 +1013,16 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(source, ["checkout", defaultBranch]); yield* git(source, ["branch", "-D", featureBranch]); - const checkoutResult = yield* (yield* GitCore).checkoutBranch({ + const checkoutResult = yield* (yield* GitVcsDriver.GitVcsDriver).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; + const realGitVcsDriver = yield* GitVcsDriver.GitVcsDriver; let fetchArgs: readonly string[] | null = null; - const core = yield* makeIsolatedGitCore((input) => { + const core = yield* makeIsolatedGitVcsDriver((input) => { if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { fetchArgs = [...input.args]; return Effect.succeed({ @@ -970,7 +1033,7 @@ it.layer(TestLayer)("git integration", (it) => { stderrTruncated: false, }); } - return realGitCore.execute(input); + return realGitVcsDriver.execute(input); }); const status = yield* core.statusDetails(source); @@ -996,7 +1059,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(source); - const defaultBranch = (yield* (yield* GitCore).listBranches({ + const defaultBranch = (yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ cwd: source, })).branches.find((branch) => branch.current)!.name; yield* git(source, ["remote", "add", "origin", remote]); @@ -1006,12 +1069,12 @@ it.layer(TestLayer)("git integration", (it) => { // would attempt to create an already-existing local branch. yield* git(source, ["branch", "--unset-upstream"]); - yield* (yield* GitCore).checkoutBranch({ + yield* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ cwd: source, branch: `origin/${defaultBranch}`, }); - const core = yield* GitCore; + const core = yield* GitVcsDriver.GitVcsDriver; const status = yield* core.statusDetails(source); expect(status.branch).toBeNull(); }), @@ -1021,7 +1084,7 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "other" }); + yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ cwd: tmp, branch: "other" }); // Create a conflicting change: modify README on current branch yield* writeTextFile(path.join(tmp, "README.md"), "modified\n"); @@ -1029,23 +1092,26 @@ it.layer(TestLayer)("git integration", (it) => { // First, checkout other branch cleanly yield* git(tmp, ["stash"]); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "other" }); + yield* (yield* GitVcsDriver.GitVcsDriver).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 }); + const defaultBranch = (yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ + cwd: tmp, + })).branches.find((b) => !b.current)!.name; + yield* (yield* GitVcsDriver.GitVcsDriver).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" }), + (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ cwd: tmp, branch: "other" }), ); expect(result._tag).toBe("Failure"); }), @@ -1059,9 +1125,9 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "new-feature" }); + yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ cwd: tmp, branch: "new-feature" }); - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); + const result = yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ cwd: tmp }); expect(result.branches.some((b) => b.name === "new-feature")).toBe(true); }), ); @@ -1070,9 +1136,9 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "dupe" }); + yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ cwd: tmp, branch: "dupe" }); const result = yield* Effect.result( - (yield* GitCore).createBranch({ cwd: tmp, branch: "dupe" }), + (yield* GitVcsDriver.GitVcsDriver).createBranch({ cwd: tmp, branch: "dupe" }), ); expect(result._tag).toBe("Failure"); }), @@ -1086,10 +1152,16 @@ it.layer(TestLayer)("git integration", (it) => { 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" }); + yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ + cwd: tmp, + branch: "feature/old-name", + }); + yield* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ + cwd: tmp, + branch: "feature/old-name", + }); - const renamed = yield* (yield* GitCore).renameBranch({ + const renamed = yield* (yield* GitVcsDriver.GitVcsDriver).renameBranch({ cwd: tmp, oldBranch: "feature/old-name", newBranch: "feature/new-name", @@ -1097,7 +1169,7 @@ it.layer(TestLayer)("git integration", (it) => { expect(renamed.branch).toBe("feature/new-name"); - const branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); + const branches = yield* (yield* GitVcsDriver.GitVcsDriver).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"); @@ -1108,11 +1180,11 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const current = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (b) => b.current, - )!; + const current = (yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ + cwd: tmp, + })).branches.find((b) => b.current)!; - const renamed = yield* (yield* GitCore).renameBranch({ + const renamed = yield* (yield* GitVcsDriver.GitVcsDriver).renameBranch({ cwd: tmp, oldBranch: current.name, newBranch: current.name, @@ -1126,18 +1198,27 @@ it.layer(TestLayer)("git integration", (it) => { 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" }); + yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ + cwd: tmp, + branch: "t3code/feat/session", + }); + yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ + cwd: tmp, + branch: "t3code/tmp-working", + }); + yield* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ + cwd: tmp, + branch: "t3code/tmp-working", + }); - const renamed = yield* (yield* GitCore).renameBranch({ + const renamed = yield* (yield* GitVcsDriver.GitVcsDriver).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 }); + const branches = yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ cwd: tmp }); expect(branches.branches.some((branch) => branch.name === "t3code/feat/session")).toBe( true, ); @@ -1153,12 +1234,24 @@ it.layer(TestLayer)("git integration", (it) => { 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" }); + yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ + cwd: tmp, + branch: "t3code/feat/session", + }); + yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ + cwd: tmp, + branch: "t3code/feat/session-1", + }); + yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ + cwd: tmp, + branch: "t3code/tmp-working", + }); + yield* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ + cwd: tmp, + branch: "t3code/tmp-working", + }); - const renamed = yield* (yield* GitCore).renameBranch({ + const renamed = yield* (yield* GitVcsDriver.GitVcsDriver).renameBranch({ cwd: tmp, oldBranch: "t3code/tmp-working", newBranch: "t3code/feat/session", @@ -1172,16 +1265,22 @@ it.layer(TestLayer)("git integration", (it) => { 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" }); + yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ + cwd: tmp, + branch: "feature/old-name", + }); + yield* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ + cwd: tmp, + branch: "feature/old-name", + }); - const realGitCore = yield* GitCore; + const realGitVcsDriver = yield* GitVcsDriver.GitVcsDriver; let renameArgs: ReadonlyArray | null = null; - const core = yield* makeIsolatedGitCore((input) => { + const core = yield* makeIsolatedGitVcsDriver((input) => { if (input.args[0] === "branch" && input.args[1] === "-m") { renameArgs = [...input.args]; } - return realGitCore.execute(input); + return realGitVcsDriver.execute(input); }); const renamed = yield* core.renameBranch({ @@ -1205,11 +1304,11 @@ it.layer(TestLayer)("git integration", (it) => { 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 currentBranch = (yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ + cwd: tmp, + })).branches.find((b) => b.current)!.name; - const result = yield* (yield* GitCore).createWorktree({ + const result = yield* (yield* GitVcsDriver.GitVcsDriver).createWorktree({ cwd: tmp, branch: currentBranch, newBranch: "wt-branch", @@ -1222,7 +1321,7 @@ it.layer(TestLayer)("git integration", (it) => { expect(existsSync(path.join(wtPath, "README.md"))).toBe(true); // Clean up worktree before tmp dir disposal - yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); + yield* (yield* GitVcsDriver.GitVcsDriver).removeWorktree({ cwd: tmp, path: wtPath }); }), ); @@ -1232,11 +1331,11 @@ it.layer(TestLayer)("git integration", (it) => { 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; + const currentBranch = (yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ + cwd: tmp, + })).branches.find((b) => b.current)!.name; - yield* (yield* GitCore).createWorktree({ + yield* (yield* GitVcsDriver.GitVcsDriver).createWorktree({ cwd: tmp, branch: currentBranch, newBranch: "wt-check", @@ -1247,7 +1346,7 @@ it.layer(TestLayer)("git integration", (it) => { const branchOutput = yield* git(wtPath, ["branch", "--show-current"]); expect(branchOutput).toBe("wt-check"); - yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); + yield* (yield* GitVcsDriver.GitVcsDriver).removeWorktree({ cwd: tmp, path: wtPath }); }), ); @@ -1255,10 +1354,13 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/existing-worktree" }); + yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ + cwd: tmp, + branch: "feature/existing-worktree", + }); const wtPath = path.join(tmp, "wt-existing"); - const result = yield* (yield* GitCore).createWorktree({ + const result = yield* (yield* GitVcsDriver.GitVcsDriver).createWorktree({ cwd: tmp, branch: "feature/existing-worktree", path: wtPath, @@ -1269,7 +1371,7 @@ it.layer(TestLayer)("git integration", (it) => { const branchOutput = yield* git(wtPath, ["branch", "--show-current"]); expect(branchOutput).toBe("feature/existing-worktree"); - yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); + yield* (yield* GitVcsDriver.GitVcsDriver).removeWorktree({ cwd: tmp, path: wtPath }); }), ); @@ -1277,15 +1379,15 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "existing" }); + yield* (yield* GitVcsDriver.GitVcsDriver).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 currentBranch = (yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ + cwd: tmp, + })).branches.find((b) => b.current)!.name; const result = yield* Effect.result( - (yield* GitCore).createWorktree({ + (yield* GitVcsDriver.GitVcsDriver).createWorktree({ cwd: tmp, branch: currentBranch, newBranch: "existing", @@ -1302,11 +1404,11 @@ it.layer(TestLayer)("git integration", (it) => { 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; + const mainBranch = (yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ + cwd: tmp, + })).branches.find((b) => b.current)!.name; - yield* (yield* GitCore).createWorktree({ + yield* (yield* GitVcsDriver.GitVcsDriver).createWorktree({ cwd: tmp, branch: mainBranch, newBranch: "wt-list", @@ -1314,17 +1416,17 @@ it.layer(TestLayer)("git integration", (it) => { }); // listGitBranches from the worktree should show wt-list as current - const wtBranches = yield* (yield* GitCore).listBranches({ cwd: wtPath }); + const wtBranches = yield* (yield* GitVcsDriver.GitVcsDriver).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 mainBranches = yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ cwd: tmp }); const mainCurrent = mainBranches.branches.find((b) => b.current); expect(mainCurrent!.name).toBe(mainBranch); - yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); + yield* (yield* GitVcsDriver.GitVcsDriver).removeWorktree({ cwd: tmp, path: wtPath }); }), ); @@ -1334,11 +1436,11 @@ it.layer(TestLayer)("git integration", (it) => { 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; + const currentBranch = (yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ + cwd: tmp, + })).branches.find((b) => b.current)!.name; - yield* (yield* GitCore).createWorktree({ + yield* (yield* GitVcsDriver.GitVcsDriver).createWorktree({ cwd: tmp, branch: currentBranch, newBranch: "wt-remove", @@ -1346,7 +1448,7 @@ it.layer(TestLayer)("git integration", (it) => { }); expect(existsSync(wtPath)).toBe(true); - yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); + yield* (yield* GitVcsDriver.GitVcsDriver).removeWorktree({ cwd: tmp, path: wtPath }); expect(existsSync(wtPath)).toBe(false); }), ); @@ -1357,11 +1459,11 @@ it.layer(TestLayer)("git integration", (it) => { 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; + const currentBranch = (yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ + cwd: tmp, + })).branches.find((b) => b.current)!.name; - yield* (yield* GitCore).createWorktree({ + yield* (yield* GitVcsDriver.GitVcsDriver).createWorktree({ cwd: tmp, branch: currentBranch, newBranch: "wt-dirty", @@ -1372,12 +1474,16 @@ it.layer(TestLayer)("git integration", (it) => { yield* writeTextFile(path.join(wtPath, "README.md"), "dirty change\n"); const failedRemove = yield* Effect.result( - (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }), + (yield* GitVcsDriver.GitVcsDriver).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 }); + yield* (yield* GitVcsDriver.GitVcsDriver).removeWorktree({ + cwd: tmp, + path: wtPath, + force: true, + }); expect(existsSync(wtPath)).toBe(false); }), ); @@ -1390,10 +1496,16 @@ it.layer(TestLayer)("git integration", (it) => { 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" }); + yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ + cwd: tmp, + branch: "feature-login", + }); + yield* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ + cwd: tmp, + branch: "feature-login", + }); - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); + const result = yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ cwd: tmp }); const current = result.branches.find((b) => b.current); expect(current!.name).toBe("feature-login"); }), @@ -1408,12 +1520,12 @@ it.layer(TestLayer)("git integration", (it) => { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (b) => b.current, - )!.name; + const currentBranch = (yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ + cwd: tmp, + })).branches.find((b) => b.current)!.name; const wtPath = path.join(tmp, "my-worktree"); - const result = yield* (yield* GitCore).createWorktree({ + const result = yield* (yield* GitVcsDriver.GitVcsDriver).createWorktree({ cwd: tmp, branch: currentBranch, newBranch: "feature-wt", @@ -1424,7 +1536,7 @@ it.layer(TestLayer)("git integration", (it) => { expect(existsSync(result.worktree.path)).toBe(true); // Main repo still on original branch - const mainBranches = yield* (yield* GitCore).listBranches({ cwd: tmp }); + const mainBranches = yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ cwd: tmp }); const mainCurrent = mainBranches.branches.find((b) => b.current); expect(mainCurrent!.name).toBe(currentBranch); @@ -1432,7 +1544,7 @@ it.layer(TestLayer)("git integration", (it) => { const wtBranch = yield* git(wtPath, ["branch", "--show-current"]); expect(wtBranch).toBe("feature-wt"); - yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); + yield* (yield* GitVcsDriver.GitVcsDriver).removeWorktree({ cwd: tmp, path: wtPath }); }), ); }); @@ -1454,7 +1566,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(tmp, ["push", "origin", "HEAD:refs/pull/55/head"]); yield* git(tmp, ["checkout", initialBranch]); - yield* (yield* GitCore).fetchPullRequestBranch({ + yield* (yield* GitVcsDriver.GitVcsDriver).fetchPullRequestBranch({ cwd: tmp, prNumber: 55, branch: "feature/pr-fetch", @@ -1475,22 +1587,22 @@ it.layer(TestLayer)("git integration", (it) => { 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" }); + yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ cwd: tmp, branch: "branch-a" }); + yield* (yield* GitVcsDriver.GitVcsDriver).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 }); + yield* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ cwd: tmp, branch: "branch-a" }); + let branches = yield* (yield* GitVcsDriver.GitVcsDriver).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 }); + yield* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ cwd: tmp, branch: "branch-b" }); + branches = yield* (yield* GitVcsDriver.GitVcsDriver).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 }); + yield* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ cwd: tmp, branch: "branch-a" }); + branches = yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ cwd: tmp }); expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); }), ); @@ -1503,40 +1615,43 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "diverged" }); + yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ cwd: tmp, branch: "diverged" }); // Make diverged branch have different file content - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "diverged" }); + yield* (yield* GitVcsDriver.GitVcsDriver).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 allBranches = yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ cwd: tmp }); const initialBranch = allBranches.branches.find((b) => b.name !== "diverged")!.name; - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: initialBranch }); + yield* (yield* GitVcsDriver.GitVcsDriver).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" }), + (yield* GitVcsDriver.GitVcsDriver).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 }); + const result = yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ cwd: tmp }); expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch); }), ); }); - describe("GitCore", () => { + describe("GitVcsDriver", () => { it.effect("supports branch lifecycle operations through the service API", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); - const core = yield* GitCore; + const core = yield* GitVcsDriver.GitVcsDriver; yield* core.initRepo({ cwd: tmp }); yield* git(tmp, ["config", "user.email", "test@test.com"]); @@ -1563,7 +1678,7 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const core = yield* GitCore; + const core = yield* GitVcsDriver.GitVcsDriver; yield* git(tmp, ["remote", "add", "origin", "git@github.com:pingdotgg/t3code.git"]); @@ -1582,7 +1697,7 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const core = yield* GitCore; + const core = yield* GitVcsDriver.GitVcsDriver; const clean = yield* core.status({ cwd: tmp }); expect(clean.hasWorkingTreeChanges).toBe(false); @@ -1600,7 +1715,7 @@ it.layer(TestLayer)("git integration", (it) => { const deletedDir = path.join(tmp, "deleted-repo"); yield* makeDirectory(deletedDir); yield* removePath(deletedDir); - const core = yield* GitCore; + const core = yield* GitVcsDriver.GitVcsDriver; const status = yield* core.statusDetails(deletedDir); const localStatus = yield* core.statusDetailsLocal(deletedDir); @@ -1629,7 +1744,7 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const core = yield* GitCore; + const core = yield* GitVcsDriver.GitVcsDriver; yield* core.createBranch({ cwd: tmp, branch: "feature/no-upstream-ahead" }); yield* core.checkoutBranch({ cwd: tmp, branch: "feature/no-upstream-ahead" }); @@ -1654,7 +1769,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(source); - const initialBranch = (yield* (yield* GitCore).listBranches({ + const initialBranch = (yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ cwd: source, })).branches.find((branch) => branch.current)!.name; yield* git(source, ["remote", "add", "origin", remote]); @@ -1668,7 +1783,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(source, ["commit", "-m", "feature commit"]); yield* git(source, ["branch", "-D", initialBranch]); - const core = yield* GitCore; + const core = yield* GitVcsDriver.GitVcsDriver; const details = yield* core.statusDetails(source); expect(details.branch).toBe("feature/remote-base-only"); expect(details.hasUpstream).toBe(false); @@ -1687,7 +1802,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(source); - const initialBranch = (yield* (yield* GitCore).listBranches({ + const initialBranch = (yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ cwd: source, })).branches.find((branch) => branch.current)!.name; yield* git(source, ["remote", "add", remoteName, remote]); @@ -1706,7 +1821,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(source, ["commit", "-m", "feature commit"]); yield* git(source, ["branch", "-D", initialBranch]); - const core = yield* GitCore; + const core = yield* GitVcsDriver.GitVcsDriver; const details = yield* core.statusDetails(source); expect(details.branch).toBe("feature/non-origin-merge-base"); expect(details.hasUpstream).toBe(false); @@ -1719,7 +1834,7 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const core = yield* GitCore; + const core = yield* GitVcsDriver.GitVcsDriver; yield* core.createBranch({ cwd: tmp, branch: "feature/no-upstream-no-ahead" }); yield* core.checkoutBranch({ cwd: tmp, branch: "feature/no-upstream-no-ahead" }); @@ -1745,7 +1860,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(tmp, ["remote", "add", "origin", remote]); yield* git(tmp, ["checkout", "-b", "feature/no-base"]); - const core = yield* GitCore; + const core = yield* GitVcsDriver.GitVcsDriver; const pushed = yield* core.pushCurrentBranch(tmp, null); expect(pushed.status).toBe("pushed"); expect(pushed.setUpstream).toBe(true); @@ -1770,7 +1885,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(tmp, ["remote", "add", "fork", remote]); yield* git(tmp, ["checkout", "-b", "feature/fork-only"]); - const core = yield* GitCore; + const core = yield* GitVcsDriver.GitVcsDriver; const pushed = yield* core.pushCurrentBranch(tmp, null); expect(pushed.status).toBe("pushed"); expect(pushed.setUpstream).toBe(true); @@ -1790,9 +1905,9 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(tmp); - const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (branch) => branch.current, - )!.name; + const initialBranch = (yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ + cwd: tmp, + })).branches.find((branch) => branch.current)!.name; yield* git(tmp, ["remote", "add", "origin", remote]); yield* git(tmp, ["push", "-u", "origin", initialBranch]); @@ -1803,7 +1918,7 @@ it.layer(TestLayer)("git integration", (it) => { const featureBranch = "feature/publish-no-upstream"; yield* git(tmp, ["checkout", "-b", featureBranch]); - const core = yield* GitCore; + const core = yield* GitVcsDriver.GitVcsDriver; const pushed = yield* core.pushCurrentBranch(tmp, null); expect(pushed.status).toBe("pushed"); expect(pushed.setUpstream).toBe(true); @@ -1826,9 +1941,9 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(fork, ["init", "--bare"]); yield* initRepoWithCommit(tmp); - const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (branch) => branch.current, - )!.name; + const initialBranch = (yield* (yield* GitVcsDriver.GitVcsDriver).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]); @@ -1840,7 +1955,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(tmp, ["add", "feature.txt"]); yield* git(tmp, ["commit", "-m", "feature commit"]); - const core = yield* GitCore; + const core = yield* GitVcsDriver.GitVcsDriver; const pushed = yield* core.pushCurrentBranch(tmp, null); expect(pushed.status).toBe("pushed"); expect(pushed.setUpstream).toBe(true); @@ -1883,7 +1998,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(tmp, ["add", "fork.txt"]); yield* git(tmp, ["commit", "-m", "update reviewed PR branch"]); - const core = yield* GitCore; + const core = yield* GitVcsDriver.GitVcsDriver; const pushed = yield* core.pushCurrentBranch(tmp, null); expect(pushed.status).toBe("pushed"); @@ -1928,7 +2043,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(tmp, ["add", "feature.txt"]); yield* git(tmp, ["commit", "-m", "feature update"]); - const core = yield* GitCore; + const core = yield* GitVcsDriver.GitVcsDriver; const pushed = yield* core.pushCurrentBranch(tmp, null); expect(pushed.status).toBe("pushed"); expect(pushed.setUpstream).toBe(false); @@ -1946,7 +2061,7 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const core = yield* GitCore; + const core = yield* GitVcsDriver.GitVcsDriver; const missingWorktreePath = path.join(tmp, "missing-worktree"); const removeResult = yield* Effect.result( @@ -1973,7 +2088,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(source); - const initialBranch = (yield* (yield* GitCore).listBranches({ + const initialBranch = (yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ cwd: source, })).branches.find((branch) => branch.current)!.name; yield* git(source, ["remote", "add", "origin", remote]); @@ -1994,7 +2109,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(clone, ["commit", "-m", "remote update"]); yield* git(clone, ["push", "origin", initialBranch]); - const core = yield* GitCore; + const core = yield* GitVcsDriver.GitVcsDriver; const details = yield* core.statusDetails(source); expect(details.branch).toBe(initialBranch); expect(details.aheadCount).toBe(0); @@ -2006,7 +2121,7 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const core = yield* GitCore; + const core = yield* GitVcsDriver.GitVcsDriver; yield* writeTextFile(path.join(tmp, "README.md"), "new content\n"); const context = yield* core.prepareCommitContext(tmp); @@ -2024,7 +2139,7 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const core = yield* GitCore; + const core = yield* GitVcsDriver.GitVcsDriver; yield* writeTextFile(path.join(tmp, "a.txt"), "file a\n"); yield* writeTextFile(path.join(tmp, "b.txt"), "file b\n"); @@ -2047,7 +2162,7 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const core = yield* GitCore; + const core = yield* GitVcsDriver.GitVcsDriver; yield* writeTextFile(path.join(tmp, "a.txt"), "file a\n"); yield* writeTextFile(path.join(tmp, "b.txt"), "file b\n"); @@ -2063,7 +2178,7 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const core = yield* GitCore; + const core = yield* GitVcsDriver.GitVcsDriver; yield* writeTextFile(path.join(tmp, "README.md"), buildLargeText()); @@ -2078,7 +2193,7 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(tmp); - const core = yield* GitCore; + const core = yield* GitVcsDriver.GitVcsDriver; yield* core.createBranch({ cwd: tmp, branch: "feature/large-range-context" }); yield* core.checkoutBranch({ cwd: tmp, branch: "feature/large-range-context" }); @@ -2100,11 +2215,17 @@ it.layer(TestLayer)("git integration", (it) => { 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* (yield* GitVcsDriver.GitVcsDriver).createBranch({ + cwd: tmp, + branch: "feature/core-push", + }); + yield* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ + cwd: tmp, + branch: "feature/core-push", + }); yield* writeTextFile(path.join(tmp, "feature.txt"), "push me\n"); - const core = yield* GitCore; + const core = yield* GitVcsDriver.GitVcsDriver; const context = yield* core.prepareCommitContext(tmp); expect(context).not.toBeNull(); yield* core.commit(tmp, "Add feature file", ""); @@ -2129,9 +2250,9 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(source); - const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( - (branch) => branch.current, - )!.name; + const initialBranch = (yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ + cwd: source, + })).branches.find((branch) => branch.current)!.name; yield* git(source, ["remote", "add", "origin", remote]); yield* git(source, ["push", "-u", "origin", initialBranch]); @@ -2143,7 +2264,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(clone, ["commit", "-m", "remote update"]); yield* git(clone, ["push", "origin", initialBranch]); - const core = yield* GitCore; + const core = yield* GitVcsDriver.GitVcsDriver; const pulled = yield* core.pullCurrentBranch(source); expect(pulled.status).toBe("pulled"); expect((yield* core.statusDetails(source)).behindCount).toBe(0); @@ -2157,7 +2278,9 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const result = yield* Effect.result((yield* GitCore).pullCurrentBranch(tmp)); + const result = yield* Effect.result( + (yield* GitVcsDriver.GitVcsDriver).pullCurrentBranch(tmp), + ); expect(result._tag).toBe("Failure"); if (result._tag === "Failure") { expect(result.failure.message.toLowerCase()).toContain("no upstream"); @@ -2169,9 +2292,9 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const realGitCore = yield* GitCore; + const realGitVcsDriver = yield* GitVcsDriver.GitVcsDriver; let didFailRecency = false; - const core = yield* makeIsolatedGitCore((input) => { + const core = yield* makeIsolatedGitVcsDriver((input) => { if (!didFailRecency && input.args[0] === "for-each-ref") { didFailRecency = true; return Effect.fail( @@ -2183,7 +2306,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); } - return realGitCore.execute(input); + return realGitVcsDriver.execute(input); }); const result = yield* core.listBranches({ cwd: tmp }); @@ -2203,10 +2326,10 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* git(tmp, ["remote", "add", "origin", remote]); - const realGitCore = yield* GitCore; + const realGitVcsDriver = yield* GitVcsDriver.GitVcsDriver; let didFailRemoteBranches = false; let didFailRemoteNames = false; - const core = yield* makeIsolatedGitCore((input) => { + const core = yield* makeIsolatedGitVcsDriver((input) => { if (input.args.join(" ") === "branch --no-color --no-column --remotes") { didFailRemoteBranches = true; return Effect.fail( @@ -2229,7 +2352,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); } - return realGitCore.execute(input); + return realGitVcsDriver.execute(input); }); const result = yield* core.listBranches({ cwd: tmp }); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts similarity index 76% rename from apps/server/src/git/Layers/GitCore.ts rename to apps/server/src/vcs/GitVcsDriverCore.ts index 2fac4b9adf1..fa18d2f15e9 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -5,7 +5,6 @@ import { Effect, Exit, FileSystem, - Layer, Option, Path, PlatformError, @@ -20,23 +19,22 @@ 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 { 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 "../remoteRefs.ts"; -import { ServerConfig } from "../../config.ts"; +} from "../git/remoteRefs.ts"; +import { ServerConfig } from "../config.ts"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -411,7 +409,7 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( 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}`, + `GitVcsDriver.trace2: failed to parse trace line for ${quoteGitCommand(input.args)} in ${input.cwd}`, traceRecord.failure, ); return; @@ -604,14 +602,14 @@ const collectOutput = Effect.fn("collectOutput")(function* ( }; }); -export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { - executeOverride?: GitCoreShape["execute"]; +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: GitCoreShape["execute"]; + let executeRaw: GitVcsDriverShape["execute"]; if (options?.executeOverride) { executeRaw = options.executeOverride; @@ -718,7 +716,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }); } - const execute: GitCoreShape["execute"] = (input) => + const execute: GitVcsDriverShape["execute"] = (input) => executeRaw(input).pipe( withMetrics({ counter: gitCommandsTotal, @@ -812,7 +810,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const branchExists = (cwd: string, branch: string): Effect.Effect => executeGit( - "GitCore.branchExists", + "GitVcsDriver.branchExists", cwd, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { @@ -839,7 +837,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { } return yield* createGitCommandError( - "GitCore.renameBranch", + "GitVcsDriver.renameBranch", cwd, ["branch", "-m", "--", desiredBranch], `Could not find an available branch name for '${desiredBranch}'.`, @@ -848,7 +846,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const resolveCurrentUpstream = Effect.fn("resolveCurrentUpstream")(function* (cwd: string) { const upstreamRef = yield* runGitStdout( - "GitCore.resolveCurrentUpstream", + "GitVcsDriver.resolveCurrentUpstream", cwd, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], true, @@ -858,7 +856,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { return null; } - const remoteNames = yield* runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe( + const remoteNames = yield* runGitStdout("GitVcsDriver.listRemoteNames", cwd, ["remote"]).pipe( Effect.map(parseRemoteNames), Effect.catch(() => Effect.succeed>([])), ); @@ -875,7 +873,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const fetchCwd = path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; return executeGit( - "GitCore.fetchRemoteForStatus", + "GitVcsDriver.fetchRemoteForStatus", fetchCwd, ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName], { @@ -886,7 +884,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }; const resolveGitCommonDir = Effect.fn("resolveGitCommonDir")(function* (cwd: string) { - const gitCommonDir = yield* runGitStdout("GitCore.resolveGitCommonDir", cwd, [ + const gitCommonDir = yield* runGitStdout("GitVcsDriver.resolveGitCommonDir", cwd, [ "rev-parse", "--git-common-dir", ]).pipe(Effect.map((stdout) => stdout.trim())); @@ -929,7 +927,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { remoteName: string, ): Effect.Effect => executeGit( - "GitCore.resolveDefaultBranchName", + "GitVcsDriver.resolveDefaultBranchName", cwd, ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`], { allowNonZeroExit: true }, @@ -948,7 +946,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { branch: string, ): Effect.Effect => executeGit( - "GitCore.remoteBranchExists", + "GitVcsDriver.remoteBranchExists", cwd, ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${branch}`], { @@ -957,12 +955,12 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { ).pipe(Effect.map((result) => result.code === 0)); const originRemoteExists = (cwd: string): Effect.Effect => - executeGit("GitCore.originRemoteExists", cwd, ["remote", "get-url", "origin"], { + executeGit("GitVcsDriver.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( + runGitStdout("GitVcsDriver.listRemoteNames", cwd, ["remote"]).pipe( Effect.map(parseRemoteNamesInGitOrder), ); @@ -976,7 +974,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { return firstRemote; } return yield* createGitCommandError( - "GitCore.resolvePrimaryRemoteName", + "GitVcsDriver.resolvePrimaryRemoteName", cwd, ["remote"], "No git remote is configured for this repository.", @@ -988,7 +986,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { branch: string, ) { const branchPushRemote = yield* runGitStdout( - "GitCore.resolvePushRemoteName.branchPushRemote", + "GitVcsDriver.resolvePushRemoteName.branchPushRemote", cwd, ["config", "--get", `branch.${branch}.pushRemote`], true, @@ -998,7 +996,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { } const pushDefaultRemote = yield* runGitStdout( - "GitCore.resolvePushRemoteName.remotePushDefault", + "GitVcsDriver.resolvePushRemoteName.remotePushDefault", cwd, ["config", "--get", "remote.pushDefault"], true, @@ -1010,37 +1008,45 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { 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; + 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; - } + 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; - }); + yield* runGit("GitVcsDriver.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", + "GitVcsDriver.resolveBaseBranchForNoUpstream.config", cwd, ["config", "--get", `branch.${branch}.gh-merge-base`], true, @@ -1098,7 +1104,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { } const result = yield* executeGit( - "GitCore.computeAheadCountAgainstBase", + "GitVcsDriver.computeAheadCountAgainstBase", cwd, ["rev-list", "--count", `${baseBranch}..HEAD`], { allowNonZeroExit: true }, @@ -1113,7 +1119,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const readBranchRecency = Effect.fn("readBranchRecency")(function* (cwd: string) { const branchRecency = yield* executeGit( - "GitCore.readBranchRecency", + "GitVcsDriver.readBranchRecency", cwd, [ "for-each-ref", @@ -1149,7 +1155,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const readStatusDetailsLocal = Effect.fn("readStatusDetailsLocal")(function* (cwd: string) { const statusResult = yield* executeGit( - "GitCore.statusDetails.status", + "GitVcsDriver.statusDetails.status", cwd, ["status", "--porcelain=2", "--branch"], { @@ -1164,7 +1170,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { if (statusResult.code !== 0) { const stderr = statusResult.stderr.trim(); return yield* createGitCommandError( - "GitCore.statusDetails.status", + "GitVcsDriver.statusDetails.status", cwd, ["status", "--porcelain=2", "--branch"], stderr || "git status failed", @@ -1174,14 +1180,14 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const [unstagedNumstatStdout, stagedNumstatStdout, defaultRefResult, hasOriginRemote] = yield* Effect.all( [ - runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]), - runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, [ + runGitStdout("GitVcsDriver.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]), + runGitStdout("GitVcsDriver.statusDetails.stagedNumstat", cwd, [ "diff", "--cached", "--numstat", ]), executeGit( - "GitCore.statusDetails.defaultRef", + "GitVcsDriver.statusDetails.defaultRef", cwd, ["symbolic-ref", "refs/remotes/origin/HEAD"], { @@ -1284,21 +1290,23 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }; }); - const statusDetailsLocal: GitCoreShape["statusDetailsLocal"] = Effect.fn("statusDetailsLocal")( + 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 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) => + const status: GitVcsDriverShape["status"] = (input) => statusDetails(input.cwd).pipe( Effect.map((details) => ({ isRepo: details.isRepo, @@ -1314,34 +1322,34 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { })), ); - const prepareCommitContext: GitCoreShape["prepareCommitContext"] = Effect.fn( + const prepareCommitContext: GitVcsDriverShape["prepareCommitContext"] = Effect.fn( "prepareCommitContext", )(function* (cwd, filePaths) { if (filePaths && filePaths.length > 0) { - yield* runGit("GitCore.prepareCommitContext.reset", cwd, ["reset"]).pipe( + yield* runGit("GitVcsDriver.prepareCommitContext.reset", cwd, ["reset"]).pipe( Effect.catch(() => Effect.void), ); - yield* runGit("GitCore.prepareCommitContext.addSelected", cwd, [ + yield* runGit("GitVcsDriver.prepareCommitContext.addSelected", cwd, [ "add", "-A", "--", ...filePaths, ]); } else { - yield* runGit("GitCore.prepareCommitContext.addAll", cwd, ["add", "-A"]); + yield* runGit("GitVcsDriver.prepareCommitContext.addAll", cwd, ["add", "-A"]); } - const stagedSummary = yield* runGitStdout("GitCore.prepareCommitContext.stagedSummary", cwd, [ - "diff", - "--cached", - "--name-status", - ]).pipe(Effect.map((stdout) => stdout.trim())); + 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( - "GitCore.prepareCommitContext.stagedPatch", + "GitVcsDriver.prepareCommitContext.stagedPatch", cwd, ["diff", "--cached", "--patch", "--minimal"], { @@ -1356,7 +1364,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }; }); - const commit: GitCoreShape["commit"] = Effect.fn("commit")(function* ( + const commit: GitVcsDriverShape["commit"] = Effect.fn("commit")(function* ( cwd, subject, body, @@ -1377,11 +1385,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { onStderrLine: (line: string) => options.progress?.onOutputLine?.({ stream: "stderr", text: line }) ?? Effect.void, }; - yield* executeGit("GitCore.commit.commit", cwd, args, { + yield* executeGit("GitVcsDriver.commit.commit", cwd, args, { ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), ...(progress ? { progress } : {}), }).pipe(Effect.asVoid); - const commitSha = yield* runGitStdout("GitCore.commit.revParseHead", cwd, [ + const commitSha = yield* runGitStdout("GitVcsDriver.commit.revParseHead", cwd, [ "rev-parse", "HEAD", ]).pipe(Effect.map((stdout) => stdout.trim())); @@ -1389,13 +1397,13 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { return { commitSha }; }); - const pushCurrentBranch: GitCoreShape["pushCurrentBranch"] = Effect.fn("pushCurrentBranch")( + 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( - "GitCore.pushCurrentBranch", + "GitVcsDriver.pushCurrentBranch", cwd, ["push"], "Cannot push from detached HEAD.", @@ -1442,13 +1450,13 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const publishRemoteName = yield* resolvePushRemoteName(cwd, branch); if (!publishRemoteName) { return yield* createGitCommandError( - "GitCore.pushCurrentBranch", + "GitVcsDriver.pushCurrentBranch", cwd, ["push"], "Cannot push because no git remote is configured for this repository.", ); } - yield* runGit("GitCore.pushCurrentBranch.pushWithUpstream", cwd, [ + yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithUpstream", cwd, [ "push", "-u", publishRemoteName, @@ -1466,7 +1474,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { Effect.catch(() => Effect.succeed(null)), ); if (currentUpstream) { - yield* runGit("GitCore.pushCurrentBranch.pushUpstream", cwd, [ + yield* runGit("GitVcsDriver.pushCurrentBranch.pushUpstream", cwd, [ "push", currentUpstream.remoteName, `HEAD:${currentUpstream.upstreamBranch}`, @@ -1479,7 +1487,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }; } - yield* runGit("GitCore.pushCurrentBranch.push", cwd, ["push"]); + yield* runGit("GitVcsDriver.pushCurrentBranch.push", cwd, ["push"]); return { status: "pushed" as const, branch, @@ -1489,13 +1497,13 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }, ); - const pullCurrentBranch: GitCoreShape["pullCurrentBranch"] = Effect.fn("pullCurrentBranch")( + const pullCurrentBranch: GitVcsDriverShape["pullCurrentBranch"] = Effect.fn("pullCurrentBranch")( function* (cwd) { const details = yield* statusDetails(cwd); const branch = details.branch; if (!branch) { return yield* createGitCommandError( - "GitCore.pullCurrentBranch", + "GitVcsDriver.pullCurrentBranch", cwd, ["pull", "--ff-only"], "Cannot pull from detached HEAD.", @@ -1503,24 +1511,24 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { } if (!details.hasUpstream) { return yield* createGitCommandError( - "GitCore.pullCurrentBranch", + "GitVcsDriver.pullCurrentBranch", cwd, ["pull", "--ff-only"], "Current branch has no upstream configured. Push with upstream first.", ); } const beforeSha = yield* runGitStdout( - "GitCore.pullCurrentBranch.beforeSha", + "GitVcsDriver.pullCurrentBranch.beforeSha", cwd, ["rev-parse", "HEAD"], true, ).pipe(Effect.map((stdout) => stdout.trim())); - yield* executeGit("GitCore.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { + yield* executeGit("GitVcsDriver.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { timeoutMs: 30_000, fallbackErrorMessage: "git pull failed", }); const afterSha = yield* runGitStdout( - "GitCore.pullCurrentBranch.afterSha", + "GitVcsDriver.pullCurrentBranch.afterSha", cwd, ["rev-parse", "HEAD"], true, @@ -1535,13 +1543,13 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }, ); - const readRangeContext: GitCoreShape["readRangeContext"] = Effect.fn("readRangeContext")( + const readRangeContext: GitVcsDriverShape["readRangeContext"] = Effect.fn("readRangeContext")( function* (cwd, baseBranch) { const range = `${baseBranch}..HEAD`; const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( [ runGitStdoutWithOptions( - "GitCore.readRangeContext.log", + "GitVcsDriver.readRangeContext.log", cwd, ["log", "--oneline", range], { @@ -1550,7 +1558,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }, ), runGitStdoutWithOptions( - "GitCore.readRangeContext.diffStat", + "GitVcsDriver.readRangeContext.diffStat", cwd, ["diff", "--stat", range], { @@ -1559,7 +1567,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }, ), runGitStdoutWithOptions( - "GitCore.readRangeContext.diffPatch", + "GitVcsDriver.readRangeContext.diffPatch", cwd, ["diff", "--patch", "--minimal", range], { @@ -1579,228 +1587,230 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }, ); - const readConfigValue: GitCoreShape["readConfigValue"] = (cwd, key) => - runGitStdout("GitCore.readConfigValue", cwd, ["config", "--get", key], true).pipe( + 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 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", + const listBranches: GitVcsDriverShape["listBranches"] = Effect.fn("listBranches")( + function* (input) { + const branchRecencyPromise = readBranchRecency(input.cwd).pipe( + Effect.catch(() => Effect.succeed(new Map())), + ); + const localBranchResult = yield* executeGit( + "GitVcsDriver.listBranches.branchNoColor", input.cwd, ["branch", "--no-color", "--no-column"], - stderr || "git branch failed", + { + timeoutMs: 10_000, + allowNonZeroExit: true, + }, + ).pipe( + Effect.catchIf(isMissingGitCwdError, () => + Effect.succeed({ + code: 128, + stdout: "", + stderr: "fatal: not a git repository", + stdoutTruncated: false, + stderrTruncated: false, + }), + ), ); - } - 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: "" })), - ), - ); + 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( + "GitVcsDriver.listBranches", + input.cwd, + ["branch", "--no-color", "--no-column"], + stderr || "git branch failed", + ); + } - 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 remoteBranchResultEffect = executeGit( + "GitVcsDriver.listBranches.remoteBranches", + input.cwd, + ["branch", "--no-color", "--no-column", "--remotes"], + { + timeoutMs: 10_000, + allowNonZeroExit: true, + }, + ).pipe( + Effect.catch((error) => + Effect.logWarning( + `GitVcsDriver.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 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 remoteNamesResultEffect = executeGit( + "GitVcsDriver.listBranches.remoteNames", + input.cwd, + ["remote"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe( + Effect.catch((error) => + Effect.logWarning( + `GitVcsDriver.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 defaultBranch = - defaultRef.code === 0 - ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") - : null; + const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] = + yield* Effect.all( + [ + executeGit( + "GitVcsDriver.listBranches.defaultRef", + input.cwd, + ["symbolic-ref", "refs/remotes/origin/HEAD"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ), + executeGit( + "GitVcsDriver.listBranches.worktreeList", + input.cwd, + ["worktree", "list", "--porcelain"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ), + remoteBranchResultEffect, + remoteNamesResultEffect, + branchRecencyPromise, + ], + { concurrency: "unbounded" }, + ); - 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 remoteNames = + remoteNamesResult.code === 0 ? parseRemoteNames(remoteNamesResult.stdout) : []; + if (remoteBranchResult.code !== 0 && remoteBranchResult.stderr.trim().length > 0) { + yield* Effect.logWarning( + `GitVcsDriver.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( + `GitVcsDriver.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 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, - }); + 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, - }; - }); + return { + branches: [...branches.branches], + isRepo: true, + hasOriginRemote: remoteNames.includes("origin"), + nextCursor: branches.nextCursor, + totalCount: branches.totalCount, + }; + }, + ); - const createWorktree: GitCoreShape["createWorktree"] = Effect.fn("createWorktree")( + const createWorktree: GitVcsDriverShape["createWorktree"] = Effect.fn("createWorktree")( function* (input) { const targetBranch = input.newBranch ?? input.branch; const sanitizedBranch = targetBranch.replace(/\//g, "-"); @@ -1810,7 +1820,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { ? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch] : ["worktree", "add", worktreePath, input.branch]; - yield* executeGit("GitCore.createWorktree", input.cwd, args, { + yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, { fallbackErrorMessage: "git worktree add failed", }); @@ -1823,12 +1833,12 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }, ); - const fetchPullRequestBranch: GitCoreShape["fetchPullRequestBranch"] = Effect.fn( + const fetchPullRequestBranch: GitVcsDriverShape["fetchPullRequestBranch"] = Effect.fn( "fetchPullRequestBranch", )(function* (input) { const remoteName = yield* resolvePrimaryRemoteName(input.cwd); yield* executeGit( - "GitCore.fetchPullRequestBranch", + "GitVcsDriver.fetchPullRequestBranch", input.cwd, [ "fetch", @@ -1843,9 +1853,9 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { ); }); - const fetchRemoteBranch: GitCoreShape["fetchRemoteBranch"] = Effect.fn("fetchRemoteBranch")( + const fetchRemoteBranch: GitVcsDriverShape["fetchRemoteBranch"] = Effect.fn("fetchRemoteBranch")( function* (input) { - yield* runGit("GitCore.fetchRemoteBranch.fetch", input.cwd, [ + yield* runGit("GitVcsDriver.fetchRemoteBranch.fetch", input.cwd, [ "fetch", "--quiet", "--no-tags", @@ -1856,7 +1866,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch); const targetRef = `${input.remoteName}/${input.remoteBranch}`; yield* runGit( - "GitCore.fetchRemoteBranch.materialize", + "GitVcsDriver.fetchRemoteBranch.materialize", input.cwd, localBranchAlreadyExists ? ["branch", "--force", input.localBranch, targetRef] @@ -1865,28 +1875,28 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }, ); - const setBranchUpstream: GitCoreShape["setBranchUpstream"] = (input) => - runGit("GitCore.setBranchUpstream", input.cwd, [ + const setBranchUpstream: GitVcsDriverShape["setBranchUpstream"] = (input) => + runGit("GitVcsDriver.setBranchUpstream", input.cwd, [ "branch", "--set-upstream-to", `${input.remoteName}/${input.remoteBranch}`, input.branch, ]); - const removeWorktree: GitCoreShape["removeWorktree"] = Effect.fn("removeWorktree")( + 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("GitCore.removeWorktree", input.cwd, args, { + yield* executeGit("GitVcsDriver.removeWorktree", input.cwd, args, { timeoutMs: 15_000, fallbackErrorMessage: "git worktree remove failed", }).pipe( Effect.mapError((error) => createGitCommandError( - "GitCore.removeWorktree", + "GitVcsDriver.removeWorktree", input.cwd, args, `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error.message}`, @@ -1897,31 +1907,33 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }, ); - 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); + 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( - "GitCore.renameBranch", - input.cwd, - ["branch", "-m", "--", input.oldBranch, targetBranch], - { - timeoutMs: 10_000, - fallbackErrorMessage: "git branch rename failed", - }, - ); + yield* executeGit( + "GitVcsDriver.renameBranch", + input.cwd, + ["branch", "-m", "--", input.oldBranch, targetBranch], + { + timeoutMs: 10_000, + fallbackErrorMessage: "git branch rename failed", + }, + ); - return { branch: targetBranch }; - }); + return { branch: targetBranch }; + }, + ); - const checkoutBranch: GitCoreShape["checkoutBranch"] = Effect.fn("checkoutBranch")( + const checkoutBranch: GitVcsDriverShape["checkoutBranch"] = Effect.fn("checkoutBranch")( function* (input) { const [localInputExists, remoteExists] = yield* Effect.all( [ executeGit( - "GitCore.checkoutBranch.localInputExists", + "GitVcsDriver.checkoutBranch.localInputExists", input.cwd, ["show-ref", "--verify", "--quiet", `refs/heads/${input.branch}`], { @@ -1930,7 +1942,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }, ).pipe(Effect.map((result) => result.code === 0)), executeGit( - "GitCore.checkoutBranch.remoteExists", + "GitVcsDriver.checkoutBranch.remoteExists", input.cwd, ["show-ref", "--verify", "--quiet", `refs/remotes/${input.branch}`], { @@ -1944,7 +1956,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const localTrackingBranch = remoteExists ? yield* executeGit( - "GitCore.checkoutBranch.localTrackingBranch", + "GitVcsDriver.checkoutBranch.localTrackingBranch", input.cwd, ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"], { @@ -1964,7 +1976,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const localTrackedBranchTargetExists = remoteExists && localTrackedBranchCandidate ? yield* executeGit( - "GitCore.checkoutBranch.localTrackedBranchTargetExists", + "GitVcsDriver.checkoutBranch.localTrackedBranchTargetExists", input.cwd, ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`], { @@ -1984,12 +1996,12 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { ? ["checkout", localTrackingBranch] : ["checkout", input.branch]; - yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, checkoutArgs, { + yield* executeGit("GitVcsDriver.checkoutBranch.checkout", input.cwd, checkoutArgs, { timeoutMs: 10_000, fallbackErrorMessage: "git checkout failed", }); - const branch = yield* runGitStdout("GitCore.checkoutBranch.currentBranch", input.cwd, [ + const branch = yield* runGitStdout("GitVcsDriver.checkoutBranch.currentBranch", input.cwd, [ "branch", "--show-current", ]).pipe(Effect.map((stdout) => stdout.trim() || null)); @@ -1998,26 +2010,28 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }, ); - 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 }); - } + const createBranch: GitVcsDriverShape["createBranch"] = Effect.fn("createBranch")( + function* (input) { + yield* executeGit("GitVcsDriver.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 }; - }); + return { branch: input.branch }; + }, + ); - const initRepo: GitCoreShape["initRepo"] = (input) => - executeGit("GitCore.initRepo", input.cwd, ["init"], { + const initRepo: GitVcsDriverShape["initRepo"] = (input) => + executeGit("GitVcsDriver.initRepo", input.cwd, ["init"], { timeoutMs: 10_000, fallbackErrorMessage: "git init failed", }).pipe(Effect.asVoid); - const listLocalBranchNames: GitCoreShape["listLocalBranchNames"] = (cwd) => - runGitStdout("GitCore.listLocalBranchNames", cwd, [ + const listLocalBranchNames: GitVcsDriverShape["listLocalBranchNames"] = (cwd) => + runGitStdout("GitVcsDriver.listLocalBranchNames", cwd, [ "branch", "--list", "--no-column", @@ -2054,7 +2068,5 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { checkoutBranch, initRepo, listLocalBranchNames, - } satisfies GitCoreShape; + } satisfies GitVcsDriverShape; }); - -export const GitCoreLive = Layer.effect(GitCore, makeGitCore()); From 8064641451508a88210a6b4113d72d80cda746e1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 1 May 2026 12:05:40 -0700 Subject: [PATCH 10/45] Add VCS driver registry for pluggable git integration - Route checkpointing and workspace services through a VCS driver registry - Split git driver construction from the driver service wrapper - Update tests and harnesses to mock the registry instead of the raw driver --- .../OrchestrationEngineHarness.integration.ts | 5 +- .../Layers/CheckpointStore.test.ts | 4 +- .../checkpointing/Layers/CheckpointStore.ts | 20 +++- .../Layers/CheckpointReactor.test.ts | 6 +- apps/server/src/server.test.ts | 70 +++++++++++- apps/server/src/server.ts | 5 +- apps/server/src/vcs/GitVcsDriver.ts | 11 +- apps/server/src/vcs/VcsDriverRegistry.ts | 103 ++++++++++++++++++ .../workspace/Layers/WorkspaceEntries.test.ts | 4 +- .../src/workspace/Layers/WorkspaceEntries.ts | 46 ++++---- .../Layers/WorkspaceFileSystem.test.ts | 4 +- 11 files changed, 236 insertions(+), 42 deletions(-) create mode 100644 apps/server/src/vcs/VcsDriverRegistry.ts diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index d2ff44a55a7..1c05c5bddf9 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -76,6 +76,7 @@ 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 * as VcsProcess from "../src/vcs/VcsProcess.ts"; function runGit(cwd: string, args: ReadonlyArray) { @@ -291,7 +292,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provide(providerEventLoggersLayer), ); - const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitVcsDriver.vcsLayer)); + const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer)); const projectionSnapshotQueryLayer = OrchestrationProjectionSnapshotQueryLive; const runtimeServicesLayer = Layer.mergeAll( projectionSnapshotQueryLayer, @@ -342,7 +343,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge( WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), - Layer.provideMerge(GitVcsDriver.vcsLayer), + Layer.provideMerge(VcsDriverRegistry.layer), Layer.provide(NodeServices.layer), ), ), diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts index 363e5ba6fc0..36739594842 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts @@ -8,7 +8,7 @@ import { describe, expect } from "vitest"; import { checkpointRefForThreadTurn } from "../Utils.ts"; import { CheckpointStoreLive } from "./CheckpointStore.ts"; import { CheckpointStore } from "../Services/CheckpointStore.ts"; -import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../../vcs/VcsProcess.ts"; import type { VcsError } from "@t3tools/contracts"; import { ServerConfig } from "../../config.ts"; @@ -18,7 +18,7 @@ const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-checkpoint-store-test-", }); const VcsProcessTestLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); -const VcsDriverTestLayer = GitVcsDriver.vcsLayer.pipe(Layer.provide(VcsProcessTestLayer)); +const VcsDriverTestLayer = VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcessTestLayer)); const CheckpointStoreTestLayer = CheckpointStoreLive.pipe( Layer.provideMerge(VcsDriverTestLayer), Layer.provideMerge(NodeServices.layer), diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.ts index 41ca7785c8f..c8b59564604 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.ts @@ -15,7 +15,7 @@ import { Effect, Layer, FileSystem, Path } from "effect"; import { CheckpointInvariantError } from "../Errors.ts"; import { VcsProcessExitError } from "@t3tools/contracts"; -import { VcsDriver } from "../../vcs/VcsDriver.ts"; +import { VcsDriverRegistry } from "../../vcs/VcsDriverRegistry.ts"; import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; import { CheckpointRef } from "@t3tools/contracts"; @@ -24,7 +24,23 @@ 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 vcs = yield* VcsDriver; + 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) => vcs diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 4ccf68f925a..8140d4e6cf2 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -25,7 +25,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts"; -import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../../vcs/VcsProcess.ts"; import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { CheckpointReactorLive } from "./CheckpointReactor.ts"; @@ -307,11 +307,11 @@ describe("CheckpointReactor", () => { Layer.provideMerge(RuntimeReceiptBusLive), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), Layer.provideMerge(gitStatusBroadcasterLayer), - Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(GitVcsDriver.vcsLayer))), + Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer))), Layer.provideMerge( WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), - Layer.provideMerge(GitVcsDriver.vcsLayer), + Layer.provideMerge(VcsDriverRegistry.layer), ), ), Layer.provideMerge(WorkspacePathsLive), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 27cc34ba856..0ac39612c4b 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -105,7 +105,12 @@ 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 { VcsDriver, type VcsDriverShape } from "./vcs/VcsDriver.ts"; +import type { VcsDriverShape } from "./vcs/VcsDriver.ts"; +import { + VcsDriverRegistry, + type VcsDriverRegistryShape, + type VcsDriverHandle, +} from "./vcs/VcsDriverRegistry.ts"; import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; @@ -316,6 +321,7 @@ const buildAppUnderTest = (options?: { serverSettings?: Partial; open?: Partial; vcsDriver?: Partial; + vcsDriverRegistry?: Partial; gitVcsDriver?: Partial; gitManager?: Partial; gitStatusBroadcaster?: Partial; @@ -364,7 +370,7 @@ const buildAppUnderTest = (options?: { ...options?.config, }; const layerConfig = Layer.succeed(ServerConfig, config); - const vcsDriverLayer = Layer.mock(VcsDriver)({ + const defaultVcsDriver: VcsDriverShape = { capabilities: { kind: "git", supportsWorktrees: true, @@ -372,6 +378,14 @@ const buildAppUnderTest = (options?: { supportsAtomicSnapshot: false, supportsPushDefaultRemote: true, }, + execute: () => + Effect.succeed({ + exitCode: 0, + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), detectRepository: () => Effect.succeed(null), isInsideWorkTree: () => Effect.succeed(false), listWorkspaceFiles: () => @@ -385,6 +399,56 @@ const buildAppUnderTest = (options?: { }), filterIgnoredPaths: (_cwd, relativePaths) => Effect.succeed(relativePaths), ...options?.layers?.vcsDriver, + }; + const vcsDriverRegistryLayer = Layer.mock(VcsDriverRegistry)({ + 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: new Date(0).toISOString(), + }, + } + : 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: new Date(0).toISOString(), + }, + }, + driver: defaultVcsDriver, + }), + ...options?.layers?.vcsDriverRegistry, }); const gitVcsDriverLayer = Layer.mock(GitVcsDriver.GitVcsDriver)({ ...options?.layers?.gitVcsDriver, @@ -394,7 +458,7 @@ const buildAppUnderTest = (options?: { }); const workspaceEntriesLayer = WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), - Layer.provideMerge(vcsDriverLayer), + Layer.provideMerge(vcsDriverRegistryLayer), ); const workspaceAndProjectServicesLayer = Layer.mergeAll( WorkspacePathsLive, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index a478be135a1..50845eb5bf9 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -47,6 +47,7 @@ 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 VcsProcess from "./vcs/VcsProcess.ts"; import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts"; import { ObservabilityLive } from "./observability/Layers/Observability.ts"; @@ -136,7 +137,7 @@ const ReactorLayerLive = Layer.empty.pipe( const CheckpointingLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointDiffQueryLive), - Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(GitVcsDriver.vcsLayer))), + Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer))), ); const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( @@ -173,7 +174,7 @@ const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive) const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), - Layer.provideMerge(GitVcsDriver.vcsLayer), + Layer.provideMerge(VcsDriverRegistry.layer), ); const WorkspaceFileSystemLayerLive = WorkspaceFileSystemLive.pipe( diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 9c692780a15..32619ccc4dc 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -278,7 +278,7 @@ const gitCommand = ( : {}), }); -export const makeVcsDriver = Effect.fn("makeGitVcsDriver")(function* () { +export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* () { const process = yield* VcsProcess; const capabilities = { kind: "git" as const, @@ -427,14 +427,19 @@ export const makeVcsDriver = Effect.fn("makeGitVcsDriver")(function* () { }, ); - return VcsDriver.of({ + return { capabilities, execute, detectRepository, isInsideWorkTree, listWorkspaceFiles, filterIgnoredPaths, - }); + } satisfies VcsDriverShape; +}); + +export const makeVcsDriver = Effect.fn("makeGitVcsDriver")(function* () { + const driver = yield* makeVcsDriverShape(); + return VcsDriver.of(driver); }); export const make = Effect.fn("makeGitVcsDriverService")(function* () { diff --git a/apps/server/src/vcs/VcsDriverRegistry.ts b/apps/server/src/vcs/VcsDriverRegistry.ts new file mode 100644 index 00000000000..b0bc79fd7d6 --- /dev/null +++ b/apps/server/src/vcs/VcsDriverRegistry.ts @@ -0,0 +1,103 @@ +import { Context, Effect, Layer } from "effect"; + +import type { VcsDriverKind, VcsError, VcsRepositoryIdentity } from "@t3tools/contracts"; +import { VcsUnsupportedOperationError } from "@t3tools/contracts"; +import * as GitVcsDriver from "./GitVcsDriver.ts"; +import type { VcsDriverShape } from "./VcsDriver.ts"; + +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 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, + }); + +export const make = Effect.fn("makeVcsDriverRegistry")(function* () { + const git = yield* GitVcsDriver.makeVcsDriverShape(); + const drivers: Partial> = { + git, + }; + + 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 detect: VcsDriverRegistryShape["detect"] = Effect.fn("VcsDriverRegistry.detect")( + function* (input) { + const requestedKind = input.requestedKind ?? "auto"; + + if (requestedKind !== "auto" && requestedKind !== "unknown") { + const driver = drivers[requestedKind]; + if (!driver) { + return yield* unsupported( + "VcsDriverRegistry.detect", + requestedKind, + `No ${requestedKind} VCS driver is registered.`, + ); + } + return yield* detectWithDriver(requestedKind, driver, input.cwd); + } + + return yield* detectWithDriver("git", git, input.cwd); + }, + ); + + 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({ + detect, + resolve, + }); +}); + +export const layer = Layer.effect(VcsDriverRegistry, make()); diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts index a0ec468a3e7..a7385794e93 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts @@ -5,7 +5,7 @@ import { it, afterEach, describe, expect, vi } from "@effect/vitest"; import { Effect, FileSystem, Layer, Path, PlatformError } from "effect"; import { ServerConfig } from "../../config.ts"; -import * as GitVcsDriver from "../../vcs/GitVcsDriver.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"; @@ -15,7 +15,7 @@ const TestLayer = Layer.empty.pipe( Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), Layer.provideMerge(WorkspacePathsLive), Layer.provideMerge(VcsProcess.layer), - Layer.provideMerge(GitVcsDriver.vcsLayer.pipe(Layer.provide(VcsProcess.layer))), + Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), Layer.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-workspace-entries-test-", diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.ts index 4406ebedd13..3046546813f 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, DateTime, 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 { VcsDriver } from "../../vcs/VcsDriver.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 vcsOption = yield* Effect.serviceOption(VcsDriver); + const vcsRegistry = yield* VcsDriverRegistry; const workspacePaths = yield* WorkspacePaths; const isInsideVcsWorkTree = (cwd: string): Effect.Effect => - Option.match(vcsOption, { - onSome: (vcs) => vcs.isInsideWorkTree(cwd).pipe(Effect.catch(() => Effect.succeed(false))), - onNone: () => Effect.succeed(false), - }); + vcsRegistry.detect({ cwd }).pipe( + Effect.map((handle) => handle !== null), + Effect.catch(() => Effect.succeed(false)), + ); const filterVcsIgnoredPaths = ( cwd: string, relativePaths: string[], ): Effect.Effect => - Option.match(vcsOption, { - onSome: (vcs) => - vcs.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 buildWorkspaceIndexFromVcs = Effect.fn("WorkspaceEntries.buildWorkspaceIndexFromVcs")( function* (cwd: string) { - if (Option.isNone(vcsOption)) { - return null; - } - if (!(yield* isInsideVcsWorkTree(cwd))) { + const vcs = yield* vcsRegistry.detect({ cwd }).pipe(Effect.catch(() => Effect.succeed(null))); + if (!vcs) { return null; } - const listedFiles = yield* vcsOption.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* filterVcsIgnoredPaths(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) { diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts index b19b810dd1b..9c38b88f851 100644 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts @@ -3,7 +3,7 @@ import { it, describe, expect } from "@effect/vitest"; import { Effect, FileSystem, Layer, Path } from "effect"; import { ServerConfig } from "../../config.ts"; -import * as GitVcsDriver from "../../vcs/GitVcsDriver.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"; @@ -20,7 +20,7 @@ const TestLayer = Layer.empty.pipe( Layer.provideMerge(ProjectLayer), Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), Layer.provideMerge(WorkspacePathsLive), - Layer.provideMerge(GitVcsDriver.vcsLayer.pipe(Layer.provide(VcsProcess.layer))), + Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), Layer.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-workspace-files-test-", From c87e67bc0497b4ddac16ff0f92501c1f31269764 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 1 May 2026 12:16:14 -0700 Subject: [PATCH 11/45] Refactor GitVcsDriverCore test setup - Simplify shared test helpers and temp repo setup - Switch the test layer wiring to merge Node services - Remove unused shell/process scaffolding from the spec --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 2395 +----------------- 1 file changed, 133 insertions(+), 2262 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 044a8dba0f9..95093c580f3 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1,72 +1,48 @@ -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 { describe, expect } from "vitest"; -import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts"; -import * as GitVcsDriver from "./GitVcsDriver.ts"; import { GitCommandError } from "@t3tools/contracts"; -import { type ProcessRunResult, runProcess } from "../processRunner.ts"; import { ServerConfig } from "../config.ts"; +import * as GitVcsDriver from "./GitVcsDriver.ts"; -// ── Helpers ── - -const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-core-test-" }); -const GitVcsDriverTestLayer = GitVcsDriver.layer.pipe( +const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-git-vcs-driver-test-", +}); +const TestLayer = GitVcsDriver.layer.pipe( Layer.provide(ServerConfigLayer), - Layer.provide(NodeServices.layer), + Layer.provideMerge(NodeServices.layer), ); -const TestLayer = Layer.mergeAll(NodeServices.layer, GitVcsDriverTestLayer); -function makeTmpDir( - prefix = "git-test-", -): Effect.Effect { - return Effect.gen(function* () { +const makeTmpDir = ( + prefix = "git-vcs-driver-test-", +): Effect.Effect => + Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; return yield* fileSystem.makeTempDirectoryScoped({ prefix }); }); -} -function writeTextFile( +const writeTextFile = ( filePath: string, contents: string, -): Effect.Effect { - return Effect.gen(function* () { +): Effect.Effect => + Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.makeDirectory(path.dirname(filePath), { recursive: true }); 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( +const git = ( cwd: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv, -): Effect.Effect { - return Effect.gen(function* () { - const gitVcsDriver = yield* GitVcsDriver.GitVcsDriver; - const result = yield* gitVcsDriver.execute({ +): Effect.Effect => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const result = yield* driver.execute({ operation: "GitVcsDriver.test.git", cwd, args, @@ -75,66 +51,17 @@ function git( }); 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 makeIsolatedGitVcsDriver = (executeOverride: GitVcsDriver.GitVcsDriverShape["execute"]) => - makeGitVcsDriverCore({ executeOverride }).pipe( - Effect.provide(Layer.provideMerge(ServerConfigLayer, NodeServices.layer)), - ); - -/** Create a repo with an initial commit so branches work. */ -function initRepoWithCommit( +const initRepoWithCommit = ( cwd: string, ): Effect.Effect< - { initialBranch: string }, + { readonly initialBranch: string }, GitCommandError | PlatformError.PlatformError, GitVcsDriver.GitVcsDriver | FileSystem.FileSystem -> { - return Effect.gen(function* () { - const core = yield* GitVcsDriver.GitVcsDriver; - yield* core.initRepo({ cwd }); +> => + 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(path.join(cwd, "README.md"), "# test\n"); @@ -143,2225 +70,169 @@ function initRepoWithCommit( 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, - GitVcsDriver.GitVcsDriver | 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"); -} - -// ── 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* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).listBranches({ cwd: tmp }); - expect(result.isRepo).toBe(true); - expect(result.hasOriginRemote).toBe(false); - expect(result.branches.length).toBeGreaterThanOrEqual(1); - }), - ); - }); - - // ── listGitBranches ── - - describe("listGitBranches", () => { - it.effect("returns isRepo: false for non-git directory", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const result = yield* (yield* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).listBranches({ - cwd: tmp, - })).branches.find((branch) => branch.current)!.name; - - yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ - cwd: tmp, - branch: "older-branch", - }); - yield* (yield* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).checkoutBranch({ - cwd: tmp, - branch: initialBranch, - }); - yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ - cwd: tmp, - branch: "newer-branch", - }); - yield* (yield* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).checkoutBranch({ - cwd: tmp, - branch: "older-branch", - }); - - const result = yield* (yield* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).createBranch({ - cwd: tmp, - branch: "current-branch", - }); - yield* (yield* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).checkoutBranch({ - cwd: tmp, - branch: defaultBranch, - }); - yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ - cwd: tmp, - branch: "newer-branch", - }); - yield* (yield* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).checkoutBranch({ - cwd: tmp, - branch: "current-branch", - }); - - const result = yield* (yield* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).createBranch({ cwd: tmp, branch: "feature-a" }); - yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ cwd: tmp, branch: "feature-b" }); - - const result = yield* (yield* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).createBranch({ cwd: tmp, branch: "feature-a" }); - yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ cwd: tmp, branch: "feature-b" }); - yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ cwd: tmp, branch: "feature-c" }); - - const firstPage = yield* (yield* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).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 realGitVcsDriver = yield* GitVcsDriver.GitVcsDriver; - const core = yield* makeIsolatedGitVcsDriver((input) => - realGitVcsDriver.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", () => +it.layer(TestLayer)("GitVcsDriver core integration", (it) => { + describe("repository status", () => { + it.effect("reports non-repository directories without failing", () => Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const result = yield* (yield* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).createBranch({ - cwd: tmp, - branch: "feature/local-only", - }); + const cwd = yield* makeTmpDir(); + const driver = yield* GitVcsDriver.GitVcsDriver; - const remoteOnlyBranch = "feature/remote-only"; - yield* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ - cwd: tmp, - branch: defaultBranch, + expect(yield* driver.listBranches({ cwd })).toMatchObject({ + isRepo: false, + branches: [], }); - 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* GitVcsDriver.GitVcsDriver).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", () => + it.effect("reports branch and dirty state for a repository", () => Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const tmp = yield* makeTmpDir(); - const remoteName = "my-org/upstream"; + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* writeTextFile(path.join(cwd, "feature.ts"), "export const value = 1;\n"); - yield* git(remote, ["init", "--bare"]); - yield* initRepoWithCommit(tmp); - const defaultBranch = (yield* (yield* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).listBranches({ cwd: tmp }); - const remoteBranch = result.branches.find( - (branch) => branch.name === `${remoteName}/${remoteOnlyBranch}`, - ); + const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); - expect(remoteBranch).toBeDefined(); - expect(remoteBranch?.isRemote).toBe(true); - expect(remoteBranch?.remoteName).toBe(remoteName); + expect(status.isRepo).toBe(true); + expect(status.branch).toBe(initialBranch); + expect(status.hasWorkingTreeChanges).toBe(true); + expect(status.workingTree.files.map((file) => file.path)).toContain("feature.ts"); }), ); - - 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* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).createBranch({ cwd: tmp, branch: "feature" }); - - yield* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ cwd: tmp, branch: "feature" }); - - const result = yield* (yield* GitVcsDriver.GitVcsDriver).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", () => + describe("branch operations", () => { + it.effect("creates, checks out, renames, and lists branches", () => 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"]); + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; - yield* initRepoWithCommit(source); - const defaultBranch = (yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ - cwd: source, - })).branches.find((branch) => branch.current)!.name; - yield* git(source, ["remote", "add", "origin", remote]); - yield* git(source, ["push", "-u", "origin", defaultBranch]); + yield* driver.createBranch({ cwd, branch: "feature/original" }); + const checkout = yield* driver.checkoutBranch({ cwd, branch: "feature/original" }); + expect(checkout.branch).toBe("feature/original"); - const featureBranch = "feature-behind"; - yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ - cwd: source, - branch: featureBranch, - }); - yield* (yield* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).checkoutBranch({ - cwd: source, - branch: defaultBranch, + const renamed = yield* driver.renameBranch({ + cwd, + oldBranch: "feature/original", + newBranch: "feature/renamed", }); + expect(renamed.branch).toBe("feature/renamed"); + expect(yield* git(cwd, ["branch", "--show-current"])).toBe("feature/renamed"); - 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* GitVcsDriver.GitVcsDriver).checkoutBranch({ - cwd: source, - branch: featureBranch, - }); - const core = yield* GitVcsDriver.GitVcsDriver; - 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, - }, - ), + const branches = yield* driver.listBranches({ cwd }); + expect(branches.branches.find((branch) => branch.name === "feature/renamed")).toMatchObject( + { + current: true, + }, ); }), ); - 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* GitVcsDriver.GitVcsDriver).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 realGitVcsDriver = yield* GitVcsDriver.GitVcsDriver; - let refreshFetchAttempts = 0; - const core = yield* makeIsolatedGitVcsDriver((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 realGitVcsDriver.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* GitVcsDriver.GitVcsDriver).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 realGitVcsDriver = yield* GitVcsDriver.GitVcsDriver; - let refreshFetchAttempts = 0; - const core = yield* makeIsolatedGitVcsDriver((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 realGitVcsDriver.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", () => + it.effect("returns the existing branch when rename source and target match", () => Effect.gen(function* () { - const ok = (stdout = "") => - Effect.succeed({ - code: 0, - stdout, - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }); + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; - let fetchCount = 0; - const core = yield* makeIsolatedGitVcsDriver((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 === "GitVcsDriver.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 === "GitVcsDriver.statusDetails.unstagedNumstat" || - input.operation === "GitVcsDriver.statusDetails.stagedNumstat" - ) { - return ok(); - } - if (input.operation === "GitVcsDriver.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.", - }), - ); + const current = yield* git(cwd, ["branch", "--show-current"]); + const result = yield* driver.renameBranch({ + cwd, + oldBranch: current, + newBranch: current, }); - 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* makeIsolatedGitVcsDriver((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 === "GitVcsDriver.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 === "GitVcsDriver.statusDetails.unstagedNumstat" || - input.operation === "GitVcsDriver.statusDetails.stagedNumstat" - ) { - return ok(); - } - if (input.operation === "GitVcsDriver.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* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).createBranch({ cwd: source, branch: "feature" }); - - const checkoutResult = yield* Effect.result( - (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ - cwd: source, - branch: "origin/feature", - }), - ); - expect(checkoutResult._tag).toBe("Failure"); - expect(yield* git(source, ["branch", "--show-current"])).toBe(defaultBranch); + expect(result.branch).toBe(current); }), ); + }); - it.effect("checks out a remote tracking branch when remote name contains slashes", () => + describe("worktree operations", () => { + it.effect("creates and removes a worktree for a new branch", () => 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"]); + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "feature-worktree"); + const driver = yield* GitVcsDriver.GitVcsDriver; - yield* initRepoWithCommit(source); - const defaultBranch = (yield* (yield* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).checkoutBranch({ - cwd: source, - branch: `${remoteName}/${featureBranch}`, + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + branch: initialBranch, + newBranch: "feature/worktree", }); - expect(checkoutResult.branch).toBe("upstream/feature"); - expect(yield* git(source, ["branch", "--show-current"])).toBe("upstream/feature"); - const realGitVcsDriver = yield* GitVcsDriver.GitVcsDriver; - let fetchArgs: readonly string[] | null = null; - const core = yield* makeIsolatedGitVcsDriver((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 realGitVcsDriver.execute(input); - }); + expect(created.worktree.path).toBe(worktreePath); + expect(created.worktree.branch).toBe("feature/worktree"); + expect(yield* git(worktreePath, ["branch", "--show-current"])).toBe("feature/worktree"); - 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, - ]); + yield* driver.removeWorktree({ cwd, path: worktreePath }); + const fileSystem = yield* FileSystem.FileSystem; + expect(yield* fileSystem.exists(worktreePath)).toBe(false); }), ); + }); - 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* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).checkoutBranch({ - cwd: source, - branch: `origin/${defaultBranch}`, - }); - - const core = yield* GitVcsDriver.GitVcsDriver; - const status = yield* core.statusDetails(source); - expect(status.branch).toBeNull(); - }), - ); - - it.effect("throws when checkout would overwrite uncommitted changes", () => + describe("commit context", () => { + it.effect("stages selected files and commits only those files", () => Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).listBranches({ - cwd: tmp, - })).branches.find((b) => !b.current)!.name; - yield* (yield* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).checkoutBranch({ cwd: tmp, branch: "other" }), - ); - expect(result._tag).toBe("Failure"); - }), - ); - }); + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; - // ── createGitBranch ── + yield* writeTextFile(path.join(cwd, "a.txt"), "a\n"); + yield* writeTextFile(path.join(cwd, "b.txt"), "b\n"); - describe("createGitBranch", () => { - it.effect("creates a new branch visible in listGitBranches", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ cwd: tmp, branch: "new-feature" }); + const context = yield* driver.prepareCommitContext(cwd, ["a.txt"]); + expect(context?.stagedSummary).toContain("a.txt"); + expect(context?.stagedSummary).not.toContain("b.txt"); - const result = yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ cwd: tmp }); - expect(result.branches.some((b) => b.name === "new-feature")).toBe(true); - }), - ); + const commit = yield* driver.commit(cwd, "Add a", ""); + expect(commit.commitSha).toMatch(/^[a-f0-9]{40}$/); + expect(yield* git(cwd, ["log", "-1", "--pretty=%s"])).toBe("Add a"); - it.effect("throws when branch already exists", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ cwd: tmp, branch: "dupe" }); - const result = yield* Effect.result( - (yield* GitVcsDriver.GitVcsDriver).createBranch({ cwd: tmp, branch: "dupe" }), - ); - expect(result._tag).toBe("Failure"); + const status = yield* git(cwd, ["status", "--porcelain"]); + expect(status).toContain("?? b.txt"); + expect(status).not.toContain("a.txt"); }), ); }); - // ── renameGitBranch ── - - describe("renameGitBranch", () => { - it.effect("renames the current branch", () => + describe("remote operations", () => { + it.effect("pushes with upstream setup and skips when already up to date", () => Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); + 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).createBranch({ - cwd: tmp, - branch: "feature/old-name", + cwd, + branch: "feature/push", }); yield* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ - cwd: tmp, - branch: "feature/old-name", + cwd, + branch: "feature/push", }); + yield* writeTextFile(path.join(cwd, "feature.txt"), "feature\n"); + yield* (yield* GitVcsDriver.GitVcsDriver).prepareCommitContext(cwd); + yield* (yield* GitVcsDriver.GitVcsDriver).commit(cwd, "Add feature", ""); - const renamed = yield* (yield* GitVcsDriver.GitVcsDriver).renameBranch({ - cwd: tmp, - oldBranch: "feature/old-name", - newBranch: "feature/new-name", + const pushed = yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch(cwd, null); + expect(pushed).toMatchObject({ + status: "pushed", + branch: "feature/push", + setUpstream: true, }); + expect(yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( + "origin/feature/push", + ); - expect(renamed.branch).toBe("feature/new-name"); - - const branches = yield* (yield* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).listBranches({ - cwd: tmp, - })).branches.find((b) => b.current)!; - - const renamed = yield* (yield* GitVcsDriver.GitVcsDriver).renameBranch({ - cwd: tmp, - oldBranch: current.name, - newBranch: current.name, + const skipped = yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch(cwd, null); + expect(skipped).toMatchObject({ + status: "skipped_up_to_date", + branch: "feature/push", }); - - 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* GitVcsDriver.GitVcsDriver).createBranch({ - cwd: tmp, - branch: "t3code/feat/session", - }); - yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ - cwd: tmp, - branch: "t3code/tmp-working", - }); - yield* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ - cwd: tmp, - branch: "t3code/tmp-working", - }); - - const renamed = yield* (yield* GitVcsDriver.GitVcsDriver).renameBranch({ - cwd: tmp, - oldBranch: "t3code/tmp-working", - newBranch: "t3code/feat/session", - }); - - expect(renamed.branch).toBe("t3code/feat/session-1"); - const branches = yield* (yield* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).createBranch({ - cwd: tmp, - branch: "t3code/feat/session", - }); - yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ - cwd: tmp, - branch: "t3code/feat/session-1", - }); - yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ - cwd: tmp, - branch: "t3code/tmp-working", - }); - yield* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ - cwd: tmp, - branch: "t3code/tmp-working", - }); - - const renamed = yield* (yield* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).createBranch({ - cwd: tmp, - branch: "feature/old-name", - }); - yield* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ - cwd: tmp, - branch: "feature/old-name", - }); - - const realGitVcsDriver = yield* GitVcsDriver.GitVcsDriver; - let renameArgs: ReadonlyArray | null = null; - const core = yield* makeIsolatedGitVcsDriver((input) => { - if (input.args[0] === "branch" && input.args[1] === "-m") { - renameArgs = [...input.args]; - } - return realGitVcsDriver.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* GitVcsDriver.GitVcsDriver).listBranches({ - cwd: tmp, - })).branches.find((b) => b.current)!.name; - - const result = yield* (yield* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).listBranches({ - cwd: tmp, - })).branches.find((b) => b.current)!.name; - - yield* (yield* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).createBranch({ - cwd: tmp, - branch: "feature/existing-worktree", - }); - - const wtPath = path.join(tmp, "wt-existing"); - const result = yield* (yield* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).createBranch({ cwd: tmp, branch: "existing" }); - - const wtPath = path.join(tmp, "wt-conflict"); - const currentBranch = (yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ - cwd: tmp, - })).branches.find((b) => b.current)!.name; - - const result = yield* Effect.result( - (yield* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).listBranches({ - cwd: tmp, - })).branches.find((b) => b.current)!.name; - - yield* (yield* GitVcsDriver.GitVcsDriver).createWorktree({ - cwd: tmp, - branch: mainBranch, - newBranch: "wt-list", - path: wtPath, - }); - - // listGitBranches from the worktree should show wt-list as current - const wtBranches = yield* (yield* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).listBranches({ cwd: tmp }); - const mainCurrent = mainBranches.branches.find((b) => b.current); - expect(mainCurrent!.name).toBe(mainBranch); - - yield* (yield* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).listBranches({ - cwd: tmp, - })).branches.find((b) => b.current)!.name; - - yield* (yield* GitVcsDriver.GitVcsDriver).createWorktree({ - cwd: tmp, - branch: currentBranch, - newBranch: "wt-remove", - path: wtPath, - }); - expect(existsSync(wtPath)).toBe(true); - - yield* (yield* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).listBranches({ - cwd: tmp, - })).branches.find((b) => b.current)!.name; - - yield* (yield* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).removeWorktree({ cwd: tmp, path: wtPath }), - ); - expect(failedRemove._tag).toBe("Failure"); - expect(existsSync(wtPath)).toBe(true); - - yield* (yield* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).createBranch({ - cwd: tmp, - branch: "feature-login", - }); - yield* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ - cwd: tmp, - branch: "feature-login", - }); - - const result = yield* (yield* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).listBranches({ - cwd: tmp, - })).branches.find((b) => b.current)!.name; - - const wtPath = path.join(tmp, "my-worktree"); - const result = yield* (yield* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).createBranch({ cwd: tmp, branch: "branch-a" }); - yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ cwd: tmp, branch: "branch-b" }); - - // Simulate switching to thread A's branch - yield* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ cwd: tmp, branch: "branch-a" }); - let branches = yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ cwd: tmp }); - expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); - - // Simulate switching to thread B's branch - yield* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ cwd: tmp, branch: "branch-b" }); - branches = yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ cwd: tmp }); - expect(branches.branches.find((b) => b.current)!.name).toBe("branch-b"); - - // Switch back to thread A - yield* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ cwd: tmp, branch: "branch-a" }); - branches = yield* (yield* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).createBranch({ cwd: tmp, branch: "diverged" }); - - // Make diverged branch have different file content - yield* (yield* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).listBranches({ cwd: tmp }); - const initialBranch = allBranches.branches.find((b) => b.name !== "diverged")!.name; - yield* (yield* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver).checkoutBranch({ cwd: tmp, branch: "diverged" }), - ); - expect(failedCheckout._tag).toBe("Failure"); - - // Current branch should still be the initial one - const result = yield* (yield* GitVcsDriver.GitVcsDriver).listBranches({ cwd: tmp }); - expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch); - }), - ); - }); - - describe("GitVcsDriver", () => { - it.effect("supports branch lifecycle operations through the service API", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const core = yield* GitVcsDriver.GitVcsDriver; - - 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* GitVcsDriver.GitVcsDriver; - - 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* GitVcsDriver.GitVcsDriver; - - 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* GitVcsDriver.GitVcsDriver; - - 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* GitVcsDriver.GitVcsDriver; - - 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* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver; - 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* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver; - 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* GitVcsDriver.GitVcsDriver; - - 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* GitVcsDriver.GitVcsDriver; - 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* GitVcsDriver.GitVcsDriver; - 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* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver; - 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* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver; - 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* GitVcsDriver.GitVcsDriver; - 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* GitVcsDriver.GitVcsDriver; - 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* GitVcsDriver.GitVcsDriver; - 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* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver; - 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* GitVcsDriver.GitVcsDriver; - - 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* GitVcsDriver.GitVcsDriver; - - 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* GitVcsDriver.GitVcsDriver; - - 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* GitVcsDriver.GitVcsDriver; - - 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* GitVcsDriver.GitVcsDriver; - - 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* GitVcsDriver.GitVcsDriver).createBranch({ - cwd: tmp, - branch: "feature/core-push", - }); - yield* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ - cwd: tmp, - branch: "feature/core-push", - }); - - yield* writeTextFile(path.join(tmp, "feature.txt"), "push me\n"); - const core = yield* GitVcsDriver.GitVcsDriver; - 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* GitVcsDriver.GitVcsDriver).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* GitVcsDriver.GitVcsDriver; - 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* GitVcsDriver.GitVcsDriver).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 realGitVcsDriver = yield* GitVcsDriver.GitVcsDriver; - let didFailRecency = false; - const core = yield* makeIsolatedGitVcsDriver((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 realGitVcsDriver.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 realGitVcsDriver = yield* GitVcsDriver.GitVcsDriver; - let didFailRemoteBranches = false; - let didFailRemoteNames = false; - const core = yield* makeIsolatedGitVcsDriver((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 realGitVcsDriver.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); }), ); }); From d8de522a0d89168a69045df3175396a3fe20fe72 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 1 May 2026 12:32:04 -0700 Subject: [PATCH 12/45] Refactor VCS driver tests into shared contract harness - Add a reusable VCS driver contract suite - Rework Git driver tests to use real repo fixtures --- apps/server/src/vcs/GitVcsDriver.test.ts | 159 ++++++------------ .../vcs/testing/VcsDriverContractHarness.ts | 159 ++++++++++++++++++ 2 files changed, 206 insertions(+), 112 deletions(-) create mode 100644 apps/server/src/vcs/testing/VcsDriverContractHarness.ts diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 7e981cd991d..13e9bdc91b2 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -1,125 +1,60 @@ -import { it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { Effect, Layer } from "effect"; -import { describe, expect } from "vitest"; +import { Effect, FileSystem, Layer, Path, PlatformError } from "effect"; +import { GitCommandError } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; -import { VcsDriver } from "./VcsDriver.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; import * as VcsProcess from "./VcsProcess.ts"; +import { runVcsDriverContractSuite } from "./testing/VcsDriverContractHarness.ts"; -const splitNullSeparatedPaths = (input: string): string[] => - input - .split("\0") - .map((value) => value.trim()) - .filter((value) => value.length > 0); +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, + }); + }); -const GitVcsDriverTestDependencies = ServerConfig.layerTest(process.cwd(), { - prefix: "t3-git-vcs-driver-test-", -}).pipe(Layer.provide(NodeServices.layer)); +type GitContractError = GitCommandError | PlatformError.PlatformError; -it.layer(Layer.empty)("GitVcsDriver.vcsLayer", (it) => { - describe("workspace helpers", () => { - it.effect("filterIgnoredPaths chunks large path lists and preserves kept paths", () => +runVcsDriverContractSuite({ + name: "Git", + kind: "git", + layer: GitContractLayer, + fixture: { + createRepo: (cwd) => 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 layer = GitVcsDriver.vcsLayer.pipe( - Layer.provideMerge(GitVcsDriverTestDependencies), - Layer.provideMerge(NodeServices.layer), - Layer.provide( - Layer.succeed(VcsProcess.VcsProcess, { - run: (input) => { - expect(input.command).toBe("git"); - expect(input.args).toEqual([ - "-c", - "core.fsmonitor=false", - "-c", - "core.untrackedCache=false", - "check-ignore", - "--no-index", - "-z", - "--stdin", - ]); - - const chunkPaths = splitNullSeparatedPaths(input.stdin ?? ""); - seenChunks.push(chunkPaths); - const ignoredPaths = chunkPaths.filter((relativePath) => - relativePath.startsWith("ignored/"), - ); - - return Effect.succeed({ - exitCode: ignoredPaths.length > 0 ? 0 : 1, - stdout: ignoredPaths.length > 0 ? `${ignoredPaths.join("\0")}\0` : "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }); - }, - }), - ), - ); - - const result = yield* Effect.gen(function* () { - const vcs = yield* VcsDriver; - return yield* vcs.filterIgnoredPaths(cwd, relativePaths); - }).pipe(Effect.provide(layer)); - - expect(seenChunks.length).toBeGreaterThan(1); - expect(seenChunks.flat()).toEqual(relativePaths); - expect(result).toEqual(expectedPaths); + yield* runGit(cwd, ["init"]); + yield* runGit(cwd, ["config", "user.email", "test@test.com"]); + yield* runGit(cwd, ["config", "user.name", "Test"]); }), - ); - - it.effect("listWorkspaceFiles disables fsmonitor and untracked cache helpers", () => + writeFile: (cwd, relativePath, contents) => Effect.gen(function* () { - const layer = GitVcsDriver.vcsLayer.pipe( - Layer.provideMerge(GitVcsDriverTestDependencies), - Layer.provideMerge(NodeServices.layer), - Layer.provide( - Layer.succeed(VcsProcess.VcsProcess, { - run: (input) => { - expect(input.command).toBe("git"); - expect(input.args).toEqual([ - "-c", - "core.fsmonitor=false", - "-c", - "core.untrackedCache=false", - "ls-files", - "--cached", - "--others", - "--exclude-standard", - "-z", - ]); - return Effect.succeed({ - exitCode: 0, - stdout: "src/index.ts\0README.md\0", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }); - }, - }), - ), - ); - - const result = yield* Effect.gen(function* () { - const vcs = yield* VcsDriver; - return yield* vcs.listWorkspaceFiles("/virtual/repo"); - }).pipe(Effect.provide(layer)); - - expect(result.paths).toEqual(["src/index.ts", "README.md"]); - expect(result.truncated).toBe(false); - expect(result.freshness.source).toBe("live-local"); + 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`); + }), + }, }); diff --git a/apps/server/src/vcs/testing/VcsDriverContractHarness.ts b/apps/server/src/vcs/testing/VcsDriverContractHarness.ts new file mode 100644 index 00000000000..136bbf3aa60 --- /dev/null +++ b/apps/server/src/vcs/testing/VcsDriverContractHarness.ts @@ -0,0 +1,159 @@ +import { realpathSync } from "node:fs"; + +import { it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path, type PlatformError, type Scope } from "effect"; +import { describe, expect } 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; + + expect(yield* driver.detectRepository(cwd)).toBeNull(); + expect(yield* driver.isInsideWorkTree(cwd)).toBe(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 expectedRootPath = realpathSync.native(cwd); + + const identity = yield* driver.detectRepository(cwd); + expect(identity).toMatchObject({ + kind: input.kind, + rootPath: expectedRootPath, + }); + expect(identity?.freshness).toMatchObject({ + source: "live-local", + observedAt: expect.any(String), + }); + expect(yield* driver.isInsideWorkTree(cwd)).toBe(true); + + const path = yield* Path.Path; + const nestedDir = path.join(cwd, "src"); + const nestedIdentity = yield* driver.detectRepository(nestedDir); + expect(nestedIdentity?.rootPath).toBe(expectedRootPath); + expect(yield* driver.isInsideWorkTree(nestedDir)).toBe(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); + + expect(result.paths).toContain("tracked.ts"); + expect(result.paths).toContain("untracked.ts"); + expect(result.truncated).toBe(false); + expect(result.freshness).toMatchObject({ + source: "live-local", + observedAt: expect.any(String), + }); + }), + ); + + 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); + + expect(result.paths).toContain("included.ts"); + expect(result.paths).not.toContain("debug.log"); + expect(result.paths).not.toContain("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", + ]); + + expect(result).toEqual(["keep.ts"]); + }), + ); + + it.effect("returns empty input unchanged", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* VcsDriver; + + yield* input.fixture.createRepo(cwd); + + expect(yield* driver.filterIgnoredPaths(cwd, [])).toEqual([]); + }), + ); + }); + }); +} From 6406e21cf64373722b253171436f121b75a3c9e9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 1 May 2026 12:38:08 -0700 Subject: [PATCH 13/45] Tighten VCS driver test assertions - Switch integration and contract tests to Effect/Vitest assertions - Avoid platform-specific realpath assumptions in repository identity checks - Use Path service for portable test file and worktree path handling --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 95 ++++++++++--------- .../vcs/testing/VcsDriverContractHarness.ts | 52 ++++------ 2 files changed, 72 insertions(+), 75 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 95093c580f3..236be6d7b05 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1,9 +1,7 @@ -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 } from "vitest"; +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"; @@ -26,12 +24,15 @@ const makeTmpDir = ( }); const writeTextFile = ( - filePath: string, + cwd: string, + relativePath: string, contents: string, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - yield* fileSystem.makeDirectory(path.dirname(filePath), { recursive: true }); + const pathService = yield* Path.Path; + const filePath = pathService.join(cwd, relativePath); + yield* fileSystem.makeDirectory(pathService.dirname(filePath), { recursive: true }); yield* fileSystem.writeFileString(filePath, contents); }); @@ -57,14 +58,14 @@ const initRepoWithCommit = ( ): Effect.Effect< { readonly initialBranch: string }, GitCommandError | PlatformError.PlatformError, - GitVcsDriver.GitVcsDriver | FileSystem.FileSystem + 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(path.join(cwd, "README.md"), "# test\n"); + 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"]); @@ -78,10 +79,9 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const cwd = yield* makeTmpDir(); const driver = yield* GitVcsDriver.GitVcsDriver; - expect(yield* driver.listBranches({ cwd })).toMatchObject({ - isRepo: false, - branches: [], - }); + const branches = yield* driver.listBranches({ cwd }); + assert.equal(branches.isRepo, false); + assert.deepStrictEqual(branches.branches, []); }), ); @@ -89,14 +89,17 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { Effect.gen(function* () { const cwd = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(cwd); - yield* writeTextFile(path.join(cwd, "feature.ts"), "export const value = 1;\n"); + yield* writeTextFile(cwd, "feature.ts", "export const value = 1;\n"); const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); - expect(status.isRepo).toBe(true); - expect(status.branch).toBe(initialBranch); - expect(status.hasWorkingTreeChanges).toBe(true); - expect(status.workingTree.files.map((file) => file.path)).toContain("feature.ts"); + 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", + ); }), ); }); @@ -110,21 +113,20 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* driver.createBranch({ cwd, branch: "feature/original" }); const checkout = yield* driver.checkoutBranch({ cwd, branch: "feature/original" }); - expect(checkout.branch).toBe("feature/original"); + assert.equal(checkout.branch, "feature/original"); const renamed = yield* driver.renameBranch({ cwd, oldBranch: "feature/original", newBranch: "feature/renamed", }); - expect(renamed.branch).toBe("feature/renamed"); - expect(yield* git(cwd, ["branch", "--show-current"])).toBe("feature/renamed"); + assert.equal(renamed.branch, "feature/renamed"); + assert.equal(yield* git(cwd, ["branch", "--show-current"]), "feature/renamed"); const branches = yield* driver.listBranches({ cwd }); - expect(branches.branches.find((branch) => branch.name === "feature/renamed")).toMatchObject( - { - current: true, - }, + assert.equal( + branches.branches.find((branch) => branch.name === "feature/renamed")?.current, + true, ); }), ); @@ -142,7 +144,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { newBranch: current, }); - expect(result.branch).toBe(current); + assert.equal(result.branch, current); }), ); }); @@ -152,7 +154,11 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { Effect.gen(function* () { const cwd = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(cwd); - const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "feature-worktree"); + 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({ @@ -162,13 +168,13 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { newBranch: "feature/worktree", }); - expect(created.worktree.path).toBe(worktreePath); - expect(created.worktree.branch).toBe("feature/worktree"); - expect(yield* git(worktreePath, ["branch", "--show-current"])).toBe("feature/worktree"); + assert.equal(created.worktree.path, worktreePath); + assert.equal(created.worktree.branch, "feature/worktree"); + assert.equal(yield* git(worktreePath, ["branch", "--show-current"]), "feature/worktree"); yield* driver.removeWorktree({ cwd, path: worktreePath }); const fileSystem = yield* FileSystem.FileSystem; - expect(yield* fileSystem.exists(worktreePath)).toBe(false); + assert.equal(yield* fileSystem.exists(worktreePath), false); }), ); }); @@ -180,20 +186,20 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* initRepoWithCommit(cwd); const driver = yield* GitVcsDriver.GitVcsDriver; - yield* writeTextFile(path.join(cwd, "a.txt"), "a\n"); - yield* writeTextFile(path.join(cwd, "b.txt"), "b\n"); + yield* writeTextFile(cwd, "a.txt", "a\n"); + yield* writeTextFile(cwd, "b.txt", "b\n"); const context = yield* driver.prepareCommitContext(cwd, ["a.txt"]); - expect(context?.stagedSummary).toContain("a.txt"); - expect(context?.stagedSummary).not.toContain("b.txt"); + assert.include(context?.stagedSummary ?? "", "a.txt"); + assert.notInclude(context?.stagedSummary ?? "", "b.txt"); const commit = yield* driver.commit(cwd, "Add a", ""); - expect(commit.commitSha).toMatch(/^[a-f0-9]{40}$/); - expect(yield* git(cwd, ["log", "-1", "--pretty=%s"])).toBe("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"]); - expect(status).toContain("?? b.txt"); - expect(status).not.toContain("a.txt"); + assert.include(status, "?? b.txt"); + assert.notInclude(status, "a.txt"); }), ); }); @@ -214,22 +220,23 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { cwd, branch: "feature/push", }); - yield* writeTextFile(path.join(cwd, "feature.txt"), "feature\n"); + 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); - expect(pushed).toMatchObject({ + assert.deepInclude(pushed, { status: "pushed", branch: "feature/push", setUpstream: true, }); - expect(yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), "origin/feature/push", ); const skipped = yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch(cwd, null); - expect(skipped).toMatchObject({ + assert.deepInclude(skipped, { status: "skipped_up_to_date", branch: "feature/push", }); diff --git a/apps/server/src/vcs/testing/VcsDriverContractHarness.ts b/apps/server/src/vcs/testing/VcsDriverContractHarness.ts index 136bbf3aa60..b7108dc0436 100644 --- a/apps/server/src/vcs/testing/VcsDriverContractHarness.ts +++ b/apps/server/src/vcs/testing/VcsDriverContractHarness.ts @@ -1,8 +1,6 @@ -import { realpathSync } from "node:fs"; - -import { it } from "@effect/vitest"; +import { assert, it } from "@effect/vitest"; import { Effect, FileSystem, Layer, Path, type PlatformError, type Scope } from "effect"; -import { describe, expect } from "vitest"; +import { describe } from "vitest"; import type { VcsDriverKind } from "@t3tools/contracts"; import { VcsDriver } from "../VcsDriver.ts"; @@ -45,8 +43,8 @@ export function runVcsDriverContractSuite(input: VcsDriverContractSuiteInp const cwd = yield* makeTmpDir(); const driver = yield* VcsDriver; - expect(yield* driver.detectRepository(cwd)).toBeNull(); - expect(yield* driver.isInsideWorkTree(cwd)).toBe(false); + assert.equal(yield* driver.detectRepository(cwd), null); + assert.equal(yield* driver.isInsideWorkTree(cwd), false); }), ); @@ -57,24 +55,18 @@ export function runVcsDriverContractSuite(input: VcsDriverContractSuiteInp yield* input.fixture.createRepo(cwd); yield* input.fixture.writeFile(cwd, "src/index.ts", "export const value = 1;\n"); - const expectedRootPath = realpathSync.native(cwd); - const identity = yield* driver.detectRepository(cwd); - expect(identity).toMatchObject({ - kind: input.kind, - rootPath: expectedRootPath, - }); - expect(identity?.freshness).toMatchObject({ - source: "live-local", - observedAt: expect.any(String), - }); - expect(yield* driver.isInsideWorkTree(cwd)).toBe(true); + assert.equal(identity?.kind, input.kind); + assert.isTrue(identity?.rootPath.endsWith(cwd)); + assert.equal(identity?.freshness.source, "live-local"); + assert.equal(typeof identity?.freshness.observedAt, "string"); + assert.equal(yield* driver.isInsideWorkTree(cwd), true); const path = yield* Path.Path; const nestedDir = path.join(cwd, "src"); const nestedIdentity = yield* driver.detectRepository(nestedDir); - expect(nestedIdentity?.rootPath).toBe(expectedRootPath); - expect(yield* driver.isInsideWorkTree(nestedDir)).toBe(true); + assert.equal(nestedIdentity?.rootPath, identity?.rootPath); + assert.equal(yield* driver.isInsideWorkTree(nestedDir), true); }), ); }); @@ -95,13 +87,11 @@ export function runVcsDriverContractSuite(input: VcsDriverContractSuiteInp const result = yield* driver.listWorkspaceFiles(cwd); - expect(result.paths).toContain("tracked.ts"); - expect(result.paths).toContain("untracked.ts"); - expect(result.truncated).toBe(false); - expect(result.freshness).toMatchObject({ - source: "live-local", - observedAt: expect.any(String), - }); + 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.equal(typeof result.freshness.observedAt, "string"); }), ); @@ -118,9 +108,9 @@ export function runVcsDriverContractSuite(input: VcsDriverContractSuiteInp const result = yield* driver.listWorkspaceFiles(cwd); - expect(result.paths).toContain("included.ts"); - expect(result.paths).not.toContain("debug.log"); - expect(result.paths).not.toContain("nested/error.log"); + assert.include(result.paths, "included.ts"); + assert.notInclude(result.paths, "debug.log"); + assert.notInclude(result.paths, "nested/error.log"); }), ); }); @@ -140,7 +130,7 @@ export function runVcsDriverContractSuite(input: VcsDriverContractSuiteInp "nested/error.log", ]); - expect(result).toEqual(["keep.ts"]); + assert.deepStrictEqual(result, ["keep.ts"]); }), ); @@ -151,7 +141,7 @@ export function runVcsDriverContractSuite(input: VcsDriverContractSuiteInp yield* input.fixture.createRepo(cwd); - expect(yield* driver.filterIgnoredPaths(cwd, [])).toEqual([]); + assert.deepStrictEqual(yield* driver.filterIgnoredPaths(cwd, []), []); }), ); }); From b449d780389f84143cefcbef84beff29185d68d3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 1 May 2026 12:42:53 -0700 Subject: [PATCH 14/45] Use mock layers for Git VCS driver tests - Replace ad hoc `succeed` casts with `Layer.mock` in integration and reactor tests - Keep Git VCS driver fixtures type-safe while preserving existing test behavior --- .../integration/OrchestrationEngineHarness.integration.ts | 4 ++-- .../src/orchestration/Layers/ProviderCommandReactor.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 1c05c5bddf9..1c5890a31a9 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -308,10 +308,10 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(serverSettingsLayer), ); - const gitVcsDriverLayer = Layer.succeed(GitVcsDriver.GitVcsDriver, { + const gitVcsDriverLayer = Layer.mock(GitVcsDriver.GitVcsDriver)({ renameBranch: (input: Parameters[0]) => Effect.succeed({ branch: input.newBranch }), - } as unknown as GitVcsDriver.GitVcsDriverShape); + }); const textGenerationLayer = Layer.succeed(TextGeneration, { generateBranchName: () => Effect.succeed({ branch: "update" }), generateThreadTitle: () => Effect.succeed({ title: "New thread" }), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index d5100418350..c2895a0e0e0 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -325,9 +325,9 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge(orchestrationLayer), Layer.provideMerge(Layer.succeed(ProviderService, service)), Layer.provideMerge( - Layer.succeed(GitVcsDriver.GitVcsDriver, { + Layer.mock(GitVcsDriver.GitVcsDriver)({ renameBranch, - } as unknown as GitVcsDriver.GitVcsDriverShape), + }), ), Layer.provideMerge( Layer.succeed(GitStatusBroadcaster, { From f473d20593ae0b3112eeeab87307b1f6322637f4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 1 May 2026 14:32:06 -0700 Subject: [PATCH 15/45] Add pluggable source control routing - Route git workflows through source control providers - Replace git status broadcaster with VCS status service - Update orchestration, server, and web contracts for provider-aware git --- .../OrchestrationEngineHarness.integration.ts | 9 +- apps/server/src/git/GitWorkflowService.ts | 209 +++++++++++ apps/server/src/git/Layers/GitManager.test.ts | 5 +- apps/server/src/git/Layers/GitManager.ts | 116 +++--- .../src/git/Layers/GitStatusBroadcaster.ts | 311 ----------------- .../src/git/Services/GitStatusBroadcaster.ts | 27 -- .../Layers/CheckpointReactor.test.ts | 6 +- .../orchestration/Layers/CheckpointReactor.ts | 6 +- .../Layers/ProviderCommandReactor.test.ts | 15 +- .../Layers/ProviderCommandReactor.ts | 12 +- apps/server/src/server.test.ts | 51 +-- apps/server/src/server.ts | 41 ++- .../GitHubSourceControlProvider.test.ts | 132 +++++++ .../GitHubSourceControlProvider.ts | 141 ++++++++ .../sourceControl/SourceControlProvider.ts | 46 +++ .../SourceControlProviderRegistry.ts | 28 ++ apps/server/src/vcs/GitVcsDriverCore.ts | 28 +- apps/server/src/vcs/VcsDriverRegistry.test.ts | 64 ++++ apps/server/src/vcs/VcsDriverRegistry.ts | 79 ++++- apps/server/src/vcs/VcsProjectConfig.test.ts | 67 ++++ apps/server/src/vcs/VcsProjectConfig.ts | 117 +++++++ .../VcsStatusBroadcaster.test.ts} | 89 +++-- apps/server/src/vcs/VcsStatusBroadcaster.ts | 330 ++++++++++++++++++ apps/server/src/ws.ts | 100 +++--- .../BranchToolbarBranchSelector.tsx | 4 +- apps/web/src/components/ChatView.browser.tsx | 15 +- .../components/KeybindingsToast.browser.tsx | 2 +- apps/web/src/environmentApi.ts | 29 +- apps/web/src/hooks/useThreadActions.ts | 2 +- apps/web/src/lib/gitReactQuery.ts | 34 +- apps/web/src/lib/gitStatusState.ts | 4 +- apps/web/src/localApi.test.ts | 16 +- apps/web/src/rpc/wsRpcClient.test.ts | 4 +- apps/web/src/rpc/wsRpcClient.ts | 55 +-- apps/web/test/wsRpcHarness.ts | 2 +- packages/contracts/src/git.ts | 2 + packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 28 +- packages/contracts/src/rpc.ts | 58 +-- packages/contracts/src/sourceControl.ts | 49 +++ 40 files changed, 1648 insertions(+), 686 deletions(-) create mode 100644 apps/server/src/git/GitWorkflowService.ts delete mode 100644 apps/server/src/git/Layers/GitStatusBroadcaster.ts delete mode 100644 apps/server/src/git/Services/GitStatusBroadcaster.ts create mode 100644 apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts create mode 100644 apps/server/src/sourceControl/GitHubSourceControlProvider.ts create mode 100644 apps/server/src/sourceControl/SourceControlProvider.ts create mode 100644 apps/server/src/sourceControl/SourceControlProviderRegistry.ts create mode 100644 apps/server/src/vcs/VcsDriverRegistry.test.ts create mode 100644 apps/server/src/vcs/VcsProjectConfig.test.ts create mode 100644 apps/server/src/vcs/VcsProjectConfig.ts rename apps/server/src/{git/Layers/GitStatusBroadcaster.test.ts => vcs/VcsStatusBroadcaster.test.ts} (78%) create mode 100644 apps/server/src/vcs/VcsStatusBroadcaster.ts create mode 100644 packages/contracts/src/sourceControl.ts diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 1c5890a31a9..4ccacbb345d 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -25,7 +25,6 @@ import { import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts"; import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts"; -import { GitStatusBroadcaster } from "../src/git/Services/GitStatusBroadcaster.ts"; import { TextGeneration, type TextGenerationShape } from "../src/git/Services/TextGeneration.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts"; import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts"; @@ -77,6 +76,8 @@ import { WorkspaceEntriesLive } from "../src/workspace/Layers/WorkspaceEntries.t 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) { @@ -308,7 +309,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(serverSettingsLayer), ); - const gitVcsDriverLayer = Layer.mock(GitVcsDriver.GitVcsDriver)({ + const gitWorkflowLayer = Layer.mock(GitWorkflowService)({ renameBranch: (input: Parameters[0]) => Effect.succeed({ branch: input.newBranch }), }); @@ -318,14 +319,14 @@ export const makeOrchestrationIntegrationHarness = ( } as unknown as TextGenerationShape); const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), - Layer.provideMerge(gitVcsDriverLayer), + 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({ diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts new file mode 100644 index 00000000000..153c0c4dc53 --- /dev/null +++ b/apps/server/src/git/GitWorkflowService.ts @@ -0,0 +1,209 @@ +import { Context, Effect, Layer } from "effect"; + +import { + GitManagerError, + GitCommandError, + type GitCheckoutInput, + type GitCheckoutResult, + type GitCreateBranchInput, + type GitCreateBranchResult, + type GitCreateWorktreeInput, + type GitCreateWorktreeResult, + type GitListBranchesInput, + type GitListBranchesResult, + type GitManagerServiceError, + type GitPreparePullRequestThreadInput, + type GitPreparePullRequestThreadResult, + type GitPullRequestRefInput, + type GitPullResult, + type GitRemoveWorktreeInput, + type GitResolvePullRequestResult, + type GitRunStackedActionInput, + type GitRunStackedActionResult, + type GitStatusInput, + type GitStatusLocalResult, + type GitStatusRemoteResult, + type GitStatusResult, +} from "@t3tools/contracts"; + +import { GitManager, type GitRunStackedActionOptions } from "./Services/GitManager.ts"; +import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; +import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; + +export interface GitWorkflowServiceShape { + readonly status: ( + input: GitStatusInput, + ) => Effect.Effect; + readonly localStatus: ( + input: GitStatusInput, + ) => Effect.Effect; + readonly remoteStatus: ( + input: GitStatusInput, + ) => 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 listBranches: ( + input: GitListBranchesInput, + ) => Effect.Effect; + readonly createWorktree: ( + input: GitCreateWorktreeInput, + ) => Effect.Effect; + readonly removeWorktree: (input: GitRemoveWorktreeInput) => Effect.Effect; + readonly createBranch: ( + input: GitCreateBranchInput, + ) => Effect.Effect; + readonly checkoutBranch: ( + input: GitCheckoutInput, + ) => Effect.Effect; + readonly initRepo: (input: { readonly cwd: string }) => Effect.Effect; + readonly renameBranch: (input: { + readonly cwd: string; + readonly oldBranch: string; + readonly newBranch: string; + }) => Effect.Effect; +} + +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, + }); + +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 routeGitManager = + ( + operation: string, + run: (input: Input) => Effect.Effect, + ) => + (input: Input) => + ensureGit(operation, input.cwd).pipe(Effect.andThen(run(input))); + + return GitWorkflowService.of({ + status: routeGitManager("GitWorkflowService.status", gitManager.status), + localStatus: routeGitManager("GitWorkflowService.localStatus", gitManager.localStatus), + remoteStatus: routeGitManager("GitWorkflowService.remoteStatus", gitManager.remoteStatus), + 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, + ), + listBranches: (input) => + ensureGitCommand("GitWorkflowService.listBranches", input.cwd).pipe( + Effect.andThen(git.listBranches(input)), + ), + 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)), + ), + createBranch: (input) => + ensureGitCommand("GitWorkflowService.createBranch", input.cwd).pipe( + Effect.andThen(git.createBranch(input)), + ), + checkoutBranch: (input) => + ensureGitCommand("GitWorkflowService.checkoutBranch", input.cwd).pipe( + Effect.andThen(Effect.scoped(git.checkoutBranch(input))), + ), + initRepo: (input) => git.initRepo(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/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 4213f6336ec..1b5db1c1bd0 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -23,6 +23,7 @@ import { import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../../vcs/VcsProcess.ts"; +import * as SourceControlProviderRegistry from "../../sourceControl/SourceControlProviderRegistry.ts"; import { makeGitManager } from "./GitManager.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; @@ -651,7 +652,7 @@ function makeManager(input?: { ); const managerLayer = Layer.mergeAll( - Layer.succeed(GitHubCli, gitHubCli), + SourceControlProviderRegistry.layer.pipe(Layer.provide(Layer.succeed(GitHubCli, gitHubCli))), Layer.succeed(TextGeneration, textGeneration), Layer.succeed( ProjectSetupScriptRunner, @@ -3126,7 +3127,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect.objectContaining({ kind: "phase_started", phase: "pr", - label: "Creating GitHub pull request...", + label: "Creating pull request...", }), ]); }), diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index d8b0f77760e..1f993faeed4 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -3,6 +3,7 @@ import { realpathSync } from "node:fs"; import { Cache, + DateTime, Duration, Effect, Exit, @@ -11,7 +12,6 @@ import { Option, Path, Ref, - Result, } from "effect"; import { GitActionProgressEvent, @@ -38,17 +38,14 @@ import { type GitManagerShape, type GitRunStackedActionOptions, } from "../Services/GitManager.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"; import { GitVcsDriver, type GitStatusDetails } from "../../vcs/GitVcsDriver.ts"; +import { SourceControlProviderRegistry } from "../../sourceControl/SourceControlProviderRegistry.ts"; +import type { ChangeRequest } from "@t3tools/contracts"; const COMMIT_TIMEOUT_MS = 10 * 60_000; const MAX_PROGRESS_TEXT_LENGTH = 500; @@ -87,9 +84,9 @@ interface ResolvedPullRequest { } interface PullRequestHeadRemoteInfo { - isCrossRepository?: boolean; - headRepositoryNameWithOwner?: string | null; - headRepositoryOwnerLogin?: string | null; + isCrossRepository?: boolean | undefined; + headRepositoryNameWithOwner?: string | null | undefined; + headRepositoryOwnerLogin?: string | null | undefined; } interface BranchHeadContext { @@ -255,7 +252,7 @@ function matchesBranchHeadContext( return true; } -function toPullRequestInfo(summary: GitHubPullRequestSummary): PullRequestInfo { +function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { return { number: summary.number, title: summary.title, @@ -263,7 +260,7 @@ function toPullRequestInfo(summary: GitHubPullRequestSummary): PullRequestInfo { baseRefName: summary.baseRefName, headRefName: summary.headRefName, state: summary.state ?? "open", - updatedAt: null, + updatedAt: summary.updatedAt, ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } : {}), @@ -276,6 +273,14 @@ function toPullRequestInfo(summary: GitHubPullRequestSummary): PullRequestInfo { }; } +function parseDateTimeMillis(value: string | null): number { + if (!value) { + return 0; + } + const parsed = DateTime.make(value); + return Option.isSome(parsed) ? DateTime.toEpochMillis(parsed.value) : 0; +} + function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError { return new GitManagerError({ operation, @@ -474,9 +479,9 @@ function shouldPreferSshRemote(url: string | null): boolean { } function toPullRequestHeadRemoteInfo(pr: { - isCrossRepository?: boolean; - headRepositoryNameWithOwner?: string | null; - headRepositoryOwnerLogin?: string | null; + isCrossRepository?: boolean | undefined; + headRepositoryNameWithOwner?: string | null | undefined; + headRepositoryOwnerLogin?: string | null | undefined; }): PullRequestHeadRemoteInfo { return { ...(pr.isCrossRepository !== undefined ? { isCrossRepository: pr.isCrossRepository } : {}), @@ -491,9 +496,11 @@ function toPullRequestHeadRemoteInfo(pr: { export const makeGitManager = Effect.fn("makeGitManager")(function* () { const gitCore = yield* GitVcsDriver; - const gitHubCli = yield* GitHubCli; + 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 = ( @@ -530,7 +537,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { return; } - const cloneUrls = yield* gitHubCli.getRepositoryCloneUrls({ + const cloneUrls = yield* (yield* sourceControlProvider(cwd)).getRepositoryCloneUrls({ cwd, repository: repositoryNameWithOwner, }); @@ -585,7 +592,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { return; } - const cloneUrls = yield* gitHubCli.getRepositoryCloneUrls({ + const cloneUrls = yield* (yield* sourceControlProvider(cwd)).getRepositoryCloneUrls({ cwd, repository: repositoryNameWithOwner, }); @@ -832,9 +839,10 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { >, ) { for (const headSelector of headContext.headSelectors) { - const pullRequests = yield* gitHubCli.listOpenPullRequests({ + const pullRequests = yield* (yield* sourceControlProvider(cwd)).listChangeRequests({ cwd, headSelector, + state: "open", limit: 1, }); const normalizedPullRequests = pullRequests.map(toPullRequestInfo); @@ -866,46 +874,14 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { 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); - }), - ); + const pullRequests = yield* (yield* sourceControlProvider(cwd)).listChangeRequests({ + cwd, + headSelector, + state: "all", + limit: 20, + }); - for (const pr of pullRequests) { + for (const pr of pullRequests.map(toPullRequestInfo)) { if (!matchesBranchHeadContext(pr, headContext)) { continue; } @@ -914,8 +890,8 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { } 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; + const left = parseDateTimeMillis(a.updatedAt); + const right = parseDateTimeMillis(b.updatedAt); return right - left; }); @@ -1034,11 +1010,11 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { } } - const defaultFromGh = yield* gitHubCli + const defaultFromProvider = yield* (yield* sourceControlProvider(cwd)) .getDefaultBranch({ cwd }) .pipe(Effect.catch(() => Effect.succeed(null))); - if (defaultFromGh) { - return defaultFromGh; + if (defaultFromProvider) { + return defaultFromProvider; } return "main"; @@ -1271,12 +1247,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { yield* emit({ kind: "phase_started", phase: "pr", - label: "Creating GitHub pull request...", + label: "Creating pull request...", }); - yield* gitHubCli - .createPullRequest({ + yield* (yield* sourceControlProvider(cwd)) + .createChangeRequest({ cwd, - baseBranch, + baseRefName: baseBranch, headSelector: headContext.preferredHeadSelector, title: generated.title, bodyFile, @@ -1334,8 +1310,8 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")( function* (input) { - const pullRequest = yield* gitHubCli - .getPullRequest({ + const pullRequest = yield* (yield* sourceControlProvider(input.cwd)) + .getChangeRequest({ cwd: input.cwd, reference: normalizePullRequestReference(input.reference), }) @@ -1369,14 +1345,14 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { return yield* Effect.gen(function* () { const normalizedReference = normalizePullRequestReference(input.reference); const rootWorktreePath = canonicalizeExistingPath(input.cwd); - const pullRequestSummary = yield* gitHubCli.getPullRequest({ + const pullRequestSummary = yield* (yield* sourceControlProvider(input.cwd)).getChangeRequest({ cwd: input.cwd, reference: normalizedReference, }); const pullRequest = toResolvedPullRequest(pullRequestSummary); if (input.mode === "local") { - yield* gitHubCli.checkoutPullRequest({ + yield* (yield* sourceControlProvider(input.cwd)).checkoutChangeRequest({ cwd: input.cwd, reference: normalizedReference, force: true, diff --git a/apps/server/src/git/Layers/GitStatusBroadcaster.ts b/apps/server/src/git/Layers/GitStatusBroadcaster.ts deleted file mode 100644 index 3ad7d095d8d..00000000000 --- 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/Services/GitStatusBroadcaster.ts b/apps/server/src/git/Services/GitStatusBroadcaster.ts deleted file mode 100644 index 647f8408242..00000000000 --- 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/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 8140d4e6cf2..96e43323eca 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -24,9 +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 { 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"; @@ -283,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(() => { @@ -306,7 +306,7 @@ describe("CheckpointReactor", () => { Layer.provideMerge(orchestrationLayer), Layer.provideMerge(RuntimeReceiptBusLive), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), - Layer.provideMerge(gitStatusBroadcasterLayer), + Layer.provideMerge(vcsStatusBroadcasterLayer), Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer))), Layer.provideMerge( WorkspaceEntriesLive.pipe( diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 71445b4671d..15677feaaf7 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 c2895a0e0e0..7837a965157 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -33,10 +33,6 @@ import { ProviderService, type ProviderServiceShape, } from "../../provider/Services/ProviderService.ts"; -import { - GitStatusBroadcaster, - type GitStatusBroadcasterShape, -} from "../../git/Services/GitStatusBroadcaster.ts"; import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts"; import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; @@ -51,7 +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 * as GitVcsDriver from "../../vcs/GitVcsDriver.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); @@ -325,18 +322,18 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge(orchestrationLayer), Layer.provideMerge(Layer.succeed(ProviderService, service)), Layer.provideMerge( - Layer.mock(GitVcsDriver.GitVcsDriver)({ + Layer.mock(GitWorkflowService)({ renameBranch, - }), + } satisfies Partial), ), Layer.provideMerge( - Layer.succeed(GitStatusBroadcaster, { + 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 d3793c7622c..974400c3776 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -16,7 +16,6 @@ import { Cache, Cause, Duration, Effect, Equal, Layer, Option, Schema, Stream } import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.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"; @@ -28,7 +27,8 @@ import { type ProviderCommandReactorShape, } from "../Services/ProviderCommandReactor.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -import { GitVcsDriver } from "../../vcs/GitVcsDriver.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* GitVcsDriver; - 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/server.test.ts b/apps/server/src/server.test.ts index 0ac39612c4b..487845d5749 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -59,11 +59,6 @@ import { type CheckpointDiffQueryShape, } from "./checkpointing/Services/CheckpointDiffQuery.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 { Keybindings, type KeybindingsShape } from "./keybindings.ts"; import { Open, type OpenShape } from "./open.ts"; import { @@ -106,11 +101,17 @@ import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem. 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 GitWorkflowServiceLayer } from "./git/GitWorkflowService.ts"; import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; @@ -324,7 +325,7 @@ const buildAppUnderTest = (options?: { vcsDriverRegistry?: Partial; gitVcsDriver?: Partial; gitManager?: Partial; - gitStatusBroadcaster?: Partial; + vcsStatusBroadcaster?: Partial; projectSetupScriptRunner?: Partial; terminalManager?: Partial; orchestrationEngine?: Partial; @@ -469,11 +470,16 @@ 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 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, @@ -514,7 +520,8 @@ const buildAppUnderTest = (options?: { ), Layer.provide(gitManagerLayer), Layer.provide(gitVcsDriverLayer), - Layer.provideMerge(gitStatusBroadcasterLayer), + Layer.provide(gitWorkflowLayer), + Layer.provideMerge(vcsStatusBroadcasterLayer), Layer.provide( Layer.mock(ProjectSetupScriptRunner)({ runForThread: () => Effect.succeed({ status: "no-script" as const }), @@ -2484,13 +2491,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); @@ -2536,14 +2543,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const branches = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitListBranches]({ cwd: "/tmp/repo" }), + client[WS_METHODS.vcsListBranches]({ cwd: "/tmp/repo" }), ), ); assert.equal(branches.branches[0]?.name, "main"); const worktree = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitCreateWorktree]({ + client[WS_METHODS.vcsCreateWorktree]({ cwd: "/tmp/repo", branch: "main", path: null, @@ -2554,7 +2561,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitRemoveWorktree]({ + client[WS_METHODS.vcsRemoveWorktree]({ cwd: "/tmp/repo", path: "/tmp/wt", }), @@ -2563,7 +2570,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitCreateBranch]({ + client[WS_METHODS.vcsCreateBranch]({ cwd: "/tmp/repo", branch: "feature/new", }), @@ -2572,7 +2579,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitCheckout]({ + client[WS_METHODS.vcsCheckout]({ cwd: "/tmp/repo", branch: "main", }), @@ -2581,7 +2588,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitInit]({ + client[WS_METHODS.vcsInit]({ cwd: "/tmp/repo", }), ), @@ -2658,7 +2665,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, ), ); @@ -2791,7 +2798,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; @@ -3594,7 +3601,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { gitVcsDriver: { createWorktree, }, - gitStatusBroadcaster: { + vcsStatusBroadcaster: { refreshStatus, }, orchestrationEngine: { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 50845eb5bf9..61200777878 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -26,7 +26,6 @@ import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts"; import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts"; import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts"; import { GitHubCliLive } from "./git/Layers/GitHubCli.ts"; -import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster.ts"; import { TextGenerationLive } from "./git/Layers/TextGenerationLive.ts"; import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; @@ -48,7 +47,11 @@ import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem. 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 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"; @@ -135,11 +138,6 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(RuntimeReceiptBusLive), ); -const CheckpointingLayerLive = Layer.empty.pipe( - Layer.provideMerge(CheckpointDiffQueryLive), - Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer))), -); - const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( Layer.provide(ProviderSessionRuntimeRepositoryLive), ); @@ -160,21 +158,41 @@ const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersisten const GitManagerLayerLive = GitManagerLive.pipe( Layer.provideMerge(ProjectSetupScriptRunnerLive), Layer.provideMerge(GitVcsDriver.layer), - Layer.provideMerge(GitHubCliLive), + Layer.provideMerge(SourceControlProviderRegistry.layer.pipe(Layer.provide(GitHubCliLive))), Layer.provideMerge(TextGenerationLive), ); const GitLayerLive = Layer.empty.pipe( Layer.provideMerge(GitManagerLayerLive), - Layer.provideMerge(GitStatusBroadcasterLive.pipe(Layer.provide(GitManagerLayerLive))), Layer.provideMerge(GitVcsDriver.layer), ); +const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( + Layer.provide(VcsProjectConfig.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(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(VcsDriverRegistry.layer), + Layer.provideMerge(VcsDriverRegistryLayerLive), ); const WorkspaceFileSystemLayerLive = WorkspaceFileSystemLive.pipe( @@ -198,10 +216,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), @@ -231,7 +250,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), diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts new file mode 100644 index 00000000000..485059d48a0 --- /dev/null +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -0,0 +1,132 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import { GitHubCli, type GitHubCliShape } from "../git/Services/GitHubCli.ts"; +import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; + +const processResult = (stdout: string) => ({ + stdout, + stderr: "", + code: 0, + signal: null, + timedOut: 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: null, + 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.strictEqual(changeRequests[0]?.updatedAt, "2026-01-02T00:00:00.000Z"); + }), +); + +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 00000000000..2cec4ac5989 --- /dev/null +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -0,0 +1,141 @@ +import { Effect, Layer, Result, Schema } from "effect"; +import { + SourceControlProviderError, + type ChangeRequest, + type ChangeRequestState, + type GitHubCliError, +} from "@t3tools/contracts"; + +import { GitHubCli, type GitHubPullRequestSummary } from "../git/Services/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: null, + ...(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) => + Effect.sync(() => decodeGitHubPullRequestListJson(result.stdout.trim())).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/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts new file mode 100644 index 00000000000..022a88dd486 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -0,0 +1,46 @@ +import { Context, Effect } from "effect"; +import type { + ChangeRequest, + ChangeRequestState, + SourceControlProviderError, + SourceControlProviderKind, + SourceControlRepositoryCloneUrls, +} from "@t3tools/contracts"; + +export interface SourceControlProviderShape { + readonly kind: SourceControlProviderKind; + readonly listChangeRequests: (input: { + readonly cwd: string; + readonly headSelector: string; + readonly state: ChangeRequestState | "all"; + readonly limit?: number; + }) => Effect.Effect, SourceControlProviderError>; + readonly getChangeRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect; + readonly createChangeRequest: (input: { + readonly cwd: string; + readonly baseRefName: string; + readonly headSelector: string; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + readonly getDefaultBranch: (input: { + readonly cwd: string; + }) => Effect.Effect; + readonly checkoutChangeRequest: (input: { + readonly cwd: string; + 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.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts new file mode 100644 index 00000000000..73488a3b62e --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -0,0 +1,28 @@ +import { Context, Effect, Layer } from "effect"; +import type { SourceControlProviderError } from "@t3tools/contracts"; + +import { SourceControlProvider, type SourceControlProviderShape } from "./SourceControlProvider.ts"; +import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; + +export interface SourceControlProviderRegistryShape { + readonly resolve: (input: { + readonly cwd: string; + }) => Effect.Effect; +} + +export class SourceControlProviderRegistry extends Context.Service< + SourceControlProviderRegistry, + SourceControlProviderRegistryShape +>()("t3/source-control/SourceControlProviderRegistry") {} + +export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () { + const github = yield* SourceControlProvider; + + return SourceControlProviderRegistry.of({ + resolve: () => Effect.succeed(github), + }); +}); + +export const layer = Layer.effect(SourceControlProviderRegistry, make()).pipe( + Layer.provide(GitHubSourceControlProvider.layer), +); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index fa18d2f15e9..eae312c7893 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1,6 +1,7 @@ import { Cache, Data, + DateTime, Duration, Effect, Exit, @@ -350,17 +351,18 @@ interface Trace2Monitor { readonly flush: Effect.Effect; } -const nowUnixNano = (): bigint => BigInt(Date.now()) * 1_000_000n; +const nowUnixNano = DateTime.now.pipe( + Effect.map((now) => BigInt(DateTime.toEpochMillis(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), - ); + 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; @@ -433,7 +435,8 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( } if (event === "child_start") { - hookStartByChildKey.set(childKey, { hookName, startedAtMs: Date.now() }); + const now = yield* DateTime.now; + hookStartByChildKey.set(childKey, { hookName, startedAtMs: DateTime.toEpochMillis(now) }); yield* addCurrentSpanEvent("git.hook.started", { hookName, }); @@ -447,7 +450,10 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( 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; + 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, diff --git a/apps/server/src/vcs/VcsDriverRegistry.test.ts b/apps/server/src/vcs/VcsDriverRegistry.test.ts new file mode 100644 index 00000000000..4741a6a12bd --- /dev/null +++ b/apps/server/src/vcs/VcsDriverRegistry.test.ts @@ -0,0 +1,64 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +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: 0, + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +describe("VcsDriverRegistry", () => { + 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 index b0bc79fd7d6..9e07070e821 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.ts @@ -1,10 +1,14 @@ -import { Context, Effect, Layer } from "effect"; +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"; @@ -34,7 +38,32 @@ const unsupported = (operation: string, kind: VcsDriverKind, detail: string) => 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, @@ -56,23 +85,39 @@ export const make = Effect.fn("makeVcsDriverRegistry")(function* () { } satisfies VcsDriverHandle; }); - const detect: VcsDriverRegistryShape["detect"] = Effect.fn("VcsDriverRegistry.detect")( - function* (input) { - const requestedKind = input.requestedKind ?? "auto"; + 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 = drivers[requestedKind]; - if (!driver) { - return yield* unsupported( - "VcsDriverRegistry.detect", - requestedKind, - `No ${requestedKind} VCS driver is registered.`, - ); - } - return yield* detectWithDriver(requestedKind, driver, input.cwd); + if (requestedKind !== "auto" && requestedKind !== "unknown") { + const driver = drivers[requestedKind]; + if (!driver) { + return yield* unsupported( + "VcsDriverRegistry.detect", + requestedKind, + `No ${requestedKind} VCS driver is registered.`, + ); } + return yield* detectWithDriver(requestedKind, driver, input.cwd); + } + + return yield* detectWithDriver("git", git, 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 })); }, ); @@ -100,4 +145,6 @@ export const make = Effect.fn("makeVcsDriverRegistry")(function* () { }); }); -export const layer = Layer.effect(VcsDriverRegistry, make()); +export const layer = Layer.effect(VcsDriverRegistry, make()).pipe( + Layer.provide(VcsProjectConfig.layer), +); diff --git a/apps/server/src/vcs/VcsProjectConfig.test.ts b/apps/server/src/vcs/VcsProjectConfig.test.ts new file mode 100644 index 00000000000..95181e94fb2 --- /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 00000000000..6b4ba008d4e --- /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/git/Layers/GitStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts similarity index 78% rename from apps/server/src/git/Layers/GitStatusBroadcaster.test.ts rename to apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 72a0c24e27b..6c9509da232 100644 --- a/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -8,9 +8,11 @@ import type { } 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"; +import { + VcsStatusBroadcaster, + layer as VcsStatusBroadcasterLayer, +} from "./VcsStatusBroadcaster.ts"; +import { GitWorkflowService, type GitWorkflowServiceShape } from "../git/GitWorkflowService.ts"; const baseLocalStatus: GitStatusLocalResult = { isRepo: true, @@ -46,38 +48,34 @@ function makeTestLayer(state: { localInvalidationCalls: number; remoteInvalidationCalls: number; }) { - const gitManager: GitManagerShape = { - localStatus: () => - Effect.sync(() => { - state.localStatusCalls += 1; - return state.currentLocalStatus; + 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; + }), }), - 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", () => { +describe("VcsStatusBroadcaster", () => { + it.effect("reuses the cached VCS status across repeated reads", () => { const state = { currentLocalStatus: baseLocalStatus, currentRemoteStatus: baseRemoteStatus, @@ -88,7 +86,7 @@ describe("GitStatusBroadcasterLive", () => { }; return Effect.gen(function* () { - const broadcaster = yield* GitStatusBroadcaster; + const broadcaster = yield* VcsStatusBroadcaster; const first = yield* broadcaster.getStatus({ cwd: "/repo" }); const second = yield* broadcaster.getStatus({ cwd: "/repo" }); @@ -113,7 +111,7 @@ describe("GitStatusBroadcasterLive", () => { }; return Effect.gen(function* () { - const broadcaster = yield* GitStatusBroadcaster; + const broadcaster = yield* VcsStatusBroadcaster; const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); state.currentLocalStatus = { @@ -154,7 +152,7 @@ describe("GitStatusBroadcasterLive", () => { }; return Effect.gen(function* () { - const broadcaster = yield* GitStatusBroadcaster; + const broadcaster = yield* VcsStatusBroadcaster; const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); state.currentLocalStatus = { @@ -190,7 +188,7 @@ describe("GitStatusBroadcasterLive", () => { }; return Effect.gen(function* () { - const broadcaster = yield* GitStatusBroadcaster; + const broadcaster = yield* VcsStatusBroadcaster; const snapshotDeferred = yield* Deferred.make(); const remoteUpdatedDeferred = yield* Deferred.make(); yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => { @@ -230,9 +228,9 @@ describe("GitStatusBroadcasterLive", () => { }; let remoteInterruptedDeferred: Deferred.Deferred | null = null; let remoteStartedDeferred: Deferred.Deferred | null = null; - const testLayer = GitStatusBroadcasterLive.pipe( + const testLayer = VcsStatusBroadcasterLayer.pipe( Layer.provide( - Layer.succeed(GitManager, { + Layer.mock(GitWorkflowService)({ localStatus: () => Effect.sync(() => { state.localStatusCalls += 1; @@ -254,7 +252,6 @@ describe("GitStatusBroadcasterLive", () => { : Effect.void, ), ), - status: () => Effect.die("status should not be called in this test"), invalidateLocalStatus: () => Effect.sync(() => { state.localInvalidationCalls += 1; @@ -263,13 +260,7 @@ describe("GitStatusBroadcasterLive", () => { 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), + } satisfies Partial), ), ); @@ -279,7 +270,7 @@ describe("GitStatusBroadcasterLive", () => { remoteInterruptedDeferred = remoteInterrupted; remoteStartedDeferred = remoteStarted; - const broadcaster = yield* GitStatusBroadcaster; + const broadcaster = yield* VcsStatusBroadcaster; const firstSnapshot = yield* Deferred.make(); const secondSnapshot = yield* Deferred.make(); const firstScope = yield* Scope.make(); @@ -302,11 +293,11 @@ describe("GitStatusBroadcasterLive", () => { assert.equal(state.remoteStatusCalls, 1); yield* Scope.close(firstScope, Exit.void); - assert.equal(Option.isNone(yield* Deferred.poll(remoteInterrupted)), true); + assert.isTrue(Option.isNone(yield* Deferred.poll(remoteInterrupted))); yield* Scope.close(secondScope, Exit.void).pipe(Effect.forkScoped); yield* Deferred.await(remoteInterrupted); - assert.equal(Option.isSome(yield* Deferred.poll(remoteInterrupted)), true); + 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 00000000000..e7c6d71ec79 --- /dev/null +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -0,0 +1,330 @@ +import { + Duration, + Effect, + Exit, + Fiber, + Layer, + PubSub, + Ref, + Scope, + Stream, + SynchronizedRef, +} from "effect"; +import type { + GitManagerServiceError, + GitStatusInput, + GitStatusLocalResult, + GitStatusRemoteResult, + GitStatusResult, + GitStatusStreamEvent, +} 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: GitStatusStreamEvent; +} + +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: GitStatusInput, + ) => Effect.Effect; + readonly refreshLocalStatus: ( + cwd: string, + ) => Effect.Effect; + readonly refreshStatus: (cwd: string) => Effect.Effect; + readonly streamStatus: ( + input: GitStatusInput, + ) => Stream.Stream; +} + +export class VcsStatusBroadcaster extends Context.Service< + VcsStatusBroadcaster, + VcsStatusBroadcasterShape +>()("t3/vcs/VcsStatusBroadcaster") {} + +function fingerprintStatusPart(status: unknown): string { + return JSON.stringify(status); +} + +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: 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("VcsStatusBroadcaster.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("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 [local, remote] = yield* Effect.all([ + getOrLoadLocalStatus(input.cwd), + getOrLoadRemoteStatus(input.cwd), + ]); + return mergeGitStatusParts(local, remote); + }); + + const refreshLocalStatus: VcsStatusBroadcasterShape["refreshLocalStatus"] = Effect.fn( + "VcsStatusBroadcaster.refreshLocalStatus", + )(function* (cwd) { + 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* (cwd) { + 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 subscription = yield* PubSub.subscribe(changesPubSub); + const initialLocal = yield* getOrLoadLocalStatus(input.cwd); + const initialRemote = (yield* getCachedStatus(input.cwd))?.remote?.value ?? null; + yield* retainRemotePoller(input.cwd); + + const release = releaseRemotePoller(input.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 === input.cwd), + Stream.map((event) => event.event), + ), + ).pipe(Stream.ensuring(release)); + }), + ); + + return VcsStatusBroadcaster.of({ + getStatus, + refreshLocalStatus, + refreshStatus, + streamStatus, + }); + }), +); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 38a34884d6e..4e06cbfd2a6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -29,8 +29,6 @@ import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery.ts"; import { ServerConfig } from "./config.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"; @@ -49,7 +47,8 @@ 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 { GitVcsDriver } from "./vcs/GitVcsDriver.ts"; +import { VcsStatusBroadcaster } from "./vcs/VcsStatusBroadcaster.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"; @@ -136,9 +135,8 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const checkpointDiffQuery = yield* CheckpointDiffQuery; const keybindings = yield* Keybindings; const open = yield* Open; - const gitManager = yield* GitManager; - const git = yield* GitVcsDriver; - const gitStatusBroadcaster = yield* GitStatusBroadcaster; + const gitWorkflow = yield* GitWorkflowService; + const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const terminalManager = yield* TerminalManager; const providerRegistry = yield* ProviderRegistry; const config = yield* ServerConfig; @@ -453,7 +451,7 @@ 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, @@ -540,7 +538,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => }); const refreshGitStatus = (cwd: string) => - gitStatusBroadcaster + vcsStatusBroadcaster .refreshStatus(cwd) .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); @@ -831,26 +829,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 +861,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => observeRpcStream( WS_METHODS.gitRunStackedAction, Stream.callback((queue) => - gitManager + gitWorkflow .runStackedAction(input, { actionId: input.actionId, progressReporter: { @@ -880,55 +878,57 @@ 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.vcsListBranches]: (input) => + observeRpcEffect(WS_METHODS.vcsListBranches, gitWorkflow.listBranches(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.vcsCreateBranch]: (input) => observeRpcEffect( - WS_METHODS.gitCreateBranch, - git.createBranch(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "git" }, + WS_METHODS.vcsCreateBranch, + gitWorkflow.createBranch(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, ), - [WS_METHODS.gitCheckout]: (input) => + [WS_METHODS.vcsCheckout]: (input) => observeRpcEffect( - WS_METHODS.gitCheckout, - Effect.scoped(git.checkoutBranch(input)).pipe( - Effect.tap(() => refreshGitStatus(input.cwd)), - ), - { "rpc.aggregate": "git" }, + WS_METHODS.vcsCheckout, + gitWorkflow.checkoutBranch(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, + gitWorkflow.initRepo(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, ), [WS_METHODS.terminalOpen]: (input) => observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index af5bd2360e9..2f0a765655b 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -343,7 +343,7 @@ export function BranchToolbarBranchSelector({ const previousBranch = resolvedActiveBranch; setOptimisticBranch(selectedBranchName); try { - const checkoutResult = await api.git.checkout({ + const checkoutResult = await api.vcs.checkout({ cwd: selectionTarget.checkoutCwd, branch: branch.name, }); @@ -377,7 +377,7 @@ export function BranchToolbarBranchSelector({ const previousBranch = resolvedActiveBranch; setOptimisticBranch(name); try { - const createBranchResult = await api.git.createBranch({ + const createBranchResult = await api.vcs.createBranch({ cwd: branchCwd, branch: name, checkout: true, diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 445fe193057..49c72b36723 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,7 +952,7 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { if (tag === WS_METHODS.serverGetConfig) { return fixture.serverConfig; } - if (tag === WS_METHODS.gitListBranches) { + if (tag === WS_METHODS.vcsListBranches) { return { isRepo: true, hasOriginRemote: true, @@ -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,7 +2573,7 @@ describe("ChatView timeline estimator parity (full app)", () => { ), }, resolveRpc: (body) => { - if (body._tag === WS_METHODS.gitListBranches) { + if (body._tag === WS_METHODS.vcsListBranches) { return { isRepo: true, hasOriginRemote: true, @@ -2665,7 +2666,7 @@ describe("ChatView timeline estimator parity (full app)", () => { ), }, resolveRpc: (body) => { - if (body._tag === WS_METHODS.gitListBranches) { + if (body._tag === WS_METHODS.vcsListBranches) { return { isRepo: true, hasOriginRemote: true, @@ -2761,7 +2762,7 @@ 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.vcsListBranches) { return { isRepo: true, hasOriginRemote: true, @@ -3021,7 +3022,7 @@ 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.vcsListBranches) { return { isRepo: true, hasOriginRemote: true, @@ -3146,7 +3147,7 @@ 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.vcsListBranches) { return { isRepo: true, hasOriginRemote: true, diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index df1c6ba542f..4f376802730 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -256,7 +256,7 @@ function resolveWsRpc(tag: string): unknown { if (tag === WS_METHODS.serverGetConfig) { return fixture.serverConfig; } - if (tag === WS_METHODS.gitListBranches) { + if (tag === WS_METHODS.vcsListBranches) { return { isRepo: true, hasOriginRemote: true, diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index 64df079df49..2bb951ad7ae 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -23,16 +23,27 @@ 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), + listBranches: rpcClient.vcs.listBranches, + createWorktree: rpcClient.vcs.createWorktree, + removeWorktree: rpcClient.vcs.removeWorktree, + createBranch: rpcClient.vcs.createBranch, + checkout: rpcClient.vcs.checkout, + 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, + pull: rpcClient.vcs.pull, + refreshStatus: rpcClient.vcs.refreshStatus, + onStatus: (input, callback, options) => rpcClient.vcs.onStatus(input, callback, options), + listBranches: rpcClient.vcs.listBranches, + createWorktree: rpcClient.vcs.createWorktree, + removeWorktree: rpcClient.vcs.removeWorktree, + createBranch: rpcClient.vcs.createBranch, + checkout: rpcClient.vcs.checkout, + init: rpcClient.vcs.init, resolvePullRequest: rpcClient.git.resolvePullRequest, preparePullRequestThread: rpcClient.git.preparePullRequestThread, }, diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index de60ffb18dc..46a1955cc49 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.ts b/apps/web/src/lib/gitReactQuery.ts index 68d2b5bf53c..41084cb3f84 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -63,6 +63,9 @@ function invalidateGitBranchQueries( return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(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; @@ -78,7 +81,7 @@ export function gitBranchSearchInfiniteQueryOptions(input: { if (!input.cwd) throw new Error("Git branches are unavailable."); if (!input.environmentId) throw new Error("Git branches are unavailable."); const api = ensureEnvironmentApi(input.environmentId); - return api.git.listBranches({ + return api.vcs.listBranches({ 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,6 +145,9 @@ 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; @@ -149,7 +158,7 @@ export function gitCheckoutMutationOptions(input: { mutationFn: async (branch: string) => { if (!input.cwd || !input.environmentId) throw new Error("Git checkout is unavailable."); const api = ensureEnvironmentApi(input.environmentId); - return api.git.checkout({ cwd: input.cwd, branch }); + return api.vcs.checkout({ cwd: input.cwd, branch }); }, 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.ts b/apps/web/src/lib/gitStatusState.ts index 2c23ae5b826..698ca74a92e 100644 --- a/apps/web/src/lib/gitStatusState.ts +++ b/apps/web/src/lib/gitStatusState.ts @@ -22,7 +22,7 @@ interface GitStatusState { readonly isPending: boolean; } -type GitStatusClient = Pick; +type GitStatusClient = Pick; interface ResolvedGitStatusClient { readonly clientIdentity: string; readonly client: GitStatusClient; @@ -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; } diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index fbdd203e99f..31201b9afb3 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -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) => registerListener(gitStatusListeners, listener), ), - runStackedAction: vi.fn(), listBranches: vi.fn(), createWorktree: vi.fn(), removeWorktree: vi.fn(), createBranch: vi.fn(), checkout: vi.fn(), init: vi.fn(), + }, + git: { + runStackedAction: vi.fn(), resolvePullRequest: vi.fn(), preparePullRequestThread: vi.fn(), }, @@ -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/rpc/wsRpcClient.test.ts b/apps/web/src/rpc/wsRpcClient.test.ts index 56f39b1bd32..355e97a24ef 100644 --- a/apps/web/src/rpc/wsRpcClient.test.ts +++ b/apps/web/src/rpc/wsRpcClient.test.ts @@ -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 [ { @@ -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 f1183c1e9c5..d3daa042290 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -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, + input: RpcInput, listener: (status: GitStatusResult) => void, options?: StreamSubscriptionOptions, ) => () => void; + readonly listBranches: RpcUnaryMethod; + readonly createWorktree: RpcUnaryMethod; + readonly removeWorktree: RpcUnaryMethod; + readonly createBranch: RpcUnaryMethod; + readonly checkout: 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 @@ -161,14 +166,14 @@ 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; return transport.subscribe( - (client) => client[WS_METHODS.subscribeGitStatus](input), + (client) => client[WS_METHODS.subscribeVcsStatus](input), (event: GitStatusStreamEvent) => { current = applyGitStatusStreamEvent(current, event); listener(current); @@ -176,6 +181,18 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { options, ); }, + listBranches: (input) => + transport.request((client) => client[WS_METHODS.vcsListBranches](input)), + createWorktree: (input) => + transport.request((client) => client[WS_METHODS.vcsCreateWorktree](input)), + removeWorktree: (input) => + transport.request((client) => client[WS_METHODS.vcsRemoveWorktree](input)), + createBranch: (input) => + transport.request((client) => client[WS_METHODS.vcsCreateBranch](input)), + checkout: (input) => transport.request((client) => client[WS_METHODS.vcsCheckout](input)), + init: (input) => transport.request((client) => client[WS_METHODS.vcsInit](input)), + }, + git: { runStackedAction: async (input, options) => { let result: GitRunStackedActionResult | null = null; @@ -195,16 +212,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) => diff --git a/apps/web/test/wsRpcHarness.ts b/apps/web/test/wsRpcHarness.ts index 56711f4ad4b..07d4c74050c 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/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 47d74dc3567..6a6e7cbe648 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,5 +1,6 @@ import { Schema } from "effect"; import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { SourceControlProviderError } from "./sourceControl.ts"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; const GIT_LIST_BRANCHES_MAX_LIMIT = 200; @@ -370,6 +371,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 6e7f6b7e62b..a0ccc624a5e 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -12,6 +12,7 @@ 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 a63c6de9626..4950bc5f74f 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -235,7 +235,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,19 +256,45 @@ export interface EnvironmentApi { filesystem: { browse: (input: FilesystemBrowseInput) => Promise; }; + vcs: { + listBranches: (input: GitListBranchesInput) => Promise; + createWorktree: (input: GitCreateWorktreeInput) => Promise; + removeWorktree: (input: GitRemoveWorktreeInput) => Promise; + createBranch: (input: GitCreateBranchInput) => Promise; + checkout: (input: GitCheckoutInput) => Promise; + init: (input: GitInitInput) => Promise; + pull: (input: GitPullInput) => Promise; + refreshStatus: (input: GitStatusInput) => Promise; + onStatus: ( + input: GitStatusInput, + callback: (status: GitStatusResult) => void, + options?: { + onResubscribe?: () => void; + }, + ) => () => void; + }; git: { + /** @deprecated Use `EnvironmentApi.vcs.listBranches` for local VCS branch/ref listing. */ listBranches: (input: GitListBranchesInput) => Promise; + /** @deprecated Use `EnvironmentApi.vcs.createWorktree` for local VCS workspace creation. */ createWorktree: (input: GitCreateWorktreeInput) => Promise; + /** @deprecated Use `EnvironmentApi.vcs.removeWorktree` for local VCS workspace removal. */ removeWorktree: (input: GitRemoveWorktreeInput) => Promise; + /** @deprecated Use `EnvironmentApi.vcs.createBranch` for local VCS branch/ref creation. */ createBranch: (input: GitCreateBranchInput) => Promise; + /** @deprecated Use `EnvironmentApi.vcs.checkout` for local VCS checkout/switch operations. */ checkout: (input: GitCheckoutInput) => Promise; + /** @deprecated Use `EnvironmentApi.vcs.init` for local VCS repository initialization. */ init: (input: GitInitInput) => Promise; resolvePullRequest: (input: GitPullRequestRefInput) => Promise; preparePullRequestThread: ( input: GitPreparePullRequestThreadInput, ) => Promise; + /** @deprecated Use `EnvironmentApi.vcs.pull` for local VCS pull/sync operations. */ pull: (input: GitPullInput) => Promise; + /** @deprecated Use `EnvironmentApi.vcs.refreshStatus` for local VCS status refreshes. */ refreshStatus: (input: GitStatusInput) => Promise; + /** @deprecated Use `EnvironmentApi.vcs.onStatus` for local VCS status subscriptions. */ onStatus: ( input: GitStatusInput, callback: (status: GitStatusResult) => void, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index ca68d5cfe4e..599c4a56be3 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -92,16 +92,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", + vcsListBranches: "vcs.listBranches", + vcsCreateWorktree: "vcs.createWorktree", + vcsRemoveWorktree: "vcs.removeWorktree", + vcsCreateBranch: "vcs.createBranch", + vcsCheckout: "vcs.checkout", + 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", @@ -121,7 +123,7 @@ export const WS_METHODS = { serverUpdateSettings: "server.updateSettings", // Streaming subscriptions - subscribeGitStatus: "subscribeGitStatus", + subscribeVcsStatus: "subscribeVcsStatus", subscribeTerminalEvents: "subscribeTerminalEvents", subscribeServerConfig: "subscribeServerConfig", subscribeServerLifecycle: "subscribeServerLifecycle", @@ -188,20 +190,20 @@ export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { error: FilesystemBrowseError, }); -export const WsSubscribeGitStatusRpc = Rpc.make(WS_METHODS.subscribeGitStatus, { +export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { payload: GitStatusInput, success: GitStatusStreamEvent, error: GitManagerServiceError, stream: true, }); -export const WsGitPullRpc = Rpc.make(WS_METHODS.gitPull, { +export const WsVcsPullRpc = Rpc.make(WS_METHODS.vcsPull, { payload: GitPullInput, success: GitPullResult, error: GitCommandError, }); -export const WsGitRefreshStatusRpc = Rpc.make(WS_METHODS.gitRefreshStatus, { +export const WsVcsRefreshStatusRpc = Rpc.make(WS_METHODS.vcsRefreshStatus, { payload: GitStatusInput, success: GitStatusResult, error: GitManagerServiceError, @@ -226,36 +228,36 @@ export const WsGitPreparePullRequestThreadRpc = Rpc.make(WS_METHODS.gitPreparePu error: GitManagerServiceError, }); -export const WsGitListBranchesRpc = Rpc.make(WS_METHODS.gitListBranches, { +export const WsVcsListBranchesRpc = Rpc.make(WS_METHODS.vcsListBranches, { payload: GitListBranchesInput, success: GitListBranchesResult, error: GitCommandError, }); -export const WsGitCreateWorktreeRpc = Rpc.make(WS_METHODS.gitCreateWorktree, { +export const WsVcsCreateWorktreeRpc = Rpc.make(WS_METHODS.vcsCreateWorktree, { payload: GitCreateWorktreeInput, success: GitCreateWorktreeResult, error: GitCommandError, }); -export const WsGitRemoveWorktreeRpc = Rpc.make(WS_METHODS.gitRemoveWorktree, { +export const WsVcsRemoveWorktreeRpc = Rpc.make(WS_METHODS.vcsRemoveWorktree, { payload: GitRemoveWorktreeInput, error: GitCommandError, }); -export const WsGitCreateBranchRpc = Rpc.make(WS_METHODS.gitCreateBranch, { +export const WsVcsCreateBranchRpc = Rpc.make(WS_METHODS.vcsCreateBranch, { payload: GitCreateBranchInput, success: GitCreateBranchResult, error: GitCommandError, }); -export const WsGitCheckoutRpc = Rpc.make(WS_METHODS.gitCheckout, { +export const WsVcsCheckoutRpc = Rpc.make(WS_METHODS.vcsCheckout, { payload: GitCheckoutInput, success: GitCheckoutResult, error: GitCommandError, }); -export const WsGitInitRpc = Rpc.make(WS_METHODS.gitInit, { +export const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, { payload: GitInitInput, error: GitCommandError, }); @@ -374,18 +376,18 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, - WsSubscribeGitStatusRpc, - WsGitPullRpc, - WsGitRefreshStatusRpc, + WsSubscribeVcsStatusRpc, + WsVcsPullRpc, + WsVcsRefreshStatusRpc, WsGitRunStackedActionRpc, WsGitResolvePullRequestRpc, WsGitPreparePullRequestThreadRpc, - WsGitListBranchesRpc, - WsGitCreateWorktreeRpc, - WsGitRemoveWorktreeRpc, - WsGitCreateBranchRpc, - WsGitCheckoutRpc, - WsGitInitRpc, + WsVcsListBranchesRpc, + WsVcsCreateWorktreeRpc, + WsVcsRemoveWorktreeRpc, + WsVcsCreateBranchRpc, + WsVcsCheckoutRpc, + WsVcsInitRpc, WsTerminalOpenRpc, WsTerminalWriteRpc, WsTerminalResizeRpc, diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts new file mode 100644 index 00000000000..55e67101808 --- /dev/null +++ b/packages/contracts/src/sourceControl.ts @@ -0,0 +1,49 @@ +import { Schema } from "effect"; +import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +export const SourceControlProviderKind = Schema.Literals([ + "github", + "gitlab", + "azure-devops", + "unknown", +]); +export type SourceControlProviderKind = typeof SourceControlProviderKind.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.NullOr(TrimmedNonEmptyString), + 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 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}`; + } +} From 03909848ec2d0a0cc4275023d6d4addc71eb7e5b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 1 May 2026 14:45:43 -0700 Subject: [PATCH 16/45] Normalize timestamps across VCS and source control contracts - Switch freshness and PR update times to typed `DateTime`/`Option` values - Update GitHub and VCS adapters, tests, and sorting to use the new shapes --- apps/server/src/git/Layers/GitManager.ts | 30 +++++++++++-------- apps/server/src/git/githubPullRequests.ts | 9 +++--- apps/server/src/server.test.ts | 15 +++++++--- .../GitHubSourceControlProvider.test.ts | 9 ++++-- .../GitHubSourceControlProvider.ts | 4 +-- apps/server/src/vcs/GitVcsDriver.ts | 5 ++-- .../vcs/testing/VcsDriverContractHarness.ts | 7 +++-- packages/contracts/src/sourceControl.ts | 2 +- packages/contracts/src/vcs.ts | 4 +-- 9 files changed, 51 insertions(+), 34 deletions(-) diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 1f993faeed4..2ebeef820cd 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -71,7 +71,7 @@ interface OpenPrInfo { interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { state: "open" | "closed" | "merged"; - updatedAt: string | null; + updatedAt: Option.Option; } interface ResolvedPullRequest { @@ -273,12 +273,18 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { }; } -function parseDateTimeMillis(value: string | null): number { - if (!value) { - return 0; - } - const parsed = DateTime.make(value); - return Option.isSome(parsed) ? DateTime.toEpochMillis(parsed.value) : 0; +function compareOptionalDateTimeDesc( + left: Option.Option, + right: Option.Option, +): number { + return Option.match(left, { + onNone: () => (Option.isNone(right) ? 0 : 1), + onSome: (leftValue) => + Option.match(right, { + onNone: () => -1, + onSome: (rightValue) => DateTime.Order(rightValue, leftValue), + }), + }); } function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError { @@ -858,7 +864,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { baseRefName: firstPullRequest.baseRefName, headRefName: firstPullRequest.headRefName, state: "open", - updatedAt: null, + updatedAt: Option.none(), } satisfies PullRequestInfo; } } @@ -889,11 +895,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { } } - const parsed = Array.from(parsedByNumber.values()).toSorted((a, b) => { - const left = parseDateTimeMillis(a.updatedAt); - const right = parseDateTimeMillis(b.updatedAt); - return right - left; - }); + const parsed = Array.from(parsedByNumber.values()).toSorted((a, b) => + compareOptionalDateTimeDesc(a.updatedAt, b.updatedAt), + ); const latestOpenPr = parsed.find((pr) => pr.state === "open"); if (latestOpenPr) { diff --git a/apps/server/src/git/githubPullRequests.ts b/apps/server/src/git/githubPullRequests.ts index d137a46d6fa..f0804dda8c6 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/server.test.ts b/apps/server/src/server.test.ts index 487845d5749..2096cf76910 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, @@ -50,6 +51,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"; @@ -395,7 +398,8 @@ const buildAppUnderTest = (options?: { truncated: false, freshness: { source: "live-local", - observedAt: new Date(0).toISOString(), + observedAt: TEST_EPOCH, + expiresAt: Option.none(), }, }), filterIgnoredPaths: (_cwd, relativePaths) => Effect.succeed(relativePaths), @@ -416,7 +420,8 @@ const buildAppUnderTest = (options?: { metadataPath: null, freshness: { source: "live-local" as const, - observedAt: new Date(0).toISOString(), + observedAt: TEST_EPOCH, + expiresAt: Option.none(), }, } : null, @@ -444,7 +449,8 @@ const buildAppUnderTest = (options?: { metadataPath: null, freshness: { source: "live-local", - observedAt: new Date(0).toISOString(), + observedAt: TEST_EPOCH, + expiresAt: Option.none(), }, }, driver: defaultVcsDriver, @@ -2154,7 +2160,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { truncated: false, freshness: { source: "live-local", - observedAt: new Date(0).toISOString(), + observedAt: TEST_EPOCH, + expiresAt: Option.none(), }, }), filterIgnoredPaths: (_cwd, relativePaths) => diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 485059d48a0..7382d00066b 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -1,5 +1,5 @@ import { assert, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { DateTime, Effect, Layer, Option } from "effect"; import { GitHubCli, type GitHubCliShape } from "../git/Services/GitHubCli.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; @@ -46,7 +46,7 @@ it.effect("maps GitHub PR summaries into provider-neutral change requests", () = baseRefName: "main", headRefName: "feature/source-control", state: "open", - updatedAt: null, + updatedAt: Option.none(), isCrossRepository: true, headRepositoryNameWithOwner: "fork/t3code", headRepositoryOwnerLogin: "fork", @@ -99,7 +99,10 @@ it.effect("uses gh json listing for non-open change request state queries", () = ]); assert.strictEqual(changeRequests[0]?.provider, "github"); assert.strictEqual(changeRequests[0]?.state, "merged"); - assert.strictEqual(changeRequests[0]?.updatedAt, "2026-01-02T00:00:00.000Z"); + assert.deepStrictEqual( + changeRequests[0]?.updatedAt, + Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")), + ); }), ); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 2cec4ac5989..cba7bc74316 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -1,4 +1,4 @@ -import { Effect, Layer, Result, Schema } from "effect"; +import { Effect, Layer, Option, Result, Schema } from "effect"; import { SourceControlProviderError, type ChangeRequest, @@ -28,7 +28,7 @@ function toChangeRequest(summary: GitHubPullRequestSummary): ChangeRequest { baseRefName: summary.baseRefName, headRefName: summary.headRefName, state: summary.state ?? "open", - updatedAt: null, + updatedAt: Option.none(), ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } : {}), diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 32619ccc4dc..eb82f6d3ac8 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -1,4 +1,4 @@ -import { Context, DateTime, Effect, Layer } from "effect"; +import { Context, DateTime, Effect, Layer, Option } from "effect"; import { GitCommandError, @@ -204,7 +204,8 @@ const nowFreshness = Effect.fn("GitVcsDriver.nowFreshness")(function* () { const now = yield* DateTime.now; return { source: "live-local" as const, - observedAt: DateTime.formatIso(now), + observedAt: now, + expiresAt: Option.none(), }; }); diff --git a/apps/server/src/vcs/testing/VcsDriverContractHarness.ts b/apps/server/src/vcs/testing/VcsDriverContractHarness.ts index b7108dc0436..c0e195558b5 100644 --- a/apps/server/src/vcs/testing/VcsDriverContractHarness.ts +++ b/apps/server/src/vcs/testing/VcsDriverContractHarness.ts @@ -1,4 +1,5 @@ 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"; @@ -59,7 +60,8 @@ export function runVcsDriverContractSuite(input: VcsDriverContractSuiteInp assert.equal(identity?.kind, input.kind); assert.isTrue(identity?.rootPath.endsWith(cwd)); assert.equal(identity?.freshness.source, "live-local"); - assert.equal(typeof identity?.freshness.observedAt, "string"); + 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; @@ -91,7 +93,8 @@ export function runVcsDriverContractSuite(input: VcsDriverContractSuiteInp assert.include(result.paths, "untracked.ts"); assert.equal(result.truncated, false); assert.equal(result.freshness.source, "live-local"); - assert.equal(typeof result.freshness.observedAt, "string"); + assert.isTrue(DateTime.isDateTime(result.freshness.observedAt)); + assert.isTrue(Option.isNone(result.freshness.expiresAt)); }), ); diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 55e67101808..7fc437324a7 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -20,7 +20,7 @@ export const ChangeRequest = Schema.Struct({ baseRefName: TrimmedNonEmptyString, headRefName: TrimmedNonEmptyString, state: ChangeRequestState, - updatedAt: Schema.NullOr(TrimmedNonEmptyString), + updatedAt: Schema.Option(Schema.DateTimeUtc), isCrossRepository: Schema.optional(Schema.Boolean), headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), headRepositoryOwnerLogin: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), diff --git a/packages/contracts/src/vcs.ts b/packages/contracts/src/vcs.ts index 3bef29a479e..bd9a81f7e2d 100644 --- a/packages/contracts/src/vcs.ts +++ b/packages/contracts/src/vcs.ts @@ -14,8 +14,8 @@ export type VcsFreshnessSource = typeof VcsFreshnessSource.Type; export const VcsFreshness = Schema.Struct({ source: VcsFreshnessSource, - observedAt: TrimmedNonEmptyString, - expiresAt: Schema.optional(TrimmedNonEmptyString), + observedAt: Schema.DateTimeUtc, + expiresAt: Schema.Option(Schema.DateTimeUtc), }); export type VcsFreshness = typeof VcsFreshness.Type; From d66295d2a370c4a41144af682236e62b8ae295d4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 1 May 2026 14:56:32 -0700 Subject: [PATCH 17/45] Sort pull requests by updated time and fix git mock shape - Use Effect ordering for PR recency sorting in the server git manager - Update web git status test mocks to match the split `vcs` and `git` APIs --- apps/server/src/git/Layers/GitManager.ts | 25 ++++++++---------------- apps/web/src/lib/gitStatusState.test.ts | 6 ++++-- 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 2ebeef820cd..4616f90ce1e 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { realpathSync } from "node:fs"; import { + Array as EffectArray, Cache, DateTime, Duration, @@ -10,6 +11,7 @@ import { FileSystem, Layer, Option, + Order, Path, Ref, } from "effect"; @@ -74,6 +76,11 @@ interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { updatedAt: Option.Option; } +const pullRequestUpdatedAtDescOrder: Order.Order = Order.mapInput( + Order.flip(Option.makeOrder(DateTime.Order)), + (pullRequest) => pullRequest.updatedAt, +); + interface ResolvedPullRequest { number: number; title: string; @@ -273,20 +280,6 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { }; } -function compareOptionalDateTimeDesc( - left: Option.Option, - right: Option.Option, -): number { - return Option.match(left, { - onNone: () => (Option.isNone(right) ? 0 : 1), - onSome: (leftValue) => - Option.match(right, { - onNone: () => -1, - onSome: (rightValue) => DateTime.Order(rightValue, leftValue), - }), - }); -} - function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError { return new GitManagerError({ operation, @@ -895,9 +888,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { } } - const parsed = Array.from(parsedByNumber.values()).toSorted((a, b) => - compareOptionalDateTimeDesc(a.updatedAt, b.updatedAt), - ); + const parsed = EffectArray.sort(parsedByNumber.values(), pullRequestUpdatedAtDescOrder); const latestOpenPr = parsed.find((pr) => pr.state === "open"); if (latestOpenPr) { diff --git a/apps/web/src/lib/gitStatusState.test.ts b/apps/web/src/lib/gitStatusState.test.ts index 2e17cd15521..1c0cfe3dac3 100644 --- a/apps/web/src/lib/gitStatusState.test.ts +++ b/apps/web/src/lib/gitStatusState.test.ts @@ -89,7 +89,7 @@ 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, @@ -98,13 +98,15 @@ function createRegisteredGitStatusClient(environmentId: EnvironmentId) { onStatus: vi.fn((_: { cwd: string }, listener: (event: GitStatusResult) => void) => registerListener(listeners, listener), ), - 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), + }, + git: { + runStackedAction: vi.fn(async () => ({}) as any), resolvePullRequest: vi.fn(async () => undefined), preparePullRequestThread: vi.fn(async () => undefined), }, From 0d4e8f75a9927b463ebe6155bb8e1015ba490b83 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 1 May 2026 15:02:44 -0700 Subject: [PATCH 18/45] Rename Array import alias in GitManager - Use `Arr` alias for Effect Array in `GitManager` - Keep pull request sorting logic unchanged --- apps/server/src/git/Layers/GitManager.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 4616f90ce1e..db09cc1f374 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { realpathSync } from "node:fs"; import { - Array as EffectArray, + Array as Arr, Cache, DateTime, Duration, @@ -888,7 +888,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { } } - const parsed = EffectArray.sort(parsedByNumber.values(), pullRequestUpdatedAtDescOrder); + const parsed = Arr.sort(parsedByNumber.values(), pullRequestUpdatedAtDescOrder); const latestOpenPr = parsed.find((pr) => pr.state === "open"); if (latestOpenPr) { From eb711cc7af6ef7b0b04db5d0a58ef19e162b0e9e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 1 May 2026 15:51:10 -0700 Subject: [PATCH 19/45] Add pluggable source control provider detection - Detect GitHub, GitLab, and Azure DevOps remotes in the shared parser - Cache provider resolution and return unsupported stubs for unregistered kinds - Remove deprecated git RPCs from the web IPC surface --- apps/server/src/git/Layers/GitManager.test.ts | 15 ++- .../SourceControlProviderRegistry.test.ts | 82 +++++++++++++ .../SourceControlProviderRegistry.ts | 108 ++++++++++++++++-- apps/web/src/environmentApi.ts | 9 -- .../environments/runtime/connection.test.ts | 9 -- packages/contracts/src/git.ts | 7 +- packages/contracts/src/ipc.ts | 24 ---- packages/shared/src/git.ts | 12 ++ 8 files changed, 214 insertions(+), 52 deletions(-) create mode 100644 apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 1b5db1c1bd0..feabb8c2ea4 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -23,6 +23,7 @@ import { import { type TextGenerationShape, TextGeneration } from "../Services/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"; @@ -650,9 +651,19 @@ function makeManager(input?: { Layer.provideMerge(NodeServices.layer), Layer.provideMerge(ServerConfigLayer), ); + const sourceControlRegistryLayer = Layer.effect( + SourceControlProviderRegistry.SourceControlProviderRegistry, + GitHubSourceControlProvider.make().pipe( + Effect.map((provider) => + SourceControlProviderRegistry.SourceControlProviderRegistry.of({ + resolve: () => Effect.succeed(provider), + }), + ), + Effect.provide(Layer.succeed(GitHubCli, gitHubCli)), + ), + ); const managerLayer = Layer.mergeAll( - SourceControlProviderRegistry.layer.pipe(Layer.provide(Layer.succeed(GitHubCli, gitHubCli))), Layer.succeed(TextGeneration, textGeneration), Layer.succeed( ProjectSetupScriptRunner, @@ -662,7 +673,7 @@ function makeManager(input?: { ), vcsDriverLayer, serverSettingsLayer, - ).pipe(Layer.provideMerge(NodeServices.layer)); + ).pipe(Layer.provideMerge(sourceControlRegistryLayer), Layer.provideMerge(NodeServices.layer)); return makeGitManager().pipe( Effect.provide(managerLayer), diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts new file mode 100644 index 00000000000..222c2022869 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -0,0 +1,82 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import { GitHubCli } from "../git/Services/GitHubCli.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; + +const processResult = (stdout: string) => ({ + stdout, + stderr: "", + code: 0, + signal: null, + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, +}); + +function makeRegistry(input: { + readonly originUrl?: string | null; + readonly remoteVerboseOutput?: string; +}) { + const gitLayer = Layer.mock(GitVcsDriver.GitVcsDriver)({ + readConfigValue: (_cwd, key) => + key === "remote.origin.url" ? Effect.succeed(input.originUrl ?? null) : Effect.succeed(null), + execute: () => Effect.succeed(processResult(input.remoteVerboseOutput ?? "")), + }); + + return SourceControlProviderRegistry.make().pipe( + Effect.provide(Layer.mergeAll(gitLayer, Layer.mock(GitHubCli)({}))), + ); +} + +it.effect("routes GitHub remotes to the GitHub provider", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + originUrl: "git@github.com:pingdotgg/t3code.git", + }); + + const provider = yield* registry.resolve({ cwd: "/repo" }); + + 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({ + originUrl: "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 remote verbose output when origin is not configured", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + originUrl: null, + remoteVerboseOutput: [ + "upstream\thttps://dev.azure.com/acme/project/_git/repo (fetch)", + "upstream\thttps://dev.azure.com/acme/project/_git/repo (push)", + ].join("\n"), + }); + + 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 index 73488a3b62e..d54d887814b 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -1,8 +1,14 @@ -import { Context, Effect, Layer } from "effect"; -import type { SourceControlProviderError } from "@t3tools/contracts"; +import { Cache, Context, Duration, Effect, Exit, Layer } from "effect"; +import { SourceControlProviderError } from "@t3tools/contracts"; +import type { SourceControlProviderKind } from "@t3tools/contracts"; +import { detectGitHostingProviderFromRemoteUrl } from "@t3tools/shared/git"; import { SourceControlProvider, type SourceControlProviderShape } from "./SourceControlProvider.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; + +const PROVIDER_DETECTION_CACHE_CAPACITY = 2_048; +const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5); export interface SourceControlProviderRegistryShape { readonly resolve: (input: { @@ -15,14 +21,102 @@ export class SourceControlProviderRegistry extends Context.Service< 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 firstRemoteUrlFromVerboseOutput(output: string): string | null { + 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); + const remoteUrl = match?.[1]?.trim() ?? ""; + if (remoteUrl.length > 0) { + return remoteUrl; + } + } + return null; +} + export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () { - const github = yield* SourceControlProvider; + const github = yield* GitHubSourceControlProvider.make(); + const git = yield* GitVcsDriver.GitVcsDriver; + const providers: Partial> = { + github, + }; + + const detectProviderKind = Effect.fn("SourceControlProviderRegistry.detectProviderKind")( + function* (cwd: string) { + const originUrl = yield* git + .readConfigValue(cwd, "remote.origin.url") + .pipe(Effect.catch(() => Effect.succeed(null))); + const remoteUrl = + originUrl ?? + (yield* git + .execute({ + operation: "SourceControlProviderRegistry.detectProvider.remoteVerbose", + cwd, + args: ["remote", "-v"], + allowNonZeroExit: true, + }) + .pipe( + Effect.map((result) => + result.code === 0 ? firstRemoteUrlFromVerboseOutput(result.stdout) : null, + ), + Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error)), + )); + + if (!remoteUrl) { + return "unknown" as const; + } + + return detectGitHostingProviderFromRemoteUrl(remoteUrl)?.kind ?? "unknown"; + }, + ); + + const providerKindCache = yield* Cache.makeWith< + string, + SourceControlProviderKind, + SourceControlProviderError + >(detectProviderKind, { + capacity: PROVIDER_DETECTION_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? PROVIDER_DETECTION_CACHE_TTL : Duration.zero), + }); return SourceControlProviderRegistry.of({ - resolve: () => Effect.succeed(github), + resolve: (input) => + Cache.get(providerKindCache, input.cwd).pipe( + Effect.map((kind) => providers[kind] ?? unsupportedProvider(kind)), + ), }); }); -export const layer = Layer.effect(SourceControlProviderRegistry, make()).pipe( - Layer.provide(GitHubSourceControlProvider.layer), -); +export const layer = Layer.effect(SourceControlProviderRegistry, make()); diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index 2bb951ad7ae..9f38cfadf66 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -35,15 +35,6 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { init: rpcClient.vcs.init, }, git: { - pull: rpcClient.vcs.pull, - refreshStatus: rpcClient.vcs.refreshStatus, - onStatus: (input, callback, options) => rpcClient.vcs.onStatus(input, callback, options), - listBranches: rpcClient.vcs.listBranches, - createWorktree: rpcClient.vcs.createWorktree, - removeWorktree: rpcClient.vcs.removeWorktree, - createBranch: rpcClient.vcs.createBranch, - checkout: rpcClient.vcs.checkout, - init: rpcClient.vcs.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 57f23241456..64ff7a1c3ca 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/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 6a6e7cbe648..837549a235c 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -45,7 +45,12 @@ const GitStatusPrState = 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 const GitHostingProviderKind = Schema.Literals([ + "github", + "gitlab", + "azure-devops", + "unknown", +]); export type GitHostingProviderKind = typeof GitHostingProviderKind.Type; export const GitHostingProvider = Schema.Struct({ kind: GitHostingProviderKind, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 4950bc5f74f..48181c7c464 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -274,34 +274,10 @@ export interface EnvironmentApi { ) => () => void; }; git: { - /** @deprecated Use `EnvironmentApi.vcs.listBranches` for local VCS branch/ref listing. */ - listBranches: (input: GitListBranchesInput) => Promise; - /** @deprecated Use `EnvironmentApi.vcs.createWorktree` for local VCS workspace creation. */ - createWorktree: (input: GitCreateWorktreeInput) => Promise; - /** @deprecated Use `EnvironmentApi.vcs.removeWorktree` for local VCS workspace removal. */ - removeWorktree: (input: GitRemoveWorktreeInput) => Promise; - /** @deprecated Use `EnvironmentApi.vcs.createBranch` for local VCS branch/ref creation. */ - createBranch: (input: GitCreateBranchInput) => Promise; - /** @deprecated Use `EnvironmentApi.vcs.checkout` for local VCS checkout/switch operations. */ - checkout: (input: GitCheckoutInput) => Promise; - /** @deprecated Use `EnvironmentApi.vcs.init` for local VCS repository initialization. */ - init: (input: GitInitInput) => Promise; resolvePullRequest: (input: GitPullRequestRefInput) => Promise; preparePullRequestThread: ( input: GitPreparePullRequestThreadInput, ) => Promise; - /** @deprecated Use `EnvironmentApi.vcs.pull` for local VCS pull/sync operations. */ - pull: (input: GitPullInput) => Promise; - /** @deprecated Use `EnvironmentApi.vcs.refreshStatus` for local VCS status refreshes. */ - refreshStatus: (input: GitStatusInput) => Promise; - /** @deprecated Use `EnvironmentApi.vcs.onStatus` for local VCS status subscriptions. */ - onStatus: ( - input: GitStatusInput, - callback: (status: GitStatusResult) => void, - options?: { - onResubscribe?: () => void; - }, - ) => () => void; }; orchestration: { dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>; diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 4570ba9ecb0..c60b49e43f0 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -225,6 +225,10 @@ 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"); +} + export function detectGitHostingProviderFromRemoteUrl( remoteUrl: string, ): GitHostingProvider | null { @@ -249,6 +253,14 @@ export function detectGitHostingProviderFromRemoteUrl( }; } + if (isAzureDevOpsHost(host)) { + return { + kind: "azure-devops", + name: "Azure DevOps", + baseUrl: toBaseUrl(host), + }; + } + return { kind: "unknown", name: host, From d1bbd73e02187a63b106a778a3d241734d8cd61c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 1 May 2026 16:20:07 -0700 Subject: [PATCH 20/45] Rename git APIs around refs - Replace branch-centric git/VCS types and methods with ref-based names - Update server, web, and tests to use the new pluggable integration shape --- .../OrchestrationEngineHarness.integration.ts | 6 +- apps/server/src/git/GitWorkflowService.ts | 84 ++- apps/server/src/git/Layers/GitManager.test.ts | 64 +- apps/server/src/git/Layers/GitManager.ts | 36 +- apps/server/src/git/Services/GitManager.ts | 20 +- .../Layers/CheckpointReactor.test.ts | 6 +- .../Layers/ProviderCommandReactor.test.ts | 6 +- apps/server/src/server.test.ts | 118 ++- apps/server/src/vcs/GitVcsDriver.ts | 68 +- apps/server/src/vcs/GitVcsDriverCore.test.ts | 40 +- apps/server/src/vcs/GitVcsDriverCore.ts | 690 +++++++++--------- .../src/vcs/VcsStatusBroadcaster.test.ts | 42 +- apps/server/src/vcs/VcsStatusBroadcaster.ts | 36 +- apps/server/src/ws.ts | 22 +- .../components/BranchToolbar.logic.test.ts | 76 +- .../web/src/components/BranchToolbar.logic.ts | 18 +- .../BranchToolbarBranchSelector.tsx | 94 +-- apps/web/src/components/ChatView.browser.tsx | 24 +- .../GitActionsControl.logic.test.ts | 142 ++-- .../src/components/GitActionsControl.logic.ts | 64 +- apps/web/src/components/GitActionsControl.tsx | 64 +- .../components/KeybindingsToast.browser.tsx | 2 +- .../src/components/ThreadStatusIndicators.tsx | 8 +- apps/web/src/environmentApi.ts | 6 +- apps/web/src/lib/gitReactQuery.test.ts | 10 +- apps/web/src/lib/gitReactQuery.ts | 28 +- apps/web/src/lib/gitStatusState.test.ts | 54 +- apps/web/src/lib/gitStatusState.ts | 10 +- apps/web/src/localApi.test.ts | 20 +- apps/web/src/rpc/wsRpcClient.test.ts | 18 +- apps/web/src/rpc/wsRpcClient.ts | 24 +- packages/contracts/src/git.test.ts | 20 +- packages/contracts/src/git.ts | 134 ++-- packages/contracts/src/ipc.ts | 48 +- packages/contracts/src/rpc.ts | 80 +- packages/shared/src/git.test.ts | 26 +- packages/shared/src/git.ts | 72 +- 37 files changed, 1139 insertions(+), 1141 deletions(-) diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 4ccacbb345d..41381bfce4c 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -331,9 +331,9 @@ export const makeOrchestrationIntegrationHarness = ( 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 }, }), diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 153c0c4dc53..f37ece9baba 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -3,27 +3,27 @@ import { Context, Effect, Layer } from "effect"; import { GitManagerError, GitCommandError, - type GitCheckoutInput, - type GitCheckoutResult, - type GitCreateBranchInput, - type GitCreateBranchResult, - type GitCreateWorktreeInput, - type GitCreateWorktreeResult, - type GitListBranchesInput, - type GitListBranchesResult, + 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 GitPullResult, - type GitRemoveWorktreeInput, + type VcsPullResult, + type VcsRemoveWorktreeInput, type GitResolvePullRequestResult, type GitRunStackedActionInput, type GitRunStackedActionResult, - type GitStatusInput, - type GitStatusLocalResult, - type GitStatusRemoteResult, - type GitStatusResult, + type VcsStatusInput, + type VcsStatusLocalResult, + type VcsStatusRemoteResult, + type VcsStatusResult, } from "@t3tools/contracts"; import { GitManager, type GitRunStackedActionOptions } from "./Services/GitManager.ts"; @@ -32,18 +32,18 @@ import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; export interface GitWorkflowServiceShape { readonly status: ( - input: GitStatusInput, - ) => Effect.Effect; + input: VcsStatusInput, + ) => Effect.Effect; readonly localStatus: ( - input: GitStatusInput, - ) => Effect.Effect; + input: VcsStatusInput, + ) => Effect.Effect; readonly remoteStatus: ( - input: GitStatusInput, - ) => Effect.Effect; + 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 pullCurrentBranch: (cwd: string) => Effect.Effect; readonly runStackedAction: ( input: GitRunStackedActionInput, options?: GitRunStackedActionOptions, @@ -54,25 +54,23 @@ export interface GitWorkflowServiceShape { readonly preparePullRequestThread: ( input: GitPreparePullRequestThreadInput, ) => Effect.Effect; - readonly listBranches: ( - input: GitListBranchesInput, - ) => Effect.Effect; + readonly listRefs: (input: VcsListRefsInput) => Effect.Effect; readonly createWorktree: ( - input: GitCreateWorktreeInput, - ) => Effect.Effect; - readonly removeWorktree: (input: GitRemoveWorktreeInput) => Effect.Effect; - readonly createBranch: ( - input: GitCreateBranchInput, - ) => Effect.Effect; - readonly checkoutBranch: ( - input: GitCheckoutInput, - ) => Effect.Effect; + input: VcsCreateWorktreeInput, + ) => Effect.Effect; + readonly removeWorktree: (input: VcsRemoveWorktreeInput) => Effect.Effect; + readonly createRef: ( + input: VcsCreateRefInput, + ) => Effect.Effect; + readonly switchRef: ( + input: VcsSwitchRefInput, + ) => Effect.Effect; readonly initRepo: (input: { readonly cwd: string }) => Effect.Effect; readonly renameBranch: (input: { readonly cwd: string; readonly oldBranch: string; readonly newBranch: string; - }) => Effect.Effect; + }) => Effect.Effect<{ readonly branch: string }, GitManagerServiceError>; } export class GitWorkflowService extends Context.Service< @@ -178,9 +176,9 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () { "GitWorkflowService.preparePullRequestThread", gitManager.preparePullRequestThread, ), - listBranches: (input) => - ensureGitCommand("GitWorkflowService.listBranches", input.cwd).pipe( - Effect.andThen(git.listBranches(input)), + listRefs: (input) => + ensureGitCommand("GitWorkflowService.listRefs", input.cwd).pipe( + Effect.andThen(git.listRefs(input)), ), createWorktree: (input) => ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe( @@ -190,13 +188,13 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () { ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe( Effect.andThen(git.removeWorktree(input)), ), - createBranch: (input) => - ensureGitCommand("GitWorkflowService.createBranch", input.cwd).pipe( - Effect.andThen(git.createBranch(input)), + createRef: (input) => + ensureGitCommand("GitWorkflowService.createRef", input.cwd).pipe( + Effect.andThen(git.createRef(input)), ), - checkoutBranch: (input) => - ensureGitCommand("GitWorkflowService.checkoutBranch", input.cwd).pipe( - Effect.andThen(Effect.scoped(git.checkoutBranch(input))), + switchRef: (input) => + ensureGitCommand("GitWorkflowService.switchRef", input.cwd).pipe( + Effect.andThen(Effect.scoped(git.switchRef(input))), ), initRepo: (input) => git.initRepo(input), renameBranch: (input) => diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index feabb8c2ea4..3a8123f198d 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -717,15 +717,15 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { 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.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", - baseBranch: "main", - headBranch: "feature/status-open-pr", + baseRef: "main", + headRef: "feature/status-open-pr", state: "open", }); }), @@ -762,8 +762,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { number: 14, title: "Existing PR title", url: "https://github.com/pingdotgg/codething-mvp/pull/14", - baseBranch: "main", - headBranch: "feature/status-trimmed-pr", + baseRef: "main", + headRef: "feature/status-trimmed-pr", state: "open", }); }), @@ -813,8 +813,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { number: 15, title: "Valid PR title", url: "https://github.com/pingdotgg/codething-mvp/pull/15", - baseBranch: "main", - headBranch: "feature/status-valid-pr-entry", + baseRef: "main", + headRef: "feature/status-valid-pr-entry", state: "open", }); }), @@ -862,8 +862,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { number: 17, title: "Merged PR", url: "https://github.com/pingdotgg/codething-mvp/pull/17", - baseBranch: "main", - headBranch: "feature/status-lowercase-state", + baseRef: "main", + headRef: "feature/status-lowercase-state", state: "merged", }); }), @@ -878,9 +878,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(status).toEqual({ isRepo: false, - hasOriginRemote: false, - isDefaultBranch: false, - branch: null, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, hasWorkingTreeChanges: false, workingTree: { files: [], @@ -907,9 +907,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(status).toEqual({ isRepo: false, - hasOriginRemote: false, - isDefaultBranch: false, - branch: null, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, hasWorkingTreeChanges: false, workingTree: { files: [], @@ -991,7 +991,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }); const status = yield* manager.status({ cwd: repoDir }); - expect(status.branch).toBe("main"); + expect(status.refName).toBe("main"); expect(status.pr).toBeNull(); }), ); @@ -1045,13 +1045,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }); const status = yield* manager.status({ cwd: repoDir }); - expect(status.branch).toBe("t3code/pr-488/statemachine"); + 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", - baseBranch: "main", - headBranch: "statemachine", + baseRef: "main", + headRef: "statemachine", state: "open", }); expect(ghCalls).toContain( @@ -1145,13 +1145,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }); const status = yield* manager.status({ cwd: repoDir }); - expect(status.branch).toBe("upstream/effect-atom"); + expect(status.refName).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", + baseRef: "main", + headRef: "effect-atom", state: "open", }); expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe( @@ -1195,13 +1195,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }); const status = yield* manager.status({ cwd: repoDir }); - expect(status.branch).toBe("feature/status-merged-pr"); + 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", - baseBranch: "main", - headBranch: "feature/status-merged-pr", + baseRef: "main", + headRef: "feature/status-merged-pr", state: "merged", }); }), @@ -1232,7 +1232,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }); const status = yield* manager.status({ cwd: repoDir }); - expect(status.branch).toBe("main"); + expect(status.refName).toBe("main"); expect(status.pr).toBeNull(); }), ); @@ -1272,13 +1272,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }); const status = yield* manager.status({ cwd: repoDir }); - expect(status.branch).toBe("feature/status-open-over-merged"); + 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", - baseBranch: "main", - headBranch: "feature/status-open-over-merged", + baseRef: "main", + headRef: "feature/status-open-over-merged", state: "open", }); }), @@ -1303,7 +1303,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }); const status = yield* manager.status({ cwd: repoDir }); - expect(status.branch).toBe("feature/status-no-gh"); + expect(status.refName).toBe("feature/status-no-gh"); expect(status.pr).toBeNull(); }), ); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index db09cc1f374..492e1335168 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -21,8 +21,8 @@ import { GitCommandError, GitRunStackedActionResult, GitStackedAction, - type GitStatusLocalResult, - type GitStatusRemoteResult, + type VcsStatusLocalResult, + type VcsStatusRemoteResult, ModelSelection, } from "@t3tools/contracts"; import { @@ -425,16 +425,16 @@ function toStatusPr(pr: PullRequestInfo): { number: number; title: string; url: string; - baseBranch: string; - headBranch: string; + baseRef: string; + headRef: string; state: "open" | "closed" | "merged"; } { return { number: pr.number, title: pr.title, url: pr.url, - baseBranch: pr.baseRefName, - headBranch: pr.headRefName, + baseRef: pr.baseRefName, + headRef: pr.headRefName, state: pr.state, }; } @@ -666,12 +666,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { return { isRepo: details.isRepo, ...(hostingProvider ? { hostingProvider } : {}), - hasOriginRemote: details.hasOriginRemote, - isDefaultBranch: details.isDefaultBranch, - branch: details.branch, + hasPrimaryRemote: details.hasOriginRemote, + isDefaultRef: details.isDefaultBranch, + refName: details.branch, hasWorkingTreeChanges: details.hasWorkingTreeChanges, workingTree: details.workingTree, - } satisfies GitStatusLocalResult; + } satisfies VcsStatusLocalResult; }); const localStatusResultCache = yield* Cache.makeWith(readLocalStatus, { capacity: STATUS_RESULT_CACHE_CAPACITY, @@ -709,7 +709,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { aheadCount: details.aheadCount, behindCount: details.behindCount, pr, - } satisfies GitStatusRemoteResult; + } satisfies VcsStatusRemoteResult; }); const remoteStatusResultCache = yield* Cache.makeWith(readRemoteStatus, { capacity: STATUS_RESULT_CACHE_CAPACITY, @@ -1390,9 +1390,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo); const findLocalHeadBranch = (cwd: string) => - gitCore.listBranches({ cwd }).pipe( + gitCore.listRefs({ cwd }).pipe( Effect.map((result) => { - const localBranch = result.branches.find( + const localBranch = result.refs.find( (branch) => !branch.isRemote && branch.name === localPullRequestBranch, ); if (localBranch) { @@ -1402,7 +1402,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { return null; } return ( - result.branches.find( + result.refs.find( (branch) => !branch.isRemote && branch.name === pullRequest.headBranch && @@ -1465,7 +1465,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const worktree = yield* gitCore.createWorktree({ cwd: input.cwd, - branch: localPullRequestBranch, + refName: localPullRequestBranch, path: null, }); yield* ensureExistingWorktreeUpstream(worktree.worktree.path); @@ -1473,7 +1473,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { return { pullRequest, - branch: worktree.worktree.branch, + branch: worktree.worktree.refName, worktreePath: worktree.worktree.path, }; }).pipe(Effect.ensuring(invalidateStatus(input.cwd))); @@ -1505,8 +1505,8 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { 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 })); + yield* gitCore.createRef({ cwd, refName: resolvedBranch }); + yield* Effect.scoped(gitCore.switchRef({ cwd, refName: resolvedBranch })); return { branchStep: { status: "created" as const, name: resolvedBranch }, diff --git a/apps/server/src/git/Services/GitManager.ts b/apps/server/src/git/Services/GitManager.ts index 29c762195e5..bf46dc46d8e 100644 --- a/apps/server/src/git/Services/GitManager.ts +++ b/apps/server/src/git/Services/GitManager.ts @@ -14,10 +14,10 @@ import { GitResolvePullRequestResult, GitRunStackedActionInput, GitRunStackedActionResult, - GitStatusLocalResult, - GitStatusRemoteResult, - GitStatusInput, - GitStatusResult, + VcsStatusLocalResult, + VcsStatusRemoteResult, + VcsStatusInput, + VcsStatusResult, } from "@t3tools/contracts"; import { Context } from "effect"; import type { Effect } from "effect"; @@ -40,22 +40,22 @@ export interface GitManagerShape { * Read current repository Git status plus open PR metadata when available. */ readonly status: ( - input: GitStatusInput, - ) => Effect.Effect; + input: VcsStatusInput, + ) => Effect.Effect; /** * Read local repository status without remote hosting enrichment. */ readonly localStatus: ( - input: GitStatusInput, - ) => Effect.Effect; + input: VcsStatusInput, + ) => Effect.Effect; /** * Read remote tracking / PR status for a repository. */ readonly remoteStatus: ( - input: GitStatusInput, - ) => Effect.Effect; + input: VcsStatusInput, + ) => Effect.Effect; /** * Clear any cached local status snapshot for a repository. diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 96e43323eca..ad5fb59bd1e 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -291,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 }, }), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 7837a965157..d0f1a1df3e4 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -240,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: [], diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 2096cf76910..04f241998e8 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -2362,9 +2362,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 }, }), @@ -2378,9 +2378,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, @@ -2465,12 +2465,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { 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, @@ -2479,17 +2479,17 @@ 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 }), + createRef: (input) => Effect.succeed({ refName: input.refName }), + switchRef: (input) => Effect.succeed({ refName: input.refName }), initRepo: () => Effect.void, }, }, @@ -2548,23 +2548,21 @@ 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.vcsListBranches]({ 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.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) => @@ -2577,18 +2575,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.vcsCreateBranch]({ + client[WS_METHODS.vcsCreateRef]({ cwd: "/tmp/repo", - branch: "feature/new", + refName: "feature/new", }), ), ); yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.vcsCheckout]({ + client[WS_METHODS.vcsSwitchRef]({ cwd: "/tmp/repo", - branch: "main", + refName: "main", }), ), ); @@ -2634,9 +2632,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 }, }), @@ -2655,9 +2653,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, @@ -2711,9 +2709,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 }, }), @@ -2732,9 +2730,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, @@ -2773,8 +2771,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { pullCurrentBranch: () => Effect.succeed({ status: "pulled" as const, - branch: "main", - upstreamBranch: "origin/main", + refName: "main", + upstreamRef: "origin/main", }), }, gitManager: { @@ -2783,9 +2781,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 }, }), @@ -2826,9 +2824,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: false, workingTree: { files: [], insertions: 0, deletions: 0 }, }), @@ -2902,9 +2900,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 }, }), @@ -3568,9 +3566,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: [], @@ -3587,7 +3585,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { (_: Parameters[0]) => Effect.succeed({ worktree: { - branch: "t3code/bootstrap-branch", + refName: "t3code/bootstrap-refName", path: "/tmp/bootstrap-worktree", }, }), @@ -3656,7 +3654,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, }, @@ -3678,8 +3676,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], { @@ -3713,7 +3711,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { (_: Parameters[0]) => Effect.succeed({ worktree: { - branch: "t3code/bootstrap-branch", + refName: "t3code/bootstrap-refName", path: "/tmp/bootstrap-worktree", }, }), @@ -3773,7 +3771,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, }, @@ -3807,7 +3805,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { (_: Parameters[0]) => Effect.succeed({ worktree: { - branch: "t3code/bootstrap-branch", + refName: "t3code/bootstrap-refName", path: "/tmp/bootstrap-worktree", }, }), @@ -3890,7 +3888,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, }, @@ -3974,7 +3972,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/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index eb82f6d3ac8..8e2a77cc080 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -3,19 +3,19 @@ import { Context, DateTime, Effect, Layer, Option } from "effect"; import { GitCommandError, VcsProcessExitError, - type GitCheckoutInput, - type GitCheckoutResult, - type GitCreateBranchInput, - type GitCreateBranchResult, - type GitCreateWorktreeInput, - type GitCreateWorktreeResult, - type GitInitInput, - type GitListBranchesInput, - type GitListBranchesResult, - type GitPullResult, - type GitRemoveWorktreeInput, - type GitStatusInput, - type GitStatusResult, + 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"; @@ -42,8 +42,18 @@ export interface ExecuteGitResult { readonly stderrTruncated: boolean; } -export interface GitStatusDetails extends Omit { +export interface GitStatusDetails { + isRepo: boolean; + hostingProvider?: VcsStatusResult["hostingProvider"]; + hasOriginRemote: boolean; + isDefaultBranch: boolean; + branch: string | null; upstreamRef: string | null; + hasWorkingTreeChanges: boolean; + workingTree: VcsStatusResult["workingTree"]; + hasUpstream: boolean; + aheadCount: number; + behindCount: number; } export interface GitPreparedCommitContext { @@ -131,7 +141,7 @@ export interface GitSetBranchUpstreamInput { export interface GitVcsDriverShape { readonly execute: (input: ExecuteGitInput) => Effect.Effect; - readonly status: (input: GitStatusInput) => Effect.Effect; + readonly status: (input: VcsStatusInput) => Effect.Effect; readonly statusDetails: (cwd: string) => Effect.Effect; readonly statusDetailsLocal: (cwd: string) => Effect.Effect; readonly prepareCommitContext: ( @@ -150,19 +160,17 @@ export interface GitVcsDriverShape { ) => Effect.Effect; readonly readRangeContext: ( cwd: string, - baseBranch: string, + baseRef: string, ) => Effect.Effect; readonly readConfigValue: ( cwd: string, key: string, ) => Effect.Effect; - readonly listBranches: ( - input: GitListBranchesInput, - ) => Effect.Effect; - readonly pullCurrentBranch: (cwd: string) => Effect.Effect; + readonly listRefs: (input: VcsListRefsInput) => Effect.Effect; + readonly pullCurrentBranch: (cwd: string) => Effect.Effect; readonly createWorktree: ( - input: GitCreateWorktreeInput, - ) => Effect.Effect; + input: VcsCreateWorktreeInput, + ) => Effect.Effect; readonly fetchPullRequestBranch: ( input: GitFetchPullRequestBranchInput, ) => Effect.Effect; @@ -173,17 +181,17 @@ export interface GitVcsDriverShape { readonly setBranchUpstream: ( input: GitSetBranchUpstreamInput, ) => Effect.Effect; - readonly removeWorktree: (input: GitRemoveWorktreeInput) => Effect.Effect; + readonly removeWorktree: (input: VcsRemoveWorktreeInput) => Effect.Effect; readonly renameBranch: ( input: GitRenameBranchInput, ) => Effect.Effect; - readonly createBranch: ( - input: GitCreateBranchInput, - ) => Effect.Effect; - readonly checkoutBranch: ( - input: GitCheckoutInput, - ) => Effect.Effect; - readonly initRepo: (input: GitInitInput) => 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; } diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 236be6d7b05..38472fd5b0d 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -79,13 +79,13 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const cwd = yield* makeTmpDir(); const driver = yield* GitVcsDriver.GitVcsDriver; - const branches = yield* driver.listBranches({ cwd }); - assert.equal(branches.isRepo, false); - assert.deepStrictEqual(branches.branches, []); + const refs = yield* driver.listRefs({ cwd }); + assert.equal(refs.isRepo, false); + assert.deepStrictEqual(refs.refs, []); }), ); - it.effect("reports branch and dirty state for a repository", () => + it.effect("reports refName and dirty state for a repository", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(cwd); @@ -104,16 +104,16 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { ); }); - describe("branch operations", () => { - it.effect("creates, checks out, renames, and lists branches", () => + 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.createBranch({ cwd, branch: "feature/original" }); - const checkout = yield* driver.checkoutBranch({ cwd, branch: "feature/original" }); - assert.equal(checkout.branch, "feature/original"); + 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, @@ -123,15 +123,15 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.equal(renamed.branch, "feature/renamed"); assert.equal(yield* git(cwd, ["branch", "--show-current"]), "feature/renamed"); - const branches = yield* driver.listBranches({ cwd }); + const refs = yield* driver.listRefs({ cwd }); assert.equal( - branches.branches.find((branch) => branch.name === "feature/renamed")?.current, + refs.refs.find((refName) => refName.name === "feature/renamed")?.current, true, ); }), ); - it.effect("returns the existing branch when rename source and target match", () => + it.effect("returns the existing refName when rename source and target match", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); yield* initRepoWithCommit(cwd); @@ -150,7 +150,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("worktree operations", () => { - it.effect("creates and removes a worktree for a new branch", () => + it.effect("creates and removes a worktree for a new refName", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(cwd); @@ -164,12 +164,12 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const created = yield* driver.createWorktree({ cwd, path: worktreePath, - branch: initialBranch, - newBranch: "feature/worktree", + refName: initialBranch, + newRefName: "feature/worktree", }); assert.equal(created.worktree.path, worktreePath); - assert.equal(created.worktree.branch, "feature/worktree"); + assert.equal(created.worktree.refName, "feature/worktree"); assert.equal(yield* git(worktreePath, ["branch", "--show-current"]), "feature/worktree"); yield* driver.removeWorktree({ cwd, path: worktreePath }); @@ -212,13 +212,13 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { yield* initRepoWithCommit(cwd); yield* git(remote, ["init", "--bare"]); yield* git(cwd, ["remote", "add", "origin", remote]); - yield* (yield* GitVcsDriver.GitVcsDriver).createBranch({ + yield* (yield* GitVcsDriver.GitVcsDriver).createRef({ cwd, - branch: "feature/push", + refName: "feature/push", }); - yield* (yield* GitVcsDriver.GitVcsDriver).checkoutBranch({ + yield* (yield* GitVcsDriver.GitVcsDriver).switchRef({ cwd, - branch: "feature/push", + refName: "feature/push", }); yield* writeTextFile(cwd, "feature.txt", "feature\n"); yield* (yield* GitVcsDriver.GitVcsDriver).prepareCommitContext(cwd); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index eae312c7893..97edb8c49a3 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -18,7 +18,7 @@ import { } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { GitCommandError, type GitBranch } from "@t3tools/contracts"; +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"; @@ -155,34 +155,34 @@ function parseBranchLine(line: string): { name: string; current: boolean } | nul } function filterBranchesForListQuery( - branches: ReadonlyArray, + refs: ReadonlyArray, query?: string, -): ReadonlyArray { +): ReadonlyArray { if (!query) { - return branches; + return refs; } const normalizedQuery = query.toLowerCase(); - return branches.filter((branch) => branch.name.toLowerCase().includes(normalizedQuery)); + return refs.filter((refName) => refName.name.toLowerCase().includes(normalizedQuery)); } function paginateBranches(input: { - branches: ReadonlyArray; + refs: ReadonlyArray; cursor?: number | undefined; limit?: number | undefined; }): { - branches: ReadonlyArray; + refs: 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; + const totalCount = input.refs.length; + const refs = input.refs.slice(cursor, cursor + limit); + const nextCursor = cursor + refs.length < totalCount ? cursor + refs.length : null; return { - branches, + refs, nextCursor, totalCount, }; @@ -223,7 +223,7 @@ function parseRemoteFetchUrls(stdout: string): Map { function parseUpstreamRefWithRemoteNames( upstreamRef: string, remoteNames: ReadonlyArray, -): { upstreamRef: string; remoteName: string; upstreamBranch: string } | null { +): { upstreamRef: string; remoteName: string; branchName: string } | null { const parsed = parseRemoteRefWithRemoteNames(upstreamRef, remoteNames); if (!parsed) { return null; @@ -232,28 +232,28 @@ function parseUpstreamRefWithRemoteNames( return { upstreamRef, remoteName: parsed.remoteName, - upstreamBranch: parsed.branchName, + branchName: parsed.branchName, }; } function parseUpstreamRefByFirstSeparator( upstreamRef: string, -): { upstreamRef: string; remoteName: string; upstreamBranch: string } | null { +): { 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 upstreamBranch = upstreamRef.slice(separatorIndex + 1).trim(); - if (remoteName.length === 0 || upstreamBranch.length === 0) { + const branchName = upstreamRef.slice(separatorIndex + 1).trim(); + if (remoteName.length === 0 || branchName.length === 0) { return null; } return { upstreamRef, remoteName, - upstreamBranch, + branchName, }; } @@ -265,11 +265,11 @@ function parseTrackingBranchByUpstreamRef(stdout: string, upstreamRef: string): } const [branchNameRaw, upstreamBranchRaw = ""] = trimmedLine.split("\t"); const branchName = branchNameRaw?.trim() ?? ""; - const upstreamBranch = upstreamBranchRaw.trim(); - if (branchName.length === 0 || upstreamBranch.length === 0) { + const candidateUpstreamRef = upstreamBranchRaw.trim(); + if (branchName.length === 0 || candidateUpstreamRef.length === 0) { continue; } - if (upstreamBranch === upstreamRef) { + if (candidateUpstreamRef === upstreamRef) { return branchName; } } @@ -296,8 +296,8 @@ function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): if (!trimmed.startsWith(prefix)) { return null; } - const branch = trimmed.slice(prefix.length).trim(); - return branch.length > 0 ? branch : null; + const refName = trimmed.slice(prefix.length).trim(); + return refName.length > 0 ? refName : null; } function createGitCommandError( @@ -814,11 +814,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ), ); - const branchExists = (cwd: string, branch: string): Effect.Effect => + const branchExists = (cwd: string, refName: string): Effect.Effect => executeGit( "GitVcsDriver.branchExists", cwd, - ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], + ["show-ref", "--verify", "--quiet", `refs/heads/${refName}`], { allowNonZeroExit: true, timeoutMs: 5_000, @@ -949,12 +949,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const remoteBranchExists = ( cwd: string, remoteName: string, - branch: string, + refName: string, ): Effect.Effect => executeGit( "GitVcsDriver.remoteBranchExists", cwd, - ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${branch}`], + ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${refName}`], { allowNonZeroExit: true, }, @@ -989,12 +989,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const resolvePushRemoteName = Effect.fn("resolvePushRemoteName")(function* ( cwd: string, - branch: string, + refName: string, ) { const branchPushRemote = yield* runGitStdout( "GitVcsDriver.resolvePushRemoteName.branchPushRemote", cwd, - ["config", "--get", `branch.${branch}.pushRemote`], + ["config", "--get", `branch.${refName}.pushRemote`], true, ).pipe(Effect.map((stdout) => stdout.trim())); if (branchPushRemote.length > 0) { @@ -1049,12 +1049,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const resolveBaseBranchForNoUpstream = Effect.fn("resolveBaseBranchForNoUpstream")(function* ( cwd: string, - branch: string, + refName: string, ) { const configuredBaseBranch = yield* runGitStdout( "GitVcsDriver.resolveBaseBranchForNoUpstream.config", cwd, - ["config", "--get", `branch.${branch}.gh-merge-base`], + ["config", "--get", `branch.${refName}.gh-merge-base`], true, ).pipe(Effect.map((stdout) => stdout.trim())); @@ -1081,7 +1081,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : remotePrefix && candidate.startsWith(remotePrefix) ? candidate.slice(remotePrefix.length) : candidate; - if (normalizedCandidate.length === 0 || normalizedCandidate === branch) { + if (normalizedCandidate.length === 0 || normalizedCandidate === refName) { continue; } @@ -1102,17 +1102,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const computeAheadCountAgainstBase = Effect.fn("computeAheadCountAgainstBase")(function* ( cwd: string, - branch: string, + refName: string, ) { - const baseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch); - if (!baseBranch) { + const baseRef = yield* resolveBaseBranchForNoUpstream(cwd, refName); + if (!baseRef) { return 0; } const result = yield* executeGit( "GitVcsDriver.computeAheadCountAgainstBase", cwd, - ["rev-list", "--count", `${baseBranch}..HEAD`], + ["rev-list", "--count", `${baseRef}..HEAD`], { allowNonZeroExit: true }, ); if (result.code !== 0) { @@ -1183,7 +1183,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); } - const [unstagedNumstatStdout, stagedNumstatStdout, defaultRefResult, hasOriginRemote] = + const [unstagedNumstatStdout, stagedNumstatStdout, defaultRefResult, hasPrimaryRemote] = yield* Effect.all( [ runGitStdout("GitVcsDriver.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]), @@ -1210,7 +1210,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "") : null; - let branch: string | null = null; + let refName: string | null = null; let upstreamRef: string | null = null; let aheadCount = 0; let behindCount = 0; @@ -1220,7 +1220,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* 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; + refName = value.startsWith("(") ? null : value; continue; } if (line.startsWith("# branch.upstream ")) { @@ -1242,8 +1242,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } } - if (!upstreamRef && branch) { - aheadCount = yield* computeAheadCountAgainstBase(cwd, branch).pipe( + if (!upstreamRef && refName) { + aheadCount = yield* computeAheadCountAgainstBase(cwd, refName).pipe( Effect.catch(() => Effect.succeed(0)), ); behindCount = 0; @@ -1277,12 +1277,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { isRepo: true, - hasOriginRemote, + hasOriginRemote: hasPrimaryRemote, isDefaultBranch: - branch !== null && - (branch === defaultBranch || - (defaultBranch === null && (branch === "main" || branch === "master"))), - branch, + refName !== null && + (refName === defaultBranch || + (defaultBranch === null && (refName === "main" || refName === "master"))), + branch: refName, upstreamRef, hasWorkingTreeChanges, workingTree: { @@ -1316,9 +1316,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* statusDetails(input.cwd).pipe( Effect.map((details) => ({ isRepo: details.isRepo, - hasOriginRemote: details.hasOriginRemote, - isDefaultBranch: details.isDefaultBranch, - branch: details.branch, + hasPrimaryRemote: details.hasOriginRemote, + isDefaultRef: details.isDefaultBranch, + refName: details.branch, hasWorkingTreeChanges: details.hasWorkingTreeChanges, workingTree: details.workingTree, hasUpstream: details.hasUpstream, @@ -1483,7 +1483,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* runGit("GitVcsDriver.pushCurrentBranch.pushUpstream", cwd, [ "push", currentUpstream.remoteName, - `HEAD:${currentUpstream.upstreamBranch}`, + `HEAD:${currentUpstream.upstreamRef}`, ]); return { status: "pushed" as const, @@ -1506,8 +1506,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const pullCurrentBranch: GitVcsDriverShape["pullCurrentBranch"] = Effect.fn("pullCurrentBranch")( function* (cwd) { const details = yield* statusDetails(cwd); - const branch = details.branch; - if (!branch) { + const refName = details.branch; + if (!refName) { return yield* createGitCommandError( "GitVcsDriver.pullCurrentBranch", cwd, @@ -1543,15 +1543,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const refreshed = yield* statusDetails(cwd); return { status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", - branch, - upstreamBranch: refreshed.upstreamRef, + refName, + upstreamRef: refreshed.upstreamRef, }; }, ); const readRangeContext: GitVcsDriverShape["readRangeContext"] = Effect.fn("readRangeContext")( - function* (cwd, baseBranch) { - const range = `${baseBranch}..HEAD`; + function* (cwd, baseRef) { + const range = `${baseRef}..HEAD`; const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( [ runGitStdoutWithOptions( @@ -1599,232 +1599,230 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), ); - const listBranches: GitVcsDriverShape["listBranches"] = Effect.fn("listBranches")( - function* (input) { - const branchRecencyPromise = readBranchRecency(input.cwd).pipe( - Effect.catch(() => Effect.succeed(new Map())), - ); - const localBranchResult = yield* executeGit( - "GitVcsDriver.listBranches.branchNoColor", + 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({ + 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 { + refs: [], + isRepo: false, + hasPrimaryRemote: false, + nextCursor: null, + totalCount: 0, + }; + } + return yield* createGitCommandError( + "GitVcsDriver.listRefs", 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, - }), - ), + stderr || "git branch failed", ); + } - 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( - "GitVcsDriver.listBranches", - 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({ code: 1, stdout: "", stderr: "" })), + ), + ); - const remoteBranchResultEffect = executeGit( - "GitVcsDriver.listBranches.remoteBranches", - input.cwd, - ["branch", "--no-color", "--no-column", "--remotes"], - { - timeoutMs: 10_000, - allowNonZeroExit: true, - }, - ).pipe( - Effect.catch((error) => - Effect.logWarning( - `GitVcsDriver.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( + "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({ code: 1, stdout: "", stderr: "" })), + ), + ); - const remoteNamesResultEffect = executeGit( - "GitVcsDriver.listBranches.remoteNames", - input.cwd, - ["remote"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ).pipe( - Effect.catch((error) => - Effect.logWarning( - `GitVcsDriver.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( + "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 [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] = - yield* Effect.all( - [ - executeGit( - "GitVcsDriver.listBranches.defaultRef", - input.cwd, - ["symbolic-ref", "refs/remotes/origin/HEAD"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ), - executeGit( - "GitVcsDriver.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( + `GitVcsDriver.listRefs: remote refName lookup returned code ${remoteBranchResult.code} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote refName list.`, + ); + } + if (remoteNamesResult.code !== 0 && remoteNamesResult.stderr.trim().length > 0) { + yield* Effect.logWarning( + `GitVcsDriver.listRefs: remote name lookup returned code ${remoteNamesResult.code} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`, + ); + } - const remoteNames = - remoteNamesResult.code === 0 ? parseRemoteNames(remoteNamesResult.stdout) : []; - if (remoteBranchResult.code !== 0 && remoteBranchResult.stderr.trim().length > 0) { - yield* Effect.logWarning( - `GitVcsDriver.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( - `GitVcsDriver.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 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 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, + 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); }); - return { - branches: [...branches.branches], - isRepo: true, - hasOriginRemote: remoteNames.includes("origin"), - nextCursor: branches.nextCursor, - totalCount: branches.totalCount, - }; - }, - ); + const remoteBranches = + remoteBranchResult.code === 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.newBranch ?? input.branch; + 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.newBranch - ? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch] - : ["worktree", "add", worktreePath, input.branch]; + 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", @@ -1833,7 +1831,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { worktree: { path: worktreePath, - branch: targetBranch, + refName: targetBranch, }, }; }, @@ -1934,101 +1932,97 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); - const checkoutBranch: GitVcsDriverShape["checkoutBranch"] = Effect.fn("checkoutBranch")( - function* (input) { - const [localInputExists, remoteExists] = yield* Effect.all( - [ - executeGit( - "GitVcsDriver.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( - "GitVcsDriver.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 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.code === 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.code === 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.code === 0 + ? parseTrackingBranchByUpstreamRef(result.stdout, input.refName) + : null, + ), + ) + : null; - const localTrackingBranch = remoteExists + const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.refName); + const localTrackedBranchTargetExists = + remoteExists && localTrackedBranchCandidate ? yield* executeGit( - "GitVcsDriver.checkoutBranch.localTrackingBranch", + "GitVcsDriver.switchRef.localTrackedBranchTargetExists", input.cwd, - ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"], + ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`], { 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( - "GitVcsDriver.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("GitVcsDriver.checkoutBranch.checkout", input.cwd, checkoutArgs, { - timeoutMs: 10_000, - fallbackErrorMessage: "git checkout failed", - }); + ).pipe(Effect.map((result) => result.code === 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 branch = yield* runGitStdout("GitVcsDriver.checkoutBranch.currentBranch", input.cwd, [ - "branch", - "--show-current", - ]).pipe(Effect.map((stdout) => stdout.trim() || null)); + const refName = yield* runGitStdout("GitVcsDriver.switchRef.currentBranch", input.cwd, [ + "branch", + "--show-current", + ]).pipe(Effect.map((stdout) => stdout.trim() || null)); - return { branch }; - }, - ); + return { refName }; + }); - const createBranch: GitVcsDriverShape["createBranch"] = Effect.fn("createBranch")( - function* (input) { - yield* executeGit("GitVcsDriver.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 }); - } + 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 { branch: input.branch }; - }, - ); + return { refName: input.refName }; + }); const initRepo: GitVcsDriverShape["initRepo"] = (input) => executeGit("GitVcsDriver.initRepo", input.cwd, ["init"], { @@ -2062,7 +2056,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* pullCurrentBranch, readRangeContext, readConfigValue, - listBranches, + listRefs, createWorktree, fetchPullRequestBranch, ensureRemote, @@ -2070,8 +2064,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* setBranchUpstream, removeWorktree, renameBranch, - createBranch, - checkoutBranch, + createRef, + switchRef, initRepo, listLocalBranchNames, } satisfies GitVcsDriverShape; diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 6c9509da232..4cb92ebe5f9 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -1,10 +1,10 @@ import { assert, it } from "@effect/vitest"; import { Deferred, Effect, Exit, Layer, Option, Scope, Stream } from "effect"; import type { - GitStatusLocalResult, - GitStatusRemoteResult, - GitStatusResult, - GitStatusStreamEvent, + VcsStatusLocalResult, + VcsStatusRemoteResult, + VcsStatusResult, + VcsStatusStreamEvent, } from "@t3tools/contracts"; import { describe } from "vitest"; @@ -14,35 +14,35 @@ import { } from "./VcsStatusBroadcaster.ts"; import { GitWorkflowService, type GitWorkflowServiceShape } from "../git/GitWorkflowService.ts"; -const baseLocalStatus: GitStatusLocalResult = { +const baseLocalStatus: VcsStatusLocalResult = { isRepo: true, hostingProvider: { kind: "github", name: "GitHub", baseUrl: "https://github.com", }, - hasOriginRemote: true, - isDefaultBranch: false, - branch: "feature/status-broadcast", + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/status-broadcast", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, }; -const baseRemoteStatus: GitStatusRemoteResult = { +const baseRemoteStatus: VcsStatusRemoteResult = { hasUpstream: true, aheadCount: 0, behindCount: 0, pr: null, }; -const baseStatus: GitStatusResult = { +const baseStatus: VcsStatusResult = { ...baseLocalStatus, ...baseRemoteStatus, }; function makeTestLayer(state: { - currentLocalStatus: GitStatusLocalResult; - currentRemoteStatus: GitStatusRemoteResult | null; + currentLocalStatus: VcsStatusLocalResult; + currentRemoteStatus: VcsStatusRemoteResult | null; localStatusCalls: number; remoteStatusCalls: number; localInvalidationCalls: number; @@ -116,7 +116,7 @@ describe("VcsStatusBroadcaster", () => { state.currentLocalStatus = { ...baseLocalStatus, - branch: "feature/updated-status", + refName: "feature/updated-status", }; state.currentRemoteStatus = { ...baseRemoteStatus, @@ -157,7 +157,7 @@ describe("VcsStatusBroadcaster", () => { state.currentLocalStatus = { ...baseLocalStatus, - branch: "feature/local-only-refresh", + refName: "feature/local-only-refresh", hasWorkingTreeChanges: true, }; @@ -189,8 +189,8 @@ describe("VcsStatusBroadcaster", () => { return Effect.gen(function* () { const broadcaster = yield* VcsStatusBroadcaster; - const snapshotDeferred = yield* Deferred.make(); - const remoteUpdatedDeferred = yield* Deferred.make(); + 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); @@ -209,11 +209,11 @@ describe("VcsStatusBroadcaster", () => { _tag: "snapshot", local: baseLocalStatus, remote: null, - } satisfies GitStatusStreamEvent); + } satisfies VcsStatusStreamEvent); assert.deepStrictEqual(remoteUpdated, { _tag: "remoteUpdated", remote: baseRemoteStatus, - } satisfies GitStatusStreamEvent); + } satisfies VcsStatusStreamEvent); }).pipe(Effect.provide(makeTestLayer(state))); }); @@ -245,7 +245,7 @@ describe("VcsStatusBroadcaster", () => { ? Deferred.succeed(remoteStartedDeferred, undefined).pipe(Effect.ignore) : Effect.void, ), - Effect.andThen(Effect.never as Effect.Effect), + Effect.andThen(Effect.never as Effect.Effect), Effect.onInterrupt(() => remoteInterruptedDeferred ? Deferred.succeed(remoteInterruptedDeferred, undefined).pipe(Effect.ignore) @@ -271,8 +271,8 @@ describe("VcsStatusBroadcaster", () => { remoteStartedDeferred = remoteStarted; const broadcaster = yield* VcsStatusBroadcaster; - const firstSnapshot = yield* Deferred.make(); - const secondSnapshot = yield* Deferred.make(); + 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) => diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index e7c6d71ec79..5a19b7aa748 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -12,11 +12,11 @@ import { } from "effect"; import type { GitManagerServiceError, - GitStatusInput, - GitStatusLocalResult, - GitStatusRemoteResult, - GitStatusResult, - GitStatusStreamEvent, + VcsStatusInput, + VcsStatusLocalResult, + VcsStatusRemoteResult, + VcsStatusResult, + VcsStatusStreamEvent, } from "@t3tools/contracts"; import { Context } from "effect"; import { mergeGitStatusParts } from "@t3tools/shared/git"; @@ -27,7 +27,7 @@ const VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); interface VcsStatusChange { readonly cwd: string; - readonly event: GitStatusStreamEvent; + readonly event: VcsStatusStreamEvent; } interface CachedValue { @@ -36,8 +36,8 @@ interface CachedValue { } interface CachedVcsStatus { - readonly local: CachedValue | null; - readonly remote: CachedValue | null; + readonly local: CachedValue | null; + readonly remote: CachedValue | null; } interface ActiveRemotePoller { @@ -47,15 +47,15 @@ interface ActiveRemotePoller { export interface VcsStatusBroadcasterShape { readonly getStatus: ( - input: GitStatusInput, - ) => Effect.Effect; + input: VcsStatusInput, + ) => Effect.Effect; readonly refreshLocalStatus: ( cwd: string, - ) => Effect.Effect; - readonly refreshStatus: (cwd: string) => Effect.Effect; + ) => Effect.Effect; + readonly refreshStatus: (cwd: string) => Effect.Effect; readonly streamStatus: ( - input: GitStatusInput, - ) => Stream.Stream; + input: VcsStatusInput, + ) => Stream.Stream; } export class VcsStatusBroadcaster extends Context.Service< @@ -88,11 +88,11 @@ export const layer = Layer.effect( }); const updateCachedLocalStatus = Effect.fn("VcsStatusBroadcaster.updateCachedLocalStatus")( - function* (cwd: string, local: GitStatusLocalResult, options?: { publish?: boolean }) { + function* (cwd: string, local: VcsStatusLocalResult, options?: { publish?: boolean }) { const nextLocal = { fingerprint: fingerprintStatusPart(local), value: local, - } satisfies CachedValue; + } satisfies CachedValue; const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { const previous = cache.get(cwd) ?? { local: null, remote: null }; const nextCache = new Map(cache); @@ -120,13 +120,13 @@ export const layer = Layer.effect( const updateCachedRemoteStatus = Effect.fn("VcsStatusBroadcaster.updateCachedRemoteStatus")( function* ( cwd: string, - remote: GitStatusRemoteResult | null, + remote: VcsStatusRemoteResult | null, options?: { publish?: boolean }, ) { const nextRemote = { fingerprint: fingerprintStatusPart(remote), value: remote, - } satisfies CachedValue; + } satisfies CachedValue; const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { const previous = cache.get(cwd) ?? { local: null, remote: null }; const nextCache = new Map(cache); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 4e06cbfd2a6..b828548040f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -453,8 +453,8 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => if (bootstrap?.prepareWorktree) { 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; @@ -462,7 +462,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); @@ -896,8 +896,8 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), { "rpc.aggregate": "git" }, ), - [WS_METHODS.vcsListBranches]: (input) => - observeRpcEffect(WS_METHODS.vcsListBranches, gitWorkflow.listBranches(input), { + [WS_METHODS.vcsListRefs]: (input) => + observeRpcEffect(WS_METHODS.vcsListRefs, gitWorkflow.listRefs(input), { "rpc.aggregate": "vcs", }), [WS_METHODS.vcsCreateWorktree]: (input) => @@ -912,16 +912,16 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => gitWorkflow.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), { "rpc.aggregate": "vcs" }, ), - [WS_METHODS.vcsCreateBranch]: (input) => + [WS_METHODS.vcsCreateRef]: (input) => observeRpcEffect( - WS_METHODS.vcsCreateBranch, - gitWorkflow.createBranch(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + WS_METHODS.vcsCreateRef, + gitWorkflow.createRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), { "rpc.aggregate": "vcs" }, ), - [WS_METHODS.vcsCheckout]: (input) => + [WS_METHODS.vcsSwitchRef]: (input) => observeRpcEffect( - WS_METHODS.vcsCheckout, - gitWorkflow.checkoutBranch(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + WS_METHODS.vcsSwitchRef, + gitWorkflow.switchRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), { "rpc.aggregate": "vcs" }, ), [WS_METHODS.vcsInit]: (input) => diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 7beaec808db..15fa851f0c7 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", @@ -120,7 +120,7 @@ describe("resolveEnvironmentOptionLabel", () => { }); describe("resolveEffectiveEnvMode", () => { - it("treats draft threads already attached to a worktree as current-checkout mode", () => { + it("treats draft threads already attached to a worktree as current-switchRef mode", () => { expect( resolveEffectiveEnvMode({ activeWorktreePath: "/repo/.t3/worktrees/feature-a", @@ -143,24 +143,24 @@ describe("resolveEffectiveEnvMode", () => { describe("resolveEnvModeLabel", () => { it("uses explicit workspace labels", () => { - expect(resolveEnvModeLabel("local")).toBe("Current checkout"); + expect(resolveEnvModeLabel("local")).toBe("Current switchRef"); expect(resolveEnvModeLabel("worktree")).toBe("New worktree"); }); }); describe("resolveCurrentWorkspaceLabel", () => { - it("describes the main repo checkout when no worktree path is active", () => { - expect(resolveCurrentWorkspaceLabel(null)).toBe("Current checkout"); + it("describes the main repo switchRef when no worktree path is active", () => { + expect(resolveCurrentWorkspaceLabel(null)).toBe("Current switchRef"); }); - it("describes the active checkout as a worktree when one is attached", () => { + it("describes the active switchRef as a worktree when one is attached", () => { expect(resolveCurrentWorkspaceLabel("/repo/.t3/worktrees/feature-a")).toBe("Current worktree"); }); }); describe("resolveLockedWorkspaceLabel", () => { - it("uses a shorter label for the main repo checkout", () => { - expect(resolveLockedWorkspaceLabel(null)).toBe("Local checkout"); + it("uses a shorter label for the main repo switchRef", () => { + expect(resolveLockedWorkspaceLabel(null)).toBe("Local switchRef"); }); it("uses a shorter label for an attached worktree", () => { @@ -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 switchRef in the current worktree for non-default refs", () => { expect( resolveBranchSelectionTarget({ activeProjectCwd: "/repo", activeWorktreePath: "/repo/.t3/worktrees/feature-a", - branch: { + refName: { isDefault: false, worktreePath: null, }, @@ -362,18 +362,18 @@ describe("resolveBranchSelectionTarget", () => { }); describe("shouldIncludeBranchPickerItem", () => { - it("keeps the synthetic checkout PR item visible for gh pr checkout input", () => { + it("keeps the synthetic switchRef PR item visible for gh pr switchRef input", () => { expect( shouldIncludeBranchPickerItem({ itemValue: "__checkout_pull_request__:1359", - normalizedQuery: "gh pr checkout 1359", - createBranchItemValue: "__create_new_branch__:gh pr checkout 1359", + normalizedQuery: "gh pr switchRef 1359", + createBranchItemValue: "__create_new_branch__:gh pr switchRef 1359", checkoutPullRequestItemValue: "__checkout_pull_request__:1359", }), ).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,12 +384,12 @@ 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", - normalizedQuery: "gh pr checkout 1359", - createBranchItemValue: "__create_new_branch__:gh pr checkout 1359", + normalizedQuery: "gh pr switchRef 1359", + createBranchItemValue: "__create_new_branch__:gh pr switchRef 1359", checkoutPullRequestItemValue: "__checkout_pull_request__:1359", }), ).toBe(false); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 7adab1a2e16..393e75d3e71 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, @@ -43,7 +43,7 @@ export function resolveEnvironmentOptionLabel(input: { } export function resolveEnvModeLabel(mode: EnvMode): string { - return mode === "worktree" ? "New worktree" : "Current checkout"; + return mode === "worktree" ? "New worktree" : "Current switchRef"; } export function resolveCurrentWorkspaceLabel(activeWorktreePath: string | null): string { @@ -51,7 +51,7 @@ export function resolveCurrentWorkspaceLabel(activeWorktreePath: string | null): } export function resolveLockedWorkspaceLabel(activeWorktreePath: string | null): string { - return activeWorktreePath ? "Worktree" : "Local checkout"; + return activeWorktreePath ? "Worktree" : "Local switchRef"; } export function resolveEffectiveEnvMode(input: { @@ -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 2f0a765655b..5d2b8f31852 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"; @@ -53,7 +53,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 +69,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 +193,7 @@ export function BranchToolbarBranchSelector({ ); // --------------------------------------------------------------------------- - // Git branch queries + // Git ref queries // --------------------------------------------------------------------------- const queryClient = useQueryClient(); const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); @@ -224,22 +224,22 @@ 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 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 +289,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 +303,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 +322,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 +343,12 @@ export function BranchToolbarBranchSelector({ const previousBranch = resolvedActiveBranch; setOptimisticBranch(selectedBranchName); try { - const checkoutResult = await api.vcs.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 +357,7 @@ export function BranchToolbarBranchSelector({ toastManager.add( stackedThreadToast({ type: "error", - title: "Failed to checkout branch.", + title: "Failed to switch ref.", description: toBranchActionErrorMessage(error), }), ); @@ -365,7 +365,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 +377,19 @@ export function BranchToolbarBranchSelector({ const previousBranch = resolvedActiveBranch; setOptimisticBranch(name); try { - const createBranchResult = await api.vcs.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 +420,7 @@ export function BranchToolbarBranchSelector({ return; } void queryClient.invalidateQueries({ - queryKey: gitQueryKeys.branches(environmentId, branchCwd), + queryKey: gitQueryKeys.refs(environmentId, branchCwd), }); }, [branchCwd, environmentId, queryClient], @@ -482,7 +482,7 @@ export function BranchToolbarBranchSelector({ useEffect(() => { if (shouldVirtualizeBranchList) return; maybeFetchNextBranchPage(); - }, [branches.length, maybeFetchNextBranchPage, shouldVirtualizeBranchList]); + }, [refs.length, maybeFetchNextBranchPage, shouldVirtualizeBranchList]); const triggerLabel = getBranchTriggerLabel({ activeWorktreePath, @@ -522,25 +522,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 +549,7 @@ export function BranchToolbarBranchSelector({ key={itemValue} index={index} value={itemValue} - onClick={() => selectBranch(branch)} + onClick={() => selectBranch(refName)} >
{itemValue} @@ -581,7 +581,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 +591,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 49c72b36723..6f35d070238 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -952,7 +952,7 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { if (tag === WS_METHODS.serverGetConfig) { return fixture.serverConfig; } - if (tag === WS_METHODS.vcsListBranches) { + if (tag === WS_METHODS.vcsListRefs) { return { isRepo: true, hasOriginRemote: true, @@ -2299,7 +2299,7 @@ describe("ChatView timeline estimator parity (full app)", () => { Array.from(document.querySelectorAll("span")).find( (element) => element.textContent?.trim() === "Checkout Pull Request", ) as HTMLSpanElement | null, - "Unable to find checkout pull request option.", + "Unable to find switchRef pull request option.", ); checkoutItem.click(); @@ -2573,7 +2573,7 @@ describe("ChatView timeline estimator parity (full app)", () => { ), }, resolveRpc: (body) => { - if (body._tag === WS_METHODS.vcsListBranches) { + if (body._tag === WS_METHODS.vcsListRefs) { return { isRepo: true, hasOriginRemote: true, @@ -2599,7 +2599,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - (await waitForButtonByText("Current checkout")).click(); + (await waitForButtonByText("Current switchRef")).click(); await page.getByText("New worktree", { exact: true }).click(); await vi.waitFor( @@ -2666,7 +2666,7 @@ describe("ChatView timeline estimator parity (full app)", () => { ), }, resolveRpc: (body) => { - if (body._tag === WS_METHODS.vcsListBranches) { + if (body._tag === WS_METHODS.vcsListRefs) { return { isRepo: true, hasOriginRemote: true, @@ -2698,7 +2698,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - (await waitForButtonByText("Current checkout")).click(); + (await waitForButtonByText("Current switchRef")).click(); await page.getByText("New worktree", { exact: true }).click(); await page.getByText("From main", { exact: true }).click(); await page.getByText("release/next", { exact: true }).click(); @@ -2762,7 +2762,7 @@ describe("ChatView timeline estimator parity (full app)", () => { viewport: DEFAULT_VIEWPORT, snapshot: snapshotWithTwoThreads, resolveRpc: (body) => { - if (body._tag === WS_METHODS.vcsListBranches) { + if (body._tag === WS_METHODS.vcsListRefs) { return { isRepo: true, hasOriginRemote: true, @@ -2794,7 +2794,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - (await waitForButtonByText("Current checkout")).click(); + (await waitForButtonByText("Current switchRef")).click(); await page.getByText("New worktree", { exact: true }).click(); await page.getByText("From main", { exact: true }).click(); await page.getByText("release/next", { exact: true }).click(); @@ -2822,13 +2822,13 @@ describe("ChatView timeline estimator parity (full app)", () => { await vi.waitFor( () => { - expect(findButtonByText("Current checkout")).toBeTruthy(); + expect(findButtonByText("Current switchRef")).toBeTruthy(); expect(findButtonByText("From release/next")).toBeNull(); }, { timeout: 8_000, interval: 16 }, ); - (await waitForButtonByText("Current checkout")).click(); + (await waitForButtonByText("Current switchRef")).click(); await page.getByText("New worktree", { exact: true }).click(); await vi.waitFor( @@ -3022,7 +3022,7 @@ describe("ChatView timeline estimator parity (full app)", () => { snapshot: createDraftOnlySnapshot(), initialPath: `/draft/${activeDraftId}`, resolveRpc: (body) => { - if (body._tag === WS_METHODS.vcsListBranches) { + if (body._tag === WS_METHODS.vcsListRefs) { return { isRepo: true, hasOriginRemote: true, @@ -3147,7 +3147,7 @@ describe("ChatView timeline estimator parity (full app)", () => { snapshot: createDraftOnlySnapshot(), initialPath: `/draft/${draftId}`, resolveRpc: (body) => { - if (body._tag === WS_METHODS.vcsListBranches) { + if (body._tag === WS_METHODS.vcsListRefs) { return { isRepo: true, hasOriginRemote: true, diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index c6a50b82c27..4187e1a380b 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,7 @@ 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: 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 +293,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 +330,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 +375,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 +420,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 +435,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 +450,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 +497,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 +534,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 +557,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 +580,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", }, }), @@ -725,10 +725,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 +744,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 +763,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 +803,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 +823,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 +838,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 PR 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 +853,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 PR on "main". You can continue on this ref or create a feature ref and run the same action there.', continueLabel: "Commit, push & create PR", }); }); @@ -944,7 +944,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 +968,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 +985,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 +996,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 +1032,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 e4e611fb87c..bb004e5e6c0 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -1,7 +1,7 @@ import type { GitRunStackedActionResult, GitStackedAction, - GitStatusResult, + VcsStatusResult, } from "@t3tools/contracts"; import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; @@ -46,7 +46,7 @@ export function buildGitActionProgressStages(input: { featureBranch?: boolean; shouldPushBeforePr?: boolean; }): string[] { - const branchStages = input.featureBranch ? ["Preparing feature branch..."] : []; + const branchStages = input.featureBranch ? ["Preparing feature ref..."] : []; const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing..."; const prStages = [ "Preparing PR...", @@ -77,17 +77,17 @@ export function buildGitActionProgressStages(input: { } export function buildMenuItems( - gitStatus: GitStatusResult | null, + gitStatus: VcsStatusResult | null, isBusy: boolean, - hasOriginRemote = true, + hasPrimaryRemote = true, ): GitActionMenuItem[] { if (!gitStatus) return []; - 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 && @@ -143,10 +143,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,7 +161,7 @@ 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; @@ -173,15 +173,15 @@ export function resolveQuickAction( 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 PR.", }; } 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 { @@ -193,7 +193,7 @@ export function resolveQuickAction( } if (!gitStatus.hasUpstream) { - if (!hasOriginRemote) { + if (!hasPrimaryRemote) { if (hasOpenPr && !isAhead) { return { label: "View PR", disabled: false, kind: "open_pr" }; } @@ -215,12 +215,12 @@ 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 { @@ -233,7 +233,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,12 +249,12 @@ 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 { @@ -279,9 +279,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" || @@ -296,18 +296,18 @@ export function resolveDefaultBranchActionDialogCopy(input: { includesCommit: boolean; }): 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.`; 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,13 +315,13 @@ export function resolveDefaultBranchActionDialogCopy(input: { if (input.includesCommit) { return { - 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${suffix}`, continueLabel: `Commit, push & create PR`, }; } return { - 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${suffix}`, continueLabel: "Push & create PR", }; @@ -341,31 +341,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 6141d3546ea..3cd5c8c0d88 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -3,7 +3,7 @@ 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"; @@ -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,18 +121,18 @@ 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; @@ -147,7 +147,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 +155,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) { @@ -168,12 +168,12 @@ function getMenuActionDisabledReason({ return "View PR is currently unavailable."; } if (!hasBranch) { - return "Detached HEAD: checkout a branch before creating a PR."; + return "Detached HEAD: checkout a refName before creating a PR."; } if (hasChanges) { return "Commit local changes before creating a PR."; } - if (!gitStatus.hasUpstream && !hasOriginRemote) { + if (!gitStatus.hasUpstream && !hasPrimaryRemote) { return 'Add an "origin" remote before creating a PR.'; } if (!isAhead) { @@ -324,7 +324,7 @@ export default function GitActionsControl({ }); // 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 +382,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.") @@ -497,8 +497,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 = @@ -775,8 +775,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) => ({ @@ -934,7 +934,7 @@ export default function GitActionsControl({ item, gitStatus: gitStatusForActions, isBusy: isGitActionRunning, - hasOriginRemote, + hasPrimaryRemote, }); if (item.disabled && disabledReason) { return ( @@ -969,13 +969,13 @@ export default function GitActionsControl({ ); })} - {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 PR actions.

)} {gitStatusForActions && - gitStatusForActions.branch !== null && + gitStatusForActions.refName !== null && !gitStatusForActions.hasWorkingTreeChanges && gitStatusForActions.behindCount > 0 && gitStatusForActions.aheadCount === 0 && ( @@ -1013,10 +1013,12 @@ export default function GitActionsControl({ Branch - {gitStatusForActions?.branch ?? "(detached HEAD)"} + {gitStatusForActions?.refName ?? "(detached HEAD)"} - {isDefaultBranch && ( - Warning: default branch + {isDefaultRef && ( + + Warning: default refName + )} @@ -1149,7 +1151,7 @@ export default function GitActionsControl({ disabled={noneSelected} onClick={runDialogActionOnNewBranch} > - Commit on new branch + Commit on new refName + + + + + + + +
+
+
Command
+
{item.executable}
+
Version
+
{version ?? "Not detected"}
+
Install
+
{item.installHint}
+
Build
+
+ {item.implemented + ? "Enabled in this branch and available for repository routing when the CLI is present." + : "Placeholder only in this branch. The matching driver/provider PR enables this row."} +
+ {detail ? ( + <> +
Probe
+
{detail}
+ + ) : null} +
+
+
+
+ + ); +} + +export function SourceControlSettingsPanel() { + const [loadState, setLoadState] = useState({ + status: "loading", + result: null, + }); + const latestResultRef = useRef(null); + const [expanded, setExpanded] = useState>>({}); + + const refresh = useCallback((options?: { readonly signal?: AbortSignal }) => { + const previous = latestResultRef.current; + setLoadState({ status: "loading", result: previous }); + + ensureLocalApi() + .server.discoverSourceControl() + .then((result) => { + if (!options?.signal?.aborted) { + latestResultRef.current = result; + setLoadState({ status: "ready", result }); + } + }) + .catch((cause: unknown) => { + if (!options?.signal?.aborted) { + setLoadState({ + status: "error", + result: previous, + message: + cause instanceof Error ? cause.message : "Failed to discover source control tools.", + }); + } + }); + }, []); + + useEffect(() => { + const controller = new AbortController(); + refresh({ signal: controller.signal }); + return () => controller.abort(); + }, [refresh]); + + const result = loadState.result ?? EMPTY_DISCOVERY_RESULT; + const statusText = useMemo(() => { + if (loadState.status === "loading") return "Scanning installed tools..."; + if (loadState.status === "error") return loadState.message; + return "Detected source control tools on this system."; + }, [loadState]); + + const setItemExpanded = (key: string, value: boolean) => { + setExpanded((current) => ({ ...current, [key]: value })); + }; + + return ( + +
+

Source Control

+

{statusText}

+
+ + } + headerAction={ + + } + > + {result.versionControlSystems.map((item) => ( + setItemExpanded(`vcs:${item.kind}`, value)} + /> + ))} + + + } + > + {result.sourceControlProviders.map((item) => ( + setItemExpanded(`provider:${item.kind}`, value)} + /> + ))} + +
+ ); +} diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index 4401b5b778e..983020390af 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -115,6 +115,7 @@ export function createLocalApi(rpcClient: WsRpcClient): LocalApi { upsertKeybinding: rpcClient.server.upsertKeybinding, getSettings: rpcClient.server.getSettings, updateSettings: rpcClient.server.updateSettings, + discoverSourceControl: rpcClient.server.discoverSourceControl, }, }; } diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 6494ecdb25f..7b71c8a401a 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -14,6 +14,7 @@ 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 SettingsGeneralRouteImport } from './routes/settings.general' +import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' @@ -43,6 +44,11 @@ const SettingsGeneralRoute = SettingsGeneralRouteImport.update({ path: '/general', getParentRoute: () => SettingsRoute, } as any) +const SettingsSourceControlRoute = SettingsSourceControlRouteImport.update({ + id: '/source-control', + path: '/source-control', + getParentRoute: () => SettingsRoute, +} as any) const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({ id: '/connections', path: '/connections', @@ -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' @@ -188,6 +200,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsArchivedRouteImport parentRoute: typeof SettingsRoute } + '/settings/source-control': { + id: '/settings/source-control' + path: '/source-control' + fullPath: '/settings/source-control' + preLoaderRoute: typeof SettingsSourceControlRouteImport + parentRoute: typeof SettingsRoute + } '/_chat/draft/$draftId': { id: '/_chat/draft/$draftId' path: '/draft/$draftId' @@ -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 00000000000..bb09b3fd33c --- /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.ts b/apps/web/src/rpc/wsRpcClient.ts index 6e0c29bb4f2..39abcb2c09c 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -119,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; @@ -224,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 index f0075135201..17e4ac99f44 100644 --- a/apps/web/src/sourceControlPresentation.ts +++ b/apps/web/src/sourceControlPresentation.ts @@ -1,7 +1,7 @@ import type { SourceControlProviderInfo } from "@t3tools/contracts"; export interface ChangeRequestPresentation { - readonly icon: "github" | "gitlab" | "azure-devops" | "change-request"; + readonly icon: "github" | "gitlab" | "azure-devops" | "bitbucket" | "change-request"; readonly providerName: string; readonly shortName: string; readonly longName: string; @@ -44,6 +44,17 @@ const AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { 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", @@ -66,6 +77,8 @@ export function resolveChangeRequestPresentation( 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; } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 2381f03cac7..35539a86e33 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -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; }; } diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 798493416d8..affa3d2385d 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -77,6 +77,7 @@ import { ServerUpsertKeybindingResult, } from "./server.ts"; import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; +import { SourceControlDiscoveryResult } from "./sourceControl.ts"; export const WS_METHODS = { // Project registry methods @@ -121,6 +122,7 @@ export const WS_METHODS = { serverUpsertKeybinding: "server.upsertKeybinding", serverGetSettings: "server.getSettings", serverUpdateSettings: "server.updateSettings", + serverDiscoverSourceControl: "server.discoverSourceControl", // Streaming subscriptions subscribeVcsStatus: "subscribeVcsStatus", @@ -167,6 +169,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, @@ -372,6 +379,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerUpsertKeybindingRpc, WsServerGetSettingsRpc, WsServerUpdateSettingsRpc, + WsServerDiscoverSourceControlRpc, WsProjectsSearchEntriesRpc, WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 5700390e56d..e7b92f52acb 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -1,10 +1,12 @@ 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; @@ -41,6 +43,43 @@ export const SourceControlRepositoryCloneUrls = Schema.Struct({ }); export type SourceControlRepositoryCloneUrls = typeof SourceControlRepositoryCloneUrls.Type; +export const SourceControlDiscoveryStatus = Schema.Literals(["available", "missing"]); +export type SourceControlDiscoveryStatus = typeof SourceControlDiscoveryStatus.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, +}); +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", { diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index 2e7b2928323..63821255a18 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -38,6 +38,10 @@ 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 { @@ -70,6 +74,14 @@ export function detectSourceControlProviderFromRemoteUrl( }; } + if (isBitbucketHost(host)) { + return { + kind: "bitbucket", + name: host === "bitbucket.org" ? "Bitbucket" : "Bitbucket Self-Hosted", + baseUrl: toBaseUrl(host), + }; + } + return { kind: "unknown", name: host, From 598407ddde8ad230d19b8d07f9d65c8ec7e08ab9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 12:18:03 -0700 Subject: [PATCH 33/45] Format source control discovery wiring --- apps/server/src/ws.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a35b3e61ce6..1b11db60ae1 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1097,7 +1097,9 @@ export const websocketRpcRouteLayer = Layer.unwrap( Effect.provide( makeWsRpcLayer(session.sessionId).pipe( Layer.provideMerge(RpcSerialization.layerJson), - Layer.provide(SourceControlDiscoveryLayer.layer.pipe(Layer.provide(VcsProcess.layer))), + Layer.provide( + SourceControlDiscoveryLayer.layer.pipe(Layer.provide(VcsProcess.layer)), + ), ), ), ); From e36a1556a218002224ac520bcda311bb04afc82e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 12:21:52 -0700 Subject: [PATCH 34/45] Regenerate source control settings route --- apps/web/src/routeTree.gen.ts | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 7b71c8a401a..f5844f2c941 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -13,8 +13,8 @@ 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 SettingsGeneralRouteImport } from './routes/settings.general' 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' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' @@ -39,16 +39,16 @@ const ChatIndexRoute = ChatIndexRouteImport.update({ path: '/', getParentRoute: () => ChatRoute, } as any) -const SettingsGeneralRoute = SettingsGeneralRouteImport.update({ - id: '/general', - path: '/general', - getParentRoute: () => SettingsRoute, -} as any) const SettingsSourceControlRoute = SettingsSourceControlRouteImport.update({ id: '/source-control', path: '/source-control', getParentRoute: () => SettingsRoute, } as any) +const SettingsGeneralRoute = SettingsGeneralRouteImport.update({ + id: '/general', + path: '/general', + getParentRoute: () => SettingsRoute, +} as any) const SettingsConnectionsRoute = SettingsConnectionsRouteImport.update({ id: '/connections', path: '/connections', @@ -179,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' @@ -200,13 +207,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsArchivedRouteImport parentRoute: typeof SettingsRoute } - '/settings/source-control': { - id: '/settings/source-control' - path: '/source-control' - fullPath: '/settings/source-control' - preLoaderRoute: typeof SettingsSourceControlRouteImport - parentRoute: typeof SettingsRoute - } '/_chat/draft/$draftId': { id: '/_chat/draft/$draftId' path: '/draft/$draftId' From b133b0dbce1baa418c59bfd8f79fda96b79040c2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 12:48:25 -0700 Subject: [PATCH 35/45] Add source control discovery state --- .../SourceControlDiscovery.test.ts | 156 +++++++++- .../sourceControl/SourceControlDiscovery.ts | 272 +++++++++++++++++- .../settings/ProviderInstanceCard.tsx | 52 +--- .../settings/RedactedSensitiveText.tsx | 61 ++++ .../settings/SourceControlSettings.tsx | 146 ++++++---- .../src/lib/sourceControlDiscoveryState.ts | 44 +++ apps/web/src/localApi.ts | 2 + bun.lock | 1 + packages/client-runtime/package.json | 3 +- packages/client-runtime/src/index.ts | 1 + .../src/sourceControlDiscoveryState.test.ts | 107 +++++++ .../src/sourceControlDiscoveryState.ts | 206 +++++++++++++ packages/contracts/src/sourceControl.ts | 16 ++ 13 files changed, 943 insertions(+), 124 deletions(-) create mode 100644 apps/web/src/components/settings/RedactedSensitiveText.tsx create mode 100644 apps/web/src/lib/sourceControlDiscoveryState.ts create mode 100644 packages/client-runtime/src/sourceControlDiscoveryState.test.ts create mode 100644 packages/client-runtime/src/sourceControlDiscoveryState.ts diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 217407b2783..3146212db47 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -1,6 +1,6 @@ import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { Effect, Layer } from "effect"; +import { Effect, Layer, Option } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { VcsProcessSpawnError } from "@t3tools/contracts"; @@ -8,10 +8,16 @@ import { ServerConfig } from "../config.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import { SourceControlDiscovery, layer } from "./SourceControlDiscovery.ts"; -const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ - exitCode: ChildProcessSpawner.ExitCode(0), +const processOutput = ( + stdout: string, + options?: { + readonly stderr?: string; + readonly exitCode?: ChildProcessSpawner.ExitCode; + }, +): VcsProcess.VcsProcessOutput => ({ + exitCode: options?.exitCode ?? ChildProcessSpawner.ExitCode(0), stdout, - stderr: "", + stderr: options?.stderr ?? "", stdoutTruncated: false, stderrTruncated: false, }); @@ -27,9 +33,20 @@ it.effect("reports implemented tools separately from locally available CLIs", () if (input.command === "git") { return Effect.succeed(processOutput("git version 2.51.0\n")); } - if (input.command === "gh") { + 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, @@ -65,12 +82,133 @@ it.effect("reports implemented tools separately from locally available CLIs", () 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", implemented: true, status: "available" }, - { kind: "gitlab", implemented: false, status: "missing" }, - { kind: "azure-devops", implemented: false, status: "missing" }, - { kind: "bitbucket", implemented: false, status: "missing" }, + { + 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 index 375b1aaa42b..a3b309995bc 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -1,4 +1,5 @@ import { + type SourceControlProviderAuth, type SourceControlDiscoveryResult, type SourceControlProviderDiscoveryItem, type SourceControlProviderKind, @@ -24,8 +25,27 @@ type VcsProbe = DiscoveryProbe & { 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", @@ -59,6 +79,8 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray = [ 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/.", }, @@ -67,6 +89,8 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray = [ 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.", @@ -76,6 +100,8 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray = [ 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`.", @@ -85,6 +111,8 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray = [ 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.", }, @@ -105,6 +133,178 @@ function detailFromCause(cause: unknown): Option.Option { 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; } @@ -122,7 +322,7 @@ export const layer = Layer.effect( const probe = ( input: DiscoveryProbe & { readonly kind: Kind }, - ) => + ): Effect.Effect> => process .run({ operation: "source-control.discovery.probe", @@ -134,18 +334,21 @@ export const layer = Layer.effect( 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(), - })), + 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, @@ -156,10 +359,49 @@ export const layer = Layer.effect( 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( @@ -167,7 +409,7 @@ export const layer = Layer.effect( { concurrency: "unbounded" }, ), sourceControlProviders: Effect.all( - SOURCE_CONTROL_PROVIDER_PROBES.map((entry) => probe(entry)) as ReadonlyArray< + SOURCE_CONTROL_PROVIDER_PROBES.map((entry) => probeProvider(entry)) as ReadonlyArray< Effect.Effect >, { concurrency: "unbounded" }, diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 6d492618f5b..236e1db5655 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 00000000000..95b31b40369 --- /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/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 502241711c9..96993ab777d 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -1,30 +1,26 @@ import { ChevronDownIcon, GitBranchIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react"; import { Option } from "effect"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useMemo, useState } from "react"; import type { SourceControlDiscoveryItem, SourceControlDiscoveryResult, + SourceControlProviderAuth, SourceControlProviderDiscoveryItem, VcsDiscoveryItem, } from "@t3tools/contracts"; -import { ensureLocalApi } from "../../localApi"; import { cn } from "../../lib/utils"; +import { + refreshSourceControlDiscovery, + useSourceControlDiscovery, +} from "../../lib/sourceControlDiscoveryState"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Collapsible, CollapsibleContent } from "../ui/collapsible"; import { Switch } from "../ui/switch"; +import { RedactedSensitiveText } from "./RedactedSensitiveText"; import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; -type DiscoveryLoadState = - | { readonly status: "loading"; readonly result: SourceControlDiscoveryResult | null } - | { readonly status: "ready"; readonly result: SourceControlDiscoveryResult } - | { - readonly status: "error"; - readonly result: SourceControlDiscoveryResult | null; - readonly message: string; - }; - const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { versionControlSystems: [], sourceControlProviders: [], @@ -34,6 +30,46 @@ 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: "success" | "warning" | "outline"; +} { + if (auth.status === "authenticated") { + return { label: "Signed in", badge: "success" }; + } + if (auth.status === "unauthenticated") { + return { label: "Sign in", badge: "warning" }; + } + return { label: "Auth unknown", badge: "outline" }; +} + +function authSummary(auth: SourceControlProviderAuth): string { + if (auth.status === "authenticated") { + return "Authenticated"; + } + if (auth.status === "unauthenticated") { + return "Not authenticated"; + } + return "Auth not checked"; +} + +function RedactedAccount(props: { readonly account: string | null }) { + return ( + + ); +} + function statusPresentation(item: SourceControlDiscoveryItem): { readonly label: string; readonly badge: "success" | "warning" | "outline"; @@ -80,6 +116,11 @@ function DiscoveryItemRow({ const version = optionLabel(item.version); const detail = optionLabel(item.detail); 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; + const authHost = auth ? optionLabel(auth.host) : null; + const authDetail = auth ? optionLabel(auth.detail) : null; return (
) : null} + {authStatus ? ( + + {authStatus.label} + + ) : null}

CLI @@ -111,6 +157,13 @@ function DiscoveryItemRow({ {item.executable} {version ? - {version} : null} + {auth ? ( + <> + - + {authSummary(auth)} + {authAccount ? : null} + + ) : null}

@@ -138,6 +191,23 @@ function DiscoveryItemRow({
{item.executable}
Version
{version ?? "Not detected"}
+ {auth ? ( + <> +
Auth
+
+
+ {authSummary(auth)} + {authAccount ? : null} + {authHost ? ( + on {authHost} + ) : null} +
+ {authDetail ? ( +
{authDetail}
+ ) : null} +
+ + ) : null}
Install
{item.installHint}
Build
@@ -161,49 +231,15 @@ function DiscoveryItemRow({ } export function SourceControlSettingsPanel() { - const [loadState, setLoadState] = useState({ - status: "loading", - result: null, - }); - const latestResultRef = useRef(null); + const discovery = useSourceControlDiscovery(); const [expanded, setExpanded] = useState>>({}); - const refresh = useCallback((options?: { readonly signal?: AbortSignal }) => { - const previous = latestResultRef.current; - setLoadState({ status: "loading", result: previous }); - - ensureLocalApi() - .server.discoverSourceControl() - .then((result) => { - if (!options?.signal?.aborted) { - latestResultRef.current = result; - setLoadState({ status: "ready", result }); - } - }) - .catch((cause: unknown) => { - if (!options?.signal?.aborted) { - setLoadState({ - status: "error", - result: previous, - message: - cause instanceof Error ? cause.message : "Failed to discover source control tools.", - }); - } - }); - }, []); - - useEffect(() => { - const controller = new AbortController(); - refresh({ signal: controller.signal }); - return () => controller.abort(); - }, [refresh]); - - const result = loadState.result ?? EMPTY_DISCOVERY_RESULT; + const result = discovery.data ?? EMPTY_DISCOVERY_RESULT; const statusText = useMemo(() => { - if (loadState.status === "loading") return "Scanning installed tools..."; - if (loadState.status === "error") return loadState.message; + if (discovery.isPending) return "Scanning installed tools..."; + if (discovery.error) return discovery.error; return "Detected source control tools on this system."; - }, [loadState]); + }, [discovery.error, discovery.isPending]); const setItemExpanded = (key: string, value: boolean) => { setExpanded((current) => ({ ...current, [key]: value })); @@ -224,12 +260,12 @@ export function SourceControlSettingsPanel() { size="sm" variant="ghost" className="h-7 gap-1.5 px-2 text-xs" - onClick={() => refresh()} - disabled={loadState.status === "loading"} + onClick={() => { + void refreshSourceControlDiscovery(); + }} + disabled={discovery.isPending} > - + Scan } diff --git a/apps/web/src/lib/sourceControlDiscoveryState.ts b/apps/web/src/lib/sourceControlDiscoveryState.ts new file mode 100644 index 00000000000..5189c22a124 --- /dev/null +++ b/apps/web/src/lib/sourceControlDiscoveryState.ts @@ -0,0 +1,44 @@ +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 { useEffect } from "react"; + +import { readLocalApi } from "../localApi"; +import { appAtomRegistry } from "../rpc/atomRegistry"; + +const SOURCE_CONTROL_DISCOVERY_TARGET = { key: "primary" } as const; + +export const sourceControlDiscoveryManager = createSourceControlDiscoveryManager({ + getRegistry: () => appAtomRegistry, + getClient: () => readLocalApi()?.server ?? null, +}); + +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); + + useEffect(() => { + void sourceControlDiscoveryManager.refresh(SOURCE_CONTROL_DISCOVERY_TARGET); + }, []); + + 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.ts b/apps/web/src/localApi.ts index 983020390af..c872a5f1030 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"; @@ -147,6 +148,7 @@ export async function __resetLocalApiForTests() { __resetClientSettingsPersistenceForTests(); await resetEnvironmentServiceForTests(); resetGitStatusStateForTests(); + resetSourceControlDiscoveryStateForTests(); resetRequestLatencyStateForTests(); resetSavedEnvironmentRegistryStoreForTests(); resetSavedEnvironmentRuntimeStoreForTests(); diff --git a/bun.lock b/bun.lock index 79be837791e..c7aeb59b76f 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 bfe2d7828eb..fbe97d0dd69 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 9ca76328a8e..f6f5b82758c 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 00000000000..9a988be00a5 --- /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 00000000000..b18d6103b80 --- /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/sourceControl.ts b/packages/contracts/src/sourceControl.ts index e7b92f52acb..5776e84ff7c 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -46,6 +46,21 @@ export type SourceControlRepositoryCloneUrls = typeof SourceControlRepositoryClo 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, @@ -71,6 +86,7 @@ export type VcsDiscoveryItem = typeof VcsDiscoveryItem.Type; export const SourceControlProviderDiscoveryItem = Schema.Struct({ kind: SourceControlProviderKind, ...SourceControlDiscoveryItemFields, + auth: SourceControlProviderAuth, }); export type SourceControlProviderDiscoveryItem = typeof SourceControlProviderDiscoveryItem.Type; From f4180a404f159a38218216c2308fd6ff86bf1a6e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 14:02:42 -0700 Subject: [PATCH 36/45] Polish source control settings Co-authored-by: codex --- .../SourceControlDiscovery.test.ts | 1 - .../sourceControl/SourceControlDiscovery.ts | 8 - .../settings/SettingsPanels.browser.tsx | 151 ++++- .../settings/SourceControlSettings.tsx | 529 ++++++++++++------ .../components/settings/settingsLayout.tsx | 4 +- .../src/lib/sourceControlDiscoveryState.ts | 20 +- packages/contracts/src/vcs.ts | 2 +- 7 files changed, 519 insertions(+), 196 deletions(-) diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 3146212db47..53ab4806593 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -74,7 +74,6 @@ Logged in to github.com account juliusmarminge (keyring) [ { kind: "git", implemented: true, status: "available" }, { kind: "jj", implemented: false, status: "missing" }, - { kind: "sapling", implemented: false, status: "missing" }, ], ); assert.deepStrictEqual( diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts index a3b309995bc..7d8b5bc2d8c 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -63,14 +63,6 @@ const VCS_PROBES: ReadonlyArray = [ implemented: false, installHint: "Install Jujutsu with `brew install jj` or from https://github.com/jj-vcs/jj.", }, - { - kind: "sapling", - label: "Sapling", - executable: "sl", - versionArgs: ["--version"], - implemented: false, - installHint: "Install Sapling (`sl`) from https://sapling-scm.com/.", - }, ]; const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray = [ diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 4406d1607c5..51cfe653279 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/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 96993ab777d..c5f7909e865 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -1,8 +1,8 @@ -import { ChevronDownIcon, GitBranchIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react"; +import { GitPullRequestIcon, RefreshCwIcon } from "lucide-react"; import { Option } from "effect"; -import { useMemo, useState } from "react"; +import { type ReactNode, useId } from "react"; import type { - SourceControlDiscoveryItem, + SourceControlProviderKind, SourceControlDiscoveryResult, SourceControlProviderAuth, SourceControlProviderDiscoveryItem, @@ -16,8 +16,18 @@ import { } from "../../lib/sourceControlDiscoveryState"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; -import { Collapsible, CollapsibleContent } from "../ui/collapsible"; +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"; @@ -26,6 +36,134 @@ const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { 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); } @@ -38,25 +176,15 @@ function isProviderDiscoveryItem( function authPresentation(auth: SourceControlProviderAuth): { readonly label: string; - readonly badge: "success" | "warning" | "outline"; + readonly badge: "warning" | null; } { if (auth.status === "authenticated") { - return { label: "Signed in", badge: "success" }; + return { label: "Signed in", badge: null }; } if (auth.status === "unauthenticated") { return { label: "Sign in", badge: "warning" }; } - return { label: "Auth unknown", badge: "outline" }; -} - -function authSummary(auth: SourceControlProviderAuth): string { - if (auth.status === "authenticated") { - return "Authenticated"; - } - if (auth.status === "unauthenticated") { - return "Not authenticated"; - } - return "Auth not checked"; + return { label: "Sign in", badge: null }; } function RedactedAccount(props: { readonly account: string | null }) { @@ -70,57 +198,93 @@ function RedactedAccount(props: { readonly account: string | null }) { ); } -function statusPresentation(item: SourceControlDiscoveryItem): { - readonly label: string; - readonly badge: "success" | "warning" | "outline"; - readonly dot: string; -} { - if (item.implemented && item.status === "available") { - return { - label: "Ready", - badge: "success", - dot: "bg-success", - }; +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 ; } - if (item.implemented) { - return { - label: "CLI missing", - badge: "warning", - dot: "bg-warning", - }; + + 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 { - label: "Detected", - badge: "outline", - dot: "bg-muted-foreground/60", - }; + + if (item.status !== "available") { + return Not found - {item.installHint}; } - return { - label: "Placeholder", - badge: "outline", - dot: "bg-muted-foreground/40", - }; + + 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, - expanded, - onExpandedChange, }: { readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem; - readonly expanded: boolean; - readonly onExpandedChange: (expanded: boolean) => void; }) { - const status = statusPresentation(item); const version = optionLabel(item.version); - const detail = optionLabel(item.detail); 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; - const authHost = auth ? optionLabel(auth.host) : null; - const authDetail = auth ? optionLabel(auth.detail) : null; return (
- +

{item.label}

- - {status.label} - + {version ? {version} : null} {!item.implemented ? ( - - Not in this branch + + Coming Soon ) : null} - {authStatus ? ( + {authStatus?.badge ? ( {authStatus.label} ) : null}

- CLI - - {item.executable} - - {version ? - {version} : null} - {auth ? ( - <> - - - {authSummary(auth)} - {authAccount ? : null} - - ) : null} + {itemSummary({ item, auth, authAccount })}

- - + {item.implemented ? ( + + ) : null}
+ + ); +} - - -
-
-
Command
-
{item.executable}
-
Version
-
{version ?? "Not detected"}
- {auth ? ( - <> -
Auth
-
-
- {authSummary(auth)} - {authAccount ? : null} - {authHost ? ( - on {authHost} - ) : null} -
- {authDetail ? ( -
{authDetail}
- ) : null} -
- - ) : null} -
Install
-
{item.installHint}
-
Build
-
- {item.implemented - ? "Enabled in this branch and available for repository routing when the CLI is present." - : "Placeholder only in this branch. The matching driver/provider PR enables this row."} -
- {detail ? ( - <> -
Probe
-
{detail}
- - ) : 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 [expanded, setExpanded] = useState>>({}); const result = discovery.data ?? EMPTY_DISCOVERY_RESULT; - const statusText = useMemo(() => { - if (discovery.isPending) return "Scanning installed tools..."; - if (discovery.error) return discovery.error; - return "Detected source control tools on this system."; - }, [discovery.error, discovery.isPending]); - - const setItemExpanded = (key: string, value: boolean) => { - setExpanded((current) => ({ ...current, [key]: value })); + const hasDiscoveryItems = + result.versionControlSystems.length > 0 || result.sourceControlProviders.length > 0; + const isInitialScanPending = discovery.isPending && discovery.data === null; + const handleScan = () => { + void refreshSourceControlDiscovery(); }; - - return ( - -
-

Source Control

-

{statusText}

-
- - } - headerAction={ + const scanButton = ( + + { - void refreshSourceControlDiscovery(); - }} + className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" + onClick={handleScan} disabled={discovery.isPending} + aria-label="Scan source control tools" > - - Scan + } - > - {result.versionControlSystems.map((item) => ( - setItemExpanded(`vcs:${item.kind}`, value)} - /> - ))} - + /> + Scan source control tools + + ); - } - > - {result.sourceControlProviders.map((item) => ( - setItemExpanded(`provider:${item.kind}`, value)} - /> - ))} - + 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 f41e32f9e5c..98769aca747 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/lib/sourceControlDiscoveryState.ts b/apps/web/src/lib/sourceControlDiscoveryState.ts index 5189c22a124..fad4f534908 100644 --- a/apps/web/src/lib/sourceControlDiscoveryState.ts +++ b/apps/web/src/lib/sourceControlDiscoveryState.ts @@ -8,18 +8,32 @@ import { sourceControlDiscoveryStateAtom, } from "@t3tools/client-runtime"; import type { SourceControlDiscoveryResult } from "@t3tools/contracts"; -import { useEffect } from "react"; +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); } @@ -31,9 +45,7 @@ export function resetSourceControlDiscoveryStateForTests(): void { export function useSourceControlDiscovery(): SourceControlDiscoveryState { const targetKey = getSourceControlDiscoveryTargetKey(SOURCE_CONTROL_DISCOVERY_TARGET); - useEffect(() => { - void sourceControlDiscoveryManager.refresh(SOURCE_CONTROL_DISCOVERY_TARGET); - }, []); + useAtomValue(sourceControlDiscoveryAutoRefreshAtom); const state = useAtomValue( targetKey !== null diff --git a/packages/contracts/src/vcs.ts b/packages/contracts/src/vcs.ts index 72ba5441476..2dd09cf04c6 100644 --- a/packages/contracts/src/vcs.ts +++ b/packages/contracts/src/vcs.ts @@ -1,7 +1,7 @@ import { Schema } from "effect"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; -export const VcsDriverKind = Schema.Literals(["git", "jj", "sapling", "unknown"]); +export const VcsDriverKind = Schema.Literals(["git", "jj", "unknown"]); export type VcsDriverKind = typeof VcsDriverKind.Type; export const VcsFreshnessSource = Schema.Literals([ From 520c898d33a3f7d808e4e4a60f88f78e45a635e7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 14:38:25 -0700 Subject: [PATCH 37/45] Add VCS provisioning routing --- apps/server/src/git/GitManager.test.ts | 1 + apps/server/src/git/GitWorkflowService.ts | 2 - apps/server/src/server.test.ts | 8 +- apps/server/src/server.ts | 2 + .../SourceControlProviderRegistry.test.ts | 13 +++ .../SourceControlProviderRegistry.ts | 7 ++ apps/server/src/vcs/GitVcsDriver.ts | 7 ++ apps/server/src/vcs/VcsDriver.ts | 2 + apps/server/src/vcs/VcsDriverRegistry.test.ts | 22 +++++ apps/server/src/vcs/VcsDriverRegistry.ts | 21 +++-- .../src/vcs/VcsProvisioningService.test.ts | 94 +++++++++++++++++++ apps/server/src/vcs/VcsProvisioningService.ts | 54 +++++++++++ apps/server/src/ws.ts | 6 +- packages/contracts/src/git.ts | 2 + packages/contracts/src/rpc.ts | 3 +- 15 files changed, 231 insertions(+), 13 deletions(-) create mode 100644 apps/server/src/vcs/VcsProvisioningService.test.ts create mode 100644 apps/server/src/vcs/VcsProvisioningService.ts diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index bd6eb8e6e86..73c71632073 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -660,6 +660,7 @@ function makeManager(input?: { GitHubSourceControlProvider.make().pipe( Effect.map((provider) => SourceControlProviderRegistry.SourceControlProviderRegistry.of({ + get: () => Effect.succeed(provider), resolveHandle: () => Effect.succeed({ provider, context: null }), resolve: () => Effect.succeed(provider), }), diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index aac57067803..0ebbc9917ee 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -65,7 +65,6 @@ export interface GitWorkflowServiceShape { readonly switchRef: ( input: VcsSwitchRefInput, ) => Effect.Effect; - readonly initRepo: (input: { readonly cwd: string }) => Effect.Effect; readonly renameBranch: (input: { readonly cwd: string; readonly oldBranch: string; @@ -196,7 +195,6 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () { ensureGitCommand("GitWorkflowService.switchRef", input.cwd).pipe( Effect.andThen(Effect.scoped(git.switchRef(input))), ), - initRepo: (input) => git.initRepo(input), renameBranch: (input) => ensureGit("GitWorkflowService.renameBranch", input.cwd).pipe( Effect.andThen(git.renameBranch(input)), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 961911aa47f..af4b4134852 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -115,6 +115,7 @@ import { 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"; @@ -414,9 +415,11 @@ const buildAppUnderTest = (options?: { }, }), filterIgnoredPaths: (_cwd, relativePaths) => Effect.succeed(relativePaths), + 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) => @@ -492,6 +495,9 @@ const buildAppUnderTest = (options?: { Layer.provideMerge(gitVcsDriverLayer), Layer.provideMerge(gitManagerLayer), ); + const vcsProvisioningLayer = VcsProvisioningServiceLayer.pipe( + Layer.provide(vcsDriverRegistryLayer), + ); const vcsStatusBroadcasterLayer = options?.layers?.vcsStatusBroadcaster ? Layer.mock(VcsStatusBroadcaster)({ ...options.layers.vcsStatusBroadcaster, @@ -538,6 +544,7 @@ const buildAppUnderTest = (options?: { Layer.provide(gitManagerLayer), Layer.provide(gitVcsDriverLayer), Layer.provide(gitWorkflowLayer), + Layer.provide(vcsProvisioningLayer), Layer.provideMerge(vcsStatusBroadcasterLayer), Layer.provide( Layer.mock(ProjectSetupScriptRunner)({ @@ -2501,7 +2508,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { removeWorktree: () => Effect.void, createRef: (input) => Effect.succeed({ refName: input.refName }), switchRef: (input) => Effect.succeed({ refName: input.refName }), - initRepo: () => Effect.void, }, }, }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 55e7689e970..4b31543e594 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -49,6 +49,7 @@ 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"; @@ -184,6 +185,7 @@ const GitWorkflowLayerLive = GitWorkflowService.layer.pipe( 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))), ); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index ab820a461cb..23cdc3e1fd2 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -31,6 +31,7 @@ function makeRegistry(input: { } satisfies Partial; const registryLayer = Layer.mock(VcsDriverRegistry)({ + get: () => Effect.succeed(driver as unknown as VcsDriverShape), resolve: () => Effect.succeed({ kind: "git", @@ -65,6 +66,18 @@ it.effect("routes GitHub remotes to the GitHub provider", () => }), ); +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", () => diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 915365c9459..bddbca15ee4 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -25,6 +25,9 @@ export interface SourceControlProviderHandle { } export interface SourceControlProviderRegistryShape { + readonly get: ( + kind: SourceControlProviderKind, + ) => Effect.Effect; readonly resolveHandle: (input: { readonly cwd: string; }) => Effect.Effect; @@ -102,6 +105,9 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit 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 @@ -136,6 +142,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit ); return SourceControlProviderRegistry.of({ + get, resolveHandle, resolve: (input) => resolveHandle(input).pipe(Effect.map((handle) => handle.provider)), }); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 8575c1d55a1..0cc73da5e67 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -511,6 +511,12 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }, ); + const initRepository: VcsDriverShape["initRepository"] = (input) => + gitCommand(process, "GitVcsDriver.initRepository", input.cwd, ["init"], { + timeoutMs: 10_000, + maxOutputBytes: 64 * 1024, + }).pipe(Effect.asVoid); + return { capabilities, execute, @@ -519,6 +525,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( listWorkspaceFiles, listRemotes, filterIgnoredPaths, + initRepository, } satisfies VcsDriverShape; }); diff --git a/apps/server/src/vcs/VcsDriver.ts b/apps/server/src/vcs/VcsDriver.ts index b04dd1333e8..14e5f68ef45 100644 --- a/apps/server/src/vcs/VcsDriver.ts +++ b/apps/server/src/vcs/VcsDriver.ts @@ -3,6 +3,7 @@ import { Context, type Effect } from "effect"; import type { VcsDriverCapabilities, VcsError, + VcsInitInput, VcsListRemotesResult, VcsListWorkspaceFilesResult, VcsRepositoryIdentity, @@ -24,6 +25,7 @@ export interface VcsDriverShape { 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 index 425b7c18652..8e482c46b81 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.test.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.test.ts @@ -16,6 +16,28 @@ const processOutput = (stdout: string): VcsProcessOutput => ({ }); 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( diff --git a/apps/server/src/vcs/VcsDriverRegistry.ts b/apps/server/src/vcs/VcsDriverRegistry.ts index 9e07070e821..7798d63a087 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.ts @@ -21,6 +21,7 @@ export interface VcsDriverHandle { } export interface VcsDriverRegistryShape { + readonly get: (kind: VcsDriverKind) => Effect.Effect; readonly detect: ( input: VcsDriverResolveInput, ) => Effect.Effect; @@ -69,6 +70,16 @@ export const make = Effect.fn("makeVcsDriverRegistry")(function* () { 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, @@ -92,14 +103,7 @@ export const make = Effect.fn("makeVcsDriverRegistry")(function* () { const requestedKind = input.requestedKind; if (requestedKind !== "auto" && requestedKind !== "unknown") { - const driver = drivers[requestedKind]; - if (!driver) { - return yield* unsupported( - "VcsDriverRegistry.detect", - requestedKind, - `No ${requestedKind} VCS driver is registered.`, - ); - } + const driver = yield* get(requestedKind); return yield* detectWithDriver(requestedKind, driver, input.cwd); } @@ -140,6 +144,7 @@ export const make = Effect.fn("makeVcsDriverRegistry")(function* () { ); return VcsDriverRegistry.of({ + get, detect, resolve, }); diff --git a/apps/server/src/vcs/VcsProvisioningService.test.ts b/apps/server/src/vcs/VcsProvisioningService.test.ts new file mode 100644 index 00000000000..5f936e7a1d7 --- /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 00000000000..7d5b93dd14d --- /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/ws.ts b/apps/server/src/ws.ts index 1b11db60ae1..c3827d98bf6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -48,6 +48,7 @@ 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"; @@ -138,6 +139,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const keybindings = yield* Keybindings; const open = yield* Open; const gitWorkflow = yield* GitWorkflowService; + const vcsProvisioning = yield* VcsProvisioningService; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const terminalManager = yield* TerminalManager; const providerRegistry = yield* ProviderRegistry; @@ -938,7 +940,9 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => [WS_METHODS.vcsInit]: (input) => observeRpcEffect( WS_METHODS.vcsInit, - gitWorkflow.initRepo(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + vcsProvisioning + .initRepository(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), { "rpc.aggregate": "vcs" }, ), [WS_METHODS.terminalOpen]: (input) => diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 2cc9377bb2c..dd12e749da4 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,6 +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; @@ -179,6 +180,7 @@ export type VcsSwitchRefInput = typeof VcsSwitchRefInput.Type; export const VcsInitInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, + kind: Schema.optional(VcsDriverKind), }); export type VcsInitInput = typeof VcsInitInput.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index affa3d2385d..f2da90e1907 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -78,6 +78,7 @@ import { } 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 @@ -266,7 +267,7 @@ export const WsVcsSwitchRefRpc = Rpc.make(WS_METHODS.vcsSwitchRef, { export const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, { payload: VcsInitInput, - error: GitCommandError, + error: VcsError, }); export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, { From ead88d925fd8f972c1c5d0caa17cec35cca6cd8a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 14:56:33 -0700 Subject: [PATCH 38/45] Move shared source control pieces to core --- apps/server/src/git/GitManager.test.ts | 68 ++--- apps/server/src/git/GitManager.ts | 125 +++++--- .../src/sourceControl/GitHubCli.test.ts | 286 +++++++++--------- apps/server/src/sourceControl/GitHubCli.ts | 110 ++++--- .../GitHubSourceControlProvider.test.ts | 10 +- .../BranchToolbarBranchSelector.tsx | 17 +- apps/web/src/components/ChatView.browser.tsx | 2 +- .../GitActionsControl.logic.test.ts | 38 ++- .../src/components/GitActionsControl.logic.ts | 57 ++-- apps/web/src/components/GitActionsControl.tsx | 68 +++-- apps/web/src/components/Icons.tsx | 15 + .../components/PullRequestThreadDialog.tsx | 34 ++- apps/web/src/components/Sidebar.tsx | 8 +- .../src/components/ThreadStatusIndicators.tsx | 48 ++- apps/web/src/pullRequestReference.test.ts | 32 ++ apps/web/src/pullRequestReference.ts | 30 +- apps/web/src/sourceControlPresentation.ts | 66 ++++ 17 files changed, 658 insertions(+), 356 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 73c71632073..2d95c5219f7 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -5,6 +5,7 @@ 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, @@ -55,6 +56,16 @@ interface FakeGhScenario { 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; @@ -390,24 +401,15 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { ? scenario.prListByHeadSelector?.[headSelector] : undefined; const stdout = (mappedQueue ?? mappedStdout ?? prListQueue.shift() ?? "[]") + "\n"; - return Effect.succeed({ - stdout, - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); + return Effect.succeed(fakeGhOutput(stdout)); } if (args[0] === "pr" && args[1] === "create") { - return Effect.succeed({ - stdout: + return Effect.succeed( + fakeGhOutput( (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") { @@ -419,8 +421,8 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { headRefName: "feature/pull-request", state: "open", }; - return Effect.succeed({ - stdout: + return Effect.succeed( + fakeGhOutput( JSON.stringify({ ...pullRequest, ...(pullRequest.headRepositoryNameWithOwner @@ -438,11 +440,8 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { } : {}), }) + "\n", - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); + ), + ); } if (args[0] === "pr" && args[1] === "checkout") { @@ -464,13 +463,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { runGitSyncForFakeGh(input.cwd, ["checkout", "-b", headBranch]); } } - return { - stdout: "", - stderr: "", - code: 0, - signal: null, - timedOut: false, - }; + return fakeGhOutput(""); }, catch: (error) => isGitHubCliError(error) @@ -497,26 +490,17 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { }), ); } - return Effect.succeed({ - stdout: + return Effect.succeed( + fakeGhOutput( 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.succeed(fakeGhOutput(`${scenario.defaultBranch ?? "main"}\n`)); } return Effect.fail( diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index bab4ae679ae..0e386a532df 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1,5 +1,4 @@ import { randomUUID } from "node:crypto"; -import { realpathSync } from "node:fs"; import { Array as Arr, @@ -49,7 +48,7 @@ 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"; +import type { ChangeRequest, SourceControlProviderKind } from "@t3tools/contracts"; export interface GitActionProgressReporter { readonly publish: (event: GitActionProgressEvent) => Effect.Effect; @@ -147,6 +146,24 @@ interface BranchHeadContext { isCrossRepository: boolean; } +interface ChangeRequestTerms { + shortLabel: string; + singular: string; +} + +function sourceControlChangeRequestTerms(kind: SourceControlProviderKind): ChangeRequestTerms { + if (kind === "gitlab") { + return { + shortLabel: "MR", + singular: "merge request", + }; + } + return { + shortLabel: "PR", + singular: "pull request", + }; +} + function parseRepositoryNameFromPullRequestUrl(url: string): string | null { const trimmed = url.trim(); const match = /^https:\/\/github\.com\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed); @@ -354,13 +371,14 @@ function withDescription(title: string, description: string | undefined) { function summarizeGitActionResult( result: Pick, + terms: ChangeRequestTerms, ): { 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}`; + const title = `${result.pr.status === "created" ? "Created" : "Opened"} ${terms.shortLabel}${prNumber}`; return withDescription(title, truncateText(result.pr.title)); } @@ -485,14 +503,6 @@ function normalizePullRequestReference(reference: string): string { return hashNumber?.[1] ?? trimmed; } -function canonicalizeExistingPath(value: string): string { - try { - return realpathSync.native(value); - } catch { - return value; - } -} - function toResolvedPullRequest(pr: { number: number; title: string; @@ -680,7 +690,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const path = yield* Path.Path; const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; - const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd); + const canonicalizeExistingPath = (value: string) => + fileSystem.realPath(value).pipe(Effect.catch(() => Effect.succeed(value))); + const normalizeStatusCacheKey = canonicalizeExistingPath; const nonRepositoryStatusDetails = { isRepo: false, hasOriginRemote: false, @@ -718,7 +730,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), }); const invalidateLocalStatusResultCache = (cwd: string) => - Cache.invalidate(localStatusResultCache, normalizeStatusCacheKey(cwd)); + normalizeStatusCacheKey(cwd).pipe( + Effect.flatMap((cacheKey) => Cache.invalidate(localStatusResultCache, cacheKey)), + ); const readRemoteStatus = Effect.fn("readRemoteStatus")(function* (cwd: string) { const details = yield* gitCore .statusDetails(cwd) @@ -756,7 +770,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), }); const invalidateRemoteStatusResultCache = (cwd: string) => - Cache.invalidate(remoteStatusResultCache, normalizeStatusCacheKey(cwd)); + 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))); @@ -941,7 +957,11 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { cwd: string, result: Pick, ) { - const summary = summarizeGitActionResult(result); + const terms = yield* sourceControlProvider(cwd).pipe( + Effect.map((provider) => sourceControlChangeRequestTerms(provider.kind)), + Effect.catch(() => Effect.succeed(sourceControlChangeRequestTerms("unknown"))), + ); + const summary = summarizeGitActionResult(result, terms); let latestOpenPr: PullRequestInfo | null = null; let currentBranchIsDefault = false; let finalBranchContext: { @@ -1006,7 +1026,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { result.pr.status === "opened_existing") ? { kind: "open_pr" as const, - label: "View PR", + label: `View ${terms.shortLabel}`, url: openPr.url, } : (result.action === "push" || result.action === "commit_push") && @@ -1014,7 +1034,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { !currentBranchIsDefault ? { kind: "run_action" as const, - label: "Create PR", + label: `Create ${terms.shortLabel}`, action: { kind: "create_pr" as const }, } : { @@ -1222,6 +1242,8 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { fallbackBranch: string | null, emit: GitActionProgressEmitter, ) { + const provider = yield* sourceControlProvider(cwd); + const terms = sourceControlChangeRequestTerms(provider.kind); const details = yield* gitCore.statusDetails(cwd); const branch = details.branch ?? fallbackBranch; if (!branch) { @@ -1258,7 +1280,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { yield* emit({ kind: "phase_started", phase: "pr", - label: "Generating PR content...", + label: `Generating ${terms.shortLabel} content...`, }); const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch); @@ -1283,9 +1305,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { yield* emit({ kind: "phase_started", phase: "pr", - label: "Creating pull request...", + label: `Creating ${terms.singular}...`, }); - yield* (yield* sourceControlProvider(cwd)) + yield* provider .createChangeRequest({ cwd, baseRefName: baseBranch, @@ -1316,11 +1338,13 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }); const localStatus: GitManagerShape["localStatus"] = Effect.fn("localStatus")(function* (input) { - return yield* Cache.get(localStatusResultCache, normalizeStatusCacheKey(input.cwd)); + const cacheKey = yield* normalizeStatusCacheKey(input.cwd); + return yield* Cache.get(localStatusResultCache, cacheKey); }); const remoteStatus: GitManagerShape["remoteStatus"] = Effect.fn("remoteStatus")( function* (input) { - return yield* Cache.get(remoteStatusResultCache, normalizeStatusCacheKey(input.cwd)); + const cacheKey = yield* normalizeStatusCacheKey(input.cwd); + return yield* Cache.get(remoteStatusResultCache, cacheKey); }, ); const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) { @@ -1380,7 +1404,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }; return yield* Effect.gen(function* () { const normalizedReference = normalizePullRequestReference(input.reference); - const rootWorktreePath = canonicalizeExistingPath(input.cwd); + const rootWorktreePath = yield* canonicalizeExistingPath(input.cwd); const pullRequestSummary = yield* (yield* sourceControlProvider(input.cwd)).getChangeRequest({ cwd: input.cwd, reference: normalizedReference, @@ -1430,33 +1454,35 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const localPullRequestBranch = resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo); - const findLocalHeadBranch = (cwd: string) => - gitCore.listRefs({ cwd }).pipe( - Effect.map((result) => { - const localBranch = result.refs.find( - (branch) => !branch.isRemote && branch.name === localPullRequestBranch, - ); - if (localBranch) { - return localBranch; - } - if (localPullRequestBranch === pullRequest.headBranch) { - return null; - } - return ( - result.refs.find( - (branch) => - !branch.isRemote && - branch.name === pullRequest.headBranch && - branch.worktreePath !== null && - canonicalizeExistingPath(branch.worktreePath) !== rootWorktreePath, - ) ?? null - ); - }), + 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 - ? canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath) + ? yield* canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath) : null; if ( existingBranchBeforeFetch?.worktreePath && @@ -1484,7 +1510,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd); const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath - ? canonicalizeExistingPath(existingBranchAfterFetch.worktreePath) + ? yield* canonicalizeExistingPath(existingBranchAfterFetch.worktreePath) : null; if ( existingBranchAfterFetch?.worktreePath && @@ -1650,6 +1676,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const currentBranch = branchStep.name ?? initialStatus.branch; const commitAction = isCommitAction(input.action) ? input.action : null; + const changeRequestTerms = wantsPr + ? sourceControlChangeRequestTerms((yield* sourceControlProvider(input.cwd)).kind) + : null; const commit = commitAction ? yield* Ref.set(currentPhase, Option.some("commit")).pipe( @@ -1687,7 +1716,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { .emit({ kind: "phase_started", phase: "pr", - label: "Preparing PR...", + label: `Preparing ${changeRequestTerms?.shortLabel ?? "PR"}...`, }) .pipe( Effect.tap(() => Ref.set(currentPhase, Option.some("pr"))), diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index acba95e6cb9..c4675fa2f16 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -1,53 +1,64 @@ import { assert, it } from "@effect/vitest"; -import { Effect } from "effect"; -import { afterEach, expect, vi } from "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"; -vi.mock("../processRunner", () => ({ - runProcess: vi.fn(), -})); - -import { runProcess } from "../processRunner.ts"; +import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; import * as GitHubCli from "./GitHubCli.ts"; -const mockedRunProcess = vi.mocked(runProcess); -const layer = it.layer(GitHubCli.layer); +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(() => { - mockedRunProcess.mockReset(); + mockRun.mockReset(); }); -layer("GitHubCli.layer", (it) => { +describe("GitHubCli.layer", () => { 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, - }); + 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 result = yield* Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; - return yield* gh.getPullRequest({ - cwd: "/repo", - reference: "#42", - }); + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.getPullRequest({ + cwd: "/repo", + reference: "#42", }); assert.deepStrictEqual(result, { @@ -61,51 +72,51 @@ layer("GitHubCli.layer", (it) => { headRepositoryNameWithOwner: "octocat/codething-mvp", headRepositoryOwnerLogin: "octocat", }); - expect(mockedRunProcess).toHaveBeenCalledWith( - "gh", - [ + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: [ "pr", "view", "#42", "--json", "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", ], - expect.objectContaining({ cwd: "/repo" }), - ); - }), + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), ); 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, - }); + 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 result = yield* Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; - return yield* gh.getPullRequest({ - cwd: "/repo", - reference: "#42", - }); + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.getPullRequest({ + cwd: "/repo", + reference: "#42", }); assert.deepStrictEqual(result, { @@ -119,46 +130,44 @@ layer("GitHubCli.layer", (it) => { headRepositoryNameWithOwner: "octocat/codething-mvp", headRepositoryOwnerLogin: "octocat", }); - }), + }).pipe(Effect.provide(layer)), ); 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, - }); + 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 result = yield* Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; - return yield* gh.listOpenPullRequests({ - cwd: "/repo", - headSelector: "feature/pr-list", - }); + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.listOpenPullRequests({ + cwd: "/repo", + headSelector: "feature/pr-list", }); assert.deepStrictEqual(result, [ @@ -171,29 +180,27 @@ layer("GitHubCli.layer", (it) => { state: "open", }, ]); - }), + }).pipe(Effect.provide(layer)), ); 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, - }); + 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 result = yield* Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; - return yield* gh.getRepositoryCloneUrls({ - cwd: "/repo", - repository: "octocat/codething-mvp", - }); + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "octocat/codething-mvp", }); assert.deepStrictEqual(result, { @@ -201,26 +208,33 @@ layer("GitHubCli.layer", (it) => { 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* () { - mockedRunProcess.mockRejectedValueOnce( - new Error( - "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)", + 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 error = yield* Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; - return yield* gh.getPullRequest({ + const gh = yield* GitHubCli.GitHubCli; + const error = yield* gh + .getPullRequest({ cwd: "/repo", reference: "4888", - }); - }).pipe(Effect.flip); + }) + .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 index 65b46808984..bb90aeba014 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -1,9 +1,8 @@ import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect"; -import { GitHubCliError, TrimmedNonEmptyString } from "@t3tools/contracts"; +import { GitHubCliError, TrimmedNonEmptyString, type VcsError } from "@t3tools/contracts"; -import type { ProcessRunResult } from "../processRunner.ts"; -import { runProcess } from "../processRunner.ts"; +import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; import { decodeGitHubPullRequestJson, decodeGitHubPullRequestListJson, @@ -35,7 +34,7 @@ export interface GitHubCliShape { readonly cwd: string; readonly args: ReadonlyArray; readonly timeoutMs?: number; - }) => Effect.Effect; + }) => Effect.Effect; readonly listOpenPullRequests: (input: { readonly cwd: string; @@ -76,53 +75,61 @@ export class GitHubCli extends Context.Service()( "t3/source-control/GitHubCli", ) {} -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, - }); - } +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); +} - 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, - }); - } +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("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, - }); - } + 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: `GitHub CLI command failed: ${error.message}`, + 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.", + detail: text, cause: error, }); } @@ -161,16 +168,19 @@ function decodeGitHubJson( ); } -export const make = Effect.sync(() => { +export const make = Effect.fn("makeGitHubCli")(function* () { + const process = yield* VcsProcess; + 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), - }); + 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, @@ -295,4 +305,4 @@ export const make = Effect.sync(() => { }); }); -export const layer = Layer.effect(GitHubCli, make); +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 index 0387decc043..ac3de1d9747 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -1,15 +1,17 @@ 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) => ({ +const processResult = (stdout: string): VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(0), stdout, stderr: "", - code: 0, - signal: null, - timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, }); function makeProvider(github: Partial) { diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 5d2b8f31852..2e30edfc02c 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -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 { @@ -230,6 +231,11 @@ export function BranchToolbarBranchSelector({ ); const currentGitBranch = 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, @@ -508,9 +514,14 @@ export function BranchToolbarBranchSelector({ onCheckoutPullRequestRequest(prReference); }} > -
- Checkout Pull Request - {prReference} +
+ + + + Checkout {sourceControlPresentation.terminology.singular} + + {prReference} +
); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 8a747dbd5e6..2a1edb91813 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2297,7 +2297,7 @@ describe("ChatView timeline estimator parity (full app)", () => { 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.", ); diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 4187e1a380b..08c85e3649f 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -253,6 +253,32 @@ describe("when: ref is clean, ahead, 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( @@ -656,7 +682,7 @@ describe("when: ref 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, }); }); @@ -840,7 +866,7 @@ describe("resolveDefaultBranchActionDialogCopy", () => { assert.deepEqual(copy, { 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 ref or create a feature ref 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", }); }); @@ -855,7 +881,7 @@ describe("resolveDefaultBranchActionDialogCopy", () => { assert.deepEqual(copy, { 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 ref or create a feature ref 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...", ]); }); }); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index bb004e5e6c0..70ca9decb63 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -4,6 +4,11 @@ import type { 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 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") { @@ -82,6 +97,7 @@ export function buildMenuItems( hasPrimaryRemote = true, ): GitActionMenuItem[] { if (!gitStatus) return []; + const terminology = resolveChangeRequestTerminology(gitStatus); const hasBranch = gitStatus.refName !== null; const hasChanges = gitStatus.hasWorkingTreeChanges; @@ -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", @@ -167,13 +183,14 @@ export function resolveQuickAction( 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 ref before pushing or opening a PR.", + hint: `Create and checkout a ref before pushing or opening a ${terminology.singular}.`, }; } @@ -185,7 +202,7 @@ export function resolveQuickAction( 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", @@ -195,18 +212,18 @@ export function resolveQuickAction( if (!gitStatus.hasUpstream) { 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", @@ -224,7 +241,7 @@ export function resolveQuickAction( }; } return { - label: "Push & create PR", + label: `Push & create ${terminology.shortLabel}`, disabled: false, kind: "run_action", action: "create_pr", @@ -258,7 +275,7 @@ export function resolveQuickAction( }; } 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 { @@ -294,9 +311,11 @@ 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 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) { @@ -315,15 +334,15 @@ export function resolveDefaultBranchActionDialogCopy(input: { if (input.includesCommit) { return { - title: "Commit, push & create PR from default ref?", - 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 ref?", - 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}`, }; } diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 3cd5c8c0d88..b4fae359293 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -8,7 +8,6 @@ import type { 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"; @@ -137,6 +137,7 @@ function getMenuActionDisabledReason({ 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) { @@ -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 refName 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 && !hasPrimaryRemote) { - return 'Add an "origin" remote before creating a PR.'; + 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,6 +335,12 @@ 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 hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false; @@ -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 } : {}), }), @@ -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), @@ -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} @@ -945,7 +969,10 @@ export default function GitActionsControl({ render={} > - + {item.label} @@ -964,14 +991,15 @@ export default function GitActionsControl({ openDialogForMenuItem(item); }} > - + {item.label} ); })} {gitStatusForActions?.refName === null && (

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

)} {gitStatusForActions && diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 7df7975fb10..08ebb41b6f9 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -15,6 +15,21 @@ export const GitHubIcon: Icon = (props) => ( ); +export const GitLabIcon: Icon = (props) => ( + + + + + +); + +export const AzureDevOpsIcon: Icon = (props) => ( + + + + +); + export const CursorIcon: Icon = ({ className, ...props }) => ( ({ isPending: debouncerState.isPending }), ); + const { data: gitStatus = null } = useGitStatus({ environmentId, cwd }); + const sourceControlPresentation = useMemo( + () => getSourceControlPresentation(gitStatus?.sourceControlProvider), + [gitStatus?.sourceControlProvider], + ); + const terminology = sourceControlPresentation.terminology; + const SourceControlIcon = sourceControlPresentation.Icon; useEffect(() => { if (!open) return; @@ -161,20 +170,20 @@ export function PullRequestThreadDialog({ const validationMessage = !referenceDirty ? null : reference.trim().length === 0 - ? "Paste a GitHub pull request URL, `gh pr checkout 123`, or enter 123 / #123." + ? `Paste a ${terminology.singular} URL, checkout command, or enter 123 / #123.` : parsedReference === null - ? "Use a GitHub pull request URL, `gh pr checkout 123`, 123, or #123." + ? `Use a ${terminology.singular} URL, checkout command, 123, or #123.` : null; const errorMessage = validationMessage ?? (resolvedPullRequest === null && resolvePullRequestQuery.isError ? resolvePullRequestQuery.error instanceof Error ? resolvePullRequestQuery.error.message - : "Failed to resolve pull request." + : `Failed to resolve ${terminology.singular}.` : preparePullRequestThreadMutation.error instanceof Error ? preparePullRequestThreadMutation.error.message : preparePullRequestThreadMutation.error - ? "Failed to prepare pull request thread." + ? `Failed to prepare ${terminology.singular} thread.` : null); return ( @@ -188,18 +197,23 @@ export function PullRequestThreadDialog({ > - Checkout Pull Request + + + Checkout {terminology.singular} + - Resolve a GitHub pull request, then create the draft thread in the main repo or in a - dedicated worktree. + Resolve a {sourceControlPresentation.providerName} {terminology.singular}, then create + the draft thread in the main repo or in a dedicated worktree. Pull request + + {terminology.singular} + { setReferenceDirty(true); @@ -237,7 +251,7 @@ export function PullRequestThreadDialog({ {isResolving ? (
- Resolving pull request... + Resolving {terminology.singular}...
) : null} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index c1c26ca534b..6f543c90328 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3,7 +3,6 @@ import { ArrowUpDownIcon, ChevronRightIcon, CloudIcon, - GitPullRequestIcon, FolderPlusIcon, SearchIcon, SettingsIcon, @@ -12,6 +11,7 @@ import { TriangleAlertIcon, } from "lucide-react"; import { + ChangeRequestStatusIcon, prStatusIndicator, resolveThreadPr, terminalStatusFromRunningIds, @@ -367,7 +367,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP }, }); const pr = resolveThreadPr(thread.branch, gitStatus.data); - const prStatus = prStatusIndicator(pr); + const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; const threadMetaClassName = isConfirmingArchive @@ -558,7 +558,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`} onClick={handlePrClick} > - + } /> @@ -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 cf1ced73228..fa2f8c2f450 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -2,6 +2,7 @@ import { scopeProjectRef, scopedThreadKey, scopeThreadRef } from "@t3tools/clien import type { VcsStatusResult } from "@t3tools/contracts"; import { CloudIcon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; import { useMemo } from "react"; +import { AzureDevOpsIcon, GitHubIcon, GitLabIcon } from "./Icons"; import { usePrimaryEnvironmentId } from "../environments/primary"; import { useSavedEnvironmentRegistryStore, @@ -11,12 +12,17 @@ import { useGitStatus } from "../lib/gitStatusState"; import { type AppState, selectProjectByRef, useStore } from "../store"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; +import { + resolveChangeRequestPresentation, + type ChangeRequestPresentation, +} 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; + icon: ChangeRequestPresentation["icon"]; colorClass: string; tooltip: string; url: string; @@ -30,36 +36,56 @@ export interface TerminalStatusIndicator { 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`, + icon: presentation.icon, 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`, + icon: presentation.icon, 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`, + icon: presentation.icon, 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({ + icon, + className, +}: { + icon: PrStatusIndicator["icon"]; + className?: string; +}) { + if (icon === "github") return ; + if (icon === "gitlab") return ; + if (icon === "azure-devops") return ; + return ; +} + export function resolveThreadPr( threadBranch: string | null, gitStatus: VcsStatusResult | null, @@ -124,7 +150,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 +172,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 +196,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar /> } > - + {prStatus.tooltip} diff --git a/apps/web/src/pullRequestReference.test.ts b/apps/web/src/pullRequestReference.test.ts index 2a1ed520799..2067c41cc1a 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 b033db7d214..93ae7caa0f7 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/sourceControlPresentation.ts b/apps/web/src/sourceControlPresentation.ts index 17e4ac99f44..b04109d2db1 100644 --- a/apps/web/src/sourceControlPresentation.ts +++ b/apps/web/src/sourceControlPresentation.ts @@ -1,4 +1,7 @@ +import { GitPullRequestIcon } from "lucide-react"; +import type { ElementType } from "react"; import type { SourceControlProviderInfo } from "@t3tools/contracts"; +import { AzureDevOpsIcon, GitHubIcon, GitLabIcon } from "./components/Icons"; export interface ChangeRequestPresentation { readonly icon: "github" | "gitlab" | "azure-devops" | "bitbucket" | "change-request"; @@ -11,6 +14,22 @@ export interface ChangeRequestPresentation { readonly urlExample: string; } +export interface ChangeRequestTerminology { + readonly shortLabel: string; + readonly singular: string; +} + +export interface SourceControlPresentation { + readonly providerName: string; + readonly terminology: ChangeRequestTerminology; + readonly Icon: ElementType<{ className?: string }>; +} + +export const DEFAULT_CHANGE_REQUEST_TERMINOLOGY: ChangeRequestTerminology = { + shortLabel: "PR", + singular: "pull request", +}; + const GITHUB_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { icon: "github", providerName: "GitHub", @@ -94,3 +113,50 @@ export function formatChangeRequestAction( 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 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": + case "change-request": + return { + providerName: provider?.name || presentation.providerName, + terminology: getChangeRequestTerminology(provider), + Icon: GitPullRequestIcon, + }; + } +} From 6106c1a17042c3175c5ee370f89b5dd6fafbd570 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 15:07:09 -0700 Subject: [PATCH 39/45] Share source control presentation helpers --- apps/server/src/git/GitManager.ts | 34 ++---- apps/web/src/sourceControlPresentation.ts | 133 +++------------------ packages/shared/src/sourceControl.test.ts | 59 ++++++++++ packages/shared/src/sourceControl.ts | 137 +++++++++++++++++++++- 4 files changed, 219 insertions(+), 144 deletions(-) create mode 100644 packages/shared/src/sourceControl.test.ts diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 0e386a532df..bc5e07ae060 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -39,6 +39,10 @@ import { 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"; @@ -48,7 +52,7 @@ 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, SourceControlProviderKind } from "@t3tools/contracts"; +import type { ChangeRequest } from "@t3tools/contracts"; export interface GitActionProgressReporter { readonly publish: (event: GitActionProgressEvent) => Effect.Effect; @@ -146,24 +150,6 @@ interface BranchHeadContext { isCrossRepository: boolean; } -interface ChangeRequestTerms { - shortLabel: string; - singular: string; -} - -function sourceControlChangeRequestTerms(kind: SourceControlProviderKind): ChangeRequestTerms { - if (kind === "gitlab") { - return { - shortLabel: "MR", - singular: "merge request", - }; - } - return { - shortLabel: "PR", - singular: "pull request", - }; -} - function parseRepositoryNameFromPullRequestUrl(url: string): string | null { const trimmed = url.trim(); const match = /^https:\/\/github\.com\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed); @@ -371,7 +357,7 @@ function withDescription(title: string, description: string | undefined) { function summarizeGitActionResult( result: Pick, - terms: ChangeRequestTerms, + terms: ChangeRequestTerminology, ): { title: string; description?: string; @@ -958,8 +944,8 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { result: Pick, ) { const terms = yield* sourceControlProvider(cwd).pipe( - Effect.map((provider) => sourceControlChangeRequestTerms(provider.kind)), - Effect.catch(() => Effect.succeed(sourceControlChangeRequestTerms("unknown"))), + Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)), + Effect.catch(() => Effect.succeed(getChangeRequestTerminologyForKind("unknown"))), ); const summary = summarizeGitActionResult(result, terms); let latestOpenPr: PullRequestInfo | null = null; @@ -1243,7 +1229,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { emit: GitActionProgressEmitter, ) { const provider = yield* sourceControlProvider(cwd); - const terms = sourceControlChangeRequestTerms(provider.kind); + const terms = getChangeRequestTerminologyForKind(provider.kind); const details = yield* gitCore.statusDetails(cwd); const branch = details.branch ?? fallbackBranch; if (!branch) { @@ -1677,7 +1663,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const currentBranch = branchStep.name ?? initialStatus.branch; const commitAction = isCommitAction(input.action) ? input.action : null; const changeRequestTerms = wantsPr - ? sourceControlChangeRequestTerms((yield* sourceControlProvider(input.cwd)).kind) + ? getChangeRequestTerminologyForKind((yield* sourceControlProvider(input.cwd)).kind) : null; const commit = commitAction diff --git a/apps/web/src/sourceControlPresentation.ts b/apps/web/src/sourceControlPresentation.ts index b04109d2db1..a5121438c74 100644 --- a/apps/web/src/sourceControlPresentation.ts +++ b/apps/web/src/sourceControlPresentation.ts @@ -1,133 +1,28 @@ 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, GitHubIcon, GitLabIcon } from "./components/Icons"; -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 interface SourceControlPresentation { readonly providerName: string; readonly terminology: ChangeRequestTerminology; readonly Icon: ElementType<{ className?: 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 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 getSourceControlPresentation( provider: SourceControlProviderInfo | null | undefined, ): SourceControlPresentation { diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts new file mode 100644 index 00000000000..697e65f0914 --- /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 index 63821255a18..59257a3de7f 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -1,4 +1,139 @@ -import type { SourceControlProviderInfo } from "@t3tools/contracts"; +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(); From 76dc275c515442450b433b0d7c0971c216107dfc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 15:09:56 -0700 Subject: [PATCH 40/45] Use Bitbucket icon in source control presentation --- apps/web/src/components/Icons.tsx | 10 ++++++++++ apps/web/src/components/ThreadStatusIndicators.tsx | 3 ++- apps/web/src/sourceControlPresentation.ts | 7 ++++++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 08ebb41b6f9..38da4abf6a4 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -30,6 +30,16 @@ export const AzureDevOpsIcon: Icon = (props) => ( ); +export const BitbucketIcon: Icon = (props) => ( + + + + +); + export const CursorIcon: Icon = ({ className, ...props }) => ( ; if (icon === "gitlab") return ; if (icon === "azure-devops") return ; + if (icon === "bitbucket") return ; return ; } diff --git a/apps/web/src/sourceControlPresentation.ts b/apps/web/src/sourceControlPresentation.ts index a5121438c74..68271228ed6 100644 --- a/apps/web/src/sourceControlPresentation.ts +++ b/apps/web/src/sourceControlPresentation.ts @@ -15,7 +15,7 @@ import { resolveChangeRequestPresentation, type ChangeRequestTerminology, } from "@t3tools/shared/sourceControl"; -import { AzureDevOpsIcon, GitHubIcon, GitLabIcon } from "./components/Icons"; +import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./components/Icons"; export interface SourceControlPresentation { readonly providerName: string; @@ -47,6 +47,11 @@ export function getSourceControlPresentation( Icon: AzureDevOpsIcon, }; case "bitbucket": + return { + providerName: provider?.name || presentation.providerName, + terminology: getChangeRequestTerminology(provider), + Icon: BitbucketIcon, + }; case "change-request": return { providerName: provider?.name || presentation.providerName, From 4bd521d8f83487e573514ddf49350b99d65a23a1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 15:15:32 -0700 Subject: [PATCH 41/45] Use generic change request icon in sidebar --- apps/web/src/components/Sidebar.tsx | 2 +- .../src/components/ThreadStatusIndicators.tsx | 24 +++---------------- 2 files changed, 4 insertions(+), 22 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 6f543c90328..1e740e5f38f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -558,7 +558,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`} onClick={handlePrClick} > - + } /> diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index bb9bd32d60a..5ed0c0e4c40 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -2,7 +2,6 @@ import { scopeProjectRef, scopedThreadKey, scopeThreadRef } from "@t3tools/clien import type { VcsStatusResult } from "@t3tools/contracts"; import { CloudIcon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; import { useMemo } from "react"; -import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; import { usePrimaryEnvironmentId } from "../environments/primary"; import { useSavedEnvironmentRegistryStore, @@ -12,17 +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, - type ChangeRequestPresentation, -} from "../sourceControlPresentation"; +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: string; - icon: ChangeRequestPresentation["icon"]; colorClass: string; tooltip: string; url: string; @@ -46,7 +41,6 @@ export function prStatusIndicator( if (pr.state === "open") { return { label: `${presentation.shortName} open`, - icon: presentation.icon, colorClass: "text-emerald-600 dark:text-emerald-300/90", tooltip: `#${pr.number} ${presentation.shortName} open: ${pr.title}`, url: pr.url, @@ -55,7 +49,6 @@ export function prStatusIndicator( if (pr.state === "closed") { return { label: `${presentation.shortName} closed`, - icon: presentation.icon, colorClass: "text-zinc-500 dark:text-zinc-400/80", tooltip: `#${pr.number} ${presentation.shortName} closed: ${pr.title}`, url: pr.url, @@ -64,7 +57,6 @@ export function prStatusIndicator( if (pr.state === "merged") { return { label: `${presentation.shortName} merged`, - icon: presentation.icon, colorClass: "text-violet-600 dark:text-violet-300/90", tooltip: `#${pr.number} ${presentation.shortName} merged: ${pr.title}`, url: pr.url, @@ -73,17 +65,7 @@ export function prStatusIndicator( return null; } -export function ChangeRequestStatusIcon({ - icon, - className, -}: { - icon: PrStatusIndicator["icon"]; - className?: string; -}) { - if (icon === "github") return ; - if (icon === "gitlab") return ; - if (icon === "azure-devops") return ; - if (icon === "bitbucket") return ; +export function ChangeRequestStatusIcon({ className }: { className?: string }) { return ; } @@ -197,7 +179,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar /> } > - + {prStatus.tooltip} From 6dd13497dc6fc5424d8d310537224aa904667d23 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 2 May 2026 22:17:11 +0000 Subject: [PATCH 42/45] Fix unhandled SourceControlProviderError in runStackedAction Wrap sourceControlProvider() call at line 1665 with Effect.catch fallback, matching the existing pattern in buildCompletionToast. The provider resolution is only used for cosmetic progress labels and should never abort the commit/push action. --- apps/server/src/git/GitManager.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index bc5e07ae060..abebc70d02b 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1663,7 +1663,10 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const currentBranch = branchStep.name ?? initialStatus.branch; const commitAction = isCommitAction(input.action) ? input.action : null; const changeRequestTerms = wantsPr - ? getChangeRequestTerminologyForKind((yield* sourceControlProvider(input.cwd)).kind) + ? yield* sourceControlProvider(input.cwd).pipe( + Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)), + Effect.catch(() => Effect.succeed(getChangeRequestTerminologyForKind("unknown"))), + ) : null; const commit = commitAction From 2c99032014346bcaf0ebf9cdbf83f8a52e1c299c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 15:22:20 -0700 Subject: [PATCH 43/45] Return empty status outside VCS repositories --- .../server/src/git/GitWorkflowService.test.ts | 111 ++++++++++++++++++ apps/server/src/git/GitWorkflowService.ts | 75 +++++++++++- 2 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/git/GitWorkflowService.test.ts diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts new file mode 100644 index 00000000000..d7c9588f17a --- /dev/null +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -0,0 +1,111 @@ +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)); + }); +}); diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 0ebbc9917ee..cee4adff50c 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -91,6 +91,31 @@ const unsupportedGitCommand = (operation: string, cwd: string, detail: string) = 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, + }; +} + export const make = Effect.fn("makeGitWorkflowService")(function* () { const registry = yield* VcsDriverRegistry; const git = yield* GitVcsDriver; @@ -144,6 +169,33 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () { } }); + 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 routeGitManager = ( operation: string, @@ -153,9 +205,26 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () { ensureGit(operation, input.cwd).pipe(Effect.andThen(run(input))); return GitWorkflowService.of({ - status: routeGitManager("GitWorkflowService.status", gitManager.status), - localStatus: routeGitManager("GitWorkflowService.localStatus", gitManager.localStatus), - remoteStatus: routeGitManager("GitWorkflowService.remoteStatus", gitManager.remoteStatus), + 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, From 1bedf182111df1505e373524e8e4f6a4bec7ce36 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 15:31:08 -0700 Subject: [PATCH 44/45] Return empty refs outside VCS repositories --- .../server/src/git/GitWorkflowService.test.ts | 21 +++++++++ apps/server/src/git/GitWorkflowService.ts | 43 ++++++++++++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index d7c9588f17a..1357ae249a9 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -108,4 +108,25 @@ describe("GitWorkflowService", () => { 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 index cee4adff50c..99f0cc2a6d3 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -116,6 +116,16 @@ function nonRepositoryStatus(): VcsStatusResult { }; } +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; @@ -196,6 +206,33 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () { }, ); + 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, @@ -245,8 +282,10 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () { gitManager.preparePullRequestThread, ), listRefs: (input) => - ensureGitCommand("GitWorkflowService.listRefs", input.cwd).pipe( - Effect.andThen(git.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( From 5e5e02059df16894d16a6d411f4685eb8c4eb480 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 2 May 2026 15:41:55 -0700 Subject: [PATCH 45/45] Mark mocked VCS repo in server routing tests --- apps/server/src/server.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index af4b4134852..a1064752e29 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -2509,6 +2509,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { createRef: (input) => Effect.succeed({ refName: input.refName }), switchRef: (input) => Effect.succeed({ refName: input.refName }), }, + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, }, }); @@ -2835,6 +2838,9 @@ 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, @@ -2908,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,