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` diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index d650f62308e..8945294555a 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -25,10 +25,7 @@ import { import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts"; import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts"; -import { GitCoreLive } from "../src/git/Layers/GitCore.ts"; -import { GitCore, type GitCoreShape } from "../src/git/Services/GitCore.ts"; -import { GitStatusBroadcaster } from "../src/git/Services/GitStatusBroadcaster.ts"; -import { TextGeneration, type TextGenerationShape } from "../src/git/Services/TextGeneration.ts"; +import { TextGeneration, type TextGenerationShape } from "../src/textGeneration/TextGeneration.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts"; import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts"; import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/ProjectionCheckpoints.ts"; @@ -77,6 +74,11 @@ import { import { deriveServerPaths, ServerConfig } from "../src/config.ts"; import { WorkspaceEntriesLive } from "../src/workspace/Layers/WorkspaceEntries.ts"; import { WorkspacePathsLive } from "../src/workspace/Layers/WorkspacePaths.ts"; +import * as GitVcsDriver from "../src/vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; +import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts"; +import { GitWorkflowService } from "../src/git/GitWorkflowService.ts"; +import * as VcsProcess from "../src/vcs/VcsProcess.ts"; function runGit(cwd: string, args: ReadonlyArray) { return execFileSync("git", args, { @@ -291,7 +293,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provide(providerEventLoggersLayer), ); - const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive)); + const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer)); const projectionSnapshotQueryLayer = OrchestrationProjectionSnapshotQueryLive; const runtimeServicesLayer = Layer.mergeAll( projectionSnapshotQueryLayer, @@ -307,31 +309,31 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(serverSettingsLayer), ); - const gitCoreLayer = Layer.succeed(GitCore, { - renameBranch: (input: Parameters[0]) => + const gitWorkflowLayer = Layer.mock(GitWorkflowService)({ + renameBranch: (input: Parameters[0]) => Effect.succeed({ branch: input.newBranch }), - } as unknown as GitCoreShape); + }); const textGenerationLayer = Layer.succeed(TextGeneration, { generateBranchName: () => Effect.succeed({ branch: "update" }), generateThreadTitle: () => Effect.succeed({ title: "New thread" }), } as unknown as TextGenerationShape); const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), - Layer.provideMerge(gitCoreLayer), + Layer.provideMerge(gitWorkflowLayer), Layer.provideMerge(textGenerationLayer), Layer.provideMerge(serverSettingsLayer), ); const checkpointReactorLayer = CheckpointReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge( - Layer.succeed(GitStatusBroadcaster, { + Layer.succeed(VcsStatusBroadcaster, { getStatus: () => Effect.die("getStatus should not be called in this test"), refreshLocalStatus: () => Effect.succeed({ isRepo: true, - hasOriginRemote: false, - isDefaultBranch: true, - branch: "main", + hasPrimaryRemote: false, + isDefaultRef: true, + refName: "main", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, }), @@ -342,11 +344,12 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge( WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), - Layer.provideMerge(gitCoreLayer), + Layer.provideMerge(VcsDriverRegistry.layer), Layer.provide(NodeServices.layer), ), ), Layer.provideMerge(WorkspacePathsLive), + Layer.provideMerge(VcsProcess.layer), ); const orchestrationReactorLayer = OrchestrationReactorLive.pipe( Layer.provideMerge(runtimeIngestionLayer), diff --git a/apps/server/src/checkpointing/Errors.ts b/apps/server/src/checkpointing/Errors.ts index 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.VcsProcess; + const result = yield* process.run({ operation: "CheckpointStore.test.git", + command: "git", cwd, args, timeoutMs: 10_000, @@ -66,12 +70,11 @@ function initRepoWithCommit( cwd: string, ): Effect.Effect< void, - GitCommandError | PlatformError.PlatformError, - GitCore | FileSystem.FileSystem + VcsError | PlatformError.PlatformError, + VcsProcess.VcsProcess | FileSystem.FileSystem > { return Effect.gen(function* () { - const core = yield* GitCore; - yield* core.initRepo({ cwd }); + yield* git(cwd, ["init"]); yield* git(cwd, ["config", "user.email", "test@test.com"]); yield* git(cwd, ["config", "user.name", "Test"]); yield* writeTextFile(path.join(cwd, "README.md"), "# test\n"); diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.ts index 211877e9b1a..c8b59564604 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.ts @@ -14,8 +14,8 @@ import { randomUUID } from "node:crypto"; import { Effect, Layer, FileSystem, Path } from "effect"; import { CheckpointInvariantError } from "../Errors.ts"; -import { GitCommandError } from "@t3tools/contracts"; -import { GitCore } from "../../git/Services/GitCore.ts"; +import { VcsProcessExitError } from "@t3tools/contracts"; +import { VcsDriverRegistry } from "../../vcs/VcsDriverRegistry.ts"; import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; import { CheckpointRef } from "@t3tools/contracts"; @@ -24,10 +24,26 @@ const CHECKPOINT_DIFF_MAX_OUTPUT_BYTES = 10_000_000; const makeCheckpointStore = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const git = yield* GitCore; - - const resolveHeadCommit = (cwd: string): Effect.Effect => - git + 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 .execute({ operation: "CheckpointStore.resolveHeadCommit", cwd, @@ -36,7 +52,7 @@ const makeCheckpointStore = Effect.gen(function* () { }) .pipe( Effect.map((result) => { - if (result.code !== 0) { + if (result.exitCode !== 0) { return null; } const commit = result.stdout.trim(); @@ -44,21 +60,18 @@ const makeCheckpointStore = Effect.gen(function* () { }), ); - const hasHeadCommit = (cwd: string): Effect.Effect => - git + const hasHeadCommit = (cwd: string) => + vcs .execute({ operation: "CheckpointStore.hasHeadCommit", cwd, args: ["rev-parse", "--verify", "HEAD"], allowNonZeroExit: true, }) - .pipe(Effect.map((result) => result.code === 0)); + .pipe(Effect.map((result) => result.exitCode === 0)); - const resolveCheckpointCommit = ( - cwd: string, - checkpointRef: CheckpointRef, - ): Effect.Effect => - git + const resolveCheckpointCommit = (cwd: string, checkpointRef: CheckpointRef) => + vcs .execute({ operation: "CheckpointStore.resolveCheckpointCommit", cwd, @@ -67,7 +80,7 @@ const makeCheckpointStore = Effect.gen(function* () { }) .pipe( Effect.map((result) => { - if (result.code !== 0) { + if (result.exitCode !== 0) { return null; } const commit = result.stdout.trim(); @@ -76,7 +89,7 @@ const makeCheckpointStore = Effect.gen(function* () { ); const isGitRepository: CheckpointStoreShape["isGitRepository"] = (cwd) => - git + vcs .execute({ operation: "CheckpointStore.isGitRepository", cwd, @@ -84,7 +97,7 @@ const makeCheckpointStore = Effect.gen(function* () { allowNonZeroExit: true, }) .pipe( - Effect.map((result) => result.code === 0 && result.stdout.trim() === "true"), + Effect.map((result) => result.exitCode === 0 && result.stdout.trim() === "true"), Effect.catch(() => Effect.succeed(false)), ); @@ -108,7 +121,7 @@ const makeCheckpointStore = Effect.gen(function* () { const headExists = yield* hasHeadCommit(input.cwd); if (headExists) { - yield* git.execute({ + yield* vcs.execute({ operation, cwd: input.cwd, args: ["read-tree", "HEAD"], @@ -116,14 +129,14 @@ const makeCheckpointStore = Effect.gen(function* () { }); } - yield* git.execute({ + yield* vcs.execute({ operation, cwd: input.cwd, args: ["add", "-A", "--", "."], env: commitEnv, }); - const writeTreeResult = yield* git.execute({ + const writeTreeResult = yield* vcs.execute({ operation, cwd: input.cwd, args: ["write-tree"], @@ -131,16 +144,17 @@ const makeCheckpointStore = Effect.gen(function* () { }); const treeOid = writeTreeResult.stdout.trim(); if (treeOid.length === 0) { - return yield* new GitCommandError({ + return yield* new VcsProcessExitError({ operation, command: "git write-tree", cwd: input.cwd, + exitCode: 0, detail: "git write-tree returned an empty tree oid.", }); } const message = `t3 checkpoint ref=${input.checkpointRef}`; - const commitTreeResult = yield* git.execute({ + const commitTreeResult = yield* vcs.execute({ operation, cwd: input.cwd, args: ["commit-tree", treeOid, "-m", message], @@ -148,15 +162,16 @@ const makeCheckpointStore = Effect.gen(function* () { }); const commitOid = commitTreeResult.stdout.trim(); if (commitOid.length === 0) { - return yield* new GitCommandError({ + return yield* new VcsProcessExitError({ operation, command: "git commit-tree", cwd: input.cwd, + exitCode: 0, detail: "git commit-tree returned an empty commit oid.", }); } - yield* git.execute({ + yield* vcs.execute({ operation, cwd: input.cwd, args: ["update-ref", input.checkpointRef, commitOid], @@ -197,12 +212,12 @@ const makeCheckpointStore = Effect.gen(function* () { return false; } - yield* git.execute({ + yield* vcs.execute({ operation, cwd: input.cwd, args: ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."], }); - yield* git.execute({ + yield* vcs.execute({ operation, cwd: input.cwd, args: ["clean", "-fd", "--", "."], @@ -210,7 +225,7 @@ const makeCheckpointStore = Effect.gen(function* () { const headExists = yield* hasHeadCommit(input.cwd); if (headExists) { - yield* git.execute({ + yield* vcs.execute({ operation, cwd: input.cwd, args: ["reset", "--quiet", "--", "."], @@ -235,15 +250,16 @@ const makeCheckpointStore = Effect.gen(function* () { } if (!fromCommitOid || !toCommitOid) { - return yield* new GitCommandError({ + return yield* new VcsProcessExitError({ operation, command: "git diff", cwd: input.cwd, + exitCode: 1, detail: "Checkpoint ref is unavailable for diff operation.", }); } - const result = yield* git.execute({ + const result = yield* vcs.execute({ operation, cwd: input.cwd, args: ["diff", "--patch", "--minimal", "--no-color", fromCommitOid, toCommitOid], @@ -262,7 +278,7 @@ const makeCheckpointStore = Effect.gen(function* () { yield* Effect.forEach( input.checkpointRefs, (checkpointRef) => - git.execute({ + vcs.execute({ operation, cwd: input.cwd, args: ["update-ref", "-d", checkpointRef], diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts similarity index 95% rename from apps/server/src/git/Layers/GitManager.test.ts rename to apps/server/src/git/GitManager.test.ts index b8eeb541892..2d95c5219f7 100644 --- a/apps/server/src/git/Layers/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, @@ -14,23 +15,25 @@ import type { } from "@t3tools/contracts"; import { GitCommandError, GitHubCliError, TextGenerationError } from "@t3tools/contracts"; -import { type GitManagerShape } from "../Services/GitManager.ts"; +import { type GitManagerShape } from "./GitManager.ts"; import { type GitHubCliShape, type GitHubPullRequestSummary, GitHubCli, -} from "../Services/GitHubCli.ts"; -import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; -import { GitCoreLive } from "./GitCore.ts"; -import { GitCore } from "../Services/GitCore.ts"; +} from "../sourceControl/GitHubCli.ts"; +import { type TextGenerationShape, TextGeneration } from "../textGeneration/TextGeneration.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; +import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import { makeGitManager } from "./GitManager.ts"; -import { ServerConfig } from "../../config.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; +import { ServerConfig } from "../config.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; import { ProjectSetupScriptRunner, type ProjectSetupScriptRunnerInput, type ProjectSetupScriptRunnerShape, -} from "../../project/Services/ProjectSetupScriptRunner.ts"; +} from "../project/Services/ProjectSetupScriptRunner.ts"; interface FakeGhScenario { prListSequence?: string[]; @@ -53,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; @@ -209,18 +222,27 @@ function runGit( args: readonly string[], allowNonZeroExit = false, ): Effect.Effect< - { readonly code: number; readonly stdout: string; readonly stderr: string }, + { + readonly exitCode: GitVcsDriver.ExecuteGitResult["exitCode"]; + readonly stdout: string; + readonly stderr: string; + }, GitCommandError, - GitCore + GitVcsDriver.GitVcsDriver > { return Effect.gen(function* () { - const gitCore = yield* GitCore; - return yield* gitCore.execute({ + const git = yield* GitVcsDriver.GitVcsDriver; + const result = yield* git.execute({ operation: "GitManager.test.runGit", cwd, args, allowNonZeroExit, }); + return { + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + }; }); } @@ -229,7 +251,7 @@ function initRepo( ): Effect.Effect< void, PlatformError.PlatformError | GitCommandError, - FileSystem.FileSystem | Scope.Scope | GitCore + FileSystem.FileSystem | Scope.Scope | GitVcsDriver.GitVcsDriver > { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -245,7 +267,7 @@ function initRepo( function createBareRemote(): Effect.Effect< string, PlatformError.PlatformError | GitCommandError, - FileSystem.FileSystem | Scope.Scope | GitCore + FileSystem.FileSystem | Scope.Scope | GitVcsDriver.GitVcsDriver > { return Effect.gen(function* () { const remoteDir = yield* makeTempDir("t3code-git-remote-"); @@ -259,7 +281,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, [ @@ -379,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") { @@ -408,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 @@ -427,11 +440,8 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { } : {}), }) + "\n", - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); + ), + ); } if (args[0] === "pr" && args[1] === "checkout") { @@ -453,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) @@ -486,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( @@ -639,13 +634,26 @@ function makeManager(input?: { const serverSettingsLayer = ServerSettingsService.layerTest(); - const gitCoreLayer = GitCoreLive.pipe( + const vcsDriverLayer = GitVcsDriver.layer.pipe( + Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(ServerConfigLayer), ); + const sourceControlRegistryLayer = Layer.effect( + SourceControlProviderRegistry.SourceControlProviderRegistry, + GitHubSourceControlProvider.make().pipe( + Effect.map((provider) => + SourceControlProviderRegistry.SourceControlProviderRegistry.of({ + get: () => Effect.succeed(provider), + resolveHandle: () => Effect.succeed({ provider, context: null }), + resolve: () => Effect.succeed(provider), + }), + ), + Effect.provide(Layer.succeed(GitHubCli, gitHubCli)), + ), + ); const managerLayer = Layer.mergeAll( - Layer.succeed(GitHubCli, gitHubCli), Layer.succeed(TextGeneration, textGeneration), Layer.succeed( ProjectSetupScriptRunner, @@ -653,9 +661,9 @@ function makeManager(input?: { runForThread: () => Effect.succeed({ status: "no-script" as const }), }, ), - gitCoreLayer, + vcsDriverLayer, serverSettingsLayer, - ).pipe(Layer.provideMerge(NodeServices.layer)); + ).pipe(Layer.provideMerge(sourceControlRegistryLayer), Layer.provideMerge(NodeServices.layer)); return makeGitManager().pipe( Effect.provide(managerLayer), @@ -665,8 +673,9 @@ function makeManager(input?: { const asThreadId = (threadId: string) => threadId as ThreadId; -const GitManagerTestLayer = GitCoreLive.pipe( +const GitManagerTestLayer = GitVcsDriver.layer.pipe( Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })), + Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(NodeServices.layer), ); @@ -698,15 +707,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", }); }), @@ -743,8 +752,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", }); }), @@ -794,8 +803,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", }); }), @@ -843,8 +852,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", }); }), @@ -859,9 +868,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: [], @@ -888,9 +897,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: [], @@ -972,7 +981,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(); }), ); @@ -1026,13 +1035,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( @@ -1126,13 +1135,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( @@ -1176,13 +1185,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", }); }), @@ -1213,7 +1222,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(); }), ); @@ -1253,13 +1262,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", }); }), @@ -1284,7 +1293,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(); }), ); @@ -1708,6 +1717,49 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("create_pr falls back to main when source control provider detection fails", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/provider-fallback"]); + fs.writeFileSync(path.join(repoDir, "provider-fallback.txt"), "fallback\n"); + yield* runGit(repoDir, ["add", "provider-fallback.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Provider fallback"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + "[]", + JSON.stringify([ + { + number: 404, + title: "Provider fallback", + url: "https://github.com/pingdotgg/codething-mvp/pull/404", + baseRefName: "main", + headRefName: "feature/provider-fallback", + }, + ]), + ], + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "create_pr", + }); + + expect(result.pr.status).toBe("created"); + expect(result.pr.number).toBe(404); + expect( + ghCalls.some((call) => + call.includes("pr create --base main --head feature/provider-fallback"), + ), + ).toBe(true); + }), + ); + it.effect("returns existing PR metadata for commit/push/pr action", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -3119,7 +3171,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/GitManager.ts similarity index 85% rename from apps/server/src/git/Layers/GitManager.ts rename to apps/server/src/git/GitManager.ts index 21f3411d1e5..abebc70d02b 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1,55 +1,96 @@ import { randomUUID } from "node:crypto"; -import { realpathSync } from "node:fs"; import { + Array as Arr, Cache, + Context, + DateTime, Duration, Effect, Exit, FileSystem, Layer, Option, + Order, Path, Ref, - Result, } from "effect"; import { GitActionProgressEvent, GitActionProgressPhase, GitCommandError, + GitPreparePullRequestThreadInput, + GitPreparePullRequestThreadResult, + GitPullRequestRefInput, + GitResolvePullRequestResult, + GitRunStackedActionInput, GitRunStackedActionResult, GitStackedAction, - type GitStatusLocalResult, - type GitStatusRemoteResult, + VcsStatusInput, + type VcsStatusLocalResult, + type VcsStatusRemoteResult, + VcsStatusResult, ModelSelection, } from "@t3tools/contracts"; import { - detectGitHostingProviderFromRemoteUrl, + detectSourceControlProviderFromGitRemoteUrl, mergeGitStatusParts, resolveAutoFeatureBranchName, sanitizeBranchFragment, sanitizeFeatureBranchName, } from "@t3tools/shared/git"; +import { + getChangeRequestTerminologyForKind, + type ChangeRequestTerminology, +} from "@t3tools/shared/sourceControl"; import { GitManagerError } from "@t3tools/contracts"; -import { - GitManager, - type GitActionProgressReporter, - type GitManagerShape, - type GitRunStackedActionOptions, -} from "../Services/GitManager.ts"; -import { GitCore } from "../Services/GitCore.ts"; -import type { GitStatusDetails } from "../Services/GitCore.ts"; -import { GitHubCli, type GitHubPullRequestSummary } from "../Services/GitHubCli.ts"; -import { TextGeneration } from "../Services/TextGeneration.ts"; -import { ProjectSetupScriptRunner } from "../../project/Services/ProjectSetupScriptRunner.ts"; -import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; +import { TextGeneration } from "../textGeneration/TextGeneration.ts"; +import { ProjectSetupScriptRunner } from "../project/Services/ProjectSetupScriptRunner.ts"; +import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; -import { - 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"; + +export interface GitActionProgressReporter { + readonly publish: (event: GitActionProgressEvent) => Effect.Effect; +} + +export interface GitRunStackedActionOptions { + readonly actionId?: string; + readonly progressReporter?: GitActionProgressReporter; +} + +export interface GitManagerShape { + readonly status: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly localStatus: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly remoteStatus: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; + readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; + readonly invalidateStatus: (cwd: string) => Effect.Effect; + readonly resolvePullRequest: ( + input: GitPullRequestRefInput, + ) => Effect.Effect; + readonly preparePullRequestThread: ( + input: GitPreparePullRequestThreadInput, + ) => Effect.Effect; + readonly runStackedAction: ( + input: GitRunStackedActionInput, + options?: GitRunStackedActionOptions, + ) => Effect.Effect; +} + +export class GitManager extends Context.Service()( + "t3/git/GitManager", +) {} const COMMIT_TIMEOUT_MS = 10 * 60_000; const MAX_PROGRESS_TEXT_LENGTH = 500; @@ -75,9 +116,14 @@ interface OpenPrInfo { interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { state: "open" | "closed" | "merged"; - updatedAt: string | null; + updatedAt: Option.Option; } +const pullRequestUpdatedAtDescOrder: Order.Order = Order.mapInput( + Order.flip(Option.makeOrder(DateTime.Order)), + (pullRequest) => pullRequest.updatedAt, +); + interface ResolvedPullRequest { number: number; title: string; @@ -88,9 +134,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 { @@ -256,7 +302,7 @@ function matchesBranchHeadContext( return true; } -function toPullRequestInfo(summary: GitHubPullRequestSummary): PullRequestInfo { +function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { return { number: summary.number, title: summary.title, @@ -264,7 +310,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 } : {}), @@ -311,13 +357,14 @@ function withDescription(title: string, description: string | undefined) { function summarizeGitActionResult( result: Pick, + terms: ChangeRequestTerminology, ): { title: string; description?: string; } { if (result.pr.status === "created" || result.pr.status === "opened_existing") { const prNumber = result.pr.number ? ` #${result.pr.number}` : ""; - const title = `${result.pr.status === "created" ? "Created PR" : "Opened PR"}${prNumber}`; + const title = `${result.pr.status === "created" ? "Created" : "Opened"} ${terms.shortLabel}${prNumber}`; return withDescription(title, truncateText(result.pr.title)); } @@ -422,16 +469,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, }; } @@ -442,14 +489,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; @@ -475,9 +514,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,10 +530,12 @@ function toPullRequestHeadRemoteInfo(pr: { } export const makeGitManager = Effect.fn("makeGitManager")(function* () { - const gitCore = yield* GitCore; - const gitHubCli = yield* GitHubCli; + const gitCore = yield* GitVcsDriver; + const sourceControlProviders = yield* SourceControlProviderRegistry; const textGeneration = yield* TextGeneration; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; + + const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); const serverSettingsService = yield* ServerSettingsService; const createProgressEmitter = ( @@ -531,7 +572,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { return; } - const cloneUrls = yield* gitHubCli.getRepositoryCloneUrls({ + const cloneUrls = yield* (yield* sourceControlProvider(cwd)).getRepositoryCloneUrls({ cwd, repository: repositoryNameWithOwner, }); @@ -586,7 +627,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { return; } - const cloneUrls = yield* gitHubCli.getRepositoryCloneUrls({ + const cloneUrls = yield* (yield* sourceControlProvider(cwd)).getRepositoryCloneUrls({ cwd, repository: repositoryNameWithOwner, }); @@ -635,7 +676,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, @@ -660,20 +703,22 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { return { isRepo: details.isRepo, - ...(hostingProvider ? { hostingProvider } : {}), - hasOriginRemote: details.hasOriginRemote, - isDefaultBranch: details.isDefaultBranch, - branch: details.branch, + ...(hostingProvider ? { sourceControlProvider: hostingProvider } : {}), + 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, 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) @@ -704,14 +749,16 @@ 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, 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))); @@ -728,7 +775,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ?? (yield* readConfigValueNullable(cwd, "remote.origin.url")); - return remoteUrl ? detectGitHostingProviderFromRemoteUrl(remoteUrl) : null; + return remoteUrl ? detectSourceControlProviderFromGitRemoteUrl(remoteUrl) : null; }); const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* ( @@ -833,9 +880,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); @@ -851,7 +899,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { baseRefName: firstPullRequest.baseRefName, headRefName: firstPullRequest.headRefName, state: "open", - updatedAt: null, + updatedAt: Option.none(), } satisfies PullRequestInfo; } } @@ -867,46 +915,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,11 +930,7 @@ 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; - return right - left; - }); + const parsed = Arr.sort(parsedByNumber.values(), pullRequestUpdatedAtDescOrder); const latestOpenPr = parsed.find((pr) => pr.state === "open"); if (latestOpenPr) { @@ -931,7 +943,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) => getChangeRequestTerminologyForKind(provider.kind)), + Effect.catch(() => Effect.succeed(getChangeRequestTerminologyForKind("unknown"))), + ); + const summary = summarizeGitActionResult(result, terms); let latestOpenPr: PullRequestInfo | null = null; let currentBranchIsDefault = false; let finalBranchContext: { @@ -996,7 +1012,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") && @@ -1004,7 +1020,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 }, } : { @@ -1035,11 +1051,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { } } - const defaultFromGh = yield* gitHubCli - .getDefaultBranch({ cwd }) - .pipe(Effect.catch(() => Effect.succeed(null))); - if (defaultFromGh) { - return defaultFromGh; + const defaultFromProvider = yield* sourceControlProvider(cwd).pipe( + Effect.flatMap((provider) => provider.getDefaultBranch({ cwd })), + Effect.catch(() => Effect.succeed(null)), + ); + if (defaultFromProvider) { + return defaultFromProvider; } return "main"; @@ -1211,6 +1228,8 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { fallbackBranch: string | null, emit: GitActionProgressEmitter, ) { + const provider = yield* sourceControlProvider(cwd); + const terms = getChangeRequestTerminologyForKind(provider.kind); const details = yield* gitCore.statusDetails(cwd); const branch = details.branch ?? fallbackBranch; if (!branch) { @@ -1247,7 +1266,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); @@ -1272,12 +1291,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { yield* emit({ kind: "phase_started", phase: "pr", - label: "Creating GitHub pull request...", + label: `Creating ${terms.singular}...`, }); - yield* gitHubCli - .createPullRequest({ + yield* provider + .createChangeRequest({ cwd, - baseBranch, + baseRefName: baseBranch, headSelector: headContext.preferredHeadSelector, title: generated.title, bodyFile, @@ -1305,11 +1324,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) { @@ -1335,8 +1356,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,15 +1390,15 @@ 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 rootWorktreePath = yield* canonicalizeExistingPath(input.cwd); + const pullRequestSummary = yield* (yield* sourceControlProvider(input.cwd)).getChangeRequest({ cwd: input.cwd, reference: normalizedReference, }); const pullRequest = toResolvedPullRequest(pullRequestSummary); if (input.mode === "local") { - yield* gitHubCli.checkoutPullRequest({ + yield* (yield* sourceControlProvider(input.cwd)).checkoutChangeRequest({ cwd: input.cwd, reference: normalizedReference, force: true, @@ -1419,33 +1440,35 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const localPullRequestBranch = resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo); - const findLocalHeadBranch = (cwd: string) => - gitCore.listBranches({ cwd }).pipe( - Effect.map((result) => { - const localBranch = result.branches.find( - (branch) => !branch.isRemote && branch.name === localPullRequestBranch, - ); - if (localBranch) { - return localBranch; - } - if (localPullRequestBranch === pullRequest.headBranch) { - return null; - } - return ( - result.branches.find( - (branch) => - !branch.isRemote && - branch.name === pullRequest.headBranch && - branch.worktreePath !== null && - canonicalizeExistingPath(branch.worktreePath) !== rootWorktreePath, - ) ?? null - ); - }), + const 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 && @@ -1473,7 +1496,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 && @@ -1495,7 +1518,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); @@ -1503,7 +1526,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))); @@ -1535,8 +1558,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 }, @@ -1639,6 +1662,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const currentBranch = branchStep.name ?? initialStatus.branch; const commitAction = isCommitAction(input.action) ? input.action : null; + const changeRequestTerms = wantsPr + ? yield* sourceControlProvider(input.cwd).pipe( + Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)), + Effect.catch(() => Effect.succeed(getChangeRequestTerminologyForKind("unknown"))), + ) + : null; const commit = commitAction ? yield* Ref.set(currentPhase, Option.some("commit")).pipe( @@ -1676,7 +1705,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"))), @@ -1737,4 +1766,4 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { } satisfies GitManagerShape; }); -export const GitManagerLive = Layer.effect(GitManager, makeGitManager()); +export const layer = Layer.effect(GitManager, makeGitManager()); diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts new file mode 100644 index 00000000000..1357ae249a9 --- /dev/null +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -0,0 +1,132 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { describe, vi } from "vitest"; + +import { GitManager } from "./GitManager.ts"; +import { GitWorkflowService, layer as GitWorkflowServiceLayer } from "./GitWorkflowService.ts"; +import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; +import { VcsDriverRegistry, type VcsDriverRegistryShape } from "../vcs/VcsDriverRegistry.ts"; + +function makeLayer(input: { readonly detect: VcsDriverRegistryShape["detect"] }) { + return GitWorkflowServiceLayer.pipe( + Layer.provide( + Layer.mock(VcsDriverRegistry)({ + detect: input.detect, + }), + ), + Layer.provide(Layer.mock(GitVcsDriver)({})), + Layer.provide(Layer.mock(GitManager)({})), + ); +} + +describe("GitWorkflowService", () => { + it.effect("returns an empty local status when no VCS repository is detected", () => + Effect.gen(function* () { + const workflow = yield* GitWorkflowService; + const status = yield* workflow.localStatus({ cwd: "/not-a-repo" }); + + assert.deepStrictEqual(status, { + isRepo: false, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + }); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => Effect.succeed(null), + }), + ), + ), + ); + + it.effect("returns an empty full status when no VCS repository is detected", () => + Effect.gen(function* () { + const workflow = yield* GitWorkflowService; + const status = yield* workflow.status({ cwd: "/not-a-repo" }); + + assert.deepStrictEqual(status, { + isRepo: false, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, + }); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => Effect.succeed(null), + }), + ), + ), + ); + + it.effect("does not call GitManager status methods when no VCS repository is detected", () => { + const localStatus = vi.fn(); + const remoteStatus = vi.fn(); + const status = vi.fn(); + + const testLayer = GitWorkflowServiceLayer.pipe( + Layer.provide( + Layer.mock(VcsDriverRegistry)({ + detect: () => Effect.succeed(null), + }), + ), + Layer.provide(Layer.mock(GitVcsDriver)({})), + Layer.provide( + Layer.mock(GitManager)({ + localStatus, + remoteStatus, + status, + }), + ), + ); + + return Effect.gen(function* () { + const workflow = yield* GitWorkflowService; + yield* workflow.localStatus({ cwd: "/not-a-repo" }); + yield* workflow.remoteStatus({ cwd: "/not-a-repo" }); + yield* workflow.status({ cwd: "/not-a-repo" }); + + assert.equal(localStatus.mock.calls.length, 0); + assert.equal(remoteStatus.mock.calls.length, 0); + assert.equal(status.mock.calls.length, 0); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("returns an empty ref list when no VCS repository is detected", () => + Effect.gen(function* () { + const workflow = yield* GitWorkflowService; + const refs = yield* workflow.listRefs({ cwd: "/not-a-repo" }); + + assert.deepStrictEqual(refs, { + refs: [], + isRepo: false, + hasPrimaryRemote: false, + nextCursor: null, + totalCount: 0, + }); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => Effect.succeed(null), + }), + ), + ), + ); +}); diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts new file mode 100644 index 00000000000..99f0cc2a6d3 --- /dev/null +++ b/apps/server/src/git/GitWorkflowService.ts @@ -0,0 +1,313 @@ +import { Context, Effect, Layer } from "effect"; + +import { + GitManagerError, + GitCommandError, + type VcsSwitchRefInput, + type VcsSwitchRefResult, + type VcsCreateRefInput, + type VcsCreateRefResult, + type VcsCreateWorktreeInput, + type VcsCreateWorktreeResult, + type VcsListRefsInput, + type VcsListRefsResult, + type GitManagerServiceError, + type GitPreparePullRequestThreadInput, + type GitPreparePullRequestThreadResult, + type GitPullRequestRefInput, + type VcsPullResult, + type VcsRemoveWorktreeInput, + type GitResolvePullRequestResult, + type GitRunStackedActionInput, + type GitRunStackedActionResult, + type VcsStatusInput, + type VcsStatusLocalResult, + type VcsStatusRemoteResult, + type VcsStatusResult, +} from "@t3tools/contracts"; + +import { GitManager, type GitRunStackedActionOptions } from "./GitManager.ts"; +import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; +import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; + +export interface GitWorkflowServiceShape { + readonly status: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly localStatus: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly remoteStatus: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; + readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; + readonly invalidateStatus: (cwd: string) => Effect.Effect; + readonly pullCurrentBranch: (cwd: string) => Effect.Effect; + readonly runStackedAction: ( + input: GitRunStackedActionInput, + options?: GitRunStackedActionOptions, + ) => Effect.Effect; + readonly resolvePullRequest: ( + input: GitPullRequestRefInput, + ) => Effect.Effect; + readonly preparePullRequestThread: ( + input: GitPreparePullRequestThreadInput, + ) => Effect.Effect; + readonly listRefs: (input: VcsListRefsInput) => Effect.Effect; + readonly createWorktree: ( + input: VcsCreateWorktreeInput, + ) => Effect.Effect; + readonly removeWorktree: (input: VcsRemoveWorktreeInput) => Effect.Effect; + readonly createRef: ( + input: VcsCreateRefInput, + ) => Effect.Effect; + readonly switchRef: ( + input: VcsSwitchRefInput, + ) => Effect.Effect; + readonly renameBranch: (input: { + readonly cwd: string; + readonly oldBranch: string; + readonly newBranch: string; + }) => Effect.Effect<{ readonly branch: string }, GitManagerServiceError>; +} + +export class GitWorkflowService extends Context.Service< + GitWorkflowService, + GitWorkflowServiceShape +>()("t3/git/GitWorkflowService") {} + +const unsupportedGitWorkflow = (operation: string, cwd: string, detail: string) => + new GitManagerError({ + operation, + detail: `${detail} (${cwd})`, + }); + +const unsupportedGitCommand = (operation: string, cwd: string, detail: string) => + new GitCommandError({ + operation, + command: "vcs-route", + cwd, + detail, + }); + +function nonRepositoryLocalStatus(): VcsStatusLocalResult { + return { + isRepo: false, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + }; +} + +function nonRepositoryStatus(): VcsStatusResult { + return { + ...nonRepositoryLocalStatus(), + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, + }; +} + +function nonRepositoryListRefs(): VcsListRefsResult { + return { + refs: [], + isRepo: false, + hasPrimaryRemote: false, + nextCursor: null, + totalCount: 0, + }; +} + +export const make = Effect.fn("makeGitWorkflowService")(function* () { + const registry = yield* VcsDriverRegistry; + const git = yield* GitVcsDriver; + const gitManager = yield* GitManager; + + const ensureGit = Effect.fn("GitWorkflowService.ensureGit")(function* ( + operation: string, + cwd: string, + ) { + const handle = yield* registry + .resolve({ cwd }) + .pipe( + Effect.mapError((error) => + unsupportedGitWorkflow( + operation, + cwd, + error instanceof Error ? error.message : String(error), + ), + ), + ); + if (handle.kind !== "git") { + return yield* unsupportedGitWorkflow( + operation, + cwd, + `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}.`, + ); + } + }); + + const ensureGitCommand = Effect.fn("GitWorkflowService.ensureGitCommand")(function* ( + operation: string, + cwd: string, + ) { + const handle = yield* registry + .resolve({ cwd }) + .pipe( + Effect.mapError((error) => + unsupportedGitCommand( + operation, + cwd, + error instanceof Error ? error.message : String(error), + ), + ), + ); + if (handle.kind !== "git") { + return yield* unsupportedGitCommand( + operation, + cwd, + `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, + ); + } + }); + + const detectGitRepositoryForStatus = Effect.fn("GitWorkflowService.detectGitRepositoryForStatus")( + function* (operation: string, cwd: string) { + const handle = yield* registry + .detect({ cwd }) + .pipe( + Effect.mapError((error) => + unsupportedGitWorkflow( + operation, + cwd, + error instanceof Error ? error.message : String(error), + ), + ), + ); + if (!handle) { + return false; + } + if (handle.kind !== "git") { + return yield* unsupportedGitWorkflow( + operation, + cwd, + `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}.`, + ); + } + return true; + }, + ); + + const detectGitRepositoryForCommand = Effect.fn( + "GitWorkflowService.detectGitRepositoryForCommand", + )(function* (operation: string, cwd: string) { + const handle = yield* registry + .detect({ cwd }) + .pipe( + Effect.mapError((error) => + unsupportedGitCommand( + operation, + cwd, + error instanceof Error ? error.message : String(error), + ), + ), + ); + if (!handle) { + return false; + } + if (handle.kind !== "git") { + return yield* unsupportedGitCommand( + operation, + cwd, + `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, + ); + } + return true; + }); + + const routeGitManager = + ( + operation: string, + run: (input: Input) => Effect.Effect, + ) => + (input: Input) => + ensureGit(operation, input.cwd).pipe(Effect.andThen(run(input))); + + return GitWorkflowService.of({ + status: (input) => + detectGitRepositoryForStatus("GitWorkflowService.status", input.cwd).pipe( + Effect.flatMap((isGitRepository) => + isGitRepository ? gitManager.status(input) : Effect.succeed(nonRepositoryStatus()), + ), + ), + localStatus: (input) => + detectGitRepositoryForStatus("GitWorkflowService.localStatus", input.cwd).pipe( + Effect.flatMap((isGitRepository) => + isGitRepository + ? gitManager.localStatus(input) + : Effect.succeed(nonRepositoryLocalStatus()), + ), + ), + remoteStatus: (input) => + detectGitRepositoryForStatus("GitWorkflowService.remoteStatus", input.cwd).pipe( + Effect.flatMap((isGitRepository) => + isGitRepository ? gitManager.remoteStatus(input) : Effect.succeed(null), + ), + ), + invalidateLocalStatus: gitManager.invalidateLocalStatus, + invalidateRemoteStatus: gitManager.invalidateRemoteStatus, + invalidateStatus: gitManager.invalidateStatus, + pullCurrentBranch: (cwd) => + ensureGitCommand("GitWorkflowService.pullCurrentBranch", cwd).pipe( + Effect.andThen(git.pullCurrentBranch(cwd)), + ), + runStackedAction: (input, options) => + ensureGit("GitWorkflowService.runStackedAction", input.cwd).pipe( + Effect.andThen(gitManager.runStackedAction(input, options)), + ), + resolvePullRequest: routeGitManager( + "GitWorkflowService.resolvePullRequest", + gitManager.resolvePullRequest, + ), + preparePullRequestThread: routeGitManager( + "GitWorkflowService.preparePullRequestThread", + gitManager.preparePullRequestThread, + ), + listRefs: (input) => + detectGitRepositoryForCommand("GitWorkflowService.listRefs", input.cwd).pipe( + Effect.flatMap((isGitRepository) => + isGitRepository ? git.listRefs(input) : Effect.succeed(nonRepositoryListRefs()), + ), + ), + createWorktree: (input) => + ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe( + Effect.andThen(git.createWorktree(input)), + ), + removeWorktree: (input) => + ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe( + Effect.andThen(git.removeWorktree(input)), + ), + createRef: (input) => + ensureGitCommand("GitWorkflowService.createRef", input.cwd).pipe( + Effect.andThen(git.createRef(input)), + ), + switchRef: (input) => + ensureGitCommand("GitWorkflowService.switchRef", input.cwd).pipe( + Effect.andThen(Effect.scoped(git.switchRef(input))), + ), + renameBranch: (input) => + ensureGit("GitWorkflowService.renameBranch", input.cwd).pipe( + Effect.andThen(git.renameBranch(input)), + ), + }); +}); + +export const layer = Layer.effect(GitWorkflowService, make()); diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts deleted file mode 100644 index 665c4b138f9..00000000000 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ /dev/null @@ -1,2333 +0,0 @@ -import { existsSync } from "node:fs"; -import path from "node:path"; - -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; -import { describe, expect, vi } from "vitest"; - -import { GitCoreLive, makeGitCore } from "./GitCore.ts"; -import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; -import { GitCommandError } from "@t3tools/contracts"; -import { type ProcessRunResult, runProcess } from "../../processRunner.ts"; -import { ServerConfig } from "../../config.ts"; - -// ── Helpers ── - -const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-core-test-" }); -const GitCoreTestLayer = GitCoreLive.pipe( - Layer.provide(ServerConfigLayer), - Layer.provide(NodeServices.layer), -); -const TestLayer = Layer.mergeAll(NodeServices.layer, GitCoreTestLayer); - -function makeTmpDir( - prefix = "git-test-", -): Effect.Effect { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - return yield* fileSystem.makeTempDirectoryScoped({ prefix }); - }); -} - -function writeTextFile( - filePath: string, - contents: string, -): Effect.Effect { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - yield* fileSystem.writeFileString(filePath, contents); - }); -} - -function removePath( - targetPath: string, -): Effect.Effect { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - yield* fileSystem.remove(targetPath, { recursive: true, force: true }); - }); -} - -function makeDirectory( - dirPath: string, -): Effect.Effect { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - yield* fileSystem.makeDirectory(dirPath, { recursive: true }); - }); -} - -/** Run a raw git command for test setup (not under test). */ -function git( - cwd: string, - args: ReadonlyArray, - env?: NodeJS.ProcessEnv, -): Effect.Effect { - return Effect.gen(function* () { - const gitCore = yield* GitCore; - const result = yield* gitCore.execute({ - operation: "GitCore.test.git", - cwd, - args, - ...(env ? { env } : {}), - timeoutMs: 10_000, - }); - return result.stdout.trim(); - }); -} - -function configureRemote( - cwd: string, - remoteName: string, - remotePath: string, - fetchNamespace: string, -): Effect.Effect { - return Effect.gen(function* () { - yield* git(cwd, ["config", `remote.${remoteName}.url`, remotePath]); - return yield* git(cwd, [ - "config", - "--replace-all", - `remote.${remoteName}.fetch`, - `+refs/heads/*:refs/remotes/${fetchNamespace}/*`, - ]); - }); -} - -function runShellCommand(input: { - command: string; - cwd: string; - timeoutMs?: number; - maxOutputBytes?: number; -}): Effect.Effect { - return Effect.promise(() => { - const shellPath = - process.platform === "win32" - ? (process.env.ComSpec ?? "cmd.exe") - : (process.env.SHELL ?? "/bin/sh"); - - const args = - process.platform === "win32" ? ["/d", "/s", "/c", input.command] : ["-lc", input.command]; - - return runProcess(shellPath, args, { - cwd: input.cwd, - timeoutMs: input.timeoutMs ?? 30_000, - allowNonZeroExit: true, - maxBufferBytes: input.maxOutputBytes ?? 1_000_000, - outputMode: "truncate", - }); - }); -} - -const makeIsolatedGitCore = (executeOverride: GitCoreShape["execute"]) => - makeGitCore({ executeOverride }).pipe( - Effect.provide(Layer.provideMerge(ServerConfigLayer, NodeServices.layer)), - ); - -/** Create a repo with an initial commit so branches work. */ -function initRepoWithCommit( - cwd: string, -): Effect.Effect< - { initialBranch: string }, - GitCommandError | PlatformError.PlatformError, - GitCore | FileSystem.FileSystem -> { - return Effect.gen(function* () { - const core = yield* GitCore; - yield* core.initRepo({ cwd }); - yield* git(cwd, ["config", "user.email", "test@test.com"]); - yield* git(cwd, ["config", "user.name", "Test"]); - yield* writeTextFile(path.join(cwd, "README.md"), "# test\n"); - yield* git(cwd, ["add", "."]); - yield* git(cwd, ["commit", "-m", "initial commit"]); - const initialBranch = yield* git(cwd, ["branch", "--show-current"]); - return { initialBranch }; - }); -} - -function commitWithDate( - cwd: string, - fileName: string, - fileContents: string, - dateIsoString: string, - message: string, -): Effect.Effect< - void, - GitCommandError | PlatformError.PlatformError, - GitCore | FileSystem.FileSystem -> { - return Effect.gen(function* () { - yield* writeTextFile(path.join(cwd, fileName), fileContents); - yield* git(cwd, ["add", fileName]); - yield* git(cwd, ["commit", "-m", message], { - ...process.env, - GIT_AUTHOR_DATE: dateIsoString, - GIT_COMMITTER_DATE: dateIsoString, - }); - }); -} - -function buildLargeText(lineCount = 20_000): string { - return Array.from({ length: lineCount }, (_, index) => `line ${String(index).padStart(5, "0")}`) - .join("\n") - .concat("\n"); -} - -function splitNullSeparatedPaths(input: string): string[] { - return input - .split("\0") - .map((value) => value.trim()) - .filter((value) => value.length > 0); -} - -// ── Tests ── - -it.layer(TestLayer)("git integration", (it) => { - describe("shell process execution", () => { - it.effect("caps captured output when maxOutputBytes is exceeded", () => - Effect.gen(function* () { - const result = yield* runShellCommand({ - command: `node -e "process.stdout.write('x'.repeat(2000))"`, - cwd: process.cwd(), - timeoutMs: 10_000, - maxOutputBytes: 128, - }); - - expect(result.code).toBe(0); - expect(result.stdout.length).toBeLessThanOrEqual(128); - expect(result.stdoutTruncated || result.stderrTruncated).toBe(true); - }), - ); - }); - - // ── initGitRepo ── - - describe("initGitRepo", () => { - it.effect("creates a valid git repo", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* (yield* GitCore).initRepo({ cwd: tmp }); - expect(existsSync(path.join(tmp, ".git"))).toBe(true); - }), - ); - - it.effect("listGitBranches reports isRepo: true after init + commit", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(result.isRepo).toBe(true); - expect(result.hasOriginRemote).toBe(false); - expect(result.branches.length).toBeGreaterThanOrEqual(1); - }), - ); - }); - - describe("workspace helpers", () => { - it.effect("filterIgnoredPaths chunks large path lists and preserves kept paths", () => - Effect.gen(function* () { - const cwd = "/virtual/repo"; - const relativePaths = Array.from({ length: 340 }, (_, index) => { - const prefix = index % 3 === 0 ? "ignored" : "kept"; - return `${prefix}/segment-${String(index).padStart(4, "0")}/${"x".repeat(900)}.ts`; - }); - const expectedPaths = relativePaths.filter( - (relativePath) => !relativePath.startsWith("ignored/"), - ); - - const seenChunks: string[][] = []; - const core = yield* makeIsolatedGitCore((input) => { - if ( - input.args.join(" ") !== - "-c core.fsmonitor=false -c core.untrackedCache=false check-ignore --no-index -z --stdin" - ) { - return Effect.fail( - new GitCommandError({ - operation: input.operation, - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "unexpected git command in chunking test", - }), - ); - } - - const chunkPaths = splitNullSeparatedPaths(input.stdin ?? ""); - seenChunks.push(chunkPaths); - const ignoredPaths = chunkPaths.filter((relativePath) => - relativePath.startsWith("ignored/"), - ); - - return Effect.succeed({ - code: ignoredPaths.length > 0 ? 0 : 1, - stdout: ignoredPaths.length > 0 ? `${ignoredPaths.join("\0")}\0` : "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }); - }); - - const result = yield* core.filterIgnoredPaths(cwd, relativePaths); - - expect(seenChunks.length).toBeGreaterThan(1); - expect(seenChunks.flat()).toEqual(relativePaths); - expect(result).toEqual(expectedPaths); - }), - ); - - it.effect("listWorkspaceFiles disables fsmonitor and untracked cache helpers", () => - Effect.gen(function* () { - const core = yield* makeIsolatedGitCore((input) => { - expect(input.args).toEqual([ - "-c", - "core.fsmonitor=false", - "-c", - "core.untrackedCache=false", - "ls-files", - "--cached", - "--others", - "--exclude-standard", - "-z", - ]); - return Effect.succeed({ - code: 0, - stdout: "src/index.ts\0README.md\0", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }); - }); - - const result = yield* core.listWorkspaceFiles("/virtual/repo"); - expect(result.paths).toEqual(["src/index.ts", "README.md"]); - expect(result.truncated).toBe(false); - }), - ); - }); - - // ── listGitBranches ── - - describe("listGitBranches", () => { - it.effect("returns isRepo: false for non-git directory", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(result.isRepo).toBe(false); - expect(result.hasOriginRemote).toBe(false); - expect(result.branches).toEqual([]); - }), - ); - - it.effect("returns isRepo: false for deleted directories", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const deletedDir = path.join(tmp, "deleted-repo"); - yield* makeDirectory(deletedDir); - yield* removePath(deletedDir); - - const result = yield* (yield* GitCore).listBranches({ cwd: deletedDir }); - - expect(result.isRepo).toBe(false); - expect(result.hasOriginRemote).toBe(false); - expect(result.branches).toEqual([]); - }), - ); - - it.effect("returns the current branch with current: true", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - const current = result.branches.find((b) => b.current); - expect(current).toBeDefined(); - expect(current!.current).toBe(true); - }), - ); - - it.effect("does not include detached HEAD pseudo-refs as branches", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* git(tmp, ["checkout", "--detach", "HEAD"]); - - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(result.branches.some((branch) => branch.name.startsWith("("))).toBe(false); - expect(result.branches.some((branch) => branch.current)).toBe(false); - }), - ); - - it.effect("keeps current branch first and sorts the remaining branches by recency", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (branch) => branch.current, - )!.name; - - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "older-branch" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "older-branch" }); - yield* commitWithDate( - tmp, - "older.txt", - "older branch change\n", - "Thu, 1 Jan 2037 00:00:00 +0000", - "older branch change", - ); - - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: initialBranch }); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "newer-branch" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "newer-branch" }); - yield* commitWithDate( - tmp, - "newer.txt", - "newer branch change\n", - "Fri, 1 Jan 2038 00:00:00 +0000", - "newer branch change", - ); - - // Switch away to show current branch is pinned, then remaining branches are recency-sorted. - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "older-branch" }); - - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(result.branches[0]!.name).toBe("older-branch"); - expect(result.branches[1]!.name).toBe("newer-branch"); - }), - ); - - it.effect("keeps default branch right after current branch", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const remote = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (branch) => branch.current, - )!.name; - - yield* git(remote, ["init", "--bare"]); - yield* git(tmp, ["remote", "add", "origin", remote]); - yield* git(tmp, ["push", "-u", "origin", defaultBranch]); - yield* git(tmp, ["remote", "set-head", "origin", defaultBranch]); - - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "current-branch" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "current-branch" }); - yield* commitWithDate( - tmp, - "current.txt", - "current change\n", - "Thu, 1 Jan 2037 00:00:00 +0000", - "current change", - ); - - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch }); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "newer-branch" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "newer-branch" }); - yield* commitWithDate( - tmp, - "newer.txt", - "newer change\n", - "Fri, 1 Jan 2038 00:00:00 +0000", - "newer change", - ); - - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "current-branch" }); - - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(result.branches[0]!.name).toBe("current-branch"); - expect(result.branches[1]!.name).toBe(defaultBranch); - expect(result.branches[2]!.name).toBe("newer-branch"); - }), - ); - - it.effect("lists multiple branches after creating them", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-a" }); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-b" }); - - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - const names = result.branches.map((b) => b.name); - expect(names).toContain("feature-a"); - expect(names).toContain("feature-b"); - }), - ); - - it.effect("paginates branch results and returns paging metadata", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-a" }); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-b" }); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-c" }); - - const firstPage = yield* (yield* GitCore).listBranches({ cwd: tmp, limit: 2 }); - expect(firstPage.totalCount).toBe(4); - expect(firstPage.nextCursor).toBe(2); - expect(firstPage.branches.map((branch) => branch.name)).toEqual([ - initialBranch, - "feature-a", - ]); - - const secondPage = yield* (yield* GitCore).listBranches({ - cwd: tmp, - cursor: firstPage.nextCursor ?? 0, - limit: 2, - }); - expect(secondPage.totalCount).toBe(4); - expect(secondPage.nextCursor).toBeNull(); - expect(secondPage.branches.map((branch) => branch.name)).toEqual([ - "feature-b", - "feature-c", - ]); - }), - ); - - it.effect("parses separate branch names when column.ui is always enabled", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(tmp); - const createdBranchNames = [ - "go-bin", - "copilot/rewrite-cli-in-go", - "copilot/rewrite-cli-in-rust", - ] as const; - for (const branchName of createdBranchNames) { - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: branchName }); - } - yield* git(tmp, ["config", "column.ui", "always"]); - - const rawBranchOutput = yield* git(tmp, ["branch", "--no-color"], { - ...process.env, - COLUMNS: "120", - }); - expect( - rawBranchOutput - .split("\n") - .some( - (line) => - createdBranchNames.filter((branchName) => line.includes(branchName)).length >= 2, - ), - ).toBe(true); - - const realGitCore = yield* GitCore; - const core = yield* makeIsolatedGitCore((input) => - realGitCore.execute( - input.args[0] === "branch" - ? { - ...input, - env: { ...input.env, COLUMNS: "120" }, - } - : input, - ), - ); - - const result = yield* core.listBranches({ cwd: tmp }); - const localBranchNames = result.branches - .filter((branch) => !branch.isRemote) - .map((branch) => branch.name); - - expect(localBranchNames).toHaveLength(4); - expect(localBranchNames).toEqual( - expect.arrayContaining([initialBranch, ...createdBranchNames]), - ); - expect( - localBranchNames.some( - (branchName) => - createdBranchNames.filter((createdBranch) => branchName.includes(createdBranch)) - .length >= 2, - ), - ).toBe(false); - }), - ); - - it.effect("isDefault is false when no remote exists", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(result.branches.every((b) => b.isDefault === false)).toBe(true); - }), - ); - - it.effect("lists local branches first and remote branches last", () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const tmp = yield* makeTmpDir(); - - yield* git(remote, ["init", "--bare"]); - yield* initRepoWithCommit(tmp); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (branch) => branch.current, - )!.name; - - yield* git(tmp, ["remote", "add", "origin", remote]); - yield* git(tmp, ["push", "-u", "origin", defaultBranch]); - - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/local-only" }); - - const remoteOnlyBranch = "feature/remote-only"; - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch }); - yield* git(tmp, ["checkout", "-b", remoteOnlyBranch]); - yield* git(tmp, ["push", "-u", "origin", remoteOnlyBranch]); - yield* git(tmp, ["checkout", defaultBranch]); - yield* git(tmp, ["branch", "-D", remoteOnlyBranch]); - - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - const firstRemoteIndex = result.branches.findIndex((branch) => branch.isRemote); - - expect(result.hasOriginRemote).toBe(true); - expect(firstRemoteIndex).toBeGreaterThan(0); - expect(result.branches.slice(0, firstRemoteIndex).every((branch) => !branch.isRemote)).toBe( - true, - ); - expect(result.branches.slice(firstRemoteIndex).every((branch) => branch.isRemote)).toBe( - true, - ); - expect( - result.branches.some( - (branch) => branch.name === "feature/local-only" && !branch.isRemote, - ), - ).toBe(true); - expect( - result.branches.some( - (branch) => branch.name === "origin/feature/remote-only" && branch.isRemote, - ), - ).toBe(true); - }), - ); - - it.effect("includes remoteName metadata for remotes with slash in the name", () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const tmp = yield* makeTmpDir(); - const remoteName = "my-org/upstream"; - - yield* git(remote, ["init", "--bare"]); - yield* initRepoWithCommit(tmp); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (branch) => branch.current, - )!.name; - - yield* git(tmp, ["remote", "add", remoteName, remote]); - yield* git(tmp, ["push", "-u", remoteName, defaultBranch]); - - const remoteOnlyBranch = "feature/remote-with-remote-name"; - yield* git(tmp, ["checkout", "-b", remoteOnlyBranch]); - yield* git(tmp, ["push", "-u", remoteName, remoteOnlyBranch]); - yield* git(tmp, ["checkout", defaultBranch]); - yield* git(tmp, ["branch", "-D", remoteOnlyBranch]); - - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - const remoteBranch = result.branches.find( - (branch) => branch.name === `${remoteName}/${remoteOnlyBranch}`, - ); - - expect(remoteBranch).toBeDefined(); - expect(remoteBranch?.isRemote).toBe(true); - expect(remoteBranch?.remoteName).toBe(remoteName); - }), - ); - - it.effect( - "filters branch queries before pagination and dedupes origin refs with local matches", - () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const tmp = yield* makeTmpDir(); - - yield* git(remote, ["init", "--bare"]); - const { initialBranch } = yield* initRepoWithCommit(tmp); - yield* git(tmp, ["remote", "add", "origin", remote]); - yield* git(tmp, ["push", "-u", "origin", initialBranch]); - - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/demo" }); - yield* git(tmp, ["push", "-u", "origin", "feature/demo"]); - - yield* git(tmp, ["checkout", "-b", "feature/remote-only"]); - yield* git(tmp, ["push", "-u", "origin", "feature/remote-only"]); - yield* git(tmp, ["checkout", initialBranch]); - yield* git(tmp, ["branch", "-D", "feature/remote-only"]); - - const result = yield* (yield* GitCore).listBranches({ - cwd: tmp, - query: "feature/", - limit: 10, - }); - - expect(result.totalCount).toBe(2); - expect(result.nextCursor).toBeNull(); - expect(result.branches.map((branch) => branch.name)).toEqual([ - "feature/demo", - "origin/feature/remote-only", - ]); - }), - ); - }); - - // ── checkoutGitBranch ── - - describe("checkoutGitBranch", () => { - it.effect("checks out an existing branch", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature" }); - - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature" }); - - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - const current = result.branches.find((b) => b.current); - expect(current!.name).toBe("feature"); - }), - ); - - it.effect("refreshes upstream behind count after checkout when remote branch advanced", () => - Effect.gen(function* () { - const context = yield* Effect.context(); - const runPromise = Effect.runPromiseWith(context); - - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - const clone = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); - - yield* initRepoWithCommit(source); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( - (branch) => branch.current, - )!.name; - yield* git(source, ["remote", "add", "origin", remote]); - yield* git(source, ["push", "-u", "origin", defaultBranch]); - - const featureBranch = "feature-behind"; - yield* (yield* GitCore).createBranch({ cwd: source, branch: featureBranch }); - yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch }); - yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n"); - yield* git(source, ["add", "feature.txt"]); - yield* git(source, ["commit", "-m", "feature base"]); - yield* git(source, ["push", "-u", "origin", featureBranch]); - yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: defaultBranch }); - - yield* git(clone, ["clone", remote, "."]); - yield* git(clone, ["config", "user.email", "test@test.com"]); - yield* git(clone, ["config", "user.name", "Test"]); - yield* git(clone, ["checkout", "-b", featureBranch, "--track", `origin/${featureBranch}`]); - yield* writeTextFile(path.join(clone, "feature.txt"), "feature from remote\n"); - yield* git(clone, ["add", "feature.txt"]); - yield* git(clone, ["commit", "-m", "remote feature update"]); - yield* git(clone, ["push", "origin", featureBranch]); - - yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch }); - const core = yield* GitCore; - yield* Effect.promise(() => - vi.waitFor( - async () => { - const details = await runPromise(core.statusDetails(source)); - expect(details.branch).toBe(featureBranch); - expect(details.aheadCount).toBe(0); - expect(details.behindCount).toBe(1); - }, - { - timeout: 10_000, - interval: 100, - }, - ), - ); - }), - ); - - it.effect("statusDetails remains successful when upstream refresh fails after checkout", () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); - - yield* initRepoWithCommit(source); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( - (branch) => branch.current, - )!.name; - yield* git(source, ["remote", "add", "origin", remote]); - yield* git(source, ["push", "-u", "origin", defaultBranch]); - - const featureBranch = "feature-refresh-failure"; - yield* git(source, ["branch", featureBranch]); - yield* git(source, ["checkout", featureBranch]); - yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n"); - yield* git(source, ["add", "feature.txt"]); - yield* git(source, ["commit", "-m", "feature base"]); - yield* git(source, ["push", "-u", "origin", featureBranch]); - yield* git(source, ["checkout", defaultBranch]); - - const realGitCore = yield* GitCore; - let refreshFetchAttempts = 0; - const core = yield* makeIsolatedGitCore((input) => { - if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { - refreshFetchAttempts += 1; - return Effect.fail( - new GitCommandError({ - operation: "git.test.refreshFailure", - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "simulated fetch timeout", - }), - ); - } - return realGitCore.execute(input); - }); - yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); - const status = yield* core.statusDetails(source); - expect(refreshFetchAttempts).toBe(1); - expect(status.branch).toBe(featureBranch); - expect(status.upstreamRef).toBe(`origin/${featureBranch}`); - expect(yield* git(source, ["branch", "--show-current"])).toBe(featureBranch); - }), - ); - - it.effect("defers upstream refresh until statusDetails is requested", () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); - - yield* initRepoWithCommit(source); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( - (branch) => branch.current, - )!.name; - yield* git(source, ["remote", "add", "origin", remote]); - yield* git(source, ["push", "-u", "origin", defaultBranch]); - - const featureBranch = "feature/scoped-fetch"; - yield* git(source, ["checkout", "-b", featureBranch]); - yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n"); - yield* git(source, ["add", "feature.txt"]); - yield* git(source, ["commit", "-m", "feature base"]); - yield* git(source, ["push", "-u", "origin", featureBranch]); - yield* git(source, ["checkout", defaultBranch]); - - const realGitCore = yield* GitCore; - let refreshFetchAttempts = 0; - const core = yield* makeIsolatedGitCore((input) => { - if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { - refreshFetchAttempts += 1; - return Effect.succeed({ - code: 0, - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }); - } - return realGitCore.execute(input); - }); - yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); - yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 50))); - expect(refreshFetchAttempts).toBe(0); - const status = yield* core.statusDetails(source); - expect(status.branch).toBe(featureBranch); - expect(refreshFetchAttempts).toBe(1); - }), - ); - - it.effect("coalesces upstream refreshes across sibling worktrees on the same remote", () => - Effect.gen(function* () { - const ok = (stdout = "") => - Effect.succeed({ - code: 0, - stdout, - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }); - - let fetchCount = 0; - const core = yield* makeIsolatedGitCore((input) => { - if ( - input.args[0] === "rev-parse" && - input.args[1] === "--abbrev-ref" && - input.args[2] === "--symbolic-full-name" && - input.args[3] === "@{upstream}" - ) { - return ok( - input.cwd === "/repo/worktrees/pr-123" ? "origin/feature/pr-123\n" : "origin/main\n", - ); - } - if (input.args[0] === "remote") { - return ok("origin\n"); - } - if (input.args[0] === "rev-parse" && input.args[1] === "--git-common-dir") { - return ok("/repo/.git\n"); - } - if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { - fetchCount += 1; - expect(input.cwd).toBe("/repo"); - expect(input.args).toEqual([ - "--git-dir", - "/repo/.git", - "fetch", - "--quiet", - "--no-tags", - "origin", - ]); - return ok(); - } - if (input.operation === "GitCore.statusDetails.status") { - return ok( - input.cwd === "/repo/worktrees/pr-123" - ? "# branch.head feature/pr-123\n# branch.upstream origin/feature/pr-123\n# branch.ab +0 -0\n" - : "# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n", - ); - } - if ( - input.operation === "GitCore.statusDetails.unstagedNumstat" || - input.operation === "GitCore.statusDetails.stagedNumstat" - ) { - return ok(); - } - if (input.operation === "GitCore.statusDetails.defaultRef") { - return ok("refs/remotes/origin/main\n"); - } - return Effect.fail( - new GitCommandError({ - operation: input.operation, - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "Unexpected git command in shared refresh cache test.", - }), - ); - }); - - yield* core.statusDetails("/repo/worktrees/main"); - yield* core.statusDetails("/repo/worktrees/pr-123"); - expect(fetchCount).toBe(1); - }), - ); - - it.effect( - "briefly backs off failed upstream refreshes across sibling worktrees on one remote", - () => - Effect.gen(function* () { - const ok = (stdout = "") => - Effect.succeed({ - code: 0, - stdout, - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }); - - let fetchCount = 0; - const core = yield* makeIsolatedGitCore((input) => { - if ( - input.args[0] === "rev-parse" && - input.args[1] === "--abbrev-ref" && - input.args[2] === "--symbolic-full-name" && - input.args[3] === "@{upstream}" - ) { - return ok( - input.cwd === "/repo/worktrees/pr-123" - ? "origin/feature/pr-123\n" - : "origin/main\n", - ); - } - if (input.args[0] === "remote") { - return ok("origin\n"); - } - if (input.args[0] === "rev-parse" && input.args[1] === "--git-common-dir") { - return ok("/repo/.git\n"); - } - if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { - fetchCount += 1; - return Effect.fail( - new GitCommandError({ - operation: input.operation, - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "simulated fetch timeout", - }), - ); - } - if (input.operation === "GitCore.statusDetails.status") { - return ok( - input.cwd === "/repo/worktrees/pr-123" - ? "# branch.head feature/pr-123\n# branch.upstream origin/feature/pr-123\n# branch.ab +0 -0\n" - : "# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n", - ); - } - if ( - input.operation === "GitCore.statusDetails.unstagedNumstat" || - input.operation === "GitCore.statusDetails.stagedNumstat" - ) { - return ok(); - } - if (input.operation === "GitCore.statusDetails.defaultRef") { - return ok("refs/remotes/origin/main\n"); - } - return Effect.fail( - new GitCommandError({ - operation: input.operation, - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "Unexpected git command in refresh failure cooldown test.", - }), - ); - }); - - yield* core.statusDetails("/repo/worktrees/main"); - yield* core.statusDetails("/repo/worktrees/pr-123"); - expect(fetchCount).toBe(1); - }), - ); - - it.effect("throws when branch does not exist", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const result = yield* Effect.result( - (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "nonexistent" }), - ); - expect(result._tag).toBe("Failure"); - }), - ); - - it.effect("does not silently checkout a local branch when a remote ref no longer exists", () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); - - yield* initRepoWithCommit(source); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( - (branch) => branch.current, - )!.name; - yield* git(source, ["remote", "add", "origin", remote]); - yield* git(source, ["push", "-u", "origin", defaultBranch]); - - yield* (yield* GitCore).createBranch({ cwd: source, branch: "feature" }); - - const checkoutResult = yield* Effect.result( - (yield* GitCore).checkoutBranch({ cwd: source, branch: "origin/feature" }), - ); - expect(checkoutResult._tag).toBe("Failure"); - expect(yield* git(source, ["branch", "--show-current"])).toBe(defaultBranch); - }), - ); - - it.effect("checks out a remote tracking branch when remote name contains slashes", () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const prefixRemote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - const prefixFetchNamespace = "prefix-my-org"; - const prefixRemoteName = "my-org"; - const remoteName = "my-org/upstream"; - const featureBranch = "feature"; - yield* git(remote, ["init", "--bare"]); - yield* git(prefixRemote, ["init", "--bare"]); - - yield* initRepoWithCommit(source); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( - (branch) => branch.current, - )!.name; - yield* configureRemote(source, prefixRemoteName, prefixRemote, prefixFetchNamespace); - yield* configureRemote(source, remoteName, remote, remoteName); - yield* git(source, ["push", "-u", remoteName, defaultBranch]); - - yield* git(source, ["checkout", "-b", featureBranch]); - yield* writeTextFile(path.join(source, "feature.txt"), "feature content\n"); - yield* git(source, ["add", "feature.txt"]); - yield* git(source, ["commit", "-m", "feature commit"]); - yield* git(source, ["push", "-u", remoteName, featureBranch]); - yield* git(source, ["checkout", defaultBranch]); - yield* git(source, ["branch", "-D", featureBranch]); - - const checkoutResult = yield* (yield* GitCore).checkoutBranch({ - cwd: source, - branch: `${remoteName}/${featureBranch}`, - }); - - expect(checkoutResult.branch).toBe("upstream/feature"); - expect(yield* git(source, ["branch", "--show-current"])).toBe("upstream/feature"); - const realGitCore = yield* GitCore; - let fetchArgs: readonly string[] | null = null; - const core = yield* makeIsolatedGitCore((input) => { - if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { - fetchArgs = [...input.args]; - return Effect.succeed({ - code: 0, - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }); - } - return realGitCore.execute(input); - }); - - const status = yield* core.statusDetails(source); - expect(status.branch).toBe("upstream/feature"); - expect(status.upstreamRef).toBe(`${remoteName}/${featureBranch}`); - expect(fetchArgs).toEqual([ - "--git-dir", - path.join(source, ".git"), - "fetch", - "--quiet", - "--no-tags", - remoteName, - ]); - }), - ); - - it.effect( - "falls back to detached checkout when --track would conflict with an existing local branch", - () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); - - yield* initRepoWithCommit(source); - const defaultBranch = (yield* (yield* GitCore).listBranches({ - cwd: source, - })).branches.find((branch) => branch.current)!.name; - yield* git(source, ["remote", "add", "origin", remote]); - yield* git(source, ["push", "-u", "origin", defaultBranch]); - - // Keep local branch but remove tracking so `--track origin/` - // would attempt to create an already-existing local branch. - yield* git(source, ["branch", "--unset-upstream"]); - - yield* (yield* GitCore).checkoutBranch({ - cwd: source, - branch: `origin/${defaultBranch}`, - }); - - const core = yield* GitCore; - const status = yield* core.statusDetails(source); - expect(status.branch).toBeNull(); - }), - ); - - it.effect("throws when checkout would overwrite uncommitted changes", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "other" }); - - // Create a conflicting change: modify README on current branch - yield* writeTextFile(path.join(tmp, "README.md"), "modified\n"); - yield* git(tmp, ["add", "README.md"]); - - // First, checkout other branch cleanly - yield* git(tmp, ["stash"]); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "other" }); - yield* writeTextFile(path.join(tmp, "README.md"), "other content\n"); - yield* git(tmp, ["add", "."]); - yield* git(tmp, ["commit", "-m", "other change"]); - - // Go back to default branch - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (b) => !b.current, - )!.name; - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch }); - - // Make uncommitted changes to the same file - yield* writeTextFile(path.join(tmp, "README.md"), "conflicting local\n"); - - // Checkout should fail due to uncommitted changes - const result = yield* Effect.result( - (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "other" }), - ); - expect(result._tag).toBe("Failure"); - }), - ); - }); - - // ── createGitBranch ── - - describe("createGitBranch", () => { - it.effect("creates a new branch visible in listGitBranches", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "new-feature" }); - - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(result.branches.some((b) => b.name === "new-feature")).toBe(true); - }), - ); - - it.effect("throws when branch already exists", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "dupe" }); - const result = yield* Effect.result( - (yield* GitCore).createBranch({ cwd: tmp, branch: "dupe" }), - ); - expect(result._tag).toBe("Failure"); - }), - ); - }); - - // ── renameGitBranch ── - - describe("renameGitBranch", () => { - it.effect("renames the current branch", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/old-name" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature/old-name" }); - - const renamed = yield* (yield* GitCore).renameBranch({ - cwd: tmp, - oldBranch: "feature/old-name", - newBranch: "feature/new-name", - }); - - expect(renamed.branch).toBe("feature/new-name"); - - const branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(branches.branches.some((branch) => branch.name === "feature/old-name")).toBe(false); - const current = branches.branches.find((branch) => branch.current); - expect(current?.name).toBe("feature/new-name"); - }), - ); - - it.effect("returns success without git invocation when old/new names match", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const current = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (b) => b.current, - )!; - - const renamed = yield* (yield* GitCore).renameBranch({ - cwd: tmp, - oldBranch: current.name, - newBranch: current.name, - }); - - expect(renamed.branch).toBe(current.name); - }), - ); - - it.effect("appends numeric suffix when target branch already exists", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/feat/session" }); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/tmp-working" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "t3code/tmp-working" }); - - const renamed = yield* (yield* GitCore).renameBranch({ - cwd: tmp, - oldBranch: "t3code/tmp-working", - newBranch: "t3code/feat/session", - }); - - expect(renamed.branch).toBe("t3code/feat/session-1"); - const branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(branches.branches.some((branch) => branch.name === "t3code/feat/session")).toBe( - true, - ); - expect(branches.branches.some((branch) => branch.name === "t3code/feat/session-1")).toBe( - true, - ); - const current = branches.branches.find((branch) => branch.current); - expect(current?.name).toBe("t3code/feat/session-1"); - }), - ); - - it.effect("increments suffix until it finds an available branch name", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/feat/session" }); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/feat/session-1" }); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/tmp-working" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "t3code/tmp-working" }); - - const renamed = yield* (yield* GitCore).renameBranch({ - cwd: tmp, - oldBranch: "t3code/tmp-working", - newBranch: "t3code/feat/session", - }); - - expect(renamed.branch).toBe("t3code/feat/session-2"); - }), - ); - - it.effect("uses '--' separator for branch rename arguments", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/old-name" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature/old-name" }); - - const realGitCore = yield* GitCore; - let renameArgs: ReadonlyArray | null = null; - const core = yield* makeIsolatedGitCore((input) => { - if (input.args[0] === "branch" && input.args[1] === "-m") { - renameArgs = [...input.args]; - } - return realGitCore.execute(input); - }); - - const renamed = yield* core.renameBranch({ - cwd: tmp, - oldBranch: "feature/old-name", - newBranch: "feature/new-name", - }); - - expect(renamed.branch).toBe("feature/new-name"); - expect(renameArgs).toEqual(["branch", "-m", "--", "feature/old-name", "feature/new-name"]); - }), - ); - }); - - // ── createGitWorktree + removeGitWorktree ── - - describe("createGitWorktree", () => { - it.effect("creates a worktree with a new branch from the base branch", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - - const wtPath = path.join(tmp, "worktree-out"); - const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (b) => b.current, - )!.name; - - const result = yield* (yield* GitCore).createWorktree({ - cwd: tmp, - branch: currentBranch, - newBranch: "wt-branch", - path: wtPath, - }); - - expect(result.worktree.path).toBe(wtPath); - expect(result.worktree.branch).toBe("wt-branch"); - expect(existsSync(wtPath)).toBe(true); - expect(existsSync(path.join(wtPath, "README.md"))).toBe(true); - - // Clean up worktree before tmp dir disposal - yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); - }), - ); - - it.effect("worktree has the new branch checked out", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - - const wtPath = path.join(tmp, "wt-check-dir"); - const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (b) => b.current, - )!.name; - - yield* (yield* GitCore).createWorktree({ - cwd: tmp, - branch: currentBranch, - newBranch: "wt-check", - path: wtPath, - }); - - // Verify the worktree is on the new branch - const branchOutput = yield* git(wtPath, ["branch", "--show-current"]); - expect(branchOutput).toBe("wt-check"); - - yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); - }), - ); - - it.effect("creates a worktree for an existing branch when newBranch is omitted", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/existing-worktree" }); - - const wtPath = path.join(tmp, "wt-existing"); - const result = yield* (yield* GitCore).createWorktree({ - cwd: tmp, - branch: "feature/existing-worktree", - path: wtPath, - }); - - expect(result.worktree.path).toBe(wtPath); - expect(result.worktree.branch).toBe("feature/existing-worktree"); - const branchOutput = yield* git(wtPath, ["branch", "--show-current"]); - expect(branchOutput).toBe("feature/existing-worktree"); - - yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); - }), - ); - - it.effect("throws when new branch name already exists", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "existing" }); - - const wtPath = path.join(tmp, "wt-conflict"); - const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (b) => b.current, - )!.name; - - const result = yield* Effect.result( - (yield* GitCore).createWorktree({ - cwd: tmp, - branch: currentBranch, - newBranch: "existing", - path: wtPath, - }), - ); - expect(result._tag).toBe("Failure"); - }), - ); - - it.effect("listGitBranches from worktree cwd reports worktree branch as current", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - - const wtPath = path.join(tmp, "wt-list-dir"); - const mainBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (b) => b.current, - )!.name; - - yield* (yield* GitCore).createWorktree({ - cwd: tmp, - branch: mainBranch, - newBranch: "wt-list", - path: wtPath, - }); - - // listGitBranches from the worktree should show wt-list as current - const wtBranches = yield* (yield* GitCore).listBranches({ cwd: wtPath }); - expect(wtBranches.isRepo).toBe(true); - const wtCurrent = wtBranches.branches.find((b) => b.current); - expect(wtCurrent!.name).toBe("wt-list"); - - // Main repo should still show the original branch as current - const mainBranches = yield* (yield* GitCore).listBranches({ cwd: tmp }); - const mainCurrent = mainBranches.branches.find((b) => b.current); - expect(mainCurrent!.name).toBe(mainBranch); - - yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); - }), - ); - - it.effect("removeGitWorktree cleans up the worktree", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - - const wtPath = path.join(tmp, "wt-remove-dir"); - const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (b) => b.current, - )!.name; - - yield* (yield* GitCore).createWorktree({ - cwd: tmp, - branch: currentBranch, - newBranch: "wt-remove", - path: wtPath, - }); - expect(existsSync(wtPath)).toBe(true); - - yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); - expect(existsSync(wtPath)).toBe(false); - }), - ); - - it.effect("removeGitWorktree force removes a dirty worktree", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - - const wtPath = path.join(tmp, "wt-dirty-dir"); - const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (b) => b.current, - )!.name; - - yield* (yield* GitCore).createWorktree({ - cwd: tmp, - branch: currentBranch, - newBranch: "wt-dirty", - path: wtPath, - }); - expect(existsSync(wtPath)).toBe(true); - - yield* writeTextFile(path.join(wtPath, "README.md"), "dirty change\n"); - - const failedRemove = yield* Effect.result( - (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }), - ); - expect(failedRemove._tag).toBe("Failure"); - expect(existsSync(wtPath)).toBe(true); - - yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath, force: true }); - expect(existsSync(wtPath)).toBe(false); - }), - ); - }); - - // ── Full flow: local branch checkout ── - - describe("full flow: local branch checkout", () => { - it.effect("init → commit → create branch → checkout → verify current", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-login" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature-login" }); - - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - const current = result.branches.find((b) => b.current); - expect(current!.name).toBe("feature-login"); - }), - ); - }); - - // ── Full flow: worktree creation from base branch ── - - describe("full flow: worktree creation", () => { - it.effect("creates worktree with new branch from current branch", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - - const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (b) => b.current, - )!.name; - - const wtPath = path.join(tmp, "my-worktree"); - const result = yield* (yield* GitCore).createWorktree({ - cwd: tmp, - branch: currentBranch, - newBranch: "feature-wt", - path: wtPath, - }); - - // Worktree exists - expect(existsSync(result.worktree.path)).toBe(true); - - // Main repo still on original branch - const mainBranches = yield* (yield* GitCore).listBranches({ cwd: tmp }); - const mainCurrent = mainBranches.branches.find((b) => b.current); - expect(mainCurrent!.name).toBe(currentBranch); - - // Worktree is on the new branch - const wtBranch = yield* git(wtPath, ["branch", "--show-current"]); - expect(wtBranch).toBe("feature-wt"); - - yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); - }), - ); - }); - - describe("fetchPullRequestBranch", () => { - it.effect("fetches a GitHub pull request ref into a local branch without checkout", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(tmp); - const remoteDir = yield* makeTmpDir("git-remote-"); - yield* git(remoteDir, ["init", "--bare"]); - yield* git(tmp, ["remote", "add", "origin", remoteDir]); - yield* git(tmp, ["push", "-u", "origin", initialBranch]); - yield* git(tmp, ["checkout", "-b", "feature/pr-fetch"]); - yield* writeTextFile(path.join(tmp, "pr-fetch.txt"), "fetch me\n"); - yield* git(tmp, ["add", "pr-fetch.txt"]); - yield* git(tmp, ["commit", "-m", "Add PR fetch branch"]); - yield* git(tmp, ["push", "-u", "origin", "feature/pr-fetch"]); - yield* git(tmp, ["push", "origin", "HEAD:refs/pull/55/head"]); - yield* git(tmp, ["checkout", initialBranch]); - - yield* (yield* GitCore).fetchPullRequestBranch({ - cwd: tmp, - prNumber: 55, - branch: "feature/pr-fetch", - }); - - const localBranches = yield* git(tmp, ["branch", "--list", "feature/pr-fetch"]); - expect(localBranches).toContain("feature/pr-fetch"); - const currentBranch = yield* git(tmp, ["branch", "--show-current"]); - expect(currentBranch).toBe(initialBranch); - }), - ); - }); - - // ── Full flow: thread switching simulation ── - - describe("full flow: thread switching (checkout toggling)", () => { - it.effect("checkout a → checkout b → checkout a → current matches", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "branch-a" }); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "branch-b" }); - - // Simulate switching to thread A's branch - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "branch-a" }); - let branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); - - // Simulate switching to thread B's branch - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "branch-b" }); - branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(branches.branches.find((b) => b.current)!.name).toBe("branch-b"); - - // Switch back to thread A - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "branch-a" }); - branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); - }), - ); - }); - - // ── Full flow: checkout conflict ── - - describe("full flow: checkout conflict", () => { - it.effect("uncommitted changes prevent checkout to a diverged branch", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "diverged" }); - - // Make diverged branch have different file content - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "diverged" }); - yield* writeTextFile(path.join(tmp, "README.md"), "diverged content\n"); - yield* git(tmp, ["add", "."]); - yield* git(tmp, ["commit", "-m", "diverge"]); - - // Actually, let's just get back to the initial branch explicitly - const allBranches = yield* (yield* GitCore).listBranches({ cwd: tmp }); - const initialBranch = allBranches.branches.find((b) => b.name !== "diverged")!.name; - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: initialBranch }); - - // Make local uncommitted changes to the same file - yield* writeTextFile(path.join(tmp, "README.md"), "local uncommitted\n"); - - // Attempt checkout should fail - const failedCheckout = yield* Effect.result( - (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "diverged" }), - ); - expect(failedCheckout._tag).toBe("Failure"); - - // Current branch should still be the initial one - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch); - }), - ); - }); - - describe("GitCore", () => { - it.effect("supports branch lifecycle operations through the service API", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const core = yield* GitCore; - - yield* core.initRepo({ cwd: tmp }); - yield* git(tmp, ["config", "user.email", "test@test.com"]); - yield* git(tmp, ["config", "user.name", "Test"]); - yield* writeTextFile(path.join(tmp, "README.md"), "# test\n"); - yield* git(tmp, ["add", "."]); - yield* git(tmp, ["commit", "-m", "initial commit"]); - - yield* core.createBranch({ cwd: tmp, branch: "feature/service-api" }); - yield* core.checkoutBranch({ cwd: tmp, branch: "feature/service-api" }); - const branches = yield* core.listBranches({ cwd: tmp }); - - expect(branches.isRepo).toBe(true); - expect( - branches.branches.find((branch: { current: boolean; name: string }) => branch.current) - ?.name, - ).toBe("feature/service-api"); - }), - ); - - it.effect( - "reuses an existing remote when the target URL only differs by a trailing slash after .git", - () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const core = yield* GitCore; - - yield* git(tmp, ["remote", "add", "origin", "git@github.com:pingdotgg/t3code.git"]); - - const remoteName = yield* core.ensureRemote({ - cwd: tmp, - preferredName: "origin", - url: "git@github.com:pingdotgg/t3code.git/", - }); - - expect(remoteName).toBe("origin"); - expect((yield* git(tmp, ["remote"])).split("\n").filter(Boolean)).toEqual(["origin"]); - }), - ); - - it.effect("reports status details and dirty state", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const core = yield* GitCore; - - const clean = yield* core.status({ cwd: tmp }); - expect(clean.hasWorkingTreeChanges).toBe(false); - expect(clean.branch).toBeTruthy(); - - yield* writeTextFile(path.join(tmp, "README.md"), "updated\n"); - const dirty = yield* core.statusDetails(tmp); - expect(dirty.hasWorkingTreeChanges).toBe(true); - }), - ); - - it.effect("returns a non-repo status for deleted directories", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const deletedDir = path.join(tmp, "deleted-repo"); - yield* makeDirectory(deletedDir); - yield* removePath(deletedDir); - const core = yield* GitCore; - - const status = yield* core.statusDetails(deletedDir); - const localStatus = yield* core.statusDetailsLocal(deletedDir); - - expect(status).toEqual({ - isRepo: false, - hasOriginRemote: false, - isDefaultBranch: false, - branch: null, - upstreamRef: null, - hasWorkingTreeChanges: false, - workingTree: { - files: [], - insertions: 0, - deletions: 0, - }, - hasUpstream: false, - aheadCount: 0, - behindCount: 0, - }); - expect(localStatus).toEqual(status); - }), - ); - - it.effect("computes ahead count against base branch when no upstream is configured", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const core = yield* GitCore; - - yield* core.createBranch({ cwd: tmp, branch: "feature/no-upstream-ahead" }); - yield* core.checkoutBranch({ cwd: tmp, branch: "feature/no-upstream-ahead" }); - yield* writeTextFile(path.join(tmp, "feature.txt"), "ahead of base\n"); - yield* git(tmp, ["add", "feature.txt"]); - yield* git(tmp, ["commit", "-m", "feature commit"]); - - const details = yield* core.statusDetails(tmp); - expect(details.branch).toBe("feature/no-upstream-ahead"); - expect(details.hasUpstream).toBe(false); - expect(details.aheadCount).toBe(1); - expect(details.behindCount).toBe(0); - }), - ); - - it.effect( - "computes ahead count against origin/default when local default branch is missing", - () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); - - yield* initRepoWithCommit(source); - const initialBranch = (yield* (yield* GitCore).listBranches({ - cwd: source, - })).branches.find((branch) => branch.current)!.name; - yield* git(source, ["remote", "add", "origin", remote]); - yield* git(source, ["push", "-u", "origin", initialBranch]); - yield* git(source, ["checkout", "-b", "feature/remote-base-only"]); - yield* writeTextFile( - path.join(source, "feature.txt"), - `ahead of origin/${initialBranch}\n`, - ); - yield* git(source, ["add", "feature.txt"]); - yield* git(source, ["commit", "-m", "feature commit"]); - yield* git(source, ["branch", "-D", initialBranch]); - - const core = yield* GitCore; - const details = yield* core.statusDetails(source); - expect(details.branch).toBe("feature/remote-base-only"); - expect(details.hasUpstream).toBe(false); - expect(details.aheadCount).toBe(1); - expect(details.behindCount).toBe(0); - }), - ); - - it.effect( - "computes ahead count against a non-origin remote-prefixed gh-merge-base candidate", - () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - const remoteName = "fork-seed"; - yield* git(remote, ["init", "--bare"]); - - yield* initRepoWithCommit(source); - const initialBranch = (yield* (yield* GitCore).listBranches({ - cwd: source, - })).branches.find((branch) => branch.current)!.name; - yield* git(source, ["remote", "add", remoteName, remote]); - yield* git(source, ["push", "-u", remoteName, initialBranch]); - yield* git(source, ["checkout", "-b", "feature/non-origin-merge-base"]); - yield* git(source, [ - "config", - "branch.feature/non-origin-merge-base.gh-merge-base", - `${remoteName}/${initialBranch}`, - ]); - yield* writeTextFile( - path.join(source, "feature.txt"), - `ahead of ${remoteName}/${initialBranch}\n`, - ); - yield* git(source, ["add", "feature.txt"]); - yield* git(source, ["commit", "-m", "feature commit"]); - yield* git(source, ["branch", "-D", initialBranch]); - - const core = yield* GitCore; - const details = yield* core.statusDetails(source); - expect(details.branch).toBe("feature/non-origin-merge-base"); - expect(details.hasUpstream).toBe(false); - expect(details.aheadCount).toBe(1); - expect(details.behindCount).toBe(0); - }), - ); - - it.effect("skips push when no upstream is configured and branch is not ahead of base", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const core = yield* GitCore; - - yield* core.createBranch({ cwd: tmp, branch: "feature/no-upstream-no-ahead" }); - yield* core.checkoutBranch({ cwd: tmp, branch: "feature/no-upstream-no-ahead" }); - - const pushed = yield* core.pushCurrentBranch(tmp, null); - expect(pushed.status).toBe("skipped_up_to_date"); - expect(pushed.branch).toBe("feature/no-upstream-no-ahead"); - expect(pushed.setUpstream).toBeUndefined(); - }), - ); - - it.effect("pushes with upstream setup when no comparable base branch exists", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const remote = yield* makeTmpDir(); - yield* git(tmp, ["init", "--initial-branch=trunk"]); - yield* git(tmp, ["config", "user.email", "test@test.com"]); - yield* git(tmp, ["config", "user.name", "Test"]); - yield* writeTextFile(path.join(tmp, "README.md"), "hello\n"); - yield* git(tmp, ["add", "README.md"]); - yield* git(tmp, ["commit", "-m", "initial"]); - yield* git(remote, ["init", "--bare"]); - yield* git(tmp, ["remote", "add", "origin", remote]); - yield* git(tmp, ["checkout", "-b", "feature/no-base"]); - - const core = yield* GitCore; - const pushed = yield* core.pushCurrentBranch(tmp, null); - expect(pushed.status).toBe("pushed"); - expect(pushed.setUpstream).toBe(true); - expect(pushed.upstreamBranch).toBe("origin/feature/no-base"); - expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( - "origin/feature/no-base", - ); - }), - ); - - it.effect("pushes with upstream setup to the only configured non-origin remote", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const remote = yield* makeTmpDir(); - yield* git(tmp, ["init", "--initial-branch=main"]); - yield* git(tmp, ["config", "user.email", "test@test.com"]); - yield* git(tmp, ["config", "user.name", "Test"]); - yield* writeTextFile(path.join(tmp, "README.md"), "hello\n"); - yield* git(tmp, ["add", "README.md"]); - yield* git(tmp, ["commit", "-m", "initial"]); - yield* git(remote, ["init", "--bare"]); - yield* git(tmp, ["remote", "add", "fork", remote]); - yield* git(tmp, ["checkout", "-b", "feature/fork-only"]); - - const core = yield* GitCore; - const pushed = yield* core.pushCurrentBranch(tmp, null); - expect(pushed.status).toBe("pushed"); - expect(pushed.setUpstream).toBe(true); - expect(pushed.upstreamBranch).toBe("fork/feature/fork-only"); - expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( - "fork/feature/fork-only", - ); - }), - ); - - it.effect( - "pushes with upstream setup when comparable base exists but remote branch is missing", - () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const remote = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); - - yield* initRepoWithCommit(tmp); - const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (branch) => branch.current, - )!.name; - yield* git(tmp, ["remote", "add", "origin", remote]); - yield* git(tmp, ["push", "-u", "origin", initialBranch]); - - yield* writeTextFile(path.join(tmp, "default-ahead.txt"), "ahead on default\n"); - yield* git(tmp, ["add", "default-ahead.txt"]); - yield* git(tmp, ["commit", "-m", "default ahead"]); - - const featureBranch = "feature/publish-no-upstream"; - yield* git(tmp, ["checkout", "-b", featureBranch]); - - const core = yield* GitCore; - const pushed = yield* core.pushCurrentBranch(tmp, null); - expect(pushed.status).toBe("pushed"); - expect(pushed.setUpstream).toBe(true); - expect(pushed.upstreamBranch).toBe(`origin/${featureBranch}`); - expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( - `origin/${featureBranch}`, - ); - expect(yield* git(tmp, ["ls-remote", "--heads", "origin", featureBranch])).toContain( - featureBranch, - ); - }), - ); - - it.effect("prefers branch pushRemote over origin when setting upstream", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const origin = yield* makeTmpDir(); - const fork = yield* makeTmpDir(); - yield* git(origin, ["init", "--bare"]); - yield* git(fork, ["init", "--bare"]); - - yield* initRepoWithCommit(tmp); - const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (branch) => branch.current, - )!.name; - yield* git(tmp, ["remote", "add", "origin", origin]); - yield* git(tmp, ["remote", "add", "fork", fork]); - yield* git(tmp, ["push", "-u", "origin", initialBranch]); - - const featureBranch = "feature/push-remote"; - yield* git(tmp, ["checkout", "-b", featureBranch]); - yield* git(tmp, ["config", `branch.${featureBranch}.pushRemote`, "fork"]); - yield* writeTextFile(path.join(tmp, "feature.txt"), "push to fork\n"); - yield* git(tmp, ["add", "feature.txt"]); - yield* git(tmp, ["commit", "-m", "feature commit"]); - - const core = yield* GitCore; - const pushed = yield* core.pushCurrentBranch(tmp, null); - expect(pushed.status).toBe("pushed"); - expect(pushed.setUpstream).toBe(true); - expect(pushed.upstreamBranch).toBe(`fork/${featureBranch}`); - expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( - `fork/${featureBranch}`, - ); - expect(yield* git(tmp, ["ls-remote", "--heads", "fork", featureBranch])).toContain( - featureBranch, - ); - }), - ); - - it.effect( - "pushes renamed PR worktree branches to their tracked upstream branch even when push.default is current", - () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const fork = yield* makeTmpDir(); - yield* git(fork, ["init", "--bare"]); - - const { initialBranch } = yield* initRepoWithCommit(tmp); - yield* git(tmp, ["remote", "add", "jasonLaster", fork]); - yield* git(tmp, ["checkout", "-b", "statemachine"]); - yield* writeTextFile(path.join(tmp, "fork.txt"), "fork branch\n"); - yield* git(tmp, ["add", "fork.txt"]); - yield* git(tmp, ["commit", "-m", "fork branch"]); - yield* git(tmp, ["push", "-u", "jasonLaster", "statemachine"]); - yield* git(tmp, ["checkout", initialBranch]); - yield* git(tmp, ["branch", "-D", "statemachine"]); - yield* git(tmp, [ - "checkout", - "-b", - "t3code/pr-488/statemachine", - "--track", - "jasonLaster/statemachine", - ]); - yield* git(tmp, ["config", "push.default", "current"]); - yield* writeTextFile(path.join(tmp, "fork.txt"), "updated fork branch\n"); - yield* git(tmp, ["add", "fork.txt"]); - yield* git(tmp, ["commit", "-m", "update reviewed PR branch"]); - - const core = yield* GitCore; - const pushed = yield* core.pushCurrentBranch(tmp, null); - - expect(pushed.status).toBe("pushed"); - expect(pushed.setUpstream).toBe(false); - expect(pushed.upstreamBranch).toBe("jasonLaster/statemachine"); - expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( - "jasonLaster/statemachine", - ); - expect( - yield* git(tmp, ["ls-remote", "--heads", "jasonLaster", "statemachine"]), - ).toContain("statemachine"); - expect( - yield* git(tmp, ["ls-remote", "--heads", "jasonLaster", "t3code/pr-488/statemachine"]), - ).toBe(""); - }), - ); - - it.effect("pushes to the tracked upstream when the remote name contains slashes", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const remote = yield* makeTmpDir(); - const prefixRemote = yield* makeTmpDir(); - const prefixFetchNamespace = "prefix-my-org"; - const prefixRemoteName = "my-org"; - const remoteName = "my-org/upstream"; - const featureBranch = "feature/slash-remote-push"; - yield* git(remote, ["init", "--bare"]); - yield* git(prefixRemote, ["init", "--bare"]); - - const { initialBranch } = yield* initRepoWithCommit(tmp); - yield* configureRemote(tmp, prefixRemoteName, prefixRemote, prefixFetchNamespace); - yield* configureRemote(tmp, remoteName, remote, remoteName); - yield* git(tmp, ["push", "-u", remoteName, initialBranch]); - - yield* git(tmp, ["checkout", "-b", featureBranch]); - yield* writeTextFile(path.join(tmp, "feature.txt"), "first revision\n"); - yield* git(tmp, ["add", "feature.txt"]); - yield* git(tmp, ["commit", "-m", "feature base"]); - yield* git(tmp, ["push", "-u", remoteName, featureBranch]); - - yield* writeTextFile(path.join(tmp, "feature.txt"), "second revision\n"); - yield* git(tmp, ["add", "feature.txt"]); - yield* git(tmp, ["commit", "-m", "feature update"]); - - const core = yield* GitCore; - const pushed = yield* core.pushCurrentBranch(tmp, null); - expect(pushed.status).toBe("pushed"); - expect(pushed.setUpstream).toBe(false); - expect(pushed.upstreamBranch).toBe(`${remoteName}/${featureBranch}`); - expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( - `${remoteName}/${featureBranch}`, - ); - expect(yield* git(tmp, ["ls-remote", "--heads", remoteName, featureBranch])).toContain( - featureBranch, - ); - }), - ); - - it.effect("includes command context when worktree removal fails", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const core = yield* GitCore; - const missingWorktreePath = path.join(tmp, "missing-worktree"); - - const removeResult = yield* Effect.result( - core.removeWorktree({ cwd: tmp, path: missingWorktreePath }), - ); - expect(removeResult._tag).toBe("Failure"); - if (removeResult._tag !== "Failure") { - return; - } - const message = removeResult.failure.message; - expect(message).toContain("git worktree remove"); - expect(message).toContain(`cwd: ${tmp}`); - expect(message).toContain(missingWorktreePath); - }), - ); - - it.effect( - "refreshes upstream before statusDetails so behind count reflects remote updates", - () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - const clone = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); - - yield* initRepoWithCommit(source); - const initialBranch = (yield* (yield* GitCore).listBranches({ - cwd: source, - })).branches.find((branch) => branch.current)!.name; - yield* git(source, ["remote", "add", "origin", remote]); - yield* git(source, ["push", "-u", "origin", initialBranch]); - - yield* git(clone, ["clone", remote, "."]); - yield* git(clone, ["config", "user.email", "test@test.com"]); - yield* git(clone, ["config", "user.name", "Test"]); - yield* git(clone, [ - "checkout", - "-B", - initialBranch, - "--track", - `origin/${initialBranch}`, - ]); - yield* writeTextFile(path.join(clone, "CHANGELOG.md"), "remote change\n"); - yield* git(clone, ["add", "CHANGELOG.md"]); - yield* git(clone, ["commit", "-m", "remote update"]); - yield* git(clone, ["push", "origin", initialBranch]); - - const core = yield* GitCore; - const details = yield* core.statusDetails(source); - expect(details.branch).toBe(initialBranch); - expect(details.aheadCount).toBe(0); - expect(details.behindCount).toBe(1); - }), - ); - - it.effect("prepares commit context by auto-staging and creates commit", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const core = yield* GitCore; - - yield* writeTextFile(path.join(tmp, "README.md"), "new content\n"); - const context = yield* core.prepareCommitContext(tmp); - expect(context).not.toBeNull(); - expect(context!.stagedSummary.length).toBeGreaterThan(0); - expect(context!.stagedPatch.length).toBeGreaterThan(0); - - const created = yield* core.commit(tmp, "Add README update", "- include updated content"); - expect(created.commitSha.length).toBeGreaterThan(0); - expect(yield* git(tmp, ["log", "-1", "--pretty=%s"])).toBe("Add README update"); - }), - ); - - it.effect("prepareCommitContext stages only selected files when filePaths provided", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const core = yield* GitCore; - - yield* writeTextFile(path.join(tmp, "a.txt"), "file a\n"); - yield* writeTextFile(path.join(tmp, "b.txt"), "file b\n"); - - const context = yield* core.prepareCommitContext(tmp, ["a.txt"]); - expect(context).not.toBeNull(); - expect(context!.stagedSummary).toContain("a.txt"); - expect(context!.stagedSummary).not.toContain("b.txt"); - - yield* core.commit(tmp, "Add only a.txt", ""); - - // b.txt should still be untracked after commit - const statusAfter = yield* git(tmp, ["status", "--porcelain"]); - expect(statusAfter).toContain("b.txt"); - expect(statusAfter).not.toContain("a.txt"); - }), - ); - - it.effect("prepareCommitContext stages everything when filePaths is undefined", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const core = yield* GitCore; - - yield* writeTextFile(path.join(tmp, "a.txt"), "file a\n"); - yield* writeTextFile(path.join(tmp, "b.txt"), "file b\n"); - - const context = yield* core.prepareCommitContext(tmp); - expect(context).not.toBeNull(); - expect(context!.stagedSummary).toContain("a.txt"); - expect(context!.stagedSummary).toContain("b.txt"); - }), - ); - - it.effect("prepareCommitContext truncates oversized staged patches instead of failing", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const core = yield* GitCore; - - yield* writeTextFile(path.join(tmp, "README.md"), buildLargeText()); - - const context = yield* core.prepareCommitContext(tmp); - expect(context).not.toBeNull(); - expect(context!.stagedSummary).toContain("README.md"); - expect(context!.stagedPatch).toContain("[truncated]"); - }), - ); - - it.effect("readRangeContext truncates oversized diff patches instead of failing", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(tmp); - const core = yield* GitCore; - - yield* core.createBranch({ cwd: tmp, branch: "feature/large-range-context" }); - yield* core.checkoutBranch({ cwd: tmp, branch: "feature/large-range-context" }); - yield* writeTextFile(path.join(tmp, "large.txt"), buildLargeText()); - yield* git(tmp, ["add", "large.txt"]); - yield* git(tmp, ["commit", "-m", "Add large range context"]); - - const rangeContext = yield* core.readRangeContext(tmp, initialBranch); - expect(rangeContext.commitSummary).toContain("Add large range context"); - expect(rangeContext.diffSummary).toContain("large.txt"); - expect(rangeContext.diffPatch).toContain("[truncated]"); - }), - ); - - it.effect("pushes with upstream setup and then skips when up to date", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const remote = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* git(remote, ["init", "--bare"]); - yield* git(tmp, ["remote", "add", "origin", remote]); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/core-push" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature/core-push" }); - - yield* writeTextFile(path.join(tmp, "feature.txt"), "push me\n"); - const core = yield* GitCore; - const context = yield* core.prepareCommitContext(tmp); - expect(context).not.toBeNull(); - yield* core.commit(tmp, "Add feature file", ""); - - const pushed = yield* core.pushCurrentBranch(tmp, null); - expect(pushed.status).toBe("pushed"); - expect(pushed.setUpstream).toBe(true); - expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( - "origin/feature/core-push", - ); - - const skipped = yield* core.pushCurrentBranch(tmp, null); - expect(skipped.status).toBe("skipped_up_to_date"); - }), - ); - - it.effect("pulls behind branch and then reports up-to-date", () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - const clone = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); - - yield* initRepoWithCommit(source); - const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( - (branch) => branch.current, - )!.name; - yield* git(source, ["remote", "add", "origin", remote]); - yield* git(source, ["push", "-u", "origin", initialBranch]); - - yield* git(clone, ["clone", remote, "."]); - yield* git(clone, ["config", "user.email", "test@test.com"]); - yield* git(clone, ["config", "user.name", "Test"]); - yield* writeTextFile(path.join(clone, "CHANGELOG.md"), "remote change\n"); - yield* git(clone, ["add", "CHANGELOG.md"]); - yield* git(clone, ["commit", "-m", "remote update"]); - yield* git(clone, ["push", "origin", initialBranch]); - - const core = yield* GitCore; - const pulled = yield* core.pullCurrentBranch(source); - expect(pulled.status).toBe("pulled"); - expect((yield* core.statusDetails(source)).behindCount).toBe(0); - - const skipped = yield* core.pullCurrentBranch(source); - expect(skipped.status).toBe("skipped_up_to_date"); - }), - ); - - it.effect("top-level pullGitBranch rejects when no upstream exists", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const result = yield* Effect.result((yield* GitCore).pullCurrentBranch(tmp)); - expect(result._tag).toBe("Failure"); - if (result._tag === "Failure") { - expect(result.failure.message.toLowerCase()).toContain("no upstream"); - } - }), - ); - - it.effect("lists branches when recency lookup fails", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const realGitCore = yield* GitCore; - let didFailRecency = false; - const core = yield* makeIsolatedGitCore((input) => { - if (!didFailRecency && input.args[0] === "for-each-ref") { - didFailRecency = true; - return Effect.fail( - new GitCommandError({ - operation: "git.test.listBranchesRecency", - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "timeout", - }), - ); - } - return realGitCore.execute(input); - }); - - const result = yield* core.listBranches({ cwd: tmp }); - - expect(result.isRepo).toBe(true); - expect(result.branches.length).toBeGreaterThan(0); - expect(result.branches[0]?.current).toBe(true); - expect(didFailRecency).toBe(true); - }), - ); - - it.effect("falls back to empty remote branch data when remote lookups fail", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const remote = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* git(remote, ["init", "--bare"]); - yield* git(tmp, ["remote", "add", "origin", remote]); - - const realGitCore = yield* GitCore; - let didFailRemoteBranches = false; - let didFailRemoteNames = false; - const core = yield* makeIsolatedGitCore((input) => { - if (input.args.join(" ") === "branch --no-color --no-column --remotes") { - didFailRemoteBranches = true; - return Effect.fail( - new GitCommandError({ - operation: "git.test.listBranchesRemoteBranches", - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "remote unavailable", - }), - ); - } - if (input.args.join(" ") === "remote") { - didFailRemoteNames = true; - return Effect.fail( - new GitCommandError({ - operation: "git.test.listBranchesRemoteNames", - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "remote unavailable", - }), - ); - } - return realGitCore.execute(input); - }); - - const result = yield* core.listBranches({ cwd: tmp }); - - expect(result.isRepo).toBe(true); - expect(result.branches.length).toBeGreaterThan(0); - expect(result.branches.every((branch) => !branch.isRemote)).toBe(true); - expect(didFailRemoteBranches).toBe(true); - expect(didFailRemoteNames).toBe(true); - }), - ); - }); -}); diff --git a/apps/server/src/git/Layers/GitHubCli.test.ts b/apps/server/src/git/Layers/GitHubCli.test.ts deleted file mode 100644 index 5a7b9cb8b1d..00000000000 --- a/apps/server/src/git/Layers/GitHubCli.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import { Effect } from "effect"; -import { afterEach, expect, vi } from "vitest"; - -vi.mock("../../processRunner", () => ({ - runProcess: vi.fn(), -})); - -import { runProcess } from "../../processRunner.ts"; -import { GitHubCli } from "../Services/GitHubCli.ts"; -import { GitHubCliLive } from "./GitHubCli.ts"; - -const mockedRunProcess = vi.mocked(runProcess); -const layer = it.layer(GitHubCliLive); - -afterEach(() => { - mockedRunProcess.mockReset(); -}); - -layer("GitHubCliLive", (it) => { - it.effect("parses pull request view output", () => - Effect.gen(function* () { - mockedRunProcess.mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 42, - title: "Add PR thread creation", - url: "https://github.com/pingdotgg/codething-mvp/pull/42", - baseRefName: "main", - headRefName: "feature/pr-threads", - state: "OPEN", - mergedAt: null, - isCrossRepository: true, - headRepository: { - nameWithOwner: "octocat/codething-mvp", - }, - headRepositoryOwner: { - login: "octocat", - }, - }), - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); - - const result = yield* Effect.gen(function* () { - const gh = yield* GitHubCli; - return yield* gh.getPullRequest({ - cwd: "/repo", - reference: "#42", - }); - }); - - assert.deepStrictEqual(result, { - number: 42, - title: "Add PR thread creation", - url: "https://github.com/pingdotgg/codething-mvp/pull/42", - baseRefName: "main", - headRefName: "feature/pr-threads", - state: "open", - isCrossRepository: true, - headRepositoryNameWithOwner: "octocat/codething-mvp", - headRepositoryOwnerLogin: "octocat", - }); - expect(mockedRunProcess).toHaveBeenCalledWith( - "gh", - [ - "pr", - "view", - "#42", - "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", - ], - expect.objectContaining({ cwd: "/repo" }), - ); - }), - ); - - it.effect("trims pull request fields decoded from gh json", () => - Effect.gen(function* () { - mockedRunProcess.mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 42, - title: " Add PR thread creation \n", - url: " https://github.com/pingdotgg/codething-mvp/pull/42 ", - baseRefName: " main ", - headRefName: "\tfeature/pr-threads\t", - state: "OPEN", - mergedAt: null, - isCrossRepository: true, - headRepository: { - nameWithOwner: " octocat/codething-mvp ", - }, - headRepositoryOwner: { - login: " octocat ", - }, - }), - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); - - const result = yield* Effect.gen(function* () { - const gh = yield* GitHubCli; - return yield* gh.getPullRequest({ - cwd: "/repo", - reference: "#42", - }); - }); - - assert.deepStrictEqual(result, { - number: 42, - title: "Add PR thread creation", - url: "https://github.com/pingdotgg/codething-mvp/pull/42", - baseRefName: "main", - headRefName: "feature/pr-threads", - state: "open", - isCrossRepository: true, - headRepositoryNameWithOwner: "octocat/codething-mvp", - headRepositoryOwnerLogin: "octocat", - }); - }), - ); - - it.effect("skips invalid entries when parsing pr lists", () => - Effect.gen(function* () { - mockedRunProcess.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 0, - title: "invalid", - url: "https://github.com/pingdotgg/codething-mvp/pull/0", - baseRefName: "main", - headRefName: "feature/invalid", - }, - { - number: 43, - title: " Valid PR ", - url: " https://github.com/pingdotgg/codething-mvp/pull/43 ", - baseRefName: " main ", - headRefName: " feature/pr-list ", - headRepository: { - nameWithOwner: " ", - }, - headRepositoryOwner: { - login: " ", - }, - }, - ]), - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); - - const result = yield* Effect.gen(function* () { - const gh = yield* GitHubCli; - return yield* gh.listOpenPullRequests({ - cwd: "/repo", - headSelector: "feature/pr-list", - }); - }); - - assert.deepStrictEqual(result, [ - { - number: 43, - title: "Valid PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/43", - baseRefName: "main", - headRefName: "feature/pr-list", - state: "open", - }, - ]); - }), - ); - - it.effect("reads repository clone URLs", () => - Effect.gen(function* () { - mockedRunProcess.mockResolvedValueOnce({ - stdout: JSON.stringify({ - nameWithOwner: "octocat/codething-mvp", - url: "https://github.com/octocat/codething-mvp", - sshUrl: "git@github.com:octocat/codething-mvp.git", - }), - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); - - const result = yield* Effect.gen(function* () { - const gh = yield* GitHubCli; - return yield* gh.getRepositoryCloneUrls({ - cwd: "/repo", - repository: "octocat/codething-mvp", - }); - }); - - assert.deepStrictEqual(result, { - nameWithOwner: "octocat/codething-mvp", - url: "https://github.com/octocat/codething-mvp", - sshUrl: "git@github.com:octocat/codething-mvp.git", - }); - }), - ); - - it.effect("surfaces a friendly error when the pull request is not found", () => - Effect.gen(function* () { - mockedRunProcess.mockRejectedValueOnce( - new Error( - "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)", - ), - ); - - const error = yield* Effect.gen(function* () { - const gh = yield* GitHubCli; - return yield* gh.getPullRequest({ - cwd: "/repo", - reference: "4888", - }); - }).pipe(Effect.flip); - - assert.equal(error.message.includes("Pull request not found"), true); - }), - ); -}); diff --git a/apps/server/src/git/Layers/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/Layers/TextGenerationLive.ts b/apps/server/src/git/Layers/TextGenerationLive.ts deleted file mode 100644 index 58e1541d55c..00000000000 --- a/apps/server/src/git/Layers/TextGenerationLive.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * TextGenerationLive — registry-backed implementation of the `TextGeneration` - * service tag. - * - * The `TextGeneration` tag is kept as a thin facade over - * `ProviderInstanceRegistry`. Every op pulls `modelSelection.instanceId`, - * looks up the matching `ProviderInstance`, and delegates to that instance's - * own `textGeneration` closure (built by its driver's `create()`). - * - * There is deliberately no per-driver dispatch here — the registry already - * knows which driver backs each instance, and each `ProviderInstance` - * carries the fully-bound `TextGenerationShape` produced by its driver. - * That means: - * - * - Multiple instances of the same driver (e.g. `codex_personal`, - * `codex_work`) each get their own text-generation closure bound to - * their own settings — the routing is by instance, not by driver. - * - Unknown or disabled instances surface a `TextGenerationError` with - * the missing `instanceId`, instead of silently falling back to a - * default. - * - * This replaces the old `RoutingTextGenerationLive`, which tried to route - * by driver-kind and misused `modelSelection.instanceId` as a driver-id - * literal. - * - * @module git/Layers/TextGenerationLive - */ -import { Effect, Layer } from "effect"; - -import { TextGenerationError } from "@t3tools/contracts"; -import type { ProviderInstanceId } from "@t3tools/contracts"; - -import { - ProviderInstanceRegistry, - type ProviderInstanceRegistryShape, -} from "../../provider/Services/ProviderInstanceRegistry.ts"; -import type { ProviderInstance } from "../../provider/ProviderDriver.ts"; -import { TextGeneration, type TextGenerationShape } from "../Services/TextGeneration.ts"; - -type TextGenerationOp = - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle"; - -const resolveInstance = ( - registry: ProviderInstanceRegistryShape, - operation: TextGenerationOp, - instanceId: ProviderInstanceId, -): Effect.Effect => - registry.getInstance(instanceId).pipe( - Effect.flatMap((instance) => - instance - ? Effect.succeed(instance.textGeneration) - : Effect.fail( - new TextGenerationError({ - operation, - detail: `No provider instance registered for id '${instanceId}'.`, - }), - ), - ), - ); - -/** - * Build a `TextGenerationShape` that routes every call through the - * registry. Exposed separately from the Layer so tests can construct it - * against a stub registry without layering gymnastics. - */ -export const makeTextGenerationFromRegistry = ( - registry: ProviderInstanceRegistryShape, -): TextGenerationShape => ({ - generateCommitMessage: (input) => - resolveInstance(registry, "generateCommitMessage", input.modelSelection.instanceId).pipe( - Effect.flatMap((tg) => tg.generateCommitMessage(input)), - ), - generatePrContent: (input) => - resolveInstance(registry, "generatePrContent", input.modelSelection.instanceId).pipe( - Effect.flatMap((tg) => tg.generatePrContent(input)), - ), - generateBranchName: (input) => - resolveInstance(registry, "generateBranchName", input.modelSelection.instanceId).pipe( - Effect.flatMap((tg) => tg.generateBranchName(input)), - ), - generateThreadTitle: (input) => - resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe( - Effect.flatMap((tg) => tg.generateThreadTitle(input)), - ), -}); - -/** - * `TextGeneration` Layer wired to the `ProviderInstanceRegistry`. The rest - * of the server keeps using `yield* TextGeneration` — only the underlying - * wiring changed from kind-based routing to instance-based routing. - */ -export const TextGenerationLive = Layer.effect( - TextGeneration, - Effect.gen(function* () { - const registry = yield* ProviderInstanceRegistry; - return makeTextGenerationFromRegistry(registry); - }), -); diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts deleted file mode 100644 index 9f3bc0b9b91..00000000000 --- a/apps/server/src/git/Services/GitCore.ts +++ /dev/null @@ -1,313 +0,0 @@ -/** - * GitCore - Effect service contract for low-level Git operations. - * - * Wraps core repository primitives used by higher-level orchestration - * services and WebSocket routes. - * - * @module GitCore - */ -import { Context } from "effect"; -import type { Effect } from "effect"; -import type { - GitCheckoutInput, - GitCheckoutResult, - GitCreateBranchInput, - GitCreateBranchResult, - GitCreateWorktreeInput, - GitCreateWorktreeResult, - GitInitInput, - GitListBranchesInput, - GitListBranchesResult, - GitPullResult, - GitRemoveWorktreeInput, - GitStatusInput, - GitStatusResult, -} from "@t3tools/contracts"; - -import type { GitCommandError } from "@t3tools/contracts"; - -export interface ExecuteGitInput { - readonly operation: string; - readonly cwd: string; - readonly args: ReadonlyArray; - readonly stdin?: string; - readonly env?: NodeJS.ProcessEnv; - readonly allowNonZeroExit?: boolean; - readonly timeoutMs?: number; - readonly maxOutputBytes?: number; - readonly truncateOutputAtMaxBytes?: boolean; - readonly progress?: ExecuteGitProgress; -} - -export interface ExecuteGitResult { - readonly code: number; - readonly stdout: string; - readonly stderr: string; - readonly stdoutTruncated: boolean; - readonly stderrTruncated: boolean; -} - -export interface GitStatusDetails extends Omit { - upstreamRef: string | null; -} - -export interface GitPreparedCommitContext { - stagedSummary: string; - stagedPatch: string; -} - -export interface ExecuteGitProgress { - readonly onStdoutLine?: (line: string) => Effect.Effect; - readonly onStderrLine?: (line: string) => Effect.Effect; - readonly onHookStarted?: (hookName: string) => Effect.Effect; - readonly onHookFinished?: (input: { - hookName: string; - exitCode: number | null; - durationMs: number | null; - }) => Effect.Effect; -} - -export interface GitCommitProgress { - readonly onOutputLine?: (input: { - stream: "stdout" | "stderr"; - text: string; - }) => Effect.Effect; - readonly onHookStarted?: (hookName: string) => Effect.Effect; - readonly onHookFinished?: (input: { - hookName: string; - exitCode: number | null; - durationMs: number | null; - }) => Effect.Effect; -} - -export interface GitCommitOptions { - readonly timeoutMs?: number; - readonly progress?: GitCommitProgress; -} - -export interface GitPushResult { - status: "pushed" | "skipped_up_to_date"; - branch: string; - upstreamBranch?: string | undefined; - setUpstream?: boolean | undefined; -} - -export interface GitRangeContext { - commitSummary: string; - diffSummary: string; - diffPatch: string; -} - -export interface GitListWorkspaceFilesResult { - readonly paths: ReadonlyArray; - readonly truncated: boolean; -} - -export interface GitRenameBranchInput { - cwd: string; - oldBranch: string; - newBranch: string; -} - -export interface GitRenameBranchResult { - branch: string; -} - -export interface GitFetchPullRequestBranchInput { - cwd: string; - prNumber: number; - branch: string; -} - -export interface GitEnsureRemoteInput { - cwd: string; - preferredName: string; - url: string; -} - -export interface GitFetchRemoteBranchInput { - cwd: string; - remoteName: string; - remoteBranch: string; - localBranch: string; -} - -export interface GitSetBranchUpstreamInput { - cwd: string; - branch: string; - remoteName: string; - remoteBranch: string; -} - -/** - * GitCoreShape - Service API for low-level Git repository interactions. - */ -export interface GitCoreShape { - /** - * Execute a raw Git command. - */ - readonly execute: (input: ExecuteGitInput) => Effect.Effect; - - /** - * Read Git status for a repository. - */ - readonly status: (input: GitStatusInput) => Effect.Effect; - - /** - * Read detailed working tree / branch status for a repository. - */ - readonly statusDetails: (cwd: string) => Effect.Effect; - - /** - * Read detailed working tree / branch status without refreshing remote tracking refs. - */ - readonly statusDetailsLocal: (cwd: string) => Effect.Effect; - - /** - * Build staged change context for commit generation. - */ - readonly prepareCommitContext: ( - cwd: string, - filePaths?: readonly string[], - ) => Effect.Effect; - - /** - * Create a commit with provided subject/body. - */ - readonly commit: ( - cwd: string, - subject: string, - body: string, - options?: GitCommitOptions, - ) => Effect.Effect<{ commitSha: string }, GitCommandError>; - - /** - * Push current branch, setting upstream if needed. - */ - readonly pushCurrentBranch: ( - cwd: string, - fallbackBranch: string | null, - ) => Effect.Effect; - - /** - * Collect commit/diff context between base branch and current HEAD. - */ - readonly readRangeContext: ( - cwd: string, - baseBranch: string, - ) => Effect.Effect; - - /** - * Read a Git config value from the local repository. - */ - readonly readConfigValue: ( - cwd: string, - key: string, - ) => Effect.Effect; - - /** - * Determine whether the provided cwd is inside a git work tree. - */ - readonly isInsideWorkTree: (cwd: string) => Effect.Effect; - - /** - * List tracked and untracked workspace file paths relative to cwd. - */ - readonly listWorkspaceFiles: ( - cwd: string, - ) => Effect.Effect; - - /** - * Remove gitignored paths from a relative path list. - */ - readonly filterIgnoredPaths: ( - cwd: string, - relativePaths: ReadonlyArray, - ) => Effect.Effect, GitCommandError>; - - /** - * List local + remote branches and branch metadata. - */ - readonly listBranches: ( - input: GitListBranchesInput, - ) => Effect.Effect; - - /** - * Pull current branch from upstream using fast-forward only. - */ - readonly pullCurrentBranch: (cwd: string) => Effect.Effect; - - /** - * Create a worktree and branch from a base branch. - */ - readonly createWorktree: ( - input: GitCreateWorktreeInput, - ) => Effect.Effect; - - /** - * Materialize a GitHub pull request head as a local branch without switching checkout. - */ - readonly fetchPullRequestBranch: ( - input: GitFetchPullRequestBranchInput, - ) => Effect.Effect; - - /** - * Ensure a named remote exists for the provided URL, returning the reused or created remote name. - */ - readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect; - - /** - * Fetch a remote branch into a local branch without checkout. - */ - readonly fetchRemoteBranch: ( - input: GitFetchRemoteBranchInput, - ) => Effect.Effect; - - /** - * Set the upstream tracking branch for a local branch. - */ - readonly setBranchUpstream: ( - input: GitSetBranchUpstreamInput, - ) => Effect.Effect; - - /** - * Remove an existing worktree. - */ - readonly removeWorktree: (input: GitRemoveWorktreeInput) => Effect.Effect; - - /** - * Rename an existing local branch. - */ - readonly renameBranch: ( - input: GitRenameBranchInput, - ) => Effect.Effect; - - /** - * Create a local branch. - */ - readonly createBranch: ( - input: GitCreateBranchInput, - ) => Effect.Effect; - - /** - * Checkout an existing branch and refresh its upstream metadata in background. - */ - readonly checkoutBranch: ( - input: GitCheckoutInput, - ) => Effect.Effect; - - /** - * Initialize a repository in the provided directory. - */ - readonly initRepo: (input: GitInitInput) => Effect.Effect; - - /** - * List local branch names (short format). - */ - readonly listLocalBranchNames: (cwd: string) => Effect.Effect; -} - -/** - * GitCore - Service tag for low-level Git repository operations. - */ -export class GitCore extends Context.Service()("t3/git/Services/GitCore") {} diff --git a/apps/server/src/git/Services/GitHubCli.ts b/apps/server/src/git/Services/GitHubCli.ts deleted file mode 100644 index 81a53761a3b..00000000000 --- a/apps/server/src/git/Services/GitHubCli.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * GitHubCli - Effect service contract for `gh` process interactions. - * - * Provides thin command execution helpers used by Git workflow orchestration. - * - * @module GitHubCli - */ -import { Context } from "effect"; -import type { Effect } from "effect"; - -import type { ProcessRunResult } from "../../processRunner.ts"; -import type { GitHubCliError } from "@t3tools/contracts"; - -export interface GitHubPullRequestSummary { - readonly number: number; - readonly title: string; - readonly url: string; - readonly baseRefName: string; - readonly headRefName: string; - readonly state?: "open" | "closed" | "merged"; - readonly isCrossRepository?: boolean; - readonly headRepositoryNameWithOwner?: string | null; - readonly headRepositoryOwnerLogin?: string | null; -} - -export interface GitHubRepositoryCloneUrls { - readonly nameWithOwner: string; - readonly url: string; - readonly sshUrl: string; -} - -/** - * GitHubCliShape - Service API for executing GitHub CLI commands. - */ -export interface GitHubCliShape { - /** - * Execute a GitHub CLI command and return full process output. - */ - readonly execute: (input: { - readonly cwd: string; - readonly args: ReadonlyArray; - readonly timeoutMs?: number; - }) => Effect.Effect; - - /** - * List open pull requests for a head branch. - */ - readonly listOpenPullRequests: (input: { - readonly cwd: string; - readonly headSelector: string; - readonly limit?: number; - }) => Effect.Effect, GitHubCliError>; - - /** - * Resolve a pull request by URL, number, or branch-ish identifier. - */ - readonly getPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - }) => Effect.Effect; - - /** - * Resolve clone URLs for a GitHub repository. - */ - readonly getRepositoryCloneUrls: (input: { - readonly cwd: string; - readonly repository: string; - }) => Effect.Effect; - - /** - * Create a pull request from branch context and body file. - */ - readonly createPullRequest: (input: { - readonly cwd: string; - readonly baseBranch: string; - readonly headSelector: string; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; - - /** - * Resolve repository default branch through GitHub metadata. - */ - readonly getDefaultBranch: (input: { - readonly cwd: string; - }) => Effect.Effect; - - /** - * Checkout a pull request into the current repository worktree. - */ - readonly checkoutPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - readonly force?: boolean; - }) => Effect.Effect; -} - -/** - * GitHubCli - Service tag for GitHub CLI process execution. - */ -export class GitHubCli extends Context.Service()( - "t3/git/Services/GitHubCli", -) {} diff --git a/apps/server/src/git/Services/GitManager.ts b/apps/server/src/git/Services/GitManager.ts deleted file mode 100644 index 29c762195e5..00000000000 --- a/apps/server/src/git/Services/GitManager.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * GitManager - Effect service contract for stacked Git workflows. - * - * Orchestrates status inspection and commit/push/PR flows by composing - * lower-level Git and external tool services. - * - * @module GitManager - */ -import { - GitActionProgressEvent, - GitPreparePullRequestThreadInput, - GitPreparePullRequestThreadResult, - GitPullRequestRefInput, - GitResolvePullRequestResult, - GitRunStackedActionInput, - GitRunStackedActionResult, - GitStatusLocalResult, - GitStatusRemoteResult, - GitStatusInput, - GitStatusResult, -} from "@t3tools/contracts"; -import { Context } from "effect"; -import type { Effect } from "effect"; -import type { GitManagerServiceError } from "@t3tools/contracts"; - -export interface GitActionProgressReporter { - readonly publish: (event: GitActionProgressEvent) => Effect.Effect; -} - -export interface GitRunStackedActionOptions { - readonly actionId?: string; - readonly progressReporter?: GitActionProgressReporter; -} - -/** - * GitManagerShape - Service API for high-level Git workflow actions. - */ -export interface GitManagerShape { - /** - * Read current repository Git status plus open PR metadata when available. - */ - readonly status: ( - input: GitStatusInput, - ) => Effect.Effect; - - /** - * Read local repository status without remote hosting enrichment. - */ - readonly localStatus: ( - input: GitStatusInput, - ) => Effect.Effect; - - /** - * Read remote tracking / PR status for a repository. - */ - readonly remoteStatus: ( - input: GitStatusInput, - ) => Effect.Effect; - - /** - * Clear any cached local status snapshot for a repository. - */ - readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; - - /** - * Clear any cached remote status snapshot for a repository. - */ - readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; - - /** - * Clear any cached status snapshot for a repository so the next read is fresh. - */ - readonly invalidateStatus: (cwd: string) => Effect.Effect; - - /** - * Resolve a pull request by URL/number against the current repository. - */ - readonly resolvePullRequest: ( - input: GitPullRequestRefInput, - ) => Effect.Effect; - - /** - * Prepare a new thread workspace from a pull request in local or worktree mode. - */ - readonly preparePullRequestThread: ( - input: GitPreparePullRequestThreadInput, - ) => Effect.Effect; - - /** - * Run a Git action (`commit`, `push`, `create_pr`, `commit_push`, `commit_push_pr`). - * When `featureBranch` is set, creates and checks out a feature branch first. - */ - readonly runStackedAction: ( - input: GitRunStackedActionInput, - options?: GitRunStackedActionOptions, - ) => Effect.Effect; -} - -/** - * GitManager - Service tag for stacked Git workflow orchestration. - */ -export class GitManager extends Context.Service()( - "t3/git/Services/GitManager", -) {} diff --git a/apps/server/src/git/Services/GitStatusBroadcaster.ts b/apps/server/src/git/Services/GitStatusBroadcaster.ts deleted file mode 100644 index 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/git/Utils.ts b/apps/server/src/git/Utils.ts index 15015e8cda5..6faf3e99c77 100644 --- a/apps/server/src/git/Utils.ts +++ b/apps/server/src/git/Utils.ts @@ -1,171 +1,6 @@ -/** - * Shared utilities for text generation layers (Codex, Claude, etc.). - * - * @module textGenerationUtils - */ -import { Schema } from "effect"; - -import { TextGenerationError } from "@t3tools/contracts"; - import { existsSync } from "node:fs"; import { join } from "node:path"; export function isGitRepository(cwd: string): boolean { return existsSync(join(cwd, ".git")); } - -/** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */ -export function toJsonSchemaObject(schema: Schema.Top): unknown { - const document = Schema.toJsonSchemaDocument(schema); - if (document.definitions && Object.keys(document.definitions).length > 0) { - return { ...document.schema, $defs: document.definitions }; - } - return document.schema; -} - -/** Truncate a text section to `maxChars`, appending a `[truncated]` marker when needed. */ -export function limitSection(value: string, maxChars: number): string { - if (value.length <= maxChars) return value; - const truncated = value.slice(0, maxChars); - return `${truncated}\n\n[truncated]`; -} - -export function extractJsonObject(raw: string): string { - const trimmed = raw.trim(); - if (trimmed.length === 0) { - return trimmed; - } - - const start = trimmed.indexOf("{"); - if (start < 0) { - return trimmed; - } - - let depth = 0; - let inString = false; - let escaping = false; - for (let index = start; index < trimmed.length; index += 1) { - const char = trimmed[index]; - if (inString) { - if (escaping) { - escaping = false; - } else if (char === "\\") { - escaping = true; - } else if (char === '"') { - inString = false; - } - continue; - } - - if (char === '"') { - inString = true; - continue; - } - - if (char === "{") { - depth += 1; - continue; - } - - if (char === "}") { - depth -= 1; - if (depth === 0) { - return trimmed.slice(start, index + 1); - } - } - } - - return trimmed.slice(start); -} - -/** Normalise a raw commit subject to imperative-mood, ≤72 chars, no trailing period. */ -export function sanitizeCommitSubject(raw: string): string { - const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; - const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim(); - if (withoutTrailingPeriod.length === 0) { - return "Update project files"; - } - - if (withoutTrailingPeriod.length <= 72) { - return withoutTrailingPeriod; - } - return withoutTrailingPeriod.slice(0, 72).trimEnd(); -} - -/** Normalise a raw PR title to a single line with a sensible fallback. */ -export function sanitizePrTitle(raw: string): string { - const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; - if (singleLine.length > 0) { - return singleLine; - } - return "Update project changes"; -} - -/** Normalise a raw thread title to a compact single-line sidebar-safe label. */ -export function sanitizeThreadTitle(raw: string): string { - const normalized = raw - .trim() - .split(/\r?\n/g)[0] - ?.trim() - .replace(/^['"`]+|['"`]+$/g, "") - .trim() - .replace(/\s+/g, " "); - - if (!normalized || normalized.trim().length === 0) { - return "New thread"; - } - - if (normalized.length <= 50) { - return normalized; - } - - return `${normalized.slice(0, 47).trimEnd()}...`; -} - -/** CLI name to human-readable label, e.g. "codex" → "Codex CLI (`codex`)" */ -function cliLabel(cliName: string): string { - const capitalized = cliName.charAt(0).toUpperCase() + cliName.slice(1); - return `${capitalized} CLI (\`${cliName}\`)`; -} - -/** - * Normalize an unknown error from a CLI text generation process into a - * typed `TextGenerationError`. Parameterized by CLI name so both Codex - * and Claude (and future providers) can share the same logic. - */ -export function normalizeCliError( - cliName: string, - operation: string, - error: unknown, - fallback: string, -): TextGenerationError { - if (Schema.is(TextGenerationError)(error)) { - return error; - } - - if (error instanceof Error) { - const lower = error.message.toLowerCase(); - if ( - error.message.includes(`Command not found: ${cliName}`) || - lower.includes(`spawn ${cliName}`) || - lower.includes("enoent") - ) { - return new TextGenerationError({ - operation, - detail: `${cliLabel(cliName)} is required but not available on PATH.`, - cause: error, - }); - } - return new TextGenerationError({ - operation, - detail: `${fallback}: ${error.message}`, - cause: error, - }); - } - - return new TextGenerationError({ - operation, - detail: fallback, - cause: error, - }); -} diff --git a/apps/server/src/git/githubPullRequests.ts b/apps/server/src/git/githubPullRequests.ts index 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/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 5603ce63252..ad5fb59bd1e 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -24,8 +24,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; -import { GitCoreLive } from "../../git/Layers/GitCore.ts"; -import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts"; +import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; +import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { CheckpointReactorLive } from "./CheckpointReactor.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; @@ -282,7 +283,7 @@ describe("CheckpointReactor", () => { const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-checkpoint-reactor-test-", }); - const gitStatusBroadcasterLayer = Layer.succeed(GitStatusBroadcaster, { + const vcsStatusBroadcasterLayer = Layer.succeed(VcsStatusBroadcaster, { getStatus: () => Effect.die("getStatus should not be called in this test"), refreshLocalStatus: (cwd: string) => Effect.sync(() => { @@ -290,9 +291,9 @@ describe("CheckpointReactor", () => { }).pipe( Effect.as({ isRepo: true, - hasOriginRemote: false, - isDefaultBranch: true, - branch: "main", + hasPrimaryRemote: false, + isDefaultRef: true, + refName: "main", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, }), @@ -305,11 +306,16 @@ describe("CheckpointReactor", () => { Layer.provideMerge(orchestrationLayer), Layer.provideMerge(RuntimeReceiptBusLive), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), - Layer.provideMerge(gitStatusBroadcasterLayer), - Layer.provideMerge(CheckpointStoreLive), - Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), + Layer.provideMerge(vcsStatusBroadcasterLayer), + Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer))), + Layer.provideMerge( + WorkspaceEntriesLive.pipe( + Layer.provide(WorkspacePathsLive), + Layer.provideMerge(VcsDriverRegistry.layer), + ), + ), Layer.provideMerge(WorkspacePathsLive), - Layer.provideMerge(GitCoreLive), + Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 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 c44f291504a..09252571c37 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -33,12 +33,7 @@ import { ProviderService, type ProviderServiceShape, } from "../../provider/Services/ProviderService.ts"; -import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.ts"; -import { - GitStatusBroadcaster, - type GitStatusBroadcasterShape, -} from "../../git/Services/GitStatusBroadcaster.ts"; -import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts"; +import { TextGeneration, type TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; @@ -52,6 +47,8 @@ import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; +import { GitWorkflowService, type GitWorkflowServiceShape } from "../../git/GitWorkflowService.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value); @@ -243,9 +240,9 @@ describe("ProviderCommandReactor", () => { const refreshStatus = vi.fn((_: string) => Effect.succeed({ isRepo: true, - hasOriginRemote: true, - isDefaultBranch: false, - branch: "renamed-branch", + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "renamed-branch", hasWorkingTreeChanges: false, workingTree: { files: [], @@ -324,15 +321,19 @@ describe("ProviderCommandReactor", () => { const layer = ProviderCommandReactorLive.pipe( Layer.provideMerge(orchestrationLayer), Layer.provideMerge(Layer.succeed(ProviderService, service)), - Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)), Layer.provideMerge( - Layer.succeed(GitStatusBroadcaster, { + Layer.mock(GitWorkflowService)({ + renameBranch, + } satisfies Partial), + ), + Layer.provideMerge( + Layer.succeed(VcsStatusBroadcaster, { getStatus: () => Effect.die("getStatus should not be called in this test"), refreshLocalStatus: () => Effect.die("refreshLocalStatus should not be called in this test"), refreshStatus, streamStatus: () => Stream.die("streamStatus should not be called in this test"), - } satisfies GitStatusBroadcasterShape), + }), ), Layer.provideMerge( Layer.mock(TextGeneration, { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 9a9f3d71b08..998475f6118 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -16,12 +16,10 @@ import { Cache, Cause, Duration, Effect, Equal, Layer, Option, Schema, Stream } import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; -import { GitCore } from "../../git/Services/GitCore.ts"; -import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts"; import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; import type { ProviderServiceError } from "../../provider/Errors.ts"; -import { TextGeneration } from "../../git/Services/TextGeneration.ts"; +import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { @@ -29,6 +27,8 @@ import { type ProviderCommandReactorShape, } from "../Services/ProviderCommandReactor.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; +import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; type ProviderIntentEvent = Extract< OrchestrationEvent, @@ -168,8 +168,8 @@ function buildGeneratedWorktreeBranchName(raw: string): string { const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const providerService = yield* ProviderService; - const git = yield* GitCore; - const gitStatusBroadcaster = yield* GitStatusBroadcaster; + const gitWorkflow = yield* GitWorkflowService; + const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; const handledTurnStartKeys = yield* Cache.make({ @@ -587,7 +587,7 @@ const make = Effect.gen(function* () { const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); if (targetBranch === oldBranch) return; - const renamed = yield* git.renameBranch({ cwd, oldBranch, newBranch: targetBranch }); + const renamed = yield* gitWorkflow.renameBranch({ cwd, oldBranch, newBranch: targetBranch }); yield* orchestrationEngine.dispatch({ type: "thread.meta.update", commandId: serverCommandId("worktree-branch-rename"), @@ -595,7 +595,7 @@ const make = Effect.gen(function* () { branch: renamed.branch, worktreePath: cwd, }); - yield* gitStatusBroadcaster.refreshStatus(cwd).pipe(Effect.ignoreCause({ log: true })); + yield* vcsStatusBroadcaster.refreshStatus(cwd).pipe(Effect.ignoreCause({ log: true })); }).pipe( Effect.catchCause((cause) => Effect.logWarning("provider command reactor failed to generate or rename worktree branch", { diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts index 307123551bb..ae35c29ce21 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts @@ -1,6 +1,9 @@ import type { RepositoryIdentity } from "@t3tools/contracts"; import { Cache, Duration, Effect, Exit, Layer } from "effect"; -import { detectGitHostingProviderFromRemoteUrl, normalizeGitRemoteUrl } from "@t3tools/shared/git"; +import { + detectSourceControlProviderFromGitRemoteUrl, + normalizeGitRemoteUrl, +} from "@t3tools/shared/git"; import { runProcess } from "../../processRunner.ts"; import { @@ -45,7 +48,7 @@ function buildRepositoryIdentity(input: { readonly rootPath: string; }): RepositoryIdentity { const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); - const hostingProvider = detectGitHostingProviderFromRemoteUrl(input.remoteUrl); + const sourceControlProvider = detectSourceControlProviderFromGitRemoteUrl(input.remoteUrl); const repositoryPath = canonicalKey.split("/").slice(1).join("/"); const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0); const [owner] = repositoryPathSegments; @@ -60,7 +63,7 @@ function buildRepositoryIdentity(input: { }, rootPath: input.rootPath, ...(repositoryPath ? { displayName: repositoryPath } : {}), - ...(hostingProvider ? { provider: hostingProvider.kind } : {}), + ...(sourceControlProvider ? { provider: sourceControlProvider.kind } : {}), ...(owner ? { owner } : {}), ...(repositoryName ? { name: repositoryName } : {}), }; diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index feddd3b86b2..311f4958651 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -16,7 +16,7 @@ import { ClaudeSettings, ProviderDriverKind, type ServerProvider } from "@t3tool import { Cache, Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { makeClaudeTextGeneration } from "../../git/Layers/ClaudeTextGeneration.ts"; +import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; import { ServerConfig } from "../../config.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 13598ab5f61..26fffd5e213 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -25,7 +25,7 @@ import { CodexSettings, ProviderDriverKind, type ServerProvider } from "@t3tools import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { makeCodexTextGeneration } from "../../git/Layers/CodexTextGeneration.ts"; +import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; import { ServerConfig } from "../../config.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index b7eda591bc8..cd058800f29 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -17,7 +17,7 @@ import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { ServerConfig } from "../../config.ts"; -import { makeCursorTextGeneration } from "../../git/Layers/CursorTextGeneration.ts"; +import { makeCursorTextGeneration } from "../../textGeneration/CursorTextGeneration.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCursorAdapter } from "../Layers/CursorAdapter.ts"; import { diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 29f74ead023..27f98a9830d 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -16,7 +16,7 @@ import { OpenCodeSettings, ProviderDriverKind, type ServerProvider } from "@t3to import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { makeOpenCodeTextGeneration } from "../../git/Layers/OpenCodeTextGeneration.ts"; +import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts"; import { ServerConfig } from "../../config.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeOpenCodeAdapter } from "../Layers/OpenCodeAdapter.ts"; diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index 05735f3a50d..eeba158ab42 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -14,7 +14,7 @@ import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import type { ProviderInstance } from "../ProviderDriver.ts"; -import type { TextGenerationShape } from "../../git/Services/TextGeneration.ts"; +import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index bedfd61194e..e7d3e40cd8c 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -28,7 +28,7 @@ import type { } from "@t3tools/contracts"; import type { Effect, Schema, Scope } from "effect"; -import type { TextGenerationShape } from "../git/Services/TextGeneration.ts"; +import type { TextGenerationShape } from "../textGeneration/TextGeneration.ts"; import type { ProviderAdapterError, ProviderDriverError } from "./Errors.ts"; import type { ProviderAdapterShape } from "./Services/ProviderAdapter.ts"; import type { ServerProviderShape } from "./Services/ServerProvider.ts"; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index e699ad8339c..a1064752e29 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -29,6 +29,7 @@ import { assert, it } from "@effect/vitest"; import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; import { Deferred, + DateTime, Duration, Effect, FileSystem, @@ -38,6 +39,7 @@ import { Path, Stream, } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { FetchHttpClient, HttpBody, @@ -50,6 +52,8 @@ import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; import * as Socket from "effect/unstable/socket/Socket"; import { vi } from "vitest"; +const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); + import type { ServerConfigShape } from "./config.ts"; import { deriveServerPaths, ServerConfig } from "./config.ts"; import { makeRoutesLayer } from "./server.ts"; @@ -58,13 +62,7 @@ import { CheckpointDiffQuery, type CheckpointDiffQueryShape, } from "./checkpointing/Services/CheckpointDiffQuery.ts"; -import { GitCore, type GitCoreShape } from "./git/Services/GitCore.ts"; -import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; -import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster.ts"; -import { - GitStatusBroadcaster, - type GitStatusBroadcasterShape, -} from "./git/Services/GitStatusBroadcaster.ts"; +import { GitManager, type GitManagerShape } from "./git/GitManager.ts"; import { Keybindings, type KeybindingsShape } from "./keybindings.ts"; import { Open, type OpenShape } from "./open.ts"; import { @@ -105,6 +103,20 @@ import { import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; +import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; +import type { VcsDriverShape } from "./vcs/VcsDriver.ts"; +import { + VcsStatusBroadcaster, + type VcsStatusBroadcasterShape, + layer as VcsStatusBroadcasterLayer, +} from "./vcs/VcsStatusBroadcaster.ts"; +import { + VcsDriverRegistry, + type VcsDriverRegistryShape, + type VcsDriverHandle, +} from "./vcs/VcsDriverRegistry.ts"; +import { layer as VcsProvisioningServiceLayer } from "./vcs/VcsProvisioningService.ts"; +import { layer as GitWorkflowServiceLayer } from "./git/GitWorkflowService.ts"; import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; @@ -195,16 +207,6 @@ const makeDefaultOrchestrationThreadShell = ( }; }; -const workspaceAndProjectServicesLayer = Layer.mergeAll( - WorkspacePathsLive, - WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive)), - WorkspaceFileSystemLive.pipe( - Layer.provide(WorkspacePathsLive), - Layer.provide(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), - ), - ProjectFaviconResolverLive, -); - const browserOtlpTracingLayer = Layer.mergeAll( FetchHttpClient.layer, OtlpSerialization.layerJson, @@ -324,9 +326,11 @@ const buildAppUnderTest = (options?: { providerRegistry?: Partial; serverSettings?: Partial; open?: Partial; - gitCore?: Partial; + vcsDriver?: Partial; + vcsDriverRegistry?: Partial; + gitVcsDriver?: Partial; gitManager?: Partial; - gitStatusBroadcaster?: Partial; + vcsStatusBroadcaster?: Partial; projectSetupScriptRunner?: Partial; terminalManager?: Partial; orchestrationEngine?: Partial; @@ -372,22 +376,110 @@ const buildAppUnderTest = (options?: { ...options?.config, }; const layerConfig = Layer.succeed(ServerConfig, config); - const gitCoreLayer = Layer.mock(GitCore)({ + const defaultVcsDriver: VcsDriverShape = { + capabilities: { + kind: "git", + supportsWorktrees: true, + supportsBookmarks: false, + supportsAtomicSnapshot: false, + supportsPushDefaultRemote: true, + ignoreClassifier: "native", + }, + execute: () => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + detectRepository: () => Effect.succeed(null), isInsideWorkTree: () => Effect.succeed(false), listWorkspaceFiles: () => Effect.succeed({ paths: [], truncated: false, + freshness: { + source: "live-local", + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, + }), + listRemotes: () => + Effect.succeed({ + remotes: [], + freshness: { + source: "live-local", + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, }), filterIgnoredPaths: (_cwd, relativePaths) => Effect.succeed(relativePaths), - ...options?.layers?.gitCore, + initRepository: () => Effect.void, + ...options?.layers?.vcsDriver, + }; + const vcsDriverRegistryLayer = Layer.mock(VcsDriverRegistry)({ + get: () => Effect.succeed(defaultVcsDriver), + detect: (input) => + defaultVcsDriver.detectRepository(input.cwd).pipe( + Effect.flatMap((repository) => + repository + ? Effect.succeed(repository) + : defaultVcsDriver.isInsideWorkTree(input.cwd).pipe( + Effect.map((isInsideWorkTree) => + isInsideWorkTree + ? { + kind: "git" as const, + rootPath: input.cwd, + metadataPath: null, + freshness: { + source: "live-local" as const, + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, + } + : null, + ), + ), + ), + Effect.map((repository) => + repository + ? ({ + kind: repository.kind, + repository, + driver: defaultVcsDriver, + } satisfies VcsDriverHandle) + : null, + ), + ), + resolve: (input) => + Effect.succeed({ + kind: + input.requestedKind === "auto" || !input.requestedKind ? "git" : input.requestedKind, + repository: { + kind: + input.requestedKind === "auto" || !input.requestedKind ? "git" : input.requestedKind, + rootPath: input.cwd, + metadataPath: null, + freshness: { + source: "live-local", + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, + }, + driver: defaultVcsDriver, + }), + ...options?.layers?.vcsDriverRegistry, + }); + const gitVcsDriverLayer = Layer.mock(GitVcsDriver.GitVcsDriver)({ + ...options?.layers?.gitVcsDriver, }); const gitManagerLayer = Layer.mock(GitManager)({ ...options?.layers?.gitManager, }); const workspaceEntriesLayer = WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), - Layer.provideMerge(gitCoreLayer), + Layer.provideMerge(vcsDriverRegistryLayer), ); const workspaceAndProjectServicesLayer = Layer.mergeAll( WorkspacePathsLive, @@ -398,11 +490,19 @@ const buildAppUnderTest = (options?: { ), ProjectFaviconResolverLive, ); - const gitStatusBroadcasterLayer = options?.layers?.gitStatusBroadcaster - ? Layer.mock(GitStatusBroadcaster)({ - ...options.layers.gitStatusBroadcaster, + const gitWorkflowLayer = GitWorkflowServiceLayer.pipe( + Layer.provideMerge(vcsDriverRegistryLayer), + Layer.provideMerge(gitVcsDriverLayer), + Layer.provideMerge(gitManagerLayer), + ); + const vcsProvisioningLayer = VcsProvisioningServiceLayer.pipe( + Layer.provide(vcsDriverRegistryLayer), + ); + const vcsStatusBroadcasterLayer = options?.layers?.vcsStatusBroadcaster + ? Layer.mock(VcsStatusBroadcaster)({ + ...options.layers.vcsStatusBroadcaster, }) - : GitStatusBroadcasterLive.pipe(Layer.provide(gitManagerLayer)); + : VcsStatusBroadcasterLayer.pipe(Layer.provide(gitWorkflowLayer)); const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, { disableListenLog: true, @@ -441,9 +541,11 @@ const buildAppUnderTest = (options?: { ...options?.layers?.open, }), ), - Layer.provide(gitCoreLayer), Layer.provide(gitManagerLayer), - Layer.provideMerge(gitStatusBroadcasterLayer), + Layer.provide(gitVcsDriverLayer), + Layer.provide(gitWorkflowLayer), + Layer.provide(vcsProvisioningLayer), + Layer.provideMerge(vcsStatusBroadcasterLayer), Layer.provide( Layer.mock(ProjectSetupScriptRunner)({ runForThread: () => Effect.succeed({ status: "no-script" as const }), @@ -2068,12 +2170,17 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { - gitCore: { + vcsDriver: { isInsideWorkTree: () => Effect.succeed(true), listWorkspaceFiles: () => Effect.succeed({ paths: ["src/tracked.ts"], truncated: false, + freshness: { + source: "live-local", + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, }), filterIgnoredPaths: (_cwd, relativePaths) => Effect.succeed( @@ -2273,9 +2380,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { localStatus: () => Effect.succeed({ isRepo: true, - hasOriginRemote: true, - isDefaultBranch: true, - branch: "main", + hasPrimaryRemote: true, + isDefaultRef: true, + refName: "main", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, }), @@ -2289,9 +2396,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { status: () => Effect.succeed({ isRepo: true, - hasOriginRemote: true, - isDefaultBranch: true, - branch: "main", + hasPrimaryRemote: true, + isDefaultRef: true, + refName: "main", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: true, @@ -2372,16 +2479,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { worktreePath: null, }), }, - gitCore: { + gitVcsDriver: { pullCurrentBranch: () => Effect.succeed({ status: "pulled", - branch: "main", - upstreamBranch: "origin/main", + refName: "main", + upstreamRef: "origin/main", }), - listBranches: () => + listRefs: () => Effect.succeed({ - branches: [ + refs: [ { name: "main", current: true, @@ -2390,18 +2497,20 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, ], isRepo: true, - hasOriginRemote: true, + hasPrimaryRemote: true, nextCursor: null, totalCount: 1, }), createWorktree: () => Effect.succeed({ - worktree: { path: "/tmp/wt", branch: "feature/demo" }, + worktree: { path: "/tmp/wt", refName: "feature/demo" }, }), removeWorktree: () => Effect.void, - createBranch: (input) => Effect.succeed({ branch: input.branch }), - checkoutBranch: (input) => Effect.succeed({ branch: input.branch }), - initRepo: () => Effect.void, + createRef: (input) => Effect.succeed({ refName: input.refName }), + switchRef: (input) => Effect.succeed({ refName: input.refName }), + }, + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), }, }, }); @@ -2409,13 +2518,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const wsUrl = yield* getWsServerUrl("/ws"); const pull = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })), + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.vcsPull]({ cwd: "/tmp/repo" })), ); assert.equal(pull.status, "pulled"); const refreshedStatus = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitRefreshStatus]({ cwd: "/tmp/repo" }), + client[WS_METHODS.vcsRefreshStatus]({ cwd: "/tmp/repo" }), ), ); assert.equal(refreshedStatus.isRepo, true); @@ -2459,27 +2568,25 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.equal(prepared.branch, "feature/demo"); - const branches = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitListBranches]({ cwd: "/tmp/repo" }), - ), + const refs = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.vcsListRefs]({ cwd: "/tmp/repo" })), ); - assert.equal(branches.branches[0]?.name, "main"); + assert.equal(refs.refs[0]?.name, "main"); const worktree = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitCreateWorktree]({ + client[WS_METHODS.vcsCreateWorktree]({ cwd: "/tmp/repo", - branch: "main", + refName: "main", path: null, }), ), ); - assert.equal(worktree.worktree.branch, "feature/demo"); + assert.equal(worktree.worktree.refName, "feature/demo"); yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitRemoveWorktree]({ + client[WS_METHODS.vcsRemoveWorktree]({ cwd: "/tmp/repo", path: "/tmp/wt", }), @@ -2488,25 +2595,25 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitCreateBranch]({ + client[WS_METHODS.vcsCreateRef]({ cwd: "/tmp/repo", - branch: "feature/new", + refName: "feature/new", }), ), ); yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitCheckout]({ + client[WS_METHODS.vcsSwitchRef]({ cwd: "/tmp/repo", - branch: "main", + refName: "main", }), ), ); yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitInit]({ + client[WS_METHODS.vcsInit]({ cwd: "/tmp/repo", }), ), @@ -2526,7 +2633,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { let statusCalls = 0; yield* buildAppUnderTest({ layers: { - gitCore: { + gitVcsDriver: { pullCurrentBranch: () => Effect.fail(gitError), }, gitManager: { @@ -2545,9 +2652,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { localStatus: () => Effect.succeed({ isRepo: true, - hasOriginRemote: true, - isDefaultBranch: true, - branch: "main", + hasPrimaryRemote: true, + isDefaultRef: true, + refName: "main", hasWorkingTreeChanges: true, workingTree: { files: [], insertions: 0, deletions: 0 }, }), @@ -2566,9 +2673,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { statusCalls += 1; return { isRepo: true, - hasOriginRemote: true, - isDefaultBranch: true, - branch: "main", + hasPrimaryRemote: true, + isDefaultRef: true, + refName: "main", hasWorkingTreeChanges: true, workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: true, @@ -2583,7 +2690,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const wsUrl = yield* getWsServerUrl("/ws"); const result = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })).pipe( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.vcsPull]({ cwd: "/tmp/repo" })).pipe( Effect.result, ), ); @@ -2622,9 +2729,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { localStatus: () => Effect.succeed({ isRepo: true, - hasOriginRemote: true, - isDefaultBranch: false, - branch: "feature/demo", + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/demo", hasWorkingTreeChanges: true, workingTree: { files: [], insertions: 0, deletions: 0 }, }), @@ -2643,9 +2750,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { statusCalls += 1; return { isRepo: true, - hasOriginRemote: true, - isDefaultBranch: false, - branch: "feature/demo", + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/demo", hasWorkingTreeChanges: true, workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: true, @@ -2680,12 +2787,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest({ layers: { - gitCore: { + gitVcsDriver: { pullCurrentBranch: () => Effect.succeed({ status: "pulled" as const, - branch: "main", - upstreamBranch: "origin/main", + refName: "main", + upstreamRef: "origin/main", }), }, gitManager: { @@ -2694,9 +2801,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { localStatus: () => Effect.succeed({ isRepo: true, - hasOriginRemote: true, - isDefaultBranch: true, - branch: "main", + hasPrimaryRemote: true, + isDefaultRef: true, + refName: "main", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, }), @@ -2716,7 +2823,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const wsUrl = yield* getWsServerUrl("/ws"); const startedAt = Date.now(); const result = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })), + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.vcsPull]({ cwd: "/tmp/repo" })), ); const elapsedMs = Date.now() - startedAt; @@ -2731,15 +2838,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest({ layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, gitManager: { invalidateLocalStatus: () => Effect.void, invalidateRemoteStatus: () => Effect.void, localStatus: () => Effect.succeed({ isRepo: true, - hasOriginRemote: true, - isDefaultBranch: false, - branch: "feature/demo", + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/demo", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, }), @@ -2804,6 +2914,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, gitManager: { invalidateLocalStatus: () => Effect.void, invalidateRemoteStatus: () => Effect.void, @@ -2813,9 +2926,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.andThen( Effect.succeed({ isRepo: true, - hasOriginRemote: true, - isDefaultBranch: false, - branch: "feature/demo", + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/demo", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, }), @@ -3479,9 +3592,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const refreshStatus = vi.fn((_: string) => Effect.succeed({ isRepo: true, - hasOriginRemote: true, - isDefaultBranch: false, - branch: "t3code/bootstrap-branch", + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "t3code/bootstrap-refName", hasWorkingTreeChanges: false, workingTree: { files: [], @@ -3494,13 +3607,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { pr: null, }), ); - const createWorktree = vi.fn((_: Parameters[0]) => - Effect.succeed({ - worktree: { - branch: "t3code/bootstrap-branch", - path: "/tmp/bootstrap-worktree", - }, - }), + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + worktree: { + refName: "t3code/bootstrap-refName", + path: "/tmp/bootstrap-worktree", + }, + }), ); const runForThread = vi.fn( (_: Parameters[0]) => @@ -3515,10 +3629,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { - gitCore: { + gitVcsDriver: { createWorktree, }, - gitStatusBroadcaster: { + vcsStatusBroadcaster: { refreshStatus, }, orchestrationEngine: { @@ -3566,7 +3680,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { prepareWorktree: { projectCwd: "/tmp/project", baseBranch: "main", - branch: "t3code/bootstrap-branch", + branch: "t3code/bootstrap-refName", }, runSetupScript: true, }, @@ -3588,8 +3702,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.deepEqual(createWorktree.mock.calls[0]?.[0], { cwd: "/tmp/project", - branch: "main", - newBranch: "t3code/bootstrap-branch", + refName: "main", + newRefName: "t3code/bootstrap-refName", path: null, }); assert.deepEqual(runForThread.mock.calls[0]?.[0], { @@ -3619,13 +3733,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("records setup-script failures without aborting bootstrap turn start", () => Effect.gen(function* () { const dispatchedCommands: Array = []; - const createWorktree = vi.fn((_: Parameters[0]) => - Effect.succeed({ - worktree: { - branch: "t3code/bootstrap-branch", - path: "/tmp/bootstrap-worktree", - }, - }), + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + worktree: { + refName: "t3code/bootstrap-refName", + path: "/tmp/bootstrap-worktree", + }, + }), ); const runForThread = vi.fn( (_: Parameters[0]) => @@ -3634,7 +3749,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { - gitCore: { + gitVcsDriver: { createWorktree, }, orchestrationEngine: { @@ -3682,7 +3797,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { prepareWorktree: { projectCwd: "/tmp/project", baseBranch: "main", - branch: "t3code/bootstrap-branch", + branch: "t3code/bootstrap-refName", }, runSetupScript: true, }, @@ -3712,13 +3827,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("does not misattribute setup activity dispatch failures as setup launch failures", () => Effect.gen(function* () { const dispatchedCommands: Array = []; - const createWorktree = vi.fn((_: Parameters[0]) => - Effect.succeed({ - worktree: { - branch: "t3code/bootstrap-branch", - path: "/tmp/bootstrap-worktree", - }, - }), + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + worktree: { + refName: "t3code/bootstrap-refName", + path: "/tmp/bootstrap-worktree", + }, + }), ); const runForThread = vi.fn( (_: Parameters[0]) => @@ -3734,7 +3850,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { - gitCore: { + gitVcsDriver: { createWorktree, }, orchestrationEngine: { @@ -3798,7 +3914,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { prepareWorktree: { projectCwd: "/tmp/project", baseBranch: "main", - branch: "t3code/bootstrap-branch", + branch: "t3code/bootstrap-refName", }, runSetupScript: true, }, @@ -3830,13 +3946,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("cleans up created bootstrap threads when worktree creation defects", () => Effect.gen(function* () { const dispatchedCommands: Array = []; - const createWorktree = vi.fn((_: Parameters[0]) => - Effect.die(new Error("worktree exploded")), + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.die(new Error("worktree exploded")), ); yield* buildAppUnderTest({ layers: { - gitCore: { + gitVcsDriver: { createWorktree, }, orchestrationEngine: { @@ -3881,7 +3998,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { prepareWorktree: { projectCwd: "/tmp/project", baseBranch: "main", - branch: "t3code/bootstrap-branch", + branch: "t3code/bootstrap-refName", }, runSetupScript: false, }, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 85f2d84ad28..4b31543e594 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -25,13 +25,11 @@ import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReap import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts"; import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts"; import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts"; -import { GitCoreLive } from "./git/Layers/GitCore.ts"; -import { GitHubCliLive } from "./git/Layers/GitHubCli.ts"; -import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster.ts"; -import { TextGenerationLive } from "./git/Layers/TextGenerationLive.ts"; +import * as GitHubCli from "./sourceControl/GitHubCli.ts"; +import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; -import { GitManagerLive } from "./git/Layers/GitManager.ts"; +import * as GitManager from "./git/GitManager.ts"; import { KeybindingsLive } from "./keybindings.ts"; import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup.ts"; import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor.ts"; @@ -47,6 +45,14 @@ import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdent import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; +import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; +import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; +import * as VcsProcess from "./vcs/VcsProcess.ts"; +import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; +import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; +import * as GitWorkflowService from "./git/GitWorkflowService.ts"; +import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts"; import { ObservabilityLive } from "./observability/Layers/Observability.ts"; import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts"; @@ -133,11 +139,6 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(RuntimeReceiptBusLive), ); -const CheckpointingLayerLive = Layer.empty.pipe( - Layer.provideMerge(CheckpointDiffQueryLive), - Layer.provideMerge(CheckpointStoreLive), -); - const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( Layer.provide(ProviderSessionRuntimeRepositoryLive), ); @@ -155,24 +156,50 @@ const ProviderLayerLive = ProviderServiceLive.pipe( const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); -const GitManagerLayerLive = GitManagerLive.pipe( +const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( + Layer.provide(VcsProjectConfig.layer), +); + +const GitManagerLayerLive = GitManager.layer.pipe( Layer.provideMerge(ProjectSetupScriptRunnerLive), - Layer.provideMerge(GitCoreLive), - Layer.provideMerge(GitHubCliLive), - Layer.provideMerge(TextGenerationLive), + Layer.provideMerge(GitVcsDriver.layer), + Layer.provideMerge( + SourceControlProviderRegistry.layer.pipe( + Layer.provide(GitHubCli.layer), + Layer.provideMerge(VcsDriverRegistryLayerLive), + ), + ), + Layer.provideMerge(TextGeneration.layer), ); const GitLayerLive = Layer.empty.pipe( Layer.provideMerge(GitManagerLayerLive), - Layer.provideMerge(GitStatusBroadcasterLive.pipe(Layer.provide(GitManagerLayerLive))), - Layer.provideMerge(GitCoreLive), + Layer.provideMerge(GitVcsDriver.layer), +); + +const GitWorkflowLayerLive = GitWorkflowService.layer.pipe( + Layer.provideMerge(VcsDriverRegistryLayerLive), + Layer.provideMerge(GitLayerLive), +); + +const VcsLayerLive = Layer.empty.pipe( + Layer.provideMerge(VcsProjectConfig.layer), + Layer.provideMerge(VcsDriverRegistryLayerLive), + Layer.provideMerge(VcsProvisioningService.layer.pipe(Layer.provide(VcsDriverRegistryLayerLive))), + Layer.provideMerge(GitWorkflowLayerLive), + Layer.provideMerge(VcsStatusBroadcaster.layer.pipe(Layer.provide(GitWorkflowLayerLive))), +); + +const CheckpointingLayerLive = Layer.empty.pipe( + Layer.provideMerge(CheckpointDiffQueryLive), + Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))), ); const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), - Layer.provideMerge(GitCoreLive), + Layer.provideMerge(VcsDriverRegistryLayerLive), ); const WorkspaceFileSystemLayerLive = WorkspaceFileSystemLive.pipe( @@ -196,10 +223,11 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( Layer.provideMerge(OrchestrationLayerLive), ); -const RuntimeDependenciesLive = ReactorLayerLive.pipe( +const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(CheckpointingLayerLive), Layer.provideMerge(GitLayerLive), + Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), Layer.provideMerge(TerminalLayerLive), Layer.provideMerge(PersistenceLayerLive), @@ -229,7 +257,9 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(RepositoryIdentityResolverLive), Layer.provideMerge(ServerEnvironmentLive), Layer.provideMerge(AuthLayerLive), +); +const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( // Misc. Layer.provideMerge(AnalyticsServiceLayerLive), Layer.provideMerge(OpenLive), @@ -310,6 +340,7 @@ export const makeServerLayer = Layer.unwrap( Layer.provideMerge(HttpServerLive), Layer.provide(ObservabilityLive), Layer.provideMerge(FetchHttpClient.layer), + Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(PlatformServicesLive), ); }), diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts new file mode 100644 index 00000000000..c4675fa2f16 --- /dev/null +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -0,0 +1,240 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { VcsProcessExitError, type VcsError } from "@t3tools/contracts"; +import { afterEach, describe, expect, vi } from "vitest"; + +import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; +import * as GitHubCli from "./GitHubCli.ts"; + +const processOutput = (stdout: string): VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +const mockRun = vi.fn<(input: VcsProcessInput) => Effect.Effect>(); + +const layer = GitHubCli.layer.pipe( + Layer.provide( + Layer.mock(VcsProcess)({ + run: mockRun, + }), + ), +); + +afterEach(() => { + mockRun.mockReset(); +}); + +describe("GitHubCli.layer", () => { + it.effect("parses pull request view output", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify({ + number: 42, + title: "Add PR thread creation", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseRefName: "main", + headRefName: "feature/pr-threads", + state: "OPEN", + mergedAt: null, + isCrossRepository: true, + headRepository: { + nameWithOwner: "octocat/codething-mvp", + }, + headRepositoryOwner: { + login: "octocat", + }, + }), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.getPullRequest({ + cwd: "/repo", + reference: "#42", + }); + + assert.deepStrictEqual(result, { + number: 42, + title: "Add PR thread creation", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseRefName: "main", + headRefName: "feature/pr-threads", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }); + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: [ + "pr", + "view", + "#42", + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("trims pull request fields decoded from gh json", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify({ + number: 42, + title: " Add PR thread creation \n", + url: " https://github.com/pingdotgg/codething-mvp/pull/42 ", + baseRefName: " main ", + headRefName: "\tfeature/pr-threads\t", + state: "OPEN", + mergedAt: null, + isCrossRepository: true, + headRepository: { + nameWithOwner: " octocat/codething-mvp ", + }, + headRepositoryOwner: { + login: " octocat ", + }, + }), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.getPullRequest({ + cwd: "/repo", + reference: "#42", + }); + + assert.deepStrictEqual(result, { + number: 42, + title: "Add PR thread creation", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseRefName: "main", + headRefName: "feature/pr-threads", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("skips invalid entries when parsing pr lists", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify([ + { + number: 0, + title: "invalid", + url: "https://github.com/pingdotgg/codething-mvp/pull/0", + baseRefName: "main", + headRefName: "feature/invalid", + }, + { + number: 43, + title: " Valid PR ", + url: " https://github.com/pingdotgg/codething-mvp/pull/43 ", + baseRefName: " main ", + headRefName: " feature/pr-list ", + headRepository: { + nameWithOwner: " ", + }, + headRepositoryOwner: { + login: " ", + }, + }, + ]), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.listOpenPullRequests({ + cwd: "/repo", + headSelector: "feature/pr-list", + }); + + assert.deepStrictEqual(result, [ + { + number: 43, + title: "Valid PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/43", + baseRefName: "main", + headRefName: "feature/pr-list", + state: "open", + }, + ]); + }).pipe(Effect.provide(layer)), + ); + + it.effect("reads repository clone URLs", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify({ + nameWithOwner: "octocat/codething-mvp", + url: "https://github.com/octocat/codething-mvp", + sshUrl: "git@github.com:octocat/codething-mvp.git", + }), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "octocat/codething-mvp", + }); + + assert.deepStrictEqual(result, { + nameWithOwner: "octocat/codething-mvp", + url: "https://github.com/octocat/codething-mvp", + sshUrl: "git@github.com:octocat/codething-mvp.git", + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("surfaces a friendly error when the pull request is not found", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.fail( + new VcsProcessExitError({ + operation: "GitHubCli.execute", + command: "gh pr view", + cwd: "/repo", + exitCode: 1, + detail: + "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)", + }), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const error = yield* gh + .getPullRequest({ + cwd: "/repo", + reference: "4888", + }) + .pipe(Effect.flip); + + assert.equal(error.message.includes("Pull request not found"), true); + }).pipe(Effect.provide(layer)), + ); +}); diff --git a/apps/server/src/git/Layers/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts similarity index 55% rename from apps/server/src/git/Layers/GitHubCli.ts rename to apps/server/src/sourceControl/GitHubCli.ts index dbacdf63226..bb90aeba014 100644 --- a/apps/server/src/git/Layers/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -1,68 +1,135 @@ -import { Effect, Layer, Result, Schema, SchemaIssue } from "effect"; -import { TrimmedNonEmptyString } from "@t3tools/contracts"; +import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect"; -import { runProcess } from "../../processRunner.ts"; -import { GitHubCliError } from "@t3tools/contracts"; -import { - GitHubCli, - type GitHubRepositoryCloneUrls, - type GitHubCliShape, -} from "../Services/GitHubCli.ts"; +import { GitHubCliError, TrimmedNonEmptyString, type VcsError } from "@t3tools/contracts"; + +import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; import { decodeGitHubPullRequestJson, decodeGitHubPullRequestListJson, formatGitHubJsonDecodeError, -} from "../githubPullRequests.ts"; +} from "../git/githubPullRequests.ts"; const DEFAULT_TIMEOUT_MS = 30_000; -function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown): GitHubCliError { - if (error instanceof Error) { - if (error.message.includes("Command not found: gh")) { - return new GitHubCliError({ - operation, - detail: "GitHub CLI (`gh`) is required but not available on PATH.", - cause: error, - }); - } - - const lower = error.message.toLowerCase(); - if ( - lower.includes("authentication failed") || - lower.includes("not logged in") || - lower.includes("gh auth login") || - lower.includes("no oauth token") - ) { - return new GitHubCliError({ - operation, - detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", - cause: error, - }); - } - - if ( - lower.includes("could not resolve to a pullrequest") || - lower.includes("repository.pullrequest") || - lower.includes("no pull requests found for branch") || - lower.includes("pull request not found") - ) { - return new GitHubCliError({ - operation, - detail: "Pull request not found. Check the PR number or URL and try again.", - cause: error, - }); - } +export interface GitHubPullRequestSummary { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; + readonly state?: "open" | "closed" | "merged"; + readonly isCrossRepository?: boolean; + readonly headRepositoryNameWithOwner?: string | null; + readonly headRepositoryOwnerLogin?: string | null; +} + +export interface GitHubRepositoryCloneUrls { + readonly nameWithOwner: string; + readonly url: string; + readonly sshUrl: string; +} + +export interface GitHubCliShape { + readonly execute: (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly timeoutMs?: number; + }) => Effect.Effect; + + readonly listOpenPullRequests: (input: { + readonly cwd: string; + readonly headSelector: string; + readonly limit?: number; + }) => Effect.Effect, GitHubCliError>; + + readonly getPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect; + + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + + readonly createPullRequest: (input: { + readonly cwd: string; + readonly baseBranch: string; + readonly headSelector: string; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + + readonly getDefaultBranch: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly checkoutPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; +} + +export class GitHubCli extends Context.Service()( + "t3/source-control/GitHubCli", +) {} + +function errorText(error: VcsError | unknown): string { + if (typeof error === "object" && error !== null) { + const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : ""; + const detail = "detail" in error && typeof error.detail === "string" ? error.detail : ""; + const message = "message" in error && typeof error.message === "string" ? error.message : ""; + return [tag, detail, message].filter(Boolean).join("\n"); + } + + return String(error); +} + +function normalizeGitHubCliError( + operation: "execute" | "stdout", + error: VcsError | unknown, +): GitHubCliError { + const text = errorText(error); + const lower = text.toLowerCase(); + if (lower.includes("command not found: gh") || lower.includes("enoent")) { return new GitHubCliError({ operation, - detail: `GitHub CLI command failed: ${error.message}`, + detail: "GitHub CLI (`gh`) is required but not available on PATH.", + cause: error, + }); + } + + if ( + lower.includes("authentication failed") || + lower.includes("not logged in") || + lower.includes("gh auth login") || + lower.includes("no oauth token") + ) { + return new GitHubCliError({ + operation, + detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", + cause: error, + }); + } + + if ( + lower.includes("could not resolve to a pullrequest") || + lower.includes("repository.pullrequest") || + lower.includes("no pull requests found for branch") || + lower.includes("pull request not found") + ) { + return new GitHubCliError({ + operation, + detail: "Pull request not found. Check the PR number or URL and try again.", cause: error, }); } return new GitHubCliError({ operation, - detail: "GitHub CLI command failed.", + detail: text, cause: error, }); } @@ -101,18 +168,21 @@ function decodeGitHubJson( ); } -const makeGitHubCli = 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))); - const service = { + return GitHubCli.of({ execute, listOpenPullRequests: (input) => execute({ @@ -232,9 +302,7 @@ const makeGitHubCli = Effect.sync(() => { cwd: input.cwd, args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], }).pipe(Effect.asVoid), - } satisfies GitHubCliShape; - - return service; + }); }); -export const GitHubCliLive = Layer.effect(GitHubCli, makeGitHubCli); +export const layer = Layer.effect(GitHubCli, make()); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts new file mode 100644 index 00000000000..ac3de1d9747 --- /dev/null +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -0,0 +1,154 @@ +import { assert, it } from "@effect/vitest"; +import { DateTime, Effect, Layer, Option } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { GitHubCli, type GitHubCliShape } from "./GitHubCli.ts"; +import type { VcsProcessOutput } from "../vcs/VcsProcess.ts"; +import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; + +const processResult = (stdout: string): VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +function makeProvider(github: Partial) { + return GitHubSourceControlProvider.make().pipe(Effect.provide(Layer.mock(GitHubCli)(github))); +} + +it.effect("maps GitHub PR summaries into provider-neutral change requests", () => + Effect.gen(function* () { + const provider = yield* makeProvider({ + getPullRequest: () => + Effect.succeed({ + number: 42, + title: "Add GitHub provider", + url: "https://github.com/pingdotgg/t3code/pull/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "fork/t3code", + headRepositoryOwnerLogin: "fork", + }), + }); + + const changeRequest = yield* provider.getChangeRequest({ + cwd: "/repo", + reference: "42", + }); + + assert.deepStrictEqual(changeRequest, { + provider: "github", + number: 42, + title: "Add GitHub provider", + url: "https://github.com/pingdotgg/t3code/pull/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "open", + updatedAt: Option.none(), + isCrossRepository: true, + headRepositoryNameWithOwner: "fork/t3code", + headRepositoryOwnerLogin: "fork", + }); + }), +); + +it.effect("uses gh json listing for non-open change request state queries", () => + Effect.gen(function* () { + let executeArgs: ReadonlyArray = []; + const provider = yield* makeProvider({ + execute: (input) => { + executeArgs = input.args; + return Effect.succeed( + processResult( + JSON.stringify([ + { + number: 7, + title: "Merged work", + url: "https://github.com/pingdotgg/t3code/pull/7", + baseRefName: "main", + headRefName: "feature/merged", + state: "merged", + updatedAt: "2026-01-02T00:00:00.000Z", + }, + ]), + ), + ); + }, + }); + + const changeRequests = yield* provider.listChangeRequests({ + cwd: "/repo", + headSelector: "feature/merged", + state: "all", + limit: 10, + }); + + assert.deepStrictEqual(executeArgs, [ + "pr", + "list", + "--head", + "feature/merged", + "--state", + "all", + "--limit", + "10", + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + ]); + assert.strictEqual(changeRequests[0]?.provider, "github"); + assert.strictEqual(changeRequests[0]?.state, "merged"); + assert.deepStrictEqual( + changeRequests[0]?.updatedAt, + Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")), + ); + }), +); + +it.effect("treats empty non-open change request listing output as no results", () => + Effect.gen(function* () { + const provider = yield* makeProvider({ + execute: () => Effect.succeed(processResult("")), + }); + + const changeRequests = yield* provider.listChangeRequests({ + cwd: "/repo", + headSelector: "feature/empty", + state: "all", + limit: 10, + }); + + assert.deepStrictEqual(changeRequests, []); + }), +); + +it.effect("creates GitHub PRs through provider-neutral input names", () => + Effect.gen(function* () { + let createInput: Parameters[0] | null = null; + const provider = yield* makeProvider({ + createPullRequest: (input) => { + createInput = input; + return Effect.void; + }, + }); + + yield* provider.createChangeRequest({ + cwd: "/repo", + baseRefName: "main", + headSelector: "owner:feature/provider", + title: "Provider PR", + bodyFile: "/tmp/body.md", + }); + + assert.deepStrictEqual(createInput, { + cwd: "/repo", + baseBranch: "main", + headSelector: "owner:feature/provider", + title: "Provider PR", + bodyFile: "/tmp/body.md", + }); + }), +); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts new file mode 100644 index 00000000000..3bdccbf97d1 --- /dev/null +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -0,0 +1,145 @@ +import { Effect, Layer, Option, Result, Schema } from "effect"; +import { + SourceControlProviderError, + type ChangeRequest, + type ChangeRequestState, + type GitHubCliError, +} from "@t3tools/contracts"; + +import { GitHubCli, type GitHubPullRequestSummary } from "./GitHubCli.ts"; +import { decodeGitHubPullRequestListJson } from "../git/githubPullRequests.ts"; +import { SourceControlProvider, type SourceControlProviderShape } from "./SourceControlProvider.ts"; + +function providerError(operation: string, cause: GitHubCliError): SourceControlProviderError { + return new SourceControlProviderError({ + provider: "github", + operation, + detail: cause.detail, + cause, + }); +} + +function toChangeRequest(summary: GitHubPullRequestSummary): ChangeRequest { + return { + provider: "github", + number: summary.number, + title: summary.title, + url: summary.url, + baseRefName: summary.baseRefName, + headRefName: summary.headRefName, + state: summary.state ?? "open", + updatedAt: Option.none(), + ...(summary.isCrossRepository !== undefined + ? { isCrossRepository: summary.isCrossRepository } + : {}), + ...(summary.headRepositoryNameWithOwner !== undefined + ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } + : {}), + ...(summary.headRepositoryOwnerLogin !== undefined + ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } + : {}), + }; +} + +export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { + const github = yield* GitHubCli; + + const listChangeRequests: SourceControlProviderShape["listChangeRequests"] = (input) => { + if (input.state === "open") { + return github + .listOpenPullRequests({ + cwd: input.cwd, + headSelector: input.headSelector, + ...(input.limit !== undefined ? { limit: input.limit } : {}), + }) + .pipe( + Effect.map((items) => items.map(toChangeRequest)), + Effect.mapError((error) => providerError("listChangeRequests", error)), + ); + } + + const stateArg: ChangeRequestState | "all" = input.state; + return github + .execute({ + cwd: input.cwd, + args: [ + "pr", + "list", + "--head", + input.headSelector, + "--state", + stateArg, + "--limit", + String(input.limit ?? 20), + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], + }) + .pipe( + Effect.flatMap((result) => { + const raw = result.stdout.trim(); + if (raw.length === 0) { + return Effect.succeed([]); + } + return Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( + Effect.flatMap((decoded) => + Result.isSuccess(decoded) + ? Effect.succeed( + decoded.success.map((item) => ({ + ...toChangeRequest(item), + updatedAt: item.updatedAt, + })), + ) + : Effect.fail( + new SourceControlProviderError({ + provider: "github", + operation: "listChangeRequests", + detail: "GitHub CLI returned invalid change request JSON.", + cause: decoded.failure, + }), + ), + ), + ); + }), + Effect.mapError((error) => + Schema.is(SourceControlProviderError)(error) + ? error + : providerError("listChangeRequests", error), + ), + ); + }; + + return SourceControlProvider.of({ + kind: "github", + listChangeRequests, + getChangeRequest: (input) => + github.getPullRequest(input).pipe( + Effect.map(toChangeRequest), + Effect.mapError((error) => providerError("getChangeRequest", error)), + ), + createChangeRequest: (input) => + github + .createPullRequest({ + cwd: input.cwd, + baseBranch: input.baseRefName, + headSelector: input.headSelector, + title: input.title, + bodyFile: input.bodyFile, + }) + .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))), + getRepositoryCloneUrls: (input) => + github + .getRepositoryCloneUrls(input) + .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), + getDefaultBranch: (input) => + github + .getDefaultBranch(input) + .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), + checkoutChangeRequest: (input) => + github + .checkoutPullRequest(input) + .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), + }); +}); + +export const layer = Layer.effect(SourceControlProvider, make()); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts new file mode 100644 index 00000000000..53ab4806593 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -0,0 +1,214 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Effect, Layer, Option } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { VcsProcessSpawnError } from "@t3tools/contracts"; + +import { ServerConfig } from "../config.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { SourceControlDiscovery, layer } from "./SourceControlDiscovery.ts"; + +const processOutput = ( + stdout: string, + options?: { + readonly stderr?: string; + readonly exitCode?: ChildProcessSpawner.ExitCode; + }, +): VcsProcess.VcsProcessOutput => ({ + exitCode: options?.exitCode ?? ChildProcessSpawner.ExitCode(0), + stdout, + stderr: options?.stderr ?? "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +it.effect("reports implemented tools separately from locally available CLIs", () => { + const testLayer = layer.pipe( + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-discovery-" }), + ), + Layer.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => { + if (input.command === "git") { + return Effect.succeed(processOutput("git version 2.51.0\n")); + } + if (input.command === "gh" && input.args[0] === "--version") { + return Effect.succeed(processOutput("gh version 2.83.0\n")); + } + if (input.command === "gh" && input.args.join(" ") === "auth status") { + return Effect.succeed( + processOutput(`github.com +Logged in to github.com account juliusmarminge (keyring) +- Active account: true +- Git operations protocol: ssh +- Token: gho_************************************ +- Token scopes: 'admin:public_key', 'gist', 'read:org', 'repo' +`), + ); + } + return Effect.fail( + new VcsProcessSpawnError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + cause: new Error(`${input.command} not found`), + }), + ); + }, + }), + ), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const discovery = yield* SourceControlDiscovery; + const result = yield* discovery.discover; + + assert.deepStrictEqual( + result.versionControlSystems.map((item) => ({ + kind: item.kind, + implemented: item.implemented, + status: item.status, + })), + [ + { kind: "git", implemented: true, status: "available" }, + { kind: "jj", implemented: false, status: "missing" }, + ], + ); + assert.deepStrictEqual( + result.sourceControlProviders.map((item) => ({ + kind: item.kind, + implemented: item.implemented, + status: item.status, + auth: item.auth.status, + account: item.auth.account, + })), + [ + { + kind: "github", + implemented: true, + status: "available", + auth: "authenticated", + account: Option.some("juliusmarminge"), + }, + { + kind: "gitlab", + implemented: false, + status: "missing", + auth: "unknown", + account: Option.none(), + }, + { + kind: "azure-devops", + implemented: false, + status: "missing", + auth: "unknown", + account: Option.none(), + }, + { + kind: "bitbucket", + implemented: false, + status: "missing", + auth: "unknown", + account: Option.none(), + }, + ], + ); + }).pipe(Effect.provide(testLayer)); +}); + +it.effect("probes provider authentication without exposing token details", () => { + const testLayer = layer.pipe( + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-auth-discovery-" }), + ), + Layer.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => { + if (input.args[0] === "--version") { + return Effect.succeed(processOutput(`${input.command} version test\n`)); + } + if (input.command === "gh" && input.args.join(" ") === "auth status") { + return Effect.succeed( + processOutput(`github.com +Logged in to github.com account octocat (keyring) +- Token: gho_************************************ +- Token scopes: 'repo' +`), + ); + } + if (input.command === "glab" && input.args.join(" ") === "auth status") { + return Effect.succeed( + processOutput(`gitlab.com +Logged in to gitlab.com as gitlab-user +`), + ); + } + if ( + input.command === "az" && + input.args.join(" ") === "account show --query user.name -o tsv" + ) { + return Effect.succeed(processOutput("azure-user@example.com\n")); + } + if (input.command === "bb" && input.args.join(" ") === "auth status") { + return Effect.succeed( + processOutput(`bitbucket.org +Logged in as bitbucket-user +`), + ); + } + return Effect.fail( + new VcsProcessSpawnError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + cause: new Error(`${input.command} not found`), + }), + ); + }, + }), + ), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const discovery = yield* SourceControlDiscovery; + const result = yield* discovery.discover; + + assert.deepStrictEqual( + result.sourceControlProviders.map((item) => ({ + kind: item.kind, + auth: item.auth.status, + account: item.auth.account, + detail: item.auth.detail, + })), + [ + { + kind: "github", + auth: "authenticated", + account: Option.some("octocat"), + detail: Option.none(), + }, + { + kind: "gitlab", + auth: "authenticated", + account: Option.some("gitlab-user"), + detail: Option.none(), + }, + { + kind: "azure-devops", + auth: "authenticated", + account: Option.some("azure-user@example.com"), + detail: Option.none(), + }, + { + kind: "bitbucket", + auth: "authenticated", + account: Option.some("bitbucket-user"), + detail: Option.none(), + }, + ], + ); + }).pipe(Effect.provide(testLayer)); +}); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts new file mode 100644 index 00000000000..7d8b5bc2d8c --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -0,0 +1,412 @@ +import { + type SourceControlProviderAuth, + type SourceControlDiscoveryResult, + type SourceControlProviderDiscoveryItem, + type SourceControlProviderKind, + type VcsDiscoveryItem, + type VcsDriverKind, +} from "@t3tools/contracts"; +import { Context, Effect, Layer, Option } from "effect"; + +import { ServerConfig } from "../config.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; + +interface DiscoveryProbe { + readonly label: string; + readonly executable: string; + readonly versionArgs: ReadonlyArray; + readonly implemented: boolean; + readonly installHint: string; +} + +type VcsProbe = DiscoveryProbe & { + readonly kind: VcsDriverKind; +}; + +type ProviderProbe = DiscoveryProbe & { + readonly kind: SourceControlProviderKind; + readonly authArgs: ReadonlyArray; + readonly parseAuth: (input: AuthProbeInput) => SourceControlProviderAuth; +}; + +interface AuthProbeInput { + readonly stdout: string; + readonly stderr: string; + readonly exitCode: VcsProcess.VcsProcessOutput["exitCode"]; +} + +interface DiscoveryProbeResult { + readonly kind: Kind; + readonly label: string; + readonly executable: string; + readonly implemented: boolean; + readonly status: "available" | "missing"; + readonly version: Option.Option; + readonly installHint: string; + readonly detail: Option.Option; +} + +const VCS_PROBES: ReadonlyArray = [ + { + kind: "git", + label: "Git", + executable: "git", + versionArgs: ["--version"], + implemented: true, + installHint: "Install Git from https://git-scm.com/downloads or with your package manager.", + }, + { + kind: "jj", + label: "Jujutsu", + executable: "jj", + versionArgs: ["--version"], + implemented: false, + installHint: "Install Jujutsu with `brew install jj` or from https://github.com/jj-vcs/jj.", + }, +]; + +const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray = [ + { + kind: "github", + label: "GitHub", + executable: "gh", + versionArgs: ["--version"], + authArgs: ["auth", "status"], + parseAuth: parseGitHubAuth, + implemented: true, + installHint: "Install GitHub CLI with `brew install gh` or from https://cli.github.com/.", + }, + { + kind: "gitlab", + label: "GitLab", + executable: "glab", + versionArgs: ["--version"], + authArgs: ["auth", "status"], + parseAuth: parseGitLabAuth, + implemented: false, + installHint: + "Install GitLab CLI with `brew install glab` or from https://gitlab.com/gitlab-org/cli.", + }, + { + kind: "azure-devops", + label: "Azure DevOps", + executable: "az", + versionArgs: ["--version"], + authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"], + parseAuth: parseAzureAuth, + implemented: false, + installHint: + "Install Azure CLI with `brew install azure-cli`, then add Azure DevOps support with `az extension add --name azure-devops`.", + }, + { + kind: "bitbucket", + label: "Bitbucket", + executable: "bb", + versionArgs: ["--version"], + authArgs: ["auth", "status"], + parseAuth: parseBitbucketAuth, + implemented: false, + installHint: "Install a Bitbucket CLI (`bb`) and authenticate it for your Bitbucket workspace.", + }, +]; + +function firstNonEmptyLine(text: string): Option.Option { + const line = text + .split(/\r?\n/) + .map((entry) => entry.trim()) + .find((entry) => entry.length > 0); + return line === undefined ? Option.none() : Option.some(line); +} + +function detailFromCause(cause: unknown): Option.Option { + if (cause instanceof Error && cause.message.trim().length > 0) { + return Option.some(cause.message.trim()); + } + return Option.none(); +} + +function authAccount(account: string | undefined): Option.Option { + const trimmed = account?.trim(); + return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); +} + +function authHost(host: string | undefined): Option.Option { + const trimmed = host?.trim(); + return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); +} + +function authDetail(detail: string | undefined): Option.Option { + const trimmed = detail?.trim(); + return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); +} + +function providerAuth(input: { + readonly status: SourceControlProviderAuth["status"]; + readonly account?: string | undefined; + readonly host?: string | undefined; + readonly detail?: string | undefined; +}): SourceControlProviderAuth { + return { + status: input.status, + account: authAccount(input.account), + host: authHost(input.host), + detail: authDetail(input.detail), + }; +} + +function unknownAuth(detail?: string): SourceControlProviderAuth { + return providerAuth({ status: "unknown", detail }); +} + +function combinedAuthOutput(input: AuthProbeInput): string { + return [input.stdout, input.stderr].filter((entry) => entry.trim().length > 0).join("\n"); +} + +function sanitizedAuthLines(text: string): ReadonlyArray { + return text + .split(/\r?\n/) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .filter((entry) => !/^[-\s]*token(?:\s+scopes?)?:/iu.test(entry)); +} + +function firstSafeAuthLine(text: string): string | undefined { + return sanitizedAuthLines(text)[0]; +} + +function parseCliHost(text: string): string | undefined { + return sanitizedAuthLines(text) + .map((line) => line.replace(/^[^a-z0-9]+/iu, "")) + .find((line) => /^[a-z0-9][a-z0-9.-]*(?::\d+)?$/iu.test(line)); +} + +function matchFirst(text: string, patterns: ReadonlyArray): string | undefined { + for (const pattern of patterns) { + const match = pattern.exec(text); + const value = match?.[1]?.trim(); + if (value && value.length > 0) return value; + } + return undefined; +} + +function parseGitHubAuth(input: AuthProbeInput): SourceControlProviderAuth { + const output = combinedAuthOutput(input); + const account = matchFirst(output, [ + /Logged in to .* account\s+([^\s(]+)/iu, + /Logged in to .* as\s+([^\s(]+)/iu, + ]); + const host = parseCliHost(output); + + if (input.exitCode !== 0) { + return providerAuth({ + status: "unauthenticated", + host, + detail: firstSafeAuthLine(output) ?? "Run `gh auth login` to authenticate GitHub CLI.", + }); + } + + if (account) { + return providerAuth({ status: "authenticated", account, host }); + } + + return providerAuth({ + status: "unknown", + host, + detail: firstSafeAuthLine(output) ?? "GitHub CLI auth status could not be parsed.", + }); +} + +function parseGitLabAuth(input: AuthProbeInput): SourceControlProviderAuth { + const output = combinedAuthOutput(input); + const account = matchFirst(output, [ + /Logged in to .* as\s+([^\s(]+)/iu, + /Logged in to .* account\s+([^\s(]+)/iu, + /account:\s*([^\s(]+)/iu, + ]); + const host = parseCliHost(output); + + if (input.exitCode !== 0) { + return providerAuth({ + status: "unauthenticated", + host, + detail: firstSafeAuthLine(output) ?? "Run `glab auth login` to authenticate GitLab CLI.", + }); + } + + if (account) { + return providerAuth({ status: "authenticated", account, host }); + } + + return providerAuth({ + status: "unknown", + host, + detail: firstSafeAuthLine(output) ?? "GitLab CLI auth status could not be parsed.", + }); +} + +function parseAzureAuth(input: AuthProbeInput): SourceControlProviderAuth { + const account = input.stdout.trim().split(/\r?\n/)[0]?.trim(); + + if (input.exitCode !== 0) { + return providerAuth({ + status: "unauthenticated", + detail: + firstSafeAuthLine(combinedAuthOutput(input)) ?? "Run `az login` to authenticate Azure CLI.", + }); + } + + if (account && account.length > 0) { + return providerAuth({ status: "authenticated", account, host: "dev.azure.com" }); + } + + return providerAuth({ + status: "unknown", + host: "dev.azure.com", + detail: "Azure CLI account status could not be parsed.", + }); +} + +function parseBitbucketAuth(input: AuthProbeInput): SourceControlProviderAuth { + const output = combinedAuthOutput(input); + const account = matchFirst(output, [ + /Logged in to .* as\s+([^\s(]+)/iu, + /Logged in as\s+([^\s(]+)/iu, + /account:\s*([^\s(]+)/iu, + /user:\s*([^\s(]+)/iu, + /username:\s*([^\s(]+)/iu, + ]); + const host = parseCliHost(output) ?? "bitbucket.org"; + + if (input.exitCode !== 0) { + return providerAuth({ + status: "unauthenticated", + host, + detail: + firstSafeAuthLine(output) ?? "Authenticate the Bitbucket CLI before enabling Bitbucket.", + }); + } + + if (account) { + return providerAuth({ status: "authenticated", account, host }); + } + + return providerAuth({ + status: "unknown", + host, + detail: firstSafeAuthLine(output) ?? "Bitbucket CLI auth status could not be parsed.", + }); +} + +export interface SourceControlDiscoveryShape { + readonly discover: Effect.Effect; +} + +export class SourceControlDiscovery extends Context.Service< + SourceControlDiscovery, + SourceControlDiscoveryShape +>()("t3/source-control/SourceControlDiscovery") {} + +export const layer = Layer.effect( + SourceControlDiscovery, + Effect.gen(function* () { + const config = yield* ServerConfig; + const process = yield* VcsProcess.VcsProcess; + + const probe = ( + input: DiscoveryProbe & { readonly kind: Kind }, + ): Effect.Effect> => + process + .run({ + operation: "source-control.discovery.probe", + command: input.executable, + args: input.versionArgs, + cwd: config.cwd, + timeoutMs: 5_000, + maxOutputBytes: 8_000, + truncateOutputAtMaxBytes: true, + }) + .pipe( + Effect.map( + (result) => + ({ + kind: input.kind, + label: input.label, + executable: input.executable, + implemented: input.implemented, + status: "available" as const, + version: Option.orElse(firstNonEmptyLine(result.stdout), () => + firstNonEmptyLine(result.stderr), + ), + installHint: input.installHint, + detail: Option.none(), + }) satisfies DiscoveryProbeResult, + ), + Effect.catch((cause) => + Effect.succeed({ + kind: input.kind, + label: input.label, + executable: input.executable, + implemented: input.implemented, + status: "missing" as const, + version: Option.none(), + installHint: input.installHint, + detail: detailFromCause(cause), + } satisfies DiscoveryProbeResult), + ), + ); + + const probeProvider = (input: ProviderProbe) => + probe(input).pipe( + Effect.flatMap((item) => { + if (item.status !== "available") { + return Effect.succeed({ + ...item, + auth: unknownAuth("CLI is not installed."), + } satisfies SourceControlProviderDiscoveryItem); + } + + return process + .run({ + operation: "source-control.discovery.auth", + command: input.executable, + args: input.authArgs, + cwd: config.cwd, + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 8_000, + truncateOutputAtMaxBytes: true, + }) + .pipe( + Effect.map( + (result) => + ({ + ...item, + auth: input.parseAuth(result), + }) satisfies SourceControlProviderDiscoveryItem, + ), + Effect.catch((cause) => + Effect.succeed({ + ...item, + auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))), + } satisfies SourceControlProviderDiscoveryItem), + ), + ); + }), + ); + + return SourceControlDiscovery.of({ + discover: Effect.all({ + versionControlSystems: Effect.all( + VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray>, + { concurrency: "unbounded" }, + ), + sourceControlProviders: Effect.all( + SOURCE_CONTROL_PROVIDER_PROBES.map((entry) => probeProvider(entry)) as ReadonlyArray< + Effect.Effect + >, + { concurrency: "unbounded" }, + ), + }), + }); + }), +); diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts new file mode 100644 index 00000000000..baa18cb9c43 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -0,0 +1,68 @@ +import { Context, Effect } from "effect"; +import type { + ChangeRequest, + ChangeRequestState, + SourceControlProviderError, + SourceControlProviderInfo, + SourceControlProviderKind, + SourceControlRepositoryCloneUrls, +} from "@t3tools/contracts"; + +export interface SourceControlProviderContext { + readonly provider: SourceControlProviderInfo; + readonly remoteName: string; + readonly remoteUrl: string; +} + +export interface SourceControlRefSelector { + readonly refName: string; + readonly owner?: string; + readonly repository?: string; +} + +export interface SourceControlProviderShape { + readonly kind: SourceControlProviderKind; + readonly listChangeRequests: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly source?: SourceControlRefSelector; + readonly headSelector: string; + readonly state: ChangeRequestState | "all"; + readonly limit?: number; + }) => Effect.Effect, SourceControlProviderError>; + readonly getChangeRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly reference: string; + }) => Effect.Effect; + readonly createChangeRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly source?: SourceControlRefSelector; + readonly target?: SourceControlRefSelector; + readonly baseRefName: string; + readonly headSelector: string; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly repository: string; + }) => Effect.Effect; + readonly getDefaultBranch: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + }) => Effect.Effect; + readonly checkoutChangeRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; +} + +export class SourceControlProvider extends Context.Service< + SourceControlProvider, + SourceControlProviderShape +>()("t3/source-control/SourceControlProvider") {} diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts new file mode 100644 index 00000000000..23cdc3e1fd2 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -0,0 +1,114 @@ +import { assert, it } from "@effect/vitest"; +import { DateTime, Effect, Layer, Option } from "effect"; + +import { GitHubCli } from "./GitHubCli.ts"; +import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; +import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; +import type { VcsDriverShape } from "../vcs/VcsDriver.ts"; + +const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); + +function makeRegistry(input: { + readonly remotes: ReadonlyArray<{ + readonly name: string; + readonly url: string; + }>; +}) { + const driver = { + listRemotes: () => + Effect.succeed({ + remotes: input.remotes.map((remote) => ({ + ...remote, + pushUrl: Option.none(), + isPrimary: remote.name === "origin", + })), + freshness: { + source: "live-local" as const, + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, + }), + } satisfies Partial; + + const registryLayer = Layer.mock(VcsDriverRegistry)({ + get: () => Effect.succeed(driver as unknown as VcsDriverShape), + resolve: () => + Effect.succeed({ + kind: "git", + repository: { + kind: "git", + rootPath: "/repo", + metadataPath: null, + freshness: { + source: "live-local" as const, + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, + }, + driver: driver as unknown as VcsDriverShape, + }), + }); + + return SourceControlProviderRegistry.make().pipe( + Effect.provide(Layer.mergeAll(registryLayer, Layer.mock(GitHubCli)({}))), + ); +} + +it.effect("routes GitHub remotes to the GitHub provider", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [{ name: "origin", url: "git@github.com:pingdotgg/t3code.git" }], + }); + + const provider = yield* registry.resolve({ cwd: "/repo" }); + + assert.strictEqual(provider.kind, "github"); + }), +); + +it.effect("routes directly by provider kind for remote-first workflows", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [], + }); + + const provider = yield* registry.get("github"); + + assert.strictEqual(provider.kind, "github"); + }), +); + +it.effect( + "detects GitLab remotes and returns an unsupported provider until one is registered", + () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [{ name: "origin", url: "git@gitlab.com:group/project.git" }], + }); + + const provider = yield* registry.resolve({ cwd: "/repo" }); + + assert.strictEqual(provider.kind, "gitlab"); + const error = yield* Effect.flip( + provider.listChangeRequests({ + cwd: "/repo", + headSelector: "feature/source-control", + state: "open", + }), + ); + + assert.strictEqual(error.provider, "gitlab"); + }), +); + +it.effect("falls back to a non-origin remote when origin is not configured", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [{ name: "upstream", url: "https://dev.azure.com/acme/project/_git/repo" }], + }); + + const provider = yield* registry.resolve({ cwd: "/repo" }); + + assert.strictEqual(provider.kind, "azure-devops"); + }), +); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts new file mode 100644 index 00000000000..bddbca15ee4 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -0,0 +1,162 @@ +import { Cache, Context, Duration, Effect, Exit, Layer } from "effect"; +import { SourceControlProviderError } from "@t3tools/contracts"; +import type { SourceControlProviderKind } from "@t3tools/contracts"; +import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; + +import { + SourceControlProvider, + type SourceControlProviderContext, + type SourceControlProviderShape, +} from "./SourceControlProvider.ts"; +import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; +import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; + +const PROVIDER_DETECTION_CACHE_CAPACITY = 2_048; +const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5); + +export interface SourceControlProviderRegistration { + readonly kind: SourceControlProviderKind; + readonly provider: SourceControlProviderShape; +} + +export interface SourceControlProviderHandle { + readonly provider: SourceControlProviderShape; + readonly context: SourceControlProviderContext | null; +} + +export interface SourceControlProviderRegistryShape { + readonly get: ( + kind: SourceControlProviderKind, + ) => Effect.Effect; + readonly resolveHandle: (input: { + readonly cwd: string; + }) => Effect.Effect; + readonly resolve: (input: { + readonly cwd: string; + }) => Effect.Effect; +} + +export class SourceControlProviderRegistry extends Context.Service< + SourceControlProviderRegistry, + SourceControlProviderRegistryShape +>()("t3/source-control/SourceControlProviderRegistry") {} + +function unsupportedProvider(kind: SourceControlProviderKind): SourceControlProviderShape { + const unsupported = (operation: string) => + Effect.fail( + new SourceControlProviderError({ + provider: kind, + operation, + detail: `No ${kind} source control provider is registered.`, + }), + ); + + return SourceControlProvider.of({ + kind, + listChangeRequests: () => unsupported("listChangeRequests"), + getChangeRequest: () => unsupported("getChangeRequest"), + createChangeRequest: () => unsupported("createChangeRequest"), + getRepositoryCloneUrls: () => unsupported("getRepositoryCloneUrls"), + getDefaultBranch: () => unsupported("getDefaultBranch"), + checkoutChangeRequest: () => unsupported("checkoutChangeRequest"), + }); +} + +function providerDetectionError(operation: string, cwd: string, cause: unknown) { + return new SourceControlProviderError({ + provider: "unknown", + operation, + detail: `Failed to detect source control provider for ${cwd}.`, + cause, + }); +} + +function selectProviderContext( + remotes: ReadonlyArray<{ + readonly name: string; + readonly url: string; + }>, +): SourceControlProviderContext | null { + const candidates = remotes + .map((remote) => { + const provider = detectSourceControlProviderFromRemoteUrl(remote.url); + return provider + ? { + provider, + remoteName: remote.name, + remoteUrl: remote.url, + } + : null; + }) + .filter((value): value is SourceControlProviderContext => value !== null); + + return ( + candidates.find((candidate) => candidate.remoteName === "origin") ?? + candidates.find((candidate) => candidate.provider.kind !== "unknown") ?? + candidates[0] ?? + null + ); +} + +export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWithProviders")( + function* (registrations: ReadonlyArray) { + const vcsRegistry = yield* VcsDriverRegistry; + const providers = new Map( + registrations.map((registration) => [registration.kind, registration.provider]), + ); + + const get: SourceControlProviderRegistryShape["get"] = (kind) => + Effect.succeed(providers.get(kind) ?? unsupportedProvider(kind)); + + const detectProviderContext = Effect.fn("SourceControlProviderRegistry.detectProviderContext")( + function* (cwd: string) { + const handle = yield* vcsRegistry + .resolve({ cwd }) + .pipe(Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error))); + const remotes = yield* handle.driver + .listRemotes(cwd) + .pipe(Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error))); + + return selectProviderContext(remotes.remotes); + }, + ); + + const providerContextCache = yield* Cache.makeWith< + string, + SourceControlProviderContext | null, + SourceControlProviderError + >(detectProviderContext, { + capacity: PROVIDER_DETECTION_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? PROVIDER_DETECTION_CACHE_TTL : Duration.zero), + }); + + const resolveHandle: SourceControlProviderRegistryShape["resolveHandle"] = (input) => + Cache.get(providerContextCache, input.cwd).pipe( + Effect.map((context) => { + const kind = context?.provider.kind ?? "unknown"; + return { + provider: providers.get(kind) ?? unsupportedProvider(kind), + context, + } satisfies SourceControlProviderHandle; + }), + ); + + return SourceControlProviderRegistry.of({ + get, + resolveHandle, + resolve: (input) => resolveHandle(input).pipe(Effect.map((handle) => handle.provider)), + }); + }, +); + +export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () { + const github = yield* GitHubSourceControlProvider.make(); + return yield* makeWithProviders([ + { + kind: "github", + provider: github, + }, + ]); +}); + +export const layer = Layer.effect(SourceControlProviderRegistry, make()); diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts similarity index 97% rename from apps/server/src/git/Layers/ClaudeTextGeneration.test.ts rename to apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index eb7bf62ad48..19cff6f0344 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -5,9 +5,9 @@ import { Effect, FileSystem, Layer, Path, Schema } from "effect"; import { createModelSelection } from "@t3tools/shared/model"; import { expect } from "vitest"; -import { ServerConfig } from "../../config.ts"; -import { type TextGenerationShape } from "../Services/TextGeneration.ts"; -import { sanitizeThreadTitle } from "../Utils.ts"; +import { ServerConfig } from "../config.ts"; +import { type TextGenerationShape } from "./TextGeneration.ts"; +import { sanitizeThreadTitle } from "./TextGenerationUtils.ts"; import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts"; const ClaudeTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { @@ -184,7 +184,7 @@ function withFakeClaudeEnv( }).pipe(Effect.scoped); } -it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => { +it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { it.effect("forwards Claude thinking settings for Haiku without passing effort", () => withFakeClaudeEnv( { diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts similarity index 97% rename from apps/server/src/git/Layers/ClaudeTextGeneration.ts rename to apps/server/src/textGeneration/ClaudeTextGeneration.ts index 33bf7bc0141..2f0cbc509b6 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -14,20 +14,20 @@ import { type ClaudeSettings, type ModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { TextGenerationError } from "@t3tools/contracts"; -import { type TextGenerationShape } from "../Services/TextGeneration.ts"; +import { type TextGenerationShape } from "./TextGeneration.ts"; import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, buildThreadTitlePrompt, -} from "../Prompts.ts"; +} from "./TextGenerationPrompts.ts"; import { normalizeCliError, sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, toJsonSchemaObject, -} from "../Utils.ts"; +} from "./TextGenerationUtils.ts"; import { getModelSelectionStringOptionValue, getProviderOptionDescriptors, @@ -37,8 +37,8 @@ import { normalizeClaudeCliEffort, resolveClaudeApiModelId, resolveClaudeEffort, -} from "../../provider/Layers/ClaudeProvider.ts"; -import { makeClaudeEnvironment } from "../../provider/Drivers/ClaudeHome.ts"; +} from "../provider/Layers/ClaudeProvider.ts"; +import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts"; const CLAUDE_TIMEOUT_MS = 180_000; diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts similarity index 99% rename from apps/server/src/git/Layers/CodexTextGeneration.test.ts rename to apps/server/src/textGeneration/CodexTextGeneration.test.ts index f38d6a68a87..07123e921b1 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -6,8 +6,8 @@ import { expect } from "vitest"; import { CodexSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; -import { ServerConfig } from "../../config.ts"; -import { type TextGenerationShape } from "../Services/TextGeneration.ts"; +import { ServerConfig } from "../config.ts"; +import { type TextGenerationShape } from "./TextGeneration.ts"; import { makeCodexTextGeneration } from "./CodexTextGeneration.ts"; const DEFAULT_TEST_MODEL_SELECTION = createModelSelection( @@ -168,7 +168,7 @@ function withFakeCodexEnv( }).pipe(Effect.scoped); } -it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { +it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { it.effect("generates and sanitizes commit messages without branch by default", () => withFakeCodexEnv( { diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts similarity index 96% rename from apps/server/src/git/Layers/CodexTextGeneration.ts rename to apps/server/src/textGeneration/CodexTextGeneration.ts index d4bc8f16327..786a0be4c49 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -4,28 +4,28 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { type CodexSettings, type ModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; -import { resolveAttachmentPath } from "../../attachmentStore.ts"; -import { ServerConfig } from "../../config.ts"; -import { expandHomePath } from "../../pathExpansion.ts"; +import { resolveAttachmentPath } from "../attachmentStore.ts"; +import { ServerConfig } from "../config.ts"; +import { expandHomePath } from "../pathExpansion.ts"; import { TextGenerationError } from "@t3tools/contracts"; import { type BranchNameGenerationInput, type ThreadTitleGenerationResult, type TextGenerationShape, -} from "../Services/TextGeneration.ts"; +} from "./TextGeneration.ts"; import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, buildThreadTitlePrompt, -} from "../Prompts.ts"; +} from "./TextGenerationPrompts.ts"; import { normalizeCliError, sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, toJsonSchemaObject, -} from "../Utils.ts"; +} from "./TextGenerationUtils.ts"; import { getModelSelectionBooleanOptionValue, getModelSelectionStringOptionValue, @@ -386,7 +386,3 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func generateThreadTitle, } satisfies TextGenerationShape; }); - -// NOTE: `CodexTextGenerationLive` (the singleton Layer) has been removed. -// `makeCodexTextGeneration(codexConfig)` is now invoked directly by -// `CodexDriver.create()` for each configured instance. diff --git a/apps/server/src/git/Layers/CursorTextGeneration.test.ts b/apps/server/src/textGeneration/CursorTextGeneration.test.ts similarity index 92% rename from apps/server/src/git/Layers/CursorTextGeneration.test.ts rename to apps/server/src/textGeneration/CursorTextGeneration.test.ts index 3718557664c..0de135c8465 100644 --- a/apps/server/src/git/Layers/CursorTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.test.ts @@ -11,12 +11,12 @@ import { expect } from "vitest"; import { CursorSettings, ProviderInstanceId } from "@t3tools/contracts"; -import { ServerConfig } from "../../config.ts"; -import { type TextGenerationShape } from "../Services/TextGeneration.ts"; +import { ServerConfig } from "../config.ts"; +import { type TextGenerationShape } from "./TextGeneration.ts"; import { makeCursorTextGeneration } from "./CursorTextGeneration.ts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); +const mockAgentPath = path.join(__dirname, "../../scripts/acp-mock-agent.ts"); function shellSingleQuote(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'`; @@ -82,7 +82,7 @@ function waitForFileContent(path: string): Effect.Effect { }); } -it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { +it.layer(CursorTextGenerationTestLayer)("CursorTextGeneration", (it) => { it.effect("uses ACP model config options instead of raw CLI model ids", () => { const requestLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-log-")); const requestLogPath = path.join(requestLogDir, "requests.ndjson"); @@ -100,9 +100,9 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { const generated = yield* textGeneration.generateCommitMessage({ cwd: process.cwd(), branch: "feature/cursor-text-generation", - stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts", + stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts", stagedPatch: - "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts", + "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts", modelSelection: { ...createModelSelection(ProviderInstanceId.make("cursor"), "gpt-5.4", [ { id: "reasoning", value: "xhigh" }, @@ -243,9 +243,9 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { const generated = yield* textGeneration.generateCommitMessage({ cwd: process.cwd(), branch: "feature/cursor-runtime-close", - stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts", + stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts", stagedPatch: - "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts", + "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts", modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2", diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts similarity index 94% rename from apps/server/src/git/Layers/CursorTextGeneration.ts rename to apps/server/src/textGeneration/CursorTextGeneration.ts index c94c6dd180a..1cde82d61b6 100644 --- a/apps/server/src/git/Layers/CursorTextGeneration.ts +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -5,26 +5,23 @@ import { type CursorSettings, type ModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { TextGenerationError } from "@t3tools/contracts"; -import { - type ThreadTitleGenerationResult, - type TextGenerationShape, -} from "../Services/TextGeneration.ts"; +import { type ThreadTitleGenerationResult, type TextGenerationShape } from "./TextGeneration.ts"; import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, buildThreadTitlePrompt, -} from "../Prompts.ts"; +} from "./TextGenerationPrompts.ts"; import { extractJsonObject, sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, -} from "../Utils.ts"; +} from "./TextGenerationUtils.ts"; import { applyCursorAcpModelSelection, makeCursorAcpRuntime, -} from "../../provider/acp/CursorAcpSupport.ts"; +} from "../provider/acp/CursorAcpSupport.ts"; const CURSOR_TIMEOUT_MS = 180_000; @@ -277,7 +274,3 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu generateThreadTitle, } satisfies TextGenerationShape; }); - -// NOTE: `CursorTextGenerationLive` (the singleton Layer) has been removed. -// `makeCursorTextGeneration(cursorConfig)` is now invoked directly by -// `CursorDriver.create()` so each provider instance owns its own closure. diff --git a/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts similarity index 97% rename from apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts rename to apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index c3bdd6035eb..907c749355f 100644 --- a/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -6,13 +6,13 @@ import { TestClock } from "effect/testing"; import { NetService } from "@t3tools/shared/Net"; import { beforeEach, expect } from "vitest"; -import { ServerConfig } from "../../config.ts"; +import { ServerConfig } from "../config.ts"; import { OpenCodeRuntime, OpenCodeRuntimeError, type OpenCodeRuntimeShape, -} from "../../provider/opencodeRuntime.ts"; -import { type TextGenerationShape } from "../Services/TextGeneration.ts"; +} from "../provider/opencodeRuntime.ts"; +import { type TextGenerationShape } from "./TextGeneration.ts"; import { makeOpenCodeTextGeneration } from "./OpenCodeTextGeneration.ts"; const runtimeMock = { @@ -158,7 +158,7 @@ const advanceIdleClock = Effect.gen(function* () { yield* Effect.yieldNow; }); -it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGenerationLive", (it) => { +it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { it.effect("reuses a warm server across back-to-back requests and closes it after idling", () => withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => Effect.gen(function* () { @@ -306,7 +306,7 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGenerationLive", (it) => }); it.layer(OpenCodeTextGenerationExistingServerTestLayer)( - "OpenCodeTextGenerationLive with configured server URL", + "OpenCodeTextGeneration with configured server URL", (it) => { it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () => withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => diff --git a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts similarity index 98% rename from apps/server/src/git/Layers/OpenCodeTextGeneration.ts rename to apps/server/src/textGeneration/OpenCodeTextGeneration.ts index f2d0b4b2724..d646e4f2e5a 100644 --- a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -10,21 +10,21 @@ import { import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; -import { ServerConfig } from "../../config.ts"; -import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../config.ts"; +import { resolveAttachmentPath } from "../attachmentStore.ts"; import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, buildThreadTitlePrompt, -} from "../Prompts.ts"; -import { type TextGenerationShape } from "../Services/TextGeneration.ts"; +} from "./TextGenerationPrompts.ts"; +import { type TextGenerationShape } from "./TextGeneration.ts"; import { extractJsonObject, sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, -} from "../Utils.ts"; +} from "./TextGenerationUtils.ts"; import { OpenCodeRuntime, type OpenCodeServerConnection, @@ -32,7 +32,7 @@ import { openCodeRuntimeErrorDetail, parseOpenCodeModelSlug, toOpenCodeFileParts, -} from "../../provider/opencodeRuntime.ts"; +} from "../provider/opencodeRuntime.ts"; const OPENCODE_TEXT_GENERATION_IDLE_TTL = "30 seconds"; diff --git a/apps/server/src/git/Layers/TextGenerationLive.test.ts b/apps/server/src/textGeneration/TextGeneration.test.ts similarity index 93% rename from apps/server/src/git/Layers/TextGenerationLive.test.ts rename to apps/server/src/textGeneration/TextGeneration.test.ts index 3b03696eb42..4f3d44f9258 100644 --- a/apps/server/src/git/Layers/TextGenerationLive.test.ts +++ b/apps/server/src/textGeneration/TextGeneration.test.ts @@ -5,11 +5,11 @@ import { describe, expect } from "vitest"; import { ProviderInstanceId } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; -import type { ProviderInstance } from "../../provider/ProviderDriver.ts"; -import type { ProviderInstanceRegistryShape } from "../../provider/Services/ProviderInstanceRegistry.ts"; -import type { TextGenerationShape } from "../Services/TextGeneration.ts"; +import type { ProviderInstance } from "../provider/ProviderDriver.ts"; +import type { ProviderInstanceRegistryShape } from "../provider/Services/ProviderInstanceRegistry.ts"; +import type { TextGenerationShape } from "./TextGeneration.ts"; -import { makeTextGenerationFromRegistry } from "./TextGenerationLive.ts"; +import { makeTextGenerationFromRegistry } from "./TextGeneration.ts"; const makeStubTextGeneration = (overrides: Partial): TextGenerationShape => ({ generateCommitMessage: () => diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts similarity index 57% rename from apps/server/src/git/Services/TextGeneration.ts rename to apps/server/src/textGeneration/TextGeneration.ts index 78d37a01088..51796faf8a7 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -1,18 +1,13 @@ -/** - * TextGeneration - Effect service contract for AI-generated Git content. - * - * Generates commit messages and pull request titles/bodies from repository - * context prepared by Git services. - * - * @module TextGeneration - */ -import { Context } from "effect"; -import type { Effect } from "effect"; -import type { ChatAttachment, ModelSelection } from "@t3tools/contracts"; +import { Context, Effect, Layer } from "effect"; +import type { ChatAttachment, ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; +import { TextGenerationError } from "@t3tools/contracts"; -import type { TextGenerationError } from "@t3tools/contracts"; +import { + ProviderInstanceRegistry, + type ProviderInstanceRegistryShape, +} from "../provider/Services/ProviderInstanceRegistry.ts"; +import type { ProviderInstance } from "../provider/ProviderDriver.ts"; -/** Providers that support git text generation (commit messages, PR content, branch names). */ export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "opencode"; export interface CommitMessageGenerationInput { @@ -119,5 +114,58 @@ export interface TextGenerationShape { * TextGeneration - Service tag for commit and PR text generation. */ export class TextGeneration extends Context.Service()( - "t3/git/Services/TextGeneration", + "t3/text-generation/TextGeneration", ) {} + +type TextGenerationOp = + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + +const resolveInstance = ( + registry: ProviderInstanceRegistryShape, + operation: TextGenerationOp, + instanceId: ProviderInstanceId, +): Effect.Effect => + registry.getInstance(instanceId).pipe( + Effect.flatMap((instance) => + instance + ? Effect.succeed(instance.textGeneration) + : Effect.fail( + new TextGenerationError({ + operation, + detail: `No provider instance registered for id '${instanceId}'.`, + }), + ), + ), + ); + +export const makeTextGenerationFromRegistry = ( + registry: ProviderInstanceRegistryShape, +): TextGenerationShape => ({ + generateCommitMessage: (input) => + resolveInstance(registry, "generateCommitMessage", input.modelSelection.instanceId).pipe( + Effect.flatMap((textGeneration) => textGeneration.generateCommitMessage(input)), + ), + generatePrContent: (input) => + resolveInstance(registry, "generatePrContent", input.modelSelection.instanceId).pipe( + Effect.flatMap((textGeneration) => textGeneration.generatePrContent(input)), + ), + generateBranchName: (input) => + resolveInstance(registry, "generateBranchName", input.modelSelection.instanceId).pipe( + Effect.flatMap((textGeneration) => textGeneration.generateBranchName(input)), + ), + generateThreadTitle: (input) => + resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe( + Effect.flatMap((textGeneration) => textGeneration.generateThreadTitle(input)), + ), +}); + +export const layer = Layer.effect( + TextGeneration, + Effect.gen(function* () { + const registry = yield* ProviderInstanceRegistry; + return makeTextGenerationFromRegistry(registry); + }), +); diff --git a/apps/server/src/textGeneration/TextGenerationPolicy.ts b/apps/server/src/textGeneration/TextGenerationPolicy.ts new file mode 100644 index 00000000000..b0e020fa4fc --- /dev/null +++ b/apps/server/src/textGeneration/TextGenerationPolicy.ts @@ -0,0 +1,19 @@ +import { Schema } from "effect"; + +export const TextGenerationPolicyKind = Schema.Literals([ + "default", + "conventional_commits", + "repo_conventions", + "custom", +]); +export type TextGenerationPolicyKind = typeof TextGenerationPolicyKind.Type; + +export const TextGenerationPolicy = Schema.Struct({ + kind: TextGenerationPolicyKind, + commitInstructions: Schema.optional(Schema.String), + changeRequestInstructions: Schema.optional(Schema.String), + branchInstructions: Schema.optional(Schema.String), + threadTitleInstructions: Schema.optional(Schema.String), + inferRepositoryConventions: Schema.Boolean, +}); +export type TextGenerationPolicy = typeof TextGenerationPolicy.Type; diff --git a/apps/server/src/textGeneration/TextGenerationPresets.ts b/apps/server/src/textGeneration/TextGenerationPresets.ts new file mode 100644 index 00000000000..70955742148 --- /dev/null +++ b/apps/server/src/textGeneration/TextGenerationPresets.ts @@ -0,0 +1,41 @@ +import type { TextGenerationPolicy, TextGenerationPolicyKind } from "./TextGenerationPolicy.ts"; + +export const defaultTextGenerationPolicy: TextGenerationPolicy = { + kind: "default", + inferRepositoryConventions: false, +}; + +export const conventionalCommitsTextGenerationPolicy: TextGenerationPolicy = { + kind: "conventional_commits", + commitInstructions: + "Use Conventional Commits when generating commit subjects. Prefer the narrowest accurate type and include a scope only when it is obvious from the diff.", + changeRequestInstructions: + "Keep the change request title concise. Do not force Conventional Commit syntax into the title unless the repository already uses it.", + inferRepositoryConventions: false, +}; + +export const repositoryConventionsTextGenerationPolicy: TextGenerationPolicy = { + kind: "repo_conventions", + commitInstructions: + "Follow the repository's established commit message style when examples are available.", + changeRequestInstructions: + "Follow the repository's established change request title and body style when examples are available.", + inferRepositoryConventions: true, +}; + +export const customTextGenerationPolicy = ( + overrides: Omit, "kind">, +): TextGenerationPolicy => ({ + kind: "custom", + inferRepositoryConventions: false, + ...overrides, +}); + +export const textGenerationPresets: Record< + Exclude, + TextGenerationPolicy +> = { + default: defaultTextGenerationPolicy, + conventional_commits: conventionalCommitsTextGenerationPolicy, + repo_conventions: repositoryConventionsTextGenerationPolicy, +}; diff --git a/apps/server/src/git/Prompts.test.ts b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts similarity index 98% rename from apps/server/src/git/Prompts.test.ts rename to apps/server/src/textGeneration/TextGenerationPrompts.test.ts index d8d079c0cf3..25fed642270 100644 --- a/apps/server/src/git/Prompts.test.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts @@ -5,8 +5,8 @@ import { buildCommitMessagePrompt, buildPrContentPrompt, buildThreadTitlePrompt, -} from "./Prompts.ts"; -import { normalizeCliError, sanitizeThreadTitle } from "./Utils.ts"; +} from "./TextGenerationPrompts.ts"; +import { normalizeCliError, sanitizeThreadTitle } from "./TextGenerationUtils.ts"; import { TextGenerationError } from "@t3tools/contracts"; describe("buildCommitMessagePrompt", () => { diff --git a/apps/server/src/git/Prompts.ts b/apps/server/src/textGeneration/TextGenerationPrompts.ts similarity index 87% rename from apps/server/src/git/Prompts.ts rename to apps/server/src/textGeneration/TextGenerationPrompts.ts index 4092358825c..43ae62047b9 100644 --- a/apps/server/src/git/Prompts.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.ts @@ -9,7 +9,13 @@ import { Schema } from "effect"; import type { ChatAttachment } from "@t3tools/contracts"; -import { limitSection } from "./Utils.ts"; +import { limitSection } from "./TextGenerationUtils.ts"; +import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; + +function policyInstruction(instruction: string | undefined): ReadonlyArray { + const trimmed = instruction?.trim(); + return trimmed ? ["", "Additional instructions:", limitSection(trimmed, 4_000)] : []; +} // --------------------------------------------------------------------------- // Commit message @@ -20,6 +26,7 @@ export interface CommitMessagePromptInput { stagedSummary: string; stagedPatch: string; includeBranch: boolean; + policy?: TextGenerationPolicy | undefined; } export function buildCommitMessagePrompt(input: CommitMessagePromptInput) { @@ -37,6 +44,7 @@ export function buildCommitMessagePrompt(input: CommitMessagePromptInput) { ? ["- branch must be a short semantic git branch fragment for this change"] : []), "- capture the primary user-visible or developer-visible change", + ...policyInstruction(input.policy?.commitInstructions), "", `Branch: ${input.branch ?? "(detached)"}`, "", @@ -77,6 +85,7 @@ export interface PrContentPromptInput { commitSummary: string; diffSummary: string; diffPatch: string; + policy?: TextGenerationPolicy | undefined; } export function buildPrContentPrompt(input: PrContentPromptInput) { @@ -88,6 +97,7 @@ export function buildPrContentPrompt(input: PrContentPromptInput) { "- body must be markdown and include headings '## Summary' and '## Testing'", "- under Summary, provide short bullet points", "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate", + ...policyInstruction(input.policy?.changeRequestInstructions), "", `Base branch: ${input.baseBranch}`, `Head branch: ${input.headBranch}`, @@ -117,6 +127,7 @@ export function buildPrContentPrompt(input: PrContentPromptInput) { export interface BranchNamePromptInput { message: string; attachments?: ReadonlyArray | undefined; + policy?: TextGenerationPolicy | undefined; } interface PromptFromMessageInput { @@ -125,6 +136,7 @@ interface PromptFromMessageInput { rules: ReadonlyArray; message: string; attachments?: ReadonlyArray | undefined; + additionalInstructions?: string | undefined; } function buildPromptFromMessage(input: PromptFromMessageInput): string { @@ -140,6 +152,7 @@ function buildPromptFromMessage(input: PromptFromMessageInput): string { "", "User message:", limitSection(input.message, 8_000), + ...policyInstruction(input.additionalInstructions), ]; if (attachmentLines.length > 0) { promptSections.push( @@ -164,6 +177,7 @@ export function buildBranchNamePrompt(input: BranchNamePromptInput) { ], message: input.message, attachments: input.attachments, + additionalInstructions: input.policy?.branchInstructions, }); const outputSchema = Schema.Struct({ branch: Schema.String, @@ -179,6 +193,7 @@ export function buildBranchNamePrompt(input: BranchNamePromptInput) { export interface ThreadTitlePromptInput { message: string; attachments?: ReadonlyArray | undefined; + policy?: TextGenerationPolicy | undefined; } export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) { @@ -193,6 +208,7 @@ export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) { ], message: input.message, attachments: input.attachments, + additionalInstructions: input.policy?.threadTitleInstructions, }); const outputSchema = Schema.Struct({ title: Schema.String, diff --git a/apps/server/src/textGeneration/TextGenerationUtils.ts b/apps/server/src/textGeneration/TextGenerationUtils.ts new file mode 100644 index 00000000000..fcd8cc8b689 --- /dev/null +++ b/apps/server/src/textGeneration/TextGenerationUtils.ts @@ -0,0 +1,159 @@ +import { Schema } from "effect"; + +import { TextGenerationError } from "@t3tools/contracts"; + +/** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */ +export function toJsonSchemaObject(schema: Schema.Top): unknown { + const document = Schema.toJsonSchemaDocument(schema); + if (document.definitions && Object.keys(document.definitions).length > 0) { + return { ...document.schema, $defs: document.definitions }; + } + return document.schema; +} + +/** Truncate a text section to `maxChars`, appending a `[truncated]` marker when needed. */ +export function limitSection(value: string, maxChars: number): string { + if (value.length <= maxChars) return value; + const truncated = value.slice(0, maxChars); + return `${truncated}\n\n[truncated]`; +} + +export function extractJsonObject(raw: string): string { + const trimmed = raw.trim(); + if (trimmed.length === 0) { + return trimmed; + } + + const start = trimmed.indexOf("{"); + if (start < 0) { + return trimmed; + } + + let depth = 0; + let inString = false; + let escaping = false; + for (let index = start; index < trimmed.length; index += 1) { + const char = trimmed[index]; + if (inString) { + if (escaping) { + escaping = false; + } else if (char === "\\") { + escaping = true; + } else if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + continue; + } + + if (char === "{") { + depth += 1; + continue; + } + + if (char === "}") { + depth -= 1; + if (depth === 0) { + return trimmed.slice(start, index + 1); + } + } + } + + return trimmed.slice(start); +} + +/** Normalise a raw commit subject to imperative-mood, ≤72 chars, no trailing period. */ +export function sanitizeCommitSubject(raw: string): string { + const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; + const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim(); + if (withoutTrailingPeriod.length === 0) { + return "Update project files"; + } + + if (withoutTrailingPeriod.length <= 72) { + return withoutTrailingPeriod; + } + return withoutTrailingPeriod.slice(0, 72).trimEnd(); +} + +/** Normalise a raw PR title to a single line with a sensible fallback. */ +export function sanitizePrTitle(raw: string): string { + const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; + if (singleLine.length > 0) { + return singleLine; + } + return "Update project changes"; +} + +/** Normalise a raw thread title to a compact single-line sidebar-safe label. */ +export function sanitizeThreadTitle(raw: string): string { + const normalized = raw + .trim() + .split(/\r?\n/g)[0] + ?.trim() + .replace(/^['"`]+|['"`]+$/g, "") + .trim() + .replace(/\s+/g, " "); + + if (!normalized || normalized.trim().length === 0) { + return "New thread"; + } + + if (normalized.length <= 50) { + return normalized; + } + + return `${normalized.slice(0, 47).trimEnd()}...`; +} + +/** CLI name to human-readable label, e.g. "codex" → "Codex CLI (`codex`)" */ +function cliLabel(cliName: string): string { + const capitalized = cliName.charAt(0).toUpperCase() + cliName.slice(1); + return `${capitalized} CLI (\`${cliName}\`)`; +} + +/** + * Normalize an unknown error from a CLI text generation process into a + * typed `TextGenerationError`. Parameterized by CLI name so both Codex + * and Claude (and future providers) can share the same logic. + */ +export function normalizeCliError( + cliName: string, + operation: string, + error: unknown, + fallback: string, +): TextGenerationError { + if (Schema.is(TextGenerationError)(error)) { + return error; + } + + if (error instanceof Error) { + const lower = error.message.toLowerCase(); + if ( + error.message.includes(`Command not found: ${cliName}`) || + lower.includes(`spawn ${cliName}`) || + lower.includes("enoent") + ) { + return new TextGenerationError({ + operation, + detail: `${cliLabel(cliName)} is required but not available on PATH.`, + cause: error, + }); + } + return new TextGenerationError({ + operation, + detail: `${fallback}: ${error.message}`, + cause: error, + }); + } + + return new TextGenerationError({ + operation, + detail: fallback, + cause: error, + }); +} diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts new file mode 100644 index 00000000000..0e5ba5b82ec --- /dev/null +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -0,0 +1,99 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Effect, FileSystem, Layer, Path, PlatformError } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { assert, it } from "@effect/vitest"; + +import { GitCommandError } from "@t3tools/contracts"; +import { ServerConfig } from "../config.ts"; +import * as GitVcsDriver from "./GitVcsDriver.ts"; +import * as VcsProcess from "./VcsProcess.ts"; +import { runVcsDriverContractSuite } from "./testing/VcsDriverContractHarness.ts"; + +const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-git-vcs-contract-", +}); +const GitContractLayer = Layer.mergeAll(GitVcsDriver.vcsLayer, GitVcsDriver.layer).pipe( + Layer.provide(ServerConfigLayer), + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), +); + +const runGit = (cwd: string, args: ReadonlyArray) => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.execute({ + operation: "GitVcsDriver.contract.git", + cwd, + args, + timeoutMs: 10_000, + }); + }); + +type GitContractError = GitCommandError | PlatformError.PlatformError; + +runVcsDriverContractSuite({ + name: "Git", + kind: "git", + layer: GitContractLayer, + fixture: { + createRepo: (cwd) => + Effect.gen(function* () { + yield* runGit(cwd, ["init"]); + yield* runGit(cwd, ["config", "user.email", "test@test.com"]); + yield* runGit(cwd, ["config", "user.name", "Test"]); + }), + writeFile: (cwd, relativePath, contents) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const absolutePath = path.join(cwd, relativePath); + yield* fileSystem.makeDirectory(path.dirname(absolutePath), { recursive: true }); + yield* fileSystem.writeFileString(absolutePath, contents); + }), + trackFile: (cwd, relativePath) => runGit(cwd, ["add", relativePath]), + commit: (cwd, message) => runGit(cwd, ["commit", "-m", message]), + ignorePath: (cwd, pattern) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fileSystem.writeFileString(path.join(cwd, ".gitignore"), `${pattern}\n`); + }), + }, +}); + +it.effect("GitVcsDriver forwards execute env to the VCS process", () => { + let observedEnv: NodeJS.ProcessEnv | undefined; + + return Effect.gen(function* () { + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + + yield* driver.execute({ + operation: "GitVcsDriver.test.env", + cwd: "/repo", + args: ["status"], + env: { + GIT_INDEX_FILE: "/tmp/t3-index", + }, + }); + + assert.deepStrictEqual(observedEnv, { + GIT_INDEX_FILE: "/tmp/t3-index", + }); + }).pipe( + Effect.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => + Effect.sync(() => { + observedEnv = input.env; + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }), + ), + ); +}); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts new file mode 100644 index 00000000000..0cc73da5e67 --- /dev/null +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -0,0 +1,543 @@ +import { Context, DateTime, Effect, Layer, Option } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + GitCommandError, + VcsProcessExitError, + type VcsSwitchRefInput, + type VcsSwitchRefResult, + type VcsCreateRefInput, + type VcsCreateRefResult, + type VcsCreateWorktreeInput, + type VcsCreateWorktreeResult, + type VcsInitInput, + type VcsListRefsInput, + type VcsListRefsResult, + type VcsPullResult, + type VcsRemoveWorktreeInput, + type VcsStatusInput, + type VcsStatusResult, +} from "@t3tools/contracts"; +import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts"; +import { VcsDriver, type VcsDriverShape } from "./VcsDriver.ts"; +import { VcsProcess, type VcsProcessShape } from "./VcsProcess.ts"; + +export interface ExecuteGitInput { + readonly operation: string; + readonly cwd: string; + readonly args: ReadonlyArray; + readonly stdin?: string; + readonly env?: NodeJS.ProcessEnv; + readonly allowNonZeroExit?: boolean; + readonly timeoutMs?: number; + readonly maxOutputBytes?: number; + readonly truncateOutputAtMaxBytes?: boolean; + readonly progress?: ExecuteGitProgress; +} + +export interface ExecuteGitResult { + readonly exitCode: ChildProcessSpawner.ExitCode; + readonly stdout: string; + readonly stderr: string; + readonly stdoutTruncated: boolean; + readonly stderrTruncated: boolean; +} + +export interface GitStatusDetails { + isRepo: boolean; + sourceControlProvider?: VcsStatusResult["sourceControlProvider"]; + hasOriginRemote: boolean; + isDefaultBranch: boolean; + branch: string | null; + upstreamRef: string | null; + hasWorkingTreeChanges: boolean; + workingTree: VcsStatusResult["workingTree"]; + hasUpstream: boolean; + aheadCount: number; + behindCount: number; +} + +export interface GitPreparedCommitContext { + stagedSummary: string; + stagedPatch: string; +} + +export interface ExecuteGitProgress { + readonly onStdoutLine?: (line: string) => Effect.Effect; + readonly onStderrLine?: (line: string) => Effect.Effect; + readonly onHookStarted?: (hookName: string) => Effect.Effect; + readonly onHookFinished?: (input: { + hookName: string; + exitCode: number | null; + durationMs: number | null; + }) => Effect.Effect; +} + +export interface GitCommitProgress { + readonly onOutputLine?: (input: { + stream: "stdout" | "stderr"; + text: string; + }) => Effect.Effect; + readonly onHookStarted?: (hookName: string) => Effect.Effect; + readonly onHookFinished?: (input: { + hookName: string; + exitCode: number | null; + durationMs: number | null; + }) => Effect.Effect; +} + +export interface GitCommitOptions { + readonly timeoutMs?: number; + readonly progress?: GitCommitProgress; +} + +export interface GitPushResult { + status: "pushed" | "skipped_up_to_date"; + branch: string; + upstreamBranch?: string | undefined; + setUpstream?: boolean | undefined; +} + +export interface GitRangeContext { + commitSummary: string; + diffSummary: string; + diffPatch: string; +} + +export interface GitRenameBranchInput { + cwd: string; + oldBranch: string; + newBranch: string; +} + +export interface GitRenameBranchResult { + branch: string; +} + +export interface GitFetchPullRequestBranchInput { + cwd: string; + prNumber: number; + branch: string; +} + +export interface GitEnsureRemoteInput { + cwd: string; + preferredName: string; + url: string; +} + +export interface GitFetchRemoteBranchInput { + cwd: string; + remoteName: string; + remoteBranch: string; + localBranch: string; +} + +export interface GitSetBranchUpstreamInput { + cwd: string; + branch: string; + remoteName: string; + remoteBranch: string; +} + +export interface GitVcsDriverShape { + readonly execute: (input: ExecuteGitInput) => Effect.Effect; + readonly status: (input: VcsStatusInput) => Effect.Effect; + readonly statusDetails: (cwd: string) => Effect.Effect; + readonly statusDetailsLocal: (cwd: string) => Effect.Effect; + readonly prepareCommitContext: ( + cwd: string, + filePaths?: readonly string[], + ) => Effect.Effect; + readonly commit: ( + cwd: string, + subject: string, + body: string, + options?: GitCommitOptions, + ) => Effect.Effect<{ commitSha: string }, GitCommandError>; + readonly pushCurrentBranch: ( + cwd: string, + fallbackBranch: string | null, + ) => Effect.Effect; + readonly readRangeContext: ( + cwd: string, + baseRef: string, + ) => Effect.Effect; + readonly readConfigValue: ( + cwd: string, + key: string, + ) => Effect.Effect; + readonly listRefs: (input: VcsListRefsInput) => Effect.Effect; + readonly pullCurrentBranch: (cwd: string) => Effect.Effect; + readonly createWorktree: ( + input: VcsCreateWorktreeInput, + ) => Effect.Effect; + readonly fetchPullRequestBranch: ( + input: GitFetchPullRequestBranchInput, + ) => Effect.Effect; + readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect; + readonly fetchRemoteBranch: ( + input: GitFetchRemoteBranchInput, + ) => Effect.Effect; + readonly setBranchUpstream: ( + input: GitSetBranchUpstreamInput, + ) => Effect.Effect; + readonly removeWorktree: (input: VcsRemoveWorktreeInput) => Effect.Effect; + readonly renameBranch: ( + input: GitRenameBranchInput, + ) => Effect.Effect; + readonly createRef: ( + input: VcsCreateRefInput, + ) => Effect.Effect; + readonly switchRef: ( + input: VcsSwitchRefInput, + ) => Effect.Effect; + readonly initRepo: (input: VcsInitInput) => Effect.Effect; + readonly listLocalBranchNames: (cwd: string) => Effect.Effect; +} + +export class GitVcsDriver extends Context.Service()( + "t3/vcs/GitVcsDriver", +) {} + +const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; +const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; +const WORKSPACE_GIT_HARDENED_CONFIG_ARGS = [ + "-c", + "core.fsmonitor=false", + "-c", + "core.untrackedCache=false", +] as const; + +const nowFreshness = Effect.fn("GitVcsDriver.nowFreshness")(function* () { + const now = yield* DateTime.now; + return { + source: "live-local" as const, + observedAt: now, + expiresAt: Option.none(), + }; +}); + +function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { + const parts = input.split("\0"); + if (parts.length === 0) return []; + + if (truncated && parts[parts.length - 1]?.length) { + parts.pop(); + } + + return parts.filter((value) => value.length > 0); +} + +function chunkPathsForGitCheckIgnore(relativePaths: ReadonlyArray): string[][] { + const chunks: string[][] = []; + let chunk: string[] = []; + let chunkBytes = 0; + + for (const relativePath of relativePaths) { + const relativePathBytes = Buffer.byteLength(relativePath) + 1; + if (chunk.length > 0 && chunkBytes + relativePathBytes > GIT_CHECK_IGNORE_MAX_STDIN_BYTES) { + chunks.push(chunk); + chunk = []; + chunkBytes = 0; + } + + chunk.push(relativePath); + chunkBytes += relativePathBytes; + + if (chunkBytes >= GIT_CHECK_IGNORE_MAX_STDIN_BYTES) { + chunks.push(chunk); + chunk = []; + chunkBytes = 0; + } + } + + if (chunk.length > 0) { + chunks.push(chunk); + } + + return chunks; +} + +function parseGitRemoteVerboseOutput( + output: string, +): Map { + const remotes = new Map(); + for (const line of output.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length === 0) { + continue; + } + + const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); + if (!match) { + continue; + } + + const name = match[1]; + const url = match[2]; + const direction = match[3]; + if (!name || !url || !direction) { + continue; + } + const remote = remotes.get(name) ?? {}; + if (direction === "fetch") { + remote.url = url; + } else { + remote.pushUrl = url; + } + remotes.set(name, remote); + } + return remotes; +} + +const gitCommand = ( + process: VcsProcessShape, + operation: string, + cwd: string, + args: ReadonlyArray, + options?: { + readonly stdin?: string; + readonly env?: NodeJS.ProcessEnv; + readonly allowNonZeroExit?: boolean; + readonly timeoutMs?: number; + readonly maxOutputBytes?: number; + readonly truncateOutputAtMaxBytes?: boolean; + }, +) => + process.run({ + operation, + command: "git", + args, + cwd, + ...(options?.stdin !== undefined ? { stdin: options.stdin } : {}), + ...(options?.env !== undefined ? { env: options.env } : {}), + ...(options?.allowNonZeroExit !== undefined + ? { allowNonZeroExit: options.allowNonZeroExit } + : {}), + ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + ...(options?.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), + ...(options?.truncateOutputAtMaxBytes !== undefined + ? { truncateOutputAtMaxBytes: options.truncateOutputAtMaxBytes } + : {}), + }); + +export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* () { + const process = yield* VcsProcess; + const capabilities = { + kind: "git" as const, + supportsWorktrees: true, + supportsBookmarks: false, + supportsAtomicSnapshot: false, + supportsPushDefaultRemote: true, + ignoreClassifier: "native" as const, + }; + + const isInsideWorkTree: VcsDriverShape["isInsideWorkTree"] = (cwd) => + gitCommand( + process, + "GitVcsDriver.isInsideWorkTree", + cwd, + ["rev-parse", "--is-inside-work-tree"], + { + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 4_096, + }, + ).pipe(Effect.map((result) => result.exitCode === 0 && result.stdout.trim() === "true")); + + const execute: VcsDriverShape["execute"] = (input) => + gitCommand(process, input.operation, input.cwd, input.args, { + ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), + ...(input.env !== undefined ? { env: input.env } : {}), + ...(input.allowNonZeroExit !== undefined ? { allowNonZeroExit: input.allowNonZeroExit } : {}), + ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), + ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}), + ...(input.truncateOutputAtMaxBytes !== undefined + ? { truncateOutputAtMaxBytes: input.truncateOutputAtMaxBytes } + : {}), + }); + + const detectRepository: VcsDriverShape["detectRepository"] = Effect.fn("detectRepository")( + function* (cwd) { + if (!(yield* isInsideWorkTree(cwd))) { + return null; + } + + const root = yield* gitCommand(process, "GitVcsDriver.detectRepository.root", cwd, [ + "rev-parse", + "--show-toplevel", + ]); + const gitCommonDir = yield* gitCommand( + process, + "GitVcsDriver.detectRepository.commonDir", + cwd, + ["rev-parse", "--git-common-dir"], + ).pipe(Effect.catch(() => Effect.succeed(null))); + + return { + kind: "git" as const, + rootPath: root.stdout.trim(), + metadataPath: gitCommonDir?.stdout.trim() || null, + freshness: yield* nowFreshness(), + }; + }, + ); + + const listWorkspaceFiles: VcsDriverShape["listWorkspaceFiles"] = (cwd) => + gitCommand( + process, + "GitVcsDriver.listWorkspaceFiles", + cwd, + [ + ...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, + "ls-files", + "--cached", + "--others", + "--exclude-standard", + "-z", + ], + { + allowNonZeroExit: true, + timeoutMs: 20_000, + maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }, + ).pipe( + Effect.flatMap((result) => + result.exitCode === 0 + ? Effect.gen(function* () { + const freshness = yield* nowFreshness(); + return { + paths: splitNullSeparatedPaths(result.stdout, result.stdoutTruncated), + truncated: result.stdoutTruncated, + freshness, + }; + }) + : Effect.fail( + new VcsProcessExitError({ + operation: "GitVcsDriver.listWorkspaceFiles", + command: "git ls-files", + cwd, + exitCode: result.exitCode, + detail: result.stderr.trim() || "git ls-files failed", + }), + ), + ), + ); + + const listRemotes: VcsDriverShape["listRemotes"] = Effect.fn("listRemotes")(function* (cwd) { + const result = yield* gitCommand(process, "GitVcsDriver.listRemotes", cwd, ["remote", "-v"], { + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 64 * 1024, + }); + + if (result.exitCode !== 0) { + return yield* new VcsProcessExitError({ + operation: "GitVcsDriver.listRemotes", + command: "git remote -v", + cwd, + exitCode: result.exitCode, + detail: result.stderr.trim() || "git remote -v failed", + }); + } + + const parsed = parseGitRemoteVerboseOutput(result.stdout); + const remotes = Array.from(parsed.entries()).flatMap(([name, remote]) => { + if (!remote.url) { + return []; + } + return [ + { + name, + url: remote.url, + pushUrl: remote.pushUrl ? Option.some(remote.pushUrl) : Option.none(), + isPrimary: name === "origin", + }, + ]; + }); + + return { + remotes, + freshness: yield* nowFreshness(), + }; + }); + + const filterIgnoredPaths: VcsDriverShape["filterIgnoredPaths"] = Effect.fn("filterIgnoredPaths")( + function* (cwd, relativePaths) { + if (relativePaths.length === 0) { + return relativePaths; + } + + const ignoredPaths = new Set(); + const chunks = chunkPathsForGitCheckIgnore(relativePaths); + + for (const chunk of chunks) { + const result = yield* gitCommand( + process, + "GitVcsDriver.filterIgnoredPaths", + cwd, + [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"], + { + stdin: `${chunk.join("\0")}\0`, + allowNonZeroExit: true, + timeoutMs: 20_000, + maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }, + ); + + if (result.exitCode !== 0 && result.exitCode !== 1) { + return yield* new VcsProcessExitError({ + operation: "GitVcsDriver.filterIgnoredPaths", + command: "git check-ignore", + cwd, + exitCode: result.exitCode, + detail: result.stderr.trim() || "git check-ignore failed", + }); + } + + for (const ignoredPath of splitNullSeparatedPaths(result.stdout, result.stdoutTruncated)) { + ignoredPaths.add(ignoredPath); + } + } + + if (ignoredPaths.size === 0) { + return relativePaths; + } + + return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath)); + }, + ); + + const initRepository: VcsDriverShape["initRepository"] = (input) => + gitCommand(process, "GitVcsDriver.initRepository", input.cwd, ["init"], { + timeoutMs: 10_000, + maxOutputBytes: 64 * 1024, + }).pipe(Effect.asVoid); + + return { + capabilities, + execute, + detectRepository, + isInsideWorkTree, + listWorkspaceFiles, + listRemotes, + filterIgnoredPaths, + initRepository, + } satisfies VcsDriverShape; +}); + +export const makeVcsDriver = Effect.fn("makeGitVcsDriver")(function* () { + const driver = yield* makeVcsDriverShape(); + return VcsDriver.of(driver); +}); + +export const make = Effect.fn("makeGitVcsDriverService")(function* () { + const git = yield* makeGitVcsDriverCore(); + return GitVcsDriver.of(git); +}); + +export const vcsLayer = Layer.effect(VcsDriver, makeVcsDriver()); +export const layer = Layer.effect(GitVcsDriver, make()); diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts new file mode 100644 index 00000000000..38472fd5b0d --- /dev/null +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -0,0 +1,246 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path, PlatformError, Scope } from "effect"; +import { describe } from "vitest"; + +import { GitCommandError } from "@t3tools/contracts"; +import { ServerConfig } from "../config.ts"; +import * as GitVcsDriver from "./GitVcsDriver.ts"; + +const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-git-vcs-driver-test-", +}); +const TestLayer = GitVcsDriver.layer.pipe( + Layer.provide(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), +); + +const makeTmpDir = ( + prefix = "git-vcs-driver-test-", +): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); + }); + +const writeTextFile = ( + cwd: string, + relativePath: string, + contents: string, +): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const filePath = pathService.join(cwd, relativePath); + yield* fileSystem.makeDirectory(pathService.dirname(filePath), { recursive: true }); + yield* fileSystem.writeFileString(filePath, contents); + }); + +const git = ( + cwd: string, + args: ReadonlyArray, + env?: NodeJS.ProcessEnv, +): Effect.Effect => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const result = yield* driver.execute({ + operation: "GitVcsDriver.test.git", + cwd, + args, + ...(env ? { env } : {}), + timeoutMs: 10_000, + }); + return result.stdout.trim(); + }); + +const initRepoWithCommit = ( + cwd: string, +): Effect.Effect< + { readonly initialBranch: string }, + GitCommandError | PlatformError.PlatformError, + GitVcsDriver.GitVcsDriver | FileSystem.FileSystem | Path.Path +> => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.initRepo({ cwd }); + yield* git(cwd, ["config", "user.email", "test@test.com"]); + yield* git(cwd, ["config", "user.name", "Test"]); + yield* writeTextFile(cwd, "README.md", "# test\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "initial commit"]); + const initialBranch = yield* git(cwd, ["branch", "--show-current"]); + return { initialBranch }; + }); + +it.layer(TestLayer)("GitVcsDriver core integration", (it) => { + describe("repository status", () => { + it.effect("reports non-repository directories without failing", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const refs = yield* driver.listRefs({ cwd }); + assert.equal(refs.isRepo, false); + assert.deepStrictEqual(refs.refs, []); + }), + ); + + it.effect("reports refName and dirty state for a repository", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "feature.ts", "export const value = 1;\n"); + + const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); + + assert.equal(status.isRepo, true); + assert.equal(status.branch, initialBranch); + assert.equal(status.hasWorkingTreeChanges, true); + assert.include( + status.workingTree.files.map((file) => file.path), + "feature.ts", + ); + }), + ); + }); + + describe("refName operations", () => { + it.effect("creates, checks out, renames, and lists refs", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* driver.createRef({ cwd, refName: "feature/original" }); + const switchRef = yield* driver.switchRef({ cwd, refName: "feature/original" }); + assert.equal(switchRef.refName, "feature/original"); + + const renamed = yield* driver.renameBranch({ + cwd, + oldBranch: "feature/original", + newBranch: "feature/renamed", + }); + assert.equal(renamed.branch, "feature/renamed"); + assert.equal(yield* git(cwd, ["branch", "--show-current"]), "feature/renamed"); + + const refs = yield* driver.listRefs({ cwd }); + assert.equal( + refs.refs.find((refName) => refName.name === "feature/renamed")?.current, + true, + ); + }), + ); + + it.effect("returns the existing refName when rename source and target match", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const current = yield* git(cwd, ["branch", "--show-current"]); + const result = yield* driver.renameBranch({ + cwd, + oldBranch: current, + newBranch: current, + }); + + assert.equal(result.branch, current); + }), + ); + }); + + describe("worktree operations", () => { + it.effect("creates and removes a worktree for a new refName", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "feature-worktree", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/worktree", + }); + + assert.equal(created.worktree.path, worktreePath); + assert.equal(created.worktree.refName, "feature/worktree"); + assert.equal(yield* git(worktreePath, ["branch", "--show-current"]), "feature/worktree"); + + yield* driver.removeWorktree({ cwd, path: worktreePath }); + const fileSystem = yield* FileSystem.FileSystem; + assert.equal(yield* fileSystem.exists(worktreePath), false); + }), + ); + }); + + describe("commit context", () => { + it.effect("stages selected files and commits only those files", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* writeTextFile(cwd, "a.txt", "a\n"); + yield* writeTextFile(cwd, "b.txt", "b\n"); + + const context = yield* driver.prepareCommitContext(cwd, ["a.txt"]); + assert.include(context?.stagedSummary ?? "", "a.txt"); + assert.notInclude(context?.stagedSummary ?? "", "b.txt"); + + const commit = yield* driver.commit(cwd, "Add a", ""); + assert.match(commit.commitSha, /^[a-f0-9]{40}$/); + assert.equal(yield* git(cwd, ["log", "-1", "--pretty=%s"]), "Add a"); + + const status = yield* git(cwd, ["status", "--porcelain"]); + assert.include(status, "?? b.txt"); + assert.notInclude(status, "a.txt"); + }), + ); + }); + + describe("remote operations", () => { + it.effect("pushes with upstream setup and skips when already up to date", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* (yield* GitVcsDriver.GitVcsDriver).createRef({ + cwd, + refName: "feature/push", + }); + yield* (yield* GitVcsDriver.GitVcsDriver).switchRef({ + cwd, + refName: "feature/push", + }); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* (yield* GitVcsDriver.GitVcsDriver).prepareCommitContext(cwd); + yield* (yield* GitVcsDriver.GitVcsDriver).commit(cwd, "Add feature", ""); + + const pushed = yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch(cwd, null); + assert.deepInclude(pushed, { + status: "pushed", + branch: "feature/push", + setUpstream: true, + }); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + "origin/feature/push", + ); + + const skipped = yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch(cwd, null); + assert.deepInclude(skipped, { + status: "skipped_up_to_date", + branch: "feature/push", + }); + }), + ); + }); +}); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts similarity index 71% rename from apps/server/src/git/Layers/GitCore.ts rename to apps/server/src/vcs/GitVcsDriverCore.ts index 3e9df316f1e..3ba1c27981e 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1,11 +1,11 @@ import { Cache, Data, + DateTime, Duration, Effect, Exit, FileSystem, - Layer, Option, Path, PlatformError, @@ -18,25 +18,24 @@ 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"; -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; @@ -46,14 +45,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 +117,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(); @@ -205,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, }; @@ -273,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; @@ -282,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, }; } @@ -315,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; } } @@ -346,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( @@ -401,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; @@ -460,7 +411,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; @@ -484,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, }); @@ -496,9 +448,12 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( if (event === "child_exit") { hookStartByChildKey.delete(childKey); - const code = traceRecord.success.code; + const code = traceRecord.success.exitCode; 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, @@ -653,14 +608,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; @@ -711,7 +666,6 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { input.progress?.onStderrLine, ), child.exitCode.pipe( - Effect.map((value) => Number(value)), Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")), ), input.stdin === undefined @@ -738,7 +692,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { } return { - code: exitCode, + exitCode, stdout: stdout.text, stderr: stderr.text, stdoutTruncated: stdout.truncated, @@ -767,7 +721,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, @@ -806,7 +760,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { ...(options.progress ? { progress: options.progress } : {}), }).pipe( Effect.flatMap((result) => { - if (options.allowNonZeroExit || result.code === 0) { + if (options.allowNonZeroExit || result.exitCode === 0) { return Effect.succeed(result); } const stderr = result.stderr.trim(); @@ -823,7 +777,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { operation, cwd, args, - `${commandLabel(args)} failed: code=${result.code ?? "null"}`, + `${commandLabel(args)} failed: code=${result.exitCode ?? "null"}`, ), ); }), @@ -859,16 +813,16 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { ), ); - const branchExists = (cwd: string, branch: string): Effect.Effect => + const branchExists = (cwd: string, refName: string): Effect.Effect => executeGit( - "GitCore.branchExists", + "GitVcsDriver.branchExists", cwd, - ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], + ["show-ref", "--verify", "--quiet", `refs/heads/${refName}`], { allowNonZeroExit: true, timeoutMs: 5_000, }, - ).pipe(Effect.map((result) => result.code === 0)); + ).pipe(Effect.map((result) => result.exitCode === 0)); const resolveAvailableBranchName = Effect.fn("resolveAvailableBranchName")(function* ( cwd: string, @@ -888,7 +842,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}'.`, @@ -897,7 +851,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, @@ -907,7 +861,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>([])), ); @@ -924,7 +878,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], { @@ -935,7 +889,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())); @@ -978,13 +932,13 @@ 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 }, ).pipe( Effect.map((result) => { - if (result.code !== 0) { + if (result.exitCode !== 0) { return null; } return parseDefaultBranchFromRemoteHeadRef(result.stdout, remoteName); @@ -994,24 +948,24 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const remoteBranchExists = ( cwd: string, remoteName: string, - branch: string, + refName: string, ): Effect.Effect => executeGit( - "GitCore.remoteBranchExists", + "GitVcsDriver.remoteBranchExists", cwd, - ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${branch}`], + ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${refName}`], { allowNonZeroExit: true, }, - ).pipe(Effect.map((result) => result.code === 0)); + ).pipe(Effect.map((result) => result.exitCode === 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)); + }).pipe(Effect.map((result) => result.exitCode === 0)); const listRemoteNames = (cwd: string): Effect.Effect, GitCommandError> => - runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe( + runGitStdout("GitVcsDriver.listRemoteNames", cwd, ["remote"]).pipe( Effect.map(parseRemoteNamesInGitOrder), ); @@ -1025,7 +979,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.", @@ -1034,12 +988,12 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const resolvePushRemoteName = Effect.fn("resolvePushRemoteName")(function* ( cwd: string, - branch: string, + refName: string, ) { const branchPushRemote = yield* runGitStdout( - "GitCore.resolvePushRemoteName.branchPushRemote", + "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) { @@ -1047,7 +1001,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, @@ -1059,39 +1013,47 @@ 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, + refName: string, ) { const configuredBaseBranch = yield* runGitStdout( - "GitCore.resolveBaseBranchForNoUpstream.config", + "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())); @@ -1118,7 +1080,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { : remotePrefix && candidate.startsWith(remotePrefix) ? candidate.slice(remotePrefix.length) : candidate; - if (normalizedCandidate.length === 0 || normalizedCandidate === branch) { + if (normalizedCandidate.length === 0 || normalizedCandidate === refName) { continue; } @@ -1139,20 +1101,20 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { 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( - "GitCore.computeAheadCountAgainstBase", + "GitVcsDriver.computeAheadCountAgainstBase", cwd, - ["rev-list", "--count", `${baseBranch}..HEAD`], + ["rev-list", "--count", `${baseRef}..HEAD`], { allowNonZeroExit: true }, ); - if (result.code !== 0) { + if (result.exitCode !== 0) { return 0; } @@ -1162,7 +1124,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", @@ -1177,7 +1139,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { ); const branchLastCommit = new Map(); - if (branchRecency.code !== 0) { + if (branchRecency.exitCode !== 0) { return branchLastCommit; } @@ -1198,7 +1160,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"], { @@ -1210,27 +1172,27 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { return NON_REPOSITORY_STATUS_DETAILS; } - if (statusResult.code !== 0) { + if (statusResult.exitCode !== 0) { const stderr = statusResult.stderr.trim(); return yield* createGitCommandError( - "GitCore.statusDetails.status", + "GitVcsDriver.statusDetails.status", cwd, ["status", "--porcelain=2", "--branch"], stderr || "git status failed", ); } - const [unstagedNumstatStdout, stagedNumstatStdout, defaultRefResult, hasOriginRemote] = + const [unstagedNumstatStdout, stagedNumstatStdout, defaultRefResult, hasPrimaryRemote] = 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"], { @@ -1243,11 +1205,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { ); const statusStdout = statusResult.stdout; const defaultBranch = - defaultRefResult.code === 0 + defaultRefResult.exitCode === 0 ? 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; @@ -1257,7 +1219,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { 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 ")) { @@ -1279,8 +1241,8 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { } } - 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; @@ -1314,12 +1276,12 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { 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: { @@ -1333,27 +1295,29 @@ 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, - 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, @@ -1363,34 +1327,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"], { @@ -1405,7 +1369,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, @@ -1426,11 +1390,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())); @@ -1438,13 +1402,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.", @@ -1491,13 +1455,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, @@ -1515,10 +1479,10 @@ 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}`, + `HEAD:${currentUpstream.upstreamRef}`, ]); return { status: "pushed" as const, @@ -1528,7 +1492,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, @@ -1538,13 +1502,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) { + const refName = details.branch; + if (!refName) { return yield* createGitCommandError( - "GitCore.pullCurrentBranch", + "GitVcsDriver.pullCurrentBranch", cwd, ["pull", "--ff-only"], "Cannot pull from detached HEAD.", @@ -1552,24 +1516,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, @@ -1578,19 +1542,19 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { 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: GitCoreShape["readRangeContext"] = Effect.fn("readRangeContext")( - function* (cwd, baseBranch) { - const range = `${baseBranch}..HEAD`; + const readRangeContext: GitVcsDriverShape["readRangeContext"] = Effect.fn("readRangeContext")( + function* (cwd, baseRef) { + const range = `${baseRef}..HEAD`; const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( [ runGitStdoutWithOptions( - "GitCore.readRangeContext.log", + "GitVcsDriver.readRangeContext.log", cwd, ["log", "--oneline", range], { @@ -1599,7 +1563,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }, ), runGitStdoutWithOptions( - "GitCore.readRangeContext.diffStat", + "GitVcsDriver.readRangeContext.diffStat", cwd, ["diff", "--stat", range], { @@ -1608,7 +1572,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }, ), runGitStdoutWithOptions( - "GitCore.readRangeContext.diffPatch", + "GitVcsDriver.readRangeContext.diffPatch", cwd, ["diff", "--patch", "--minimal", range], { @@ -1628,112 +1592,18 @@ 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 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 listRefs: GitVcsDriverShape["listRefs"] = Effect.fn("listRefs")(function* (input) { const branchRecencyPromise = readBranchRecency(input.cwd).pipe( Effect.catch(() => Effect.succeed(new Map())), ); const localBranchResult = yield* executeGit( - "GitCore.listBranches.branchNoColor", + "GitVcsDriver.listRefs.branchNoColor", input.cwd, ["branch", "--no-color", "--no-column"], { @@ -1743,7 +1613,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { ).pipe( Effect.catchIf(isMissingGitCwdError, () => Effect.succeed({ - code: 128, + exitCode: ChildProcessSpawner.ExitCode(128), stdout: "", stderr: "fatal: not a git repository", stdoutTruncated: false, @@ -1752,19 +1622,19 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { ), ); - if (localBranchResult.code !== 0) { + if (localBranchResult.exitCode !== 0) { const stderr = localBranchResult.stderr.trim(); if (stderr.toLowerCase().includes("not a git repository")) { return { - branches: [], + refs: [], isRepo: false, - hasOriginRemote: false, + hasPrimaryRemote: false, nextCursor: null, totalCount: 0, }; } return yield* createGitCommandError( - "GitCore.listBranches", + "GitVcsDriver.listRefs", input.cwd, ["branch", "--no-color", "--no-column"], stderr || "git branch failed", @@ -1772,7 +1642,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { } const remoteBranchResultEffect = executeGit( - "GitCore.listBranches.remoteBranches", + "GitVcsDriver.listRefs.remoteBranches", input.cwd, ["branch", "--no-color", "--no-column", "--remotes"], { @@ -1782,13 +1652,21 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { ).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: "" })), + `GitVcsDriver.listRefs: remote refName lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote refName list.`, + ).pipe( + Effect.as({ + exitCode: ChildProcessSpawner.ExitCode(1), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + } satisfies ExecuteGitResult), + ), ), ); const remoteNamesResultEffect = executeGit( - "GitCore.listBranches.remoteNames", + "GitVcsDriver.listRefs.remoteNames", input.cwd, ["remote"], { @@ -1798,8 +1676,16 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { ).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: "" })), + `GitVcsDriver.listRefs: remote name lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote name list.`, + ).pipe( + Effect.as({ + exitCode: ChildProcessSpawner.ExitCode(1), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + } satisfies ExecuteGitResult), + ), ), ); @@ -1807,7 +1693,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { yield* Effect.all( [ executeGit( - "GitCore.listBranches.defaultRef", + "GitVcsDriver.listRefs.defaultRef", input.cwd, ["symbolic-ref", "refs/remotes/origin/HEAD"], { @@ -1816,7 +1702,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }, ), executeGit( - "GitCore.listBranches.worktreeList", + "GitVcsDriver.listRefs.worktreeList", input.cwd, ["worktree", "list", "--porcelain"], { @@ -1832,25 +1718,25 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { ); const remoteNames = - remoteNamesResult.code === 0 ? parseRemoteNames(remoteNamesResult.stdout) : []; - if (remoteBranchResult.code !== 0 && remoteBranchResult.stderr.trim().length > 0) { + remoteNamesResult.exitCode === 0 ? parseRemoteNames(remoteNamesResult.stdout) : []; + if (remoteBranchResult.exitCode !== 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.`, + `GitVcsDriver.listRefs: remote refName lookup returned code ${remoteBranchResult.exitCode} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote refName list.`, ); } - if (remoteNamesResult.code !== 0 && remoteNamesResult.stderr.trim().length > 0) { + if (remoteNamesResult.exitCode !== 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.`, + `GitVcsDriver.listRefs: remote name lookup returned code ${remoteNamesResult.exitCode} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`, ); } const defaultBranch = - defaultRef.code === 0 + defaultRef.exitCode === 0 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") : null; const worktreeMap = new Map(); - if (worktreeList.code === 0) { + if (worktreeList.exitCode === 0) { let currentPath: string | null = null; for (const line of worktreeList.stdout.split("\n")) { if (line.startsWith("worktree ")) { @@ -1871,13 +1757,13 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { 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, + .filter((refName): refName is { name: string; current: boolean } => refName !== null) + .map((refName) => ({ + name: refName.name, + current: refName.current, isRemote: false, - isDefault: branch.name === defaultBranch, - worktreePath: worktreeMap.get(branch.name) ?? null, + isDefault: refName.name === defaultBranch, + worktreePath: worktreeMap.get(refName.name) ?? null, })) .toSorted((a, b) => { const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; @@ -1891,13 +1777,13 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }); const remoteBranches = - remoteBranchResult.code === 0 + remoteBranchResult.exitCode === 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); + .filter((refName): refName is { name: string; current: boolean } => refName !== null) + .map((refName) => { + const parsedRemoteRef = parseRemoteRefWithRemoteNames(refName.name, remoteNames); const remoteBranch: { name: string; current: boolean; @@ -1906,7 +1792,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { isDefault: boolean; worktreePath: string | null; } = { - name: branch.name, + name: refName.name, current: false, isRemote: true, isDefault: false, @@ -1925,8 +1811,8 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }) : []; - const branches = paginateBranches({ - branches: filterBranchesForListQuery( + const refs = paginateBranches({ + refs: filterBranchesForListQuery( dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]), input.query, ), @@ -1935,43 +1821,43 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }); return { - branches: [...branches.branches], + refs: [...refs.refs], isRepo: true, - hasOriginRemote: remoteNames.includes("origin"), - nextCursor: branches.nextCursor, - totalCount: branches.totalCount, + hasPrimaryRemote: remoteNames.includes("origin"), + nextCursor: refs.nextCursor, + totalCount: refs.totalCount, }; }); - const createWorktree: GitCoreShape["createWorktree"] = Effect.fn("createWorktree")( + 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("GitCore.createWorktree", input.cwd, args, { + yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, { fallbackErrorMessage: "git worktree add failed", }); return { worktree: { path: worktreePath, - branch: targetBranch, + refName: targetBranch, }, }; }, ); - 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", @@ -1986,9 +1872,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", @@ -1999,7 +1885,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] @@ -2008,28 +1894,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}`, @@ -2040,127 +1926,127 @@ 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")( - function* (input) { - const [localInputExists, remoteExists] = yield* Effect.all( - [ - executeGit( - "GitCore.checkoutBranch.localInputExists", - input.cwd, - ["show-ref", "--verify", "--quiet", `refs/heads/${input.branch}`], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ).pipe(Effect.map((result) => result.code === 0)), - executeGit( - "GitCore.checkoutBranch.remoteExists", - input.cwd, - ["show-ref", "--verify", "--quiet", `refs/remotes/${input.branch}`], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ).pipe(Effect.map((result) => result.code === 0)), - ], - { concurrency: "unbounded" }, - ); + const switchRef: GitVcsDriverShape["switchRef"] = Effect.fn("switchRef")(function* (input) { + const [localInputExists, remoteExists] = yield* Effect.all( + [ + executeGit( + "GitVcsDriver.switchRef.localInputExists", + input.cwd, + ["show-ref", "--verify", "--quiet", `refs/heads/${input.refName}`], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe(Effect.map((result) => result.exitCode === 0)), + executeGit( + "GitVcsDriver.switchRef.remoteExists", + input.cwd, + ["show-ref", "--verify", "--quiet", `refs/remotes/${input.refName}`], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe(Effect.map((result) => result.exitCode === 0)), + ], + { concurrency: "unbounded" }, + ); - const localTrackingBranch = remoteExists + const localTrackingBranch = remoteExists + ? yield* executeGit( + "GitVcsDriver.switchRef.localTrackingBranch", + input.cwd, + ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe( + Effect.map((result) => + result.exitCode === 0 + ? parseTrackingBranchByUpstreamRef(result.stdout, input.refName) + : null, + ), + ) + : null; + + const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.refName); + const localTrackedBranchTargetExists = + remoteExists && localTrackedBranchCandidate ? yield* executeGit( - "GitCore.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( - "GitCore.checkoutBranch.localTrackedBranchTargetExists", - input.cwd, - ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ).pipe(Effect.map((result) => result.code === 0)) - : false; - - const checkoutArgs = localInputExists - ? ["checkout", input.branch] - : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists - ? ["checkout", input.branch] - : remoteExists && !localTrackingBranch - ? ["checkout", "--track", input.branch] - : remoteExists && localTrackingBranch - ? ["checkout", localTrackingBranch] - : ["checkout", input.branch]; - - yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, checkoutArgs, { - timeoutMs: 10_000, - fallbackErrorMessage: "git checkout failed", - }); + ).pipe(Effect.map((result) => result.exitCode === 0)) + : false; + + const checkoutArgs = localInputExists + ? ["checkout", input.refName] + : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists + ? ["checkout", input.refName] + : remoteExists && !localTrackingBranch + ? ["checkout", "--track", input.refName] + : remoteExists && localTrackingBranch + ? ["checkout", localTrackingBranch] + : ["checkout", input.refName]; + + yield* executeGit("GitVcsDriver.switchRef.checkout", input.cwd, checkoutArgs, { + timeoutMs: 10_000, + fallbackErrorMessage: "git checkout failed", + }); - const branch = yield* runGitStdout("GitCore.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: GitCoreShape["createBranch"] = Effect.fn("createBranch")(function* (input) { - yield* executeGit("GitCore.createBranch", 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.checkout) { - yield* checkoutBranch({ cwd: input.cwd, branch: input.branch }); + if (input.switchRef) { + yield* switchRef({ cwd: input.cwd, refName: input.refName }); } - return { branch: input.branch }; + return { refName: input.refName }; }); - 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", @@ -2185,10 +2071,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { pullCurrentBranch, readRangeContext, readConfigValue, - isInsideWorkTree, - listWorkspaceFiles, - filterIgnoredPaths, - listBranches, + listRefs, createWorktree, fetchPullRequestBranch, ensureRemote, @@ -2196,11 +2079,9 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { setBranchUpstream, removeWorktree, renameBranch, - createBranch, - checkoutBranch, + createRef, + switchRef, initRepo, listLocalBranchNames, - } satisfies GitCoreShape; + } satisfies GitVcsDriverShape; }); - -export const GitCoreLive = Layer.effect(GitCore, makeGitCore()); diff --git a/apps/server/src/vcs/VcsDriver.ts b/apps/server/src/vcs/VcsDriver.ts new file mode 100644 index 00000000000..14e5f68ef45 --- /dev/null +++ b/apps/server/src/vcs/VcsDriver.ts @@ -0,0 +1,31 @@ +import { Context, type Effect } from "effect"; + +import type { + VcsDriverCapabilities, + VcsError, + VcsInitInput, + VcsListRemotesResult, + VcsListWorkspaceFilesResult, + VcsRepositoryIdentity, +} from "@t3tools/contracts"; +import type { VcsProcessInput, VcsProcessOutput } from "./VcsProcess.ts"; + +export interface VcsDriverShape { + readonly capabilities: VcsDriverCapabilities; + readonly execute: ( + input: Omit, + ) => Effect.Effect; + readonly detectRepository: (cwd: string) => Effect.Effect; + readonly isInsideWorkTree: (cwd: string) => Effect.Effect; + readonly listWorkspaceFiles: ( + cwd: string, + ) => Effect.Effect; + readonly listRemotes: (cwd: string) => Effect.Effect; + readonly filterIgnoredPaths: ( + cwd: string, + relativePaths: ReadonlyArray, + ) => Effect.Effect, VcsError>; + readonly initRepository: (input: VcsInitInput) => Effect.Effect; +} + +export class VcsDriver extends Context.Service()("t3/vcs/VcsDriver") {} diff --git a/apps/server/src/vcs/VcsDriverRegistry.test.ts b/apps/server/src/vcs/VcsDriverRegistry.test.ts new file mode 100644 index 00000000000..8e482c46b81 --- /dev/null +++ b/apps/server/src/vcs/VcsDriverRegistry.test.ts @@ -0,0 +1,87 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { describe } from "vitest"; + +import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "./VcsProcess.ts"; +import { VcsProjectConfig } from "./VcsProjectConfig.ts"; +import { VcsDriverRegistry, make as makeVcsDriverRegistry } from "./VcsDriverRegistry.ts"; + +const processOutput = (stdout: string): VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +describe("VcsDriverRegistry", () => { + it.effect("routes directly by VCS driver kind for non-repository workflows", () => { + const layer = Layer.effect(VcsDriverRegistry, makeVcsDriverRegistry()).pipe( + Layer.provide( + Layer.mock(VcsProjectConfig)({ + resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), + }), + ), + Layer.provide( + Layer.mock(VcsProcess)({ + run: () => Effect.succeed(processOutput("")), + }), + ), + ); + + return Effect.gen(function* () { + const registry = yield* VcsDriverRegistry; + const driver = yield* registry.get("git"); + + assert.strictEqual(driver.capabilities.kind, "git"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("caches repository detection for repeated resolves in the same cwd and kind", () => { + const calls: VcsProcessInput[] = []; + const layer = Layer.effect(VcsDriverRegistry, makeVcsDriverRegistry()).pipe( + Layer.provide( + Layer.mock(VcsProjectConfig)({ + resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), + }), + ), + Layer.provide( + Layer.mock(VcsProcess)({ + run: (input) => + Effect.sync(() => { + calls.push(input); + const command = input.args.join(" "); + if (command === "rev-parse --is-inside-work-tree") { + return processOutput("true\n"); + } + if (command === "rev-parse --show-toplevel") { + return processOutput("/repo\n"); + } + if (command === "rev-parse --git-common-dir") { + return processOutput("/repo/.git\n"); + } + return processOutput(""); + }), + }), + ), + ); + + return Effect.gen(function* () { + const registry = yield* VcsDriverRegistry; + const first = yield* registry.resolve({ cwd: "/repo", requestedKind: "git" }); + const second = yield* registry.resolve({ cwd: "/repo", requestedKind: "git" }); + + assert.equal(first.repository.rootPath, "/repo"); + assert.equal(second.repository.rootPath, "/repo"); + assert.deepStrictEqual( + calls.map((call) => call.args.join(" ")), + [ + "rev-parse --is-inside-work-tree", + "rev-parse --show-toplevel", + "rev-parse --git-common-dir", + ], + ); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/server/src/vcs/VcsDriverRegistry.ts b/apps/server/src/vcs/VcsDriverRegistry.ts new file mode 100644 index 00000000000..7798d63a087 --- /dev/null +++ b/apps/server/src/vcs/VcsDriverRegistry.ts @@ -0,0 +1,155 @@ +import { Cache, Context, Duration, Effect, Exit, Layer } from "effect"; + +import type { VcsDriverKind, VcsError, VcsRepositoryIdentity } from "@t3tools/contracts"; +import { VcsUnsupportedOperationError } from "@t3tools/contracts"; +import * as GitVcsDriver from "./GitVcsDriver.ts"; +import * as VcsProjectConfig from "./VcsProjectConfig.ts"; +import type { VcsDriverShape } from "./VcsDriver.ts"; + +const DETECTION_CACHE_CAPACITY = 2_048; +const DETECTION_CACHE_TTL = Duration.seconds(2); + +export interface VcsDriverResolveInput { + readonly cwd: string; + readonly requestedKind?: VcsDriverKind | "auto"; +} + +export interface VcsDriverHandle { + readonly kind: VcsDriverKind; + readonly repository: VcsRepositoryIdentity; + readonly driver: VcsDriverShape; +} + +export interface VcsDriverRegistryShape { + readonly get: (kind: VcsDriverKind) => Effect.Effect; + readonly detect: ( + input: VcsDriverResolveInput, + ) => Effect.Effect; + readonly resolve: (input: VcsDriverResolveInput) => Effect.Effect; +} + +export class VcsDriverRegistry extends Context.Service()( + "t3/vcs/VcsDriverRegistry", +) {} + +const unsupported = (operation: string, kind: VcsDriverKind, detail: string) => + new VcsUnsupportedOperationError({ + operation, + kind, + detail, + }); + +function detectionCacheKey(input: { + readonly cwd: string; + readonly requestedKind: VcsDriverKind | "auto"; +}): string { + return `${input.requestedKind}\0${input.cwd}`; +} + +function parseDetectionCacheKey(key: string): { + readonly cwd: string; + readonly requestedKind: VcsDriverKind | "auto"; +} { + const separatorIndex = key.indexOf("\0"); + if (separatorIndex === -1) { + return { + cwd: key, + requestedKind: "auto", + }; + } + return { + requestedKind: key.slice(0, separatorIndex) as VcsDriverKind | "auto", + cwd: key.slice(separatorIndex + 1), + }; +} + +export const make = Effect.fn("makeVcsDriverRegistry")(function* () { + const projectConfig = yield* VcsProjectConfig.VcsProjectConfig; + const git = yield* GitVcsDriver.makeVcsDriverShape(); + const drivers: Partial> = { + git, + }; + + const get: VcsDriverRegistryShape["get"] = (kind) => { + const driver = drivers[kind]; + if (!driver) { + return Effect.fail( + unsupported("VcsDriverRegistry.get", kind, `No ${kind} VCS driver is registered.`), + ); + } + return Effect.succeed(driver); + }; + + const detectWithDriver = Effect.fn("VcsDriverRegistry.detectWithDriver")(function* ( + kind: VcsDriverKind, + driver: VcsDriverShape, + cwd: string, + ) { + const repository = yield* driver.detectRepository(cwd); + if (!repository) { + return null; + } + return { + kind, + repository, + driver, + } satisfies VcsDriverHandle; + }); + + const detectResolvedKind = Effect.fn("VcsDriverRegistry.detectResolvedKind")(function* (input: { + readonly cwd: string; + readonly requestedKind: VcsDriverKind | "auto"; + }) { + const requestedKind = input.requestedKind; + + if (requestedKind !== "auto" && requestedKind !== "unknown") { + const driver = yield* get(requestedKind); + return yield* detectWithDriver(requestedKind, driver, input.cwd); + } + + return yield* detectWithDriver("git", git, input.cwd); + }); + + const detectionCache = yield* Cache.makeWith( + (key) => detectResolvedKind(parseDetectionCacheKey(key)), + { + capacity: DETECTION_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? DETECTION_CACHE_TTL : Duration.zero), + }, + ); + + const detect: VcsDriverRegistryShape["detect"] = Effect.fn("VcsDriverRegistry.detect")( + function* (input) { + const requestedKind = yield* projectConfig.resolveKind(input); + return yield* Cache.get(detectionCache, detectionCacheKey({ cwd: input.cwd, requestedKind })); + }, + ); + + const resolve: VcsDriverRegistryShape["resolve"] = Effect.fn("VcsDriverRegistry.resolve")( + function* (input) { + const detected = yield* detect(input); + if (detected) { + return detected; + } + + const requestedKind = input.requestedKind ?? "auto"; + return yield* unsupported( + "VcsDriverRegistry.resolve", + requestedKind === "auto" ? "unknown" : requestedKind, + requestedKind === "auto" + ? `No supported VCS repository was detected at ${input.cwd}.` + : `No ${requestedKind} repository was detected at ${input.cwd}.`, + ); + }, + ); + + return VcsDriverRegistry.of({ + get, + detect, + resolve, + }); +}); + +export const layer = Layer.effect(VcsDriverRegistry, make()).pipe( + Layer.provide(VcsProjectConfig.layer), +); diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts new file mode 100644 index 00000000000..33e03a2551d --- /dev/null +++ b/apps/server/src/vcs/VcsProcess.ts @@ -0,0 +1,259 @@ +import { Duration, Context, Effect, Layer, Option, PlatformError, Sink, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + VcsOutputDecodeError, + type VcsError, + VcsProcessExitError, + VcsProcessSpawnError, + VcsProcessTimeoutError, +} from "@t3tools/contracts"; + +export interface VcsProcessInput { + readonly operation: string; + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd: string; + readonly stdin?: string; + readonly env?: NodeJS.ProcessEnv; + readonly allowNonZeroExit?: boolean; + readonly timeoutMs?: number; + readonly maxOutputBytes?: number; + readonly truncateOutputAtMaxBytes?: boolean; +} + +export interface VcsProcessOutput { + readonly exitCode: ChildProcessSpawner.ExitCode; + readonly stdout: string; + readonly stderr: string; + readonly stdoutTruncated: boolean; + readonly stderrTruncated: boolean; +} + +export interface VcsProcessCollectedText { + readonly text: string; + readonly truncated: boolean; +} + +export interface VcsProcessHandle { + readonly pid: ChildProcessSpawner.ProcessId; + readonly stdin: Sink.Sink; + readonly stdout: Stream.Stream; + readonly stderr: Stream.Stream; + readonly exitCode: Effect.Effect; + readonly writeStdin: (input: string) => Effect.Effect; +} + +export interface VcsProcessShape { + readonly withProcess: ( + input: VcsProcessInput, + use: (handle: VcsProcessHandle) => Effect.Effect, + ) => Effect.Effect; + readonly run: (input: VcsProcessInput) => Effect.Effect; +} + +export class VcsProcess extends Context.Service()( + "t3/vcs/VcsProcess", +) {} + +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; +const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; + +function commandLabel(command: string, args: ReadonlyArray): string { + return [command, ...args].join(" "); +} + +function outputDecodeError( + input: VcsProcessInput, + detail: string, + cause: unknown, +): VcsOutputDecodeError { + return new VcsOutputDecodeError({ + operation: input.operation, + command: commandLabel(input.command, input.args), + cwd: input.cwd, + detail, + cause, + }); +} + +export const collectText = Effect.fn("VcsProcess.collectText")(function* (input: { + readonly operation: string; + readonly command: string; + readonly cwd: string; + readonly stream: Stream.Stream; + readonly maxOutputBytes?: number; + readonly truncateOutputAtMaxBytes?: boolean; +}) { + const decoder = new TextDecoder(); + let text = ""; + let bytes = 0; + let truncated = false; + const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; + const truncateOutputAtMaxBytes = input.truncateOutputAtMaxBytes ?? false; + + yield* Stream.runForEach(input.stream, (chunk) => + Effect.sync(() => { + if (truncated) return; + + const remainingBytes = maxOutputBytes - bytes; + if (remainingBytes <= 0) { + truncated = true; + if (truncateOutputAtMaxBytes) { + text += OUTPUT_TRUNCATED_MARKER; + } + return; + } + + const nextChunk = chunk.byteLength > remainingBytes ? chunk.slice(0, remainingBytes) : chunk; + text += decoder.decode(nextChunk, { stream: true }); + bytes += nextChunk.byteLength; + + if (chunk.byteLength > remainingBytes) { + truncated = true; + if (truncateOutputAtMaxBytes) { + text += OUTPUT_TRUNCATED_MARKER; + } + } + }), + ); + + if (!truncated) { + text += decoder.decode(); + } + + return { text, truncated } satisfies VcsProcessCollectedText; +}); + +export const make = Effect.fn("makeVcsProcess")(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const spawn = Effect.fn("VcsProcess.spawn")(function* (input: VcsProcessInput) { + const label = commandLabel(input.command, input.args); + const child = yield* spawner + .spawn( + ChildProcess.make(input.command, [...input.args], { + cwd: input.cwd, + env: { + ...process.env, + ...input.env, + }, + }), + ) + .pipe( + Effect.mapError( + (cause) => + new VcsProcessSpawnError({ + operation: input.operation, + command: label, + cwd: input.cwd, + cause, + }), + ), + ); + yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore)); + + const mapStreamError = (streamName: "stdout" | "stderr") => + Stream.mapError((cause: PlatformError.PlatformError) => + outputDecodeError(input, `failed to read process ${streamName}`, cause), + ); + const mapEffectError = (detail: string) => + Effect.mapError((cause: PlatformError.PlatformError) => + outputDecodeError(input, detail, cause), + ); + const writeStdin = (stdin: string) => + Stream.run(Stream.encodeText(Stream.make(stdin)), child.stdin).pipe( + mapEffectError("failed to write process stdin"), + ); + + return { + pid: child.pid, + stdin: child.stdin.pipe( + Sink.mapError((cause) => outputDecodeError(input, "failed to write process stdin", cause)), + ), + stdout: child.stdout.pipe(mapStreamError("stdout")), + stderr: child.stderr.pipe(mapStreamError("stderr")), + exitCode: child.exitCode.pipe(mapEffectError("failed to read process exit code")), + writeStdin, + } satisfies VcsProcessHandle; + }); + + const withProcess: VcsProcessShape["withProcess"] = (input, use) => + Effect.scoped(spawn(input).pipe(Effect.flatMap(use))); + + const run = Effect.fn("VcsProcess.run")(function* (input: VcsProcessInput) { + const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; + const label = commandLabel(input.command, input.args); + + const runProcess = Effect.gen(function* () { + const [stdout, stderr, exitCode] = yield* withProcess(input, (child) => + Effect.all( + [ + collectText({ + operation: input.operation, + command: label, + cwd: input.cwd, + stream: child.stdout, + maxOutputBytes, + truncateOutputAtMaxBytes: input.truncateOutputAtMaxBytes ?? false, + }), + collectText({ + operation: input.operation, + command: label, + cwd: input.cwd, + stream: child.stderr, + maxOutputBytes, + truncateOutputAtMaxBytes: input.truncateOutputAtMaxBytes ?? false, + }), + child.exitCode, + input.stdin === undefined ? Effect.void : child.writeStdin(input.stdin), + ], + { concurrency: "unbounded" }, + ), + ).pipe(Effect.map(([stdout, stderr, exitCode]) => [stdout, stderr, exitCode] as const)); + + if (!input.allowNonZeroExit && exitCode !== 0) { + return yield* new VcsProcessExitError({ + operation: input.operation, + command: label, + cwd: input.cwd, + exitCode, + detail: stderr.text.trim() || `${label} exited with code ${exitCode}.`, + }); + } + + return { + exitCode, + stdout: stdout.text, + stderr: stderr.text, + stdoutTruncated: stdout.truncated, + stderrTruncated: stderr.truncated, + } satisfies VcsProcessOutput; + }); + + return yield* runProcess.pipe( + Effect.scoped, + Effect.timeoutOption(Duration.millis(timeoutMs)), + Effect.flatMap((result) => + Option.match(result, { + onSome: Effect.succeed, + onNone: () => + Effect.fail( + new VcsProcessTimeoutError({ + operation: input.operation, + command: label, + cwd: input.cwd, + timeoutMs, + }), + ), + }), + ), + ); + }); + + return VcsProcess.of({ withProcess, run }); +}); + +export const layer = Layer.effect(VcsProcess, make()); diff --git a/apps/server/src/vcs/VcsProjectConfig.test.ts b/apps/server/src/vcs/VcsProjectConfig.test.ts new file mode 100644 index 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/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/git/Layers/GitStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts similarity index 62% rename from apps/server/src/git/Layers/GitStatusBroadcaster.test.ts rename to apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 72a0c24e27b..e8bcf4bdf03 100644 --- a/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -1,83 +1,82 @@ import { assert, it } from "@effect/vitest"; -import { Deferred, Effect, Exit, Layer, Option, Scope, Stream } from "effect"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Deferred, Effect, Exit, FileSystem, Layer, Option, Path, Scope, Stream } from "effect"; import type { - GitStatusLocalResult, - GitStatusRemoteResult, - GitStatusResult, - GitStatusStreamEvent, + VcsStatusLocalResult, + VcsStatusRemoteResult, + VcsStatusResult, + VcsStatusStreamEvent, } 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 = { +const baseLocalStatus: VcsStatusLocalResult = { isRepo: true, - hostingProvider: { + sourceControlProvider: { 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; 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 +87,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,12 +112,12 @@ describe("GitStatusBroadcasterLive", () => { }; return Effect.gen(function* () { - const broadcaster = yield* GitStatusBroadcaster; + const broadcaster = yield* VcsStatusBroadcaster; const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); state.currentLocalStatus = { ...baseLocalStatus, - branch: "feature/updated-status", + refName: "feature/updated-status", }; state.currentRemoteStatus = { ...baseRemoteStatus, @@ -154,12 +153,12 @@ describe("GitStatusBroadcasterLive", () => { }; return Effect.gen(function* () { - const broadcaster = yield* GitStatusBroadcaster; + const broadcaster = yield* VcsStatusBroadcaster; const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); state.currentLocalStatus = { ...baseLocalStatus, - branch: "feature/local-only-refresh", + refName: "feature/local-only-refresh", hasWorkingTreeChanges: true, }; @@ -179,6 +178,66 @@ describe("GitStatusBroadcasterLive", () => { }).pipe(Effect.provide(makeTestLayer(state))); }); + it.effect("normalizes symlinked CWDs before cache lookup and workflow calls", () => { + const seenCwds: string[] = []; + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + const testLayer = VcsStatusBroadcasterLayer.pipe( + Layer.provide( + Layer.mock(GitWorkflowService)({ + localStatus: (input) => + Effect.sync(() => { + seenCwds.push(input.cwd); + state.localStatusCalls += 1; + return state.currentLocalStatus; + }), + remoteStatus: (input) => + Effect.sync(() => { + seenCwds.push(input.cwd); + state.remoteStatusCalls += 1; + return state.currentRemoteStatus; + }), + invalidateLocalStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + state.remoteInvalidationCalls += 1; + }), + } satisfies Partial), + ), + ); + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const realDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-status-real-", + }); + const linkParent = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-status-link-", + }); + const linkDir = path.join(linkParent, "repo-link"); + yield* fileSystem.symlink(realDir, linkDir); + const realPath = yield* fileSystem.realPath(realDir); + + const broadcaster = yield* VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: linkDir }); + yield* broadcaster.getStatus({ cwd: realDir }); + + assert.deepStrictEqual(seenCwds, [realPath, realPath]); + assert.equal(state.localStatusCalls, 1); + assert.equal(state.remoteStatusCalls, 1); + }).pipe(Effect.provide(Layer.mergeAll(testLayer, NodeServices.layer))); + }); + it.effect("streams a local snapshot first and remote updates later", () => { const state = { currentLocalStatus: baseLocalStatus, @@ -190,9 +249,9 @@ describe("GitStatusBroadcasterLive", () => { }; return Effect.gen(function* () { - const broadcaster = yield* GitStatusBroadcaster; - const snapshotDeferred = yield* Deferred.make(); - const remoteUpdatedDeferred = yield* Deferred.make(); + const broadcaster = yield* VcsStatusBroadcaster; + const snapshotDeferred = yield* Deferred.make(); + const remoteUpdatedDeferred = yield* Deferred.make(); yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => { if (event._tag === "snapshot") { return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); @@ -211,11 +270,11 @@ describe("GitStatusBroadcasterLive", () => { _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))); }); @@ -230,9 +289,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; @@ -247,14 +306,13 @@ describe("GitStatusBroadcasterLive", () => { ? 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) : Effect.void, ), ), - status: () => Effect.die("status should not be called in this test"), invalidateLocalStatus: () => Effect.sync(() => { state.localInvalidationCalls += 1; @@ -263,13 +321,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,9 +331,9 @@ describe("GitStatusBroadcasterLive", () => { remoteInterruptedDeferred = remoteInterrupted; remoteStartedDeferred = remoteStarted; - const broadcaster = yield* GitStatusBroadcaster; - const firstSnapshot = yield* Deferred.make(); - const secondSnapshot = yield* Deferred.make(); + const broadcaster = yield* VcsStatusBroadcaster; + const firstSnapshot = yield* Deferred.make(); + const secondSnapshot = yield* Deferred.make(); const firstScope = yield* Scope.make(); const secondScope = yield* Scope.make(); yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => @@ -302,11 +354,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..028d3f1a1d9 --- /dev/null +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -0,0 +1,344 @@ +import { realpathSync } from "node:fs"; + +import { + Duration, + Effect, + Exit, + Fiber, + Layer, + PubSub, + Ref, + Scope, + Stream, + SynchronizedRef, +} from "effect"; +import type { + GitManagerServiceError, + VcsStatusInput, + VcsStatusLocalResult, + VcsStatusRemoteResult, + VcsStatusResult, + VcsStatusStreamEvent, +} from "@t3tools/contracts"; +import { Context } from "effect"; +import { mergeGitStatusParts } from "@t3tools/shared/git"; + +import { GitWorkflowService } from "../git/GitWorkflowService.ts"; + +const VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); + +interface VcsStatusChange { + readonly cwd: string; + readonly event: VcsStatusStreamEvent; +} + +interface CachedValue { + readonly fingerprint: string; + readonly value: T; +} + +interface CachedVcsStatus { + readonly local: CachedValue | null; + readonly remote: CachedValue | null; +} + +interface ActiveRemotePoller { + readonly fiber: Fiber.Fiber; + readonly subscriberCount: number; +} + +export interface VcsStatusBroadcasterShape { + readonly getStatus: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly refreshLocalStatus: ( + cwd: string, + ) => Effect.Effect; + readonly refreshStatus: (cwd: string) => Effect.Effect; + readonly streamStatus: ( + input: VcsStatusInput, + ) => Stream.Stream; +} + +export class VcsStatusBroadcaster extends Context.Service< + VcsStatusBroadcaster, + VcsStatusBroadcasterShape +>()("t3/vcs/VcsStatusBroadcaster") {} + +function fingerprintStatusPart(status: unknown): string { + return JSON.stringify(status); +} + +function normalizeCwd(cwd: string): string { + try { + return realpathSync.native(cwd); + } catch { + return cwd; + } +} + +export const layer = Layer.effect( + VcsStatusBroadcaster, + Effect.gen(function* () { + const workflow = yield* GitWorkflowService; + const changesPubSub = yield* Effect.acquireRelease( + PubSub.unbounded(), + (pubsub) => PubSub.shutdown(pubsub), + ); + const broadcasterScope = yield* Effect.acquireRelease(Scope.make(), (scope) => + Scope.close(scope, Exit.void), + ); + const cacheRef = yield* Ref.make(new Map()); + const pollersRef = yield* SynchronizedRef.make(new Map()); + + const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* ( + cwd: string, + ) { + return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null)); + }); + + const updateCachedLocalStatus = Effect.fn("VcsStatusBroadcaster.updateCachedLocalStatus")( + function* (cwd: string, local: VcsStatusLocalResult, options?: { publish?: boolean }) { + const nextLocal = { + fingerprint: fingerprintStatusPart(local), + value: local, + } satisfies CachedValue; + const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + const previous = cache.get(cwd) ?? { local: null, remote: null }; + const nextCache = new Map(cache); + nextCache.set(cwd, { + ...previous, + local: nextLocal, + }); + return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const; + }); + + if (options?.publish && shouldPublish) { + yield* PubSub.publish(changesPubSub, { + cwd, + event: { + _tag: "localUpdated", + local, + }, + }); + } + + return local; + }, + ); + + const updateCachedRemoteStatus = Effect.fn("VcsStatusBroadcaster.updateCachedRemoteStatus")( + function* ( + cwd: string, + remote: VcsStatusRemoteResult | null, + options?: { publish?: boolean }, + ) { + const nextRemote = { + fingerprint: fingerprintStatusPart(remote), + value: remote, + } satisfies CachedValue; + const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + const previous = cache.get(cwd) ?? { local: null, remote: null }; + const nextCache = new Map(cache); + nextCache.set(cwd, { + ...previous, + remote: nextRemote, + }); + return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const; + }); + + if (options?.publish && shouldPublish) { + yield* PubSub.publish(changesPubSub, { + cwd, + event: { + _tag: "remoteUpdated", + remote, + }, + }); + } + + return remote; + }, + ); + + const loadLocalStatus = Effect.fn("VcsStatusBroadcaster.loadLocalStatus")(function* ( + cwd: string, + ) { + const local = yield* workflow.localStatus({ cwd }); + return yield* updateCachedLocalStatus(cwd, local); + }); + + const loadRemoteStatus = Effect.fn("VcsStatusBroadcaster.loadRemoteStatus")(function* ( + cwd: string, + ) { + const remote = yield* workflow.remoteStatus({ cwd }); + return yield* updateCachedRemoteStatus(cwd, remote); + }); + + const getOrLoadLocalStatus = Effect.fn("VcsStatusBroadcaster.getOrLoadLocalStatus")(function* ( + cwd: string, + ) { + const cached = yield* getCachedStatus(cwd); + if (cached?.local) { + return cached.local.value; + } + return yield* loadLocalStatus(cwd); + }); + + const getOrLoadRemoteStatus = Effect.fn("VcsStatusBroadcaster.getOrLoadRemoteStatus")( + function* (cwd: string) { + const cached = yield* getCachedStatus(cwd); + if (cached?.remote) { + return cached.remote.value; + } + return yield* loadRemoteStatus(cwd); + }, + ); + + const getStatus: VcsStatusBroadcasterShape["getStatus"] = Effect.fn( + "VcsStatusBroadcaster.getStatus", + )(function* (input) { + const cwd = normalizeCwd(input.cwd); + const [local, remote] = yield* Effect.all([ + getOrLoadLocalStatus(cwd), + getOrLoadRemoteStatus(cwd), + ]); + return mergeGitStatusParts(local, remote); + }); + + const refreshLocalStatus: VcsStatusBroadcasterShape["refreshLocalStatus"] = Effect.fn( + "VcsStatusBroadcaster.refreshLocalStatus", + )(function* (rawCwd) { + const cwd = normalizeCwd(rawCwd); + yield* workflow.invalidateLocalStatus(cwd); + const local = yield* workflow.localStatus({ cwd }); + return yield* updateCachedLocalStatus(cwd, local, { publish: true }); + }); + + const refreshRemoteStatus = Effect.fn("VcsStatusBroadcaster.refreshRemoteStatus")(function* ( + cwd: string, + ) { + yield* workflow.invalidateRemoteStatus(cwd); + const remote = yield* workflow.remoteStatus({ cwd }); + return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); + }); + + const refreshStatus: VcsStatusBroadcasterShape["refreshStatus"] = Effect.fn( + "VcsStatusBroadcaster.refreshStatus", + )(function* (rawCwd) { + const cwd = normalizeCwd(rawCwd); + const [local, remote] = yield* Effect.all([ + refreshLocalStatus(cwd), + refreshRemoteStatus(cwd), + ]); + return mergeGitStatusParts(local, remote); + }); + + const makeRemoteRefreshLoop = (cwd: string) => { + const logRefreshFailure = (error: Error) => + Effect.logWarning("VCS remote status refresh failed", { + cwd, + detail: error.message, + }); + + return refreshRemoteStatus(cwd).pipe( + Effect.catch(logRefreshFailure), + Effect.andThen( + Effect.forever( + Effect.sleep(VCS_STATUS_REFRESH_INTERVAL).pipe( + Effect.andThen(refreshRemoteStatus(cwd).pipe(Effect.catch(logRefreshFailure))), + ), + ), + ), + ); + }; + + const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* ( + cwd: string, + ) { + yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { + const existing = activePollers.get(cwd); + if (existing) { + const nextPollers = new Map(activePollers); + nextPollers.set(cwd, { + ...existing, + subscriberCount: existing.subscriberCount + 1, + }); + return Effect.succeed([undefined, nextPollers] as const); + } + + return makeRemoteRefreshLoop(cwd).pipe( + Effect.forkIn(broadcasterScope), + Effect.map((fiber) => { + const nextPollers = new Map(activePollers); + nextPollers.set(cwd, { + fiber, + subscriberCount: 1, + }); + return [undefined, nextPollers] as const; + }), + ); + }); + }); + + const releaseRemotePoller = Effect.fn("VcsStatusBroadcaster.releaseRemotePoller")(function* ( + cwd: string, + ) { + const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => { + const existing = activePollers.get(cwd); + if (!existing) { + return [null, activePollers] as const; + } + + if (existing.subscriberCount > 1) { + const nextPollers = new Map(activePollers); + nextPollers.set(cwd, { + ...existing, + subscriberCount: existing.subscriberCount - 1, + }); + return [null, nextPollers] as const; + } + + const nextPollers = new Map(activePollers); + nextPollers.delete(cwd); + return [existing.fiber, nextPollers] as const; + }); + + if (pollerToInterrupt) { + yield* Fiber.interrupt(pollerToInterrupt).pipe(Effect.ignore); + } + }); + + const streamStatus: VcsStatusBroadcasterShape["streamStatus"] = (input) => + Stream.unwrap( + Effect.gen(function* () { + const cwd = normalizeCwd(input.cwd); + const subscription = yield* PubSub.subscribe(changesPubSub); + const initialLocal = yield* getOrLoadLocalStatus(cwd); + const initialRemote = (yield* getCachedStatus(cwd))?.remote?.value ?? null; + yield* retainRemotePoller(cwd); + + const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid); + + return Stream.concat( + Stream.make({ + _tag: "snapshot" as const, + local: initialLocal, + remote: initialRemote, + }), + Stream.fromSubscription(subscription).pipe( + Stream.filter((event) => event.cwd === cwd), + Stream.map((event) => event.event), + ), + ).pipe(Stream.ensuring(release)); + }), + ); + + return VcsStatusBroadcaster.of({ + getStatus, + refreshLocalStatus, + refreshStatus, + streamStatus, + }); + }), +); diff --git a/apps/server/src/vcs/testing/VcsDriverContractHarness.ts b/apps/server/src/vcs/testing/VcsDriverContractHarness.ts new file mode 100644 index 00000000000..c0e195558b5 --- /dev/null +++ b/apps/server/src/vcs/testing/VcsDriverContractHarness.ts @@ -0,0 +1,152 @@ +import { assert, it } from "@effect/vitest"; +import { DateTime, Option } from "effect"; +import { Effect, FileSystem, Layer, Path, type PlatformError, type Scope } from "effect"; +import { describe } from "vitest"; + +import type { VcsDriverKind } from "@t3tools/contracts"; +import { VcsDriver } from "../VcsDriver.ts"; + +export interface VcsDriverFixture { + readonly createRepo: (cwd: string) => Effect.Effect; + readonly writeFile: ( + cwd: string, + relativePath: string, + contents: string, + ) => Effect.Effect; + readonly trackFile?: (cwd: string, relativePath: string) => Effect.Effect; + readonly commit?: (cwd: string, message: string) => Effect.Effect; + readonly ignorePath: ( + cwd: string, + pattern: string, + ) => Effect.Effect; +} + +export interface VcsDriverContractSuiteInput { + readonly name: string; + readonly kind: VcsDriverKind; + readonly layer: Layer.Layer; + readonly fixture: VcsDriverFixture; +} + +export function runVcsDriverContractSuite(input: VcsDriverContractSuiteInput) { + const makeTmpDir = ( + prefix = `t3-${input.kind}-vcs-contract-`, + ): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); + }); + + it.layer(input.layer)(`${input.name} VCS driver contract`, (it) => { + describe("repository detection", () => { + it.effect("returns null outside a repository", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* VcsDriver; + + assert.equal(yield* driver.detectRepository(cwd), null); + assert.equal(yield* driver.isInsideWorkTree(cwd), false); + }), + ); + + it.effect("detects repository identity inside a repository and nested directories", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* VcsDriver; + + yield* input.fixture.createRepo(cwd); + yield* input.fixture.writeFile(cwd, "src/index.ts", "export const value = 1;\n"); + const identity = yield* driver.detectRepository(cwd); + assert.equal(identity?.kind, input.kind); + assert.isTrue(identity?.rootPath.endsWith(cwd)); + assert.equal(identity?.freshness.source, "live-local"); + assert.isTrue(DateTime.isDateTime(identity?.freshness.observedAt)); + assert.isTrue(Option.isNone(identity?.freshness.expiresAt ?? Option.none())); + assert.equal(yield* driver.isInsideWorkTree(cwd), true); + + const path = yield* Path.Path; + const nestedDir = path.join(cwd, "src"); + const nestedIdentity = yield* driver.detectRepository(nestedDir); + assert.equal(nestedIdentity?.rootPath, identity?.rootPath); + assert.equal(yield* driver.isInsideWorkTree(nestedDir), true); + }), + ); + }); + + describe("workspace files", () => { + it.effect("lists tracked and untracked non-ignored files", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* VcsDriver; + + yield* input.fixture.createRepo(cwd); + yield* input.fixture.writeFile(cwd, "tracked.ts", "export const tracked = true;\n"); + if (input.fixture.trackFile && input.fixture.commit) { + yield* input.fixture.trackFile(cwd, "tracked.ts"); + yield* input.fixture.commit(cwd, "Track file"); + } + yield* input.fixture.writeFile(cwd, "untracked.ts", "export const untracked = true;\n"); + + const result = yield* driver.listWorkspaceFiles(cwd); + + assert.include(result.paths, "tracked.ts"); + assert.include(result.paths, "untracked.ts"); + assert.equal(result.truncated, false); + assert.equal(result.freshness.source, "live-local"); + assert.isTrue(DateTime.isDateTime(result.freshness.observedAt)); + assert.isTrue(Option.isNone(result.freshness.expiresAt)); + }), + ); + + it.effect("excludes ignored files from workspace listing", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* VcsDriver; + + yield* input.fixture.createRepo(cwd); + yield* input.fixture.ignorePath(cwd, "*.log"); + yield* input.fixture.writeFile(cwd, "included.ts", "export const included = true;\n"); + yield* input.fixture.writeFile(cwd, "debug.log", "ignore me\n"); + yield* input.fixture.writeFile(cwd, "nested/error.log", "ignore me too\n"); + + const result = yield* driver.listWorkspaceFiles(cwd); + + assert.include(result.paths, "included.ts"); + assert.notInclude(result.paths, "debug.log"); + assert.notInclude(result.paths, "nested/error.log"); + }), + ); + }); + + describe("ignored path filtering", () => { + it.effect("filters ignored paths", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* VcsDriver; + + yield* input.fixture.createRepo(cwd); + yield* input.fixture.ignorePath(cwd, "*.log"); + + const result = yield* driver.filterIgnoredPaths(cwd, [ + "keep.ts", + "debug.log", + "nested/error.log", + ]); + + assert.deepStrictEqual(result, ["keep.ts"]); + }), + ); + + it.effect("returns empty input unchanged", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* VcsDriver; + + yield* input.fixture.createRepo(cwd); + + assert.deepStrictEqual(yield* driver.filterIgnoredPaths(cwd, []), []); + }), + ); + }); + }); +} diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts index 85b43ab37f6..a7385794e93 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts @@ -5,8 +5,8 @@ import { it, afterEach, describe, expect, vi } from "@effect/vitest"; import { Effect, FileSystem, Layer, Path, PlatformError } from "effect"; import { ServerConfig } from "../../config.ts"; -import { GitCoreLive } from "../../git/Layers/GitCore.ts"; -import { GitCore } from "../../git/Services/GitCore.ts"; +import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; import { WorkspacePathsLive } from "./WorkspacePaths.ts"; @@ -14,7 +14,8 @@ import { WorkspacePathsLive } from "./WorkspacePaths.ts"; const TestLayer = Layer.empty.pipe( Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), Layer.provideMerge(WorkspacePathsLive), - Layer.provideMerge(GitCoreLive), + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), Layer.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-workspace-entries-test-", @@ -25,12 +26,11 @@ const TestLayer = Layer.empty.pipe( const makeTempDir = Effect.fn(function* (opts?: { prefix?: string; git?: boolean }) { const fileSystem = yield* FileSystem.FileSystem; - const gitCore = yield* GitCore; const dir = yield* fileSystem.makeTempDirectoryScoped({ prefix: opts?.prefix ?? "t3code-workspace-entries-", }); if (opts?.git) { - yield* gitCore.initRepo({ cwd: dir }); + yield* git(dir, ["init"]); } return dir; }); @@ -51,9 +51,10 @@ function writeTextFile( const git = (cwd: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) => Effect.gen(function* () { - const gitCore = yield* GitCore; - const result = yield* gitCore.execute({ + const process = yield* VcsProcess.VcsProcess; + const result = yield* process.run({ operation: "WorkspaceEntries.test.git", + command: "git", cwd, args, ...(env ? { env } : {}), diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.ts index 7cb16b652be..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, Duration, Effect, Exit, Layer, Option, Path } from "effect"; +import { Cache, DateTime, Duration, Effect, Exit, Layer, Path } from "effect"; import { type FilesystemBrowseInput, type ProjectEntry } from "@t3tools/contracts"; import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; @@ -13,7 +13,7 @@ import { type RankedSearchResult, } from "@t3tools/shared/searchRanking"; -import { GitCore } from "../../git/Services/GitCore.ts"; +import { VcsDriverRegistry } from "../../vcs/VcsDriverRegistry.ts"; import { WorkspaceEntries, WorkspaceEntriesBrowseError, @@ -174,38 +174,39 @@ const resolveBrowseTarget = ( export const makeWorkspaceEntries = Effect.gen(function* () { const path = yield* Path.Path; - const gitOption = yield* Effect.serviceOption(GitCore); + const vcsRegistry = yield* VcsDriverRegistry; const workspacePaths = yield* WorkspacePaths; - const isInsideGitWorkTree = (cwd: string): Effect.Effect => - Option.match(gitOption, { - onSome: (git) => git.isInsideWorkTree(cwd).pipe(Effect.catch(() => Effect.succeed(false))), - onNone: () => Effect.succeed(false), - }); + const isInsideVcsWorkTree = (cwd: string): Effect.Effect => + vcsRegistry.detect({ cwd }).pipe( + Effect.map((handle) => handle !== null), + Effect.catch(() => Effect.succeed(false)), + ); - const filterGitIgnoredPaths = ( + const filterVcsIgnoredPaths = ( cwd: string, relativePaths: string[], ): Effect.Effect => - Option.match(gitOption, { - onSome: (git) => - git.filterIgnoredPaths(cwd, relativePaths).pipe( - Effect.map((paths) => [...paths]), - Effect.catch(() => Effect.succeed(relativePaths)), - ), - onNone: () => Effect.succeed(relativePaths), - }); - - const buildWorkspaceIndexFromGit = Effect.fn("WorkspaceEntries.buildWorkspaceIndexFromGit")( + 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(gitOption)) { - return null; - } - if (!(yield* isInsideGitWorkTree(cwd))) { + const vcs = yield* vcsRegistry.detect({ cwd }).pipe(Effect.catch(() => Effect.succeed(null))); + if (!vcs) { return null; } - const listedFiles = yield* gitOption.value + const listedFiles = yield* vcs.driver .listWorkspaceFiles(cwd) .pipe(Effect.catch(() => Effect.succeed(null))); @@ -216,7 +217,10 @@ export const makeWorkspaceEntries = Effect.gen(function* () { const listedPaths = [...listedFiles.paths] .map((entry) => toPosixPath(entry)) .filter((entry) => entry.length > 0 && !isPathInIgnoredDirectory(entry)); - const filePaths = yield* filterGitIgnoredPaths(cwd, listedPaths); + const filePaths = yield* vcs.driver.filterIgnoredPaths(cwd, listedPaths).pipe( + Effect.map((paths) => [...paths]), + Effect.catch(() => filterVcsIgnoredPaths(cwd, listedPaths)), + ); const directorySet = new Set(); for (const filePath of filePaths) { @@ -248,9 +252,10 @@ export const makeWorkspaceEntries = Effect.gen(function* () { ) .map(toSearchableWorkspaceEntry); + const now = yield* DateTime.now; const entries = [...directoryEntries, ...fileEntries]; return { - scannedAt: Date.now(), + scannedAt: now.epochMilliseconds, entries: entries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES), truncated: listedFiles.truncated || entries.length > WORKSPACE_INDEX_MAX_ENTRIES, }; @@ -288,7 +293,7 @@ export const makeWorkspaceEntries = Effect.gen(function* () { const buildWorkspaceIndexFromFilesystem = Effect.fn( "WorkspaceEntries.buildWorkspaceIndexFromFilesystem", )(function* (cwd: string): Effect.fn.Return { - const shouldFilterWithGitIgnore = yield* isInsideGitWorkTree(cwd); + const shouldFilterWithGitIgnore = yield* isInsideVcsWorkTree(cwd); let pendingDirectories: string[] = [""]; const entries: SearchableWorkspaceEntry[] = []; @@ -336,7 +341,7 @@ export const makeWorkspaceEntries = Effect.gen(function* () { candidateEntries.map((entry) => entry.relativePath), ); const allowedPathSet = shouldFilterWithGitIgnore - ? new Set(yield* filterGitIgnoredPaths(cwd, candidatePaths)) + ? new Set(yield* filterVcsIgnoredPaths(cwd, candidatePaths)) : null; for (const candidateEntries of candidateEntriesByDirectory) { @@ -368,8 +373,9 @@ export const makeWorkspaceEntries = Effect.gen(function* () { } } + const now = yield* DateTime.now; return { - scannedAt: Date.now(), + scannedAt: now.epochMilliseconds, entries, truncated, }; @@ -378,9 +384,9 @@ export const makeWorkspaceEntries = Effect.gen(function* () { const buildWorkspaceIndex = Effect.fn("WorkspaceEntries.buildWorkspaceIndex")(function* ( cwd: string, ): Effect.fn.Return { - const gitIndexed = yield* buildWorkspaceIndexFromGit(cwd); - if (gitIndexed) { - return gitIndexed; + const vcsIndexed = yield* buildWorkspaceIndexFromVcs(cwd); + if (vcsIndexed) { + return vcsIndexed; } return yield* buildWorkspaceIndexFromFilesystem(cwd); }); diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts index fcfd13c912e..9c38b88f851 100644 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts @@ -3,7 +3,8 @@ import { it, describe, expect } from "@effect/vitest"; import { Effect, FileSystem, Layer, Path } from "effect"; import { ServerConfig } from "../../config.ts"; -import { GitCoreLive } from "../../git/Layers/GitCore.ts"; +import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; import { WorkspaceFileSystem } from "../Services/WorkspaceFileSystem.ts"; import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; @@ -19,7 +20,7 @@ const TestLayer = Layer.empty.pipe( Layer.provideMerge(ProjectLayer), Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), Layer.provideMerge(WorkspacePathsLive), - Layer.provideMerge(GitCoreLive), + Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), Layer.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-workspace-files-test-", diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fd256b32dfa..c3827d98bf6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -29,9 +29,6 @@ import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery.ts"; import { ServerConfig } from "./config.ts"; -import { GitCore } from "./git/Services/GitCore.ts"; -import { GitManager } from "./git/Services/GitManager.ts"; -import { GitStatusBroadcaster } from "./git/Services/GitStatusBroadcaster.ts"; import { Keybindings } from "./keybindings.ts"; import { Open, resolveAvailableEditors } from "./open.ts"; import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; @@ -50,10 +47,15 @@ import { TerminalManager } from "./terminal/Services/Manager.ts"; import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; +import { VcsStatusBroadcaster } from "./vcs/VcsStatusBroadcaster.ts"; +import { VcsProvisioningService } from "./vcs/VcsProvisioningService.ts"; +import { GitWorkflowService } from "./git/GitWorkflowService.ts"; import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner.ts"; import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver.ts"; import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; import { ServerAuth } from "./auth/Services/ServerAuth.ts"; +import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts"; +import * as VcsProcess from "./vcs/VcsProcess.ts"; import { BootstrapCredentialService, type BootstrapCredentialChange, @@ -136,9 +138,9 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const checkpointDiffQuery = yield* CheckpointDiffQuery; const keybindings = yield* Keybindings; const open = yield* Open; - const gitManager = yield* GitManager; - const git = yield* GitCore; - const gitStatusBroadcaster = yield* GitStatusBroadcaster; + const gitWorkflow = yield* GitWorkflowService; + const vcsProvisioning = yield* VcsProvisioningService; + const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const terminalManager = yield* TerminalManager; const providerRegistry = yield* ProviderRegistry; const config = yield* ServerConfig; @@ -151,6 +153,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const repositoryIdentityResolver = yield* RepositoryIdentityResolver; const serverEnvironment = yield* ServerEnvironment; const serverAuth = yield* ServerAuth; + const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery; const bootstrapCredentials = yield* BootstrapCredentialService; const sessions = yield* SessionCredentialService; const serverCommandId = (tag: string) => @@ -453,10 +456,10 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => } if (bootstrap?.prepareWorktree) { - const worktree = yield* git.createWorktree({ + const worktree = yield* gitWorkflow.createWorktree({ cwd: bootstrap.prepareWorktree.projectCwd, - branch: bootstrap.prepareWorktree.baseBranch, - newBranch: bootstrap.prepareWorktree.branch, + refName: bootstrap.prepareWorktree.baseBranch, + newRefName: bootstrap.prepareWorktree.branch, path: null, }); targetWorktreePath = worktree.worktree.path; @@ -464,7 +467,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => type: "thread.meta.update", commandId: serverCommandId("bootstrap-thread-meta-update"), threadId: command.threadId, - branch: worktree.worktree.branch, + branch: worktree.worktree.refName, worktreePath: targetWorktreePath, }); yield* refreshGitStatus(targetWorktreePath); @@ -540,7 +543,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => }); const refreshGitStatus = (cwd: string) => - gitStatusBroadcaster + vcsStatusBroadcaster .refreshStatus(cwd) .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); @@ -783,6 +786,14 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => "rpc.aggregate": "server", }, ), + [WS_METHODS.serverDiscoverSourceControl]: (_input) => + observeRpcEffect( + WS_METHODS.serverDiscoverSourceControl, + sourceControlDiscovery.discover, + { + "rpc.aggregate": "server", + }, + ), [WS_METHODS.projectsSearchEntries]: (input) => observeRpcEffect( WS_METHODS.projectsSearchEntries, @@ -831,26 +842,26 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => ), { "rpc.aggregate": "workspace" }, ), - [WS_METHODS.subscribeGitStatus]: (input) => + [WS_METHODS.subscribeVcsStatus]: (input) => observeRpcStream( - WS_METHODS.subscribeGitStatus, - gitStatusBroadcaster.streamStatus(input), + WS_METHODS.subscribeVcsStatus, + vcsStatusBroadcaster.streamStatus(input), { - "rpc.aggregate": "git", + "rpc.aggregate": "vcs", }, ), - [WS_METHODS.gitRefreshStatus]: (input) => + [WS_METHODS.vcsRefreshStatus]: (input) => observeRpcEffect( - WS_METHODS.gitRefreshStatus, - gitStatusBroadcaster.refreshStatus(input.cwd), + WS_METHODS.vcsRefreshStatus, + vcsStatusBroadcaster.refreshStatus(input.cwd), { - "rpc.aggregate": "git", + "rpc.aggregate": "vcs", }, ), - [WS_METHODS.gitPull]: (input) => + [WS_METHODS.vcsPull]: (input) => observeRpcEffect( - WS_METHODS.gitPull, - git.pullCurrentBranch(input.cwd).pipe( + WS_METHODS.vcsPull, + gitWorkflow.pullCurrentBranch(input.cwd).pipe( Effect.matchCauseEffect({ onFailure: (cause) => Effect.failCause(cause), onSuccess: (result) => @@ -863,7 +874,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => observeRpcStream( WS_METHODS.gitRunStackedAction, Stream.callback((queue) => - gitManager + gitWorkflow .runStackedAction(input, { actionId: input.actionId, progressReporter: { @@ -880,55 +891,59 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => }), ), ), - { "rpc.aggregate": "git" }, + { "rpc.aggregate": "vcs" }, ), [WS_METHODS.gitResolvePullRequest]: (input) => - observeRpcEffect(WS_METHODS.gitResolvePullRequest, gitManager.resolvePullRequest(input), { - "rpc.aggregate": "git", - }), + observeRpcEffect( + WS_METHODS.gitResolvePullRequest, + gitWorkflow.resolvePullRequest(input), + { + "rpc.aggregate": "git", + }, + ), [WS_METHODS.gitPreparePullRequestThread]: (input) => observeRpcEffect( WS_METHODS.gitPreparePullRequestThread, - gitManager + gitWorkflow .preparePullRequestThread(input) .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), { "rpc.aggregate": "git" }, ), - [WS_METHODS.gitListBranches]: (input) => - observeRpcEffect(WS_METHODS.gitListBranches, git.listBranches(input), { - "rpc.aggregate": "git", + [WS_METHODS.vcsListRefs]: (input) => + observeRpcEffect(WS_METHODS.vcsListRefs, gitWorkflow.listRefs(input), { + "rpc.aggregate": "vcs", }), - [WS_METHODS.gitCreateWorktree]: (input) => + [WS_METHODS.vcsCreateWorktree]: (input) => observeRpcEffect( - WS_METHODS.gitCreateWorktree, - git.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "git" }, + WS_METHODS.vcsCreateWorktree, + gitWorkflow.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, ), - [WS_METHODS.gitRemoveWorktree]: (input) => + [WS_METHODS.vcsRemoveWorktree]: (input) => observeRpcEffect( - WS_METHODS.gitRemoveWorktree, - git.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "git" }, + WS_METHODS.vcsRemoveWorktree, + gitWorkflow.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, ), - [WS_METHODS.gitCreateBranch]: (input) => + [WS_METHODS.vcsCreateRef]: (input) => observeRpcEffect( - WS_METHODS.gitCreateBranch, - git.createBranch(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "git" }, + WS_METHODS.vcsCreateRef, + gitWorkflow.createRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, ), - [WS_METHODS.gitCheckout]: (input) => + [WS_METHODS.vcsSwitchRef]: (input) => observeRpcEffect( - WS_METHODS.gitCheckout, - Effect.scoped(git.checkoutBranch(input)).pipe( - Effect.tap(() => refreshGitStatus(input.cwd)), - ), - { "rpc.aggregate": "git" }, + WS_METHODS.vcsSwitchRef, + gitWorkflow.switchRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, ), - [WS_METHODS.gitInit]: (input) => + [WS_METHODS.vcsInit]: (input) => observeRpcEffect( - WS_METHODS.gitInit, - git.initRepo(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "git" }, + WS_METHODS.vcsInit, + vcsProvisioning + .initRepository(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, ), [WS_METHODS.terminalOpen]: (input) => observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { @@ -1084,7 +1099,12 @@ export const websocketRpcRouteLayer = Layer.unwrap( }, }).pipe( Effect.provide( - makeWsRpcLayer(session.sessionId).pipe(Layer.provideMerge(RpcSerialization.layerJson)), + makeWsRpcLayer(session.sessionId).pipe( + Layer.provideMerge(RpcSerialization.layerJson), + Layer.provide( + SourceControlDiscoveryLayer.layer.pipe(Layer.provide(VcsProcess.layer)), + ), + ), ), ); return yield* Effect.acquireUseRelease( diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 7beaec808db..3db5237373a 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -1,4 +1,4 @@ -import { EnvironmentId, type GitBranch } from "@t3tools/contracts"; +import { EnvironmentId, type VcsRef } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; import { dedupeRemoteBranchesWithLocalMatches, @@ -28,7 +28,7 @@ describe("resolveDraftEnvModeAfterBranchChange", () => { ).toBe("local"); }); - it("keeps new-worktree mode when selecting a base branch before worktree creation", () => { + it("keeps new-worktree mode when selecting a base ref before worktree creation", () => { expect( resolveDraftEnvModeAfterBranchChange({ nextWorktreePath: null, @@ -38,7 +38,7 @@ describe("resolveDraftEnvModeAfterBranchChange", () => { ).toBe("worktree"); }); - it("uses worktree mode when selecting a branch already attached to a worktree", () => { + it("uses worktree mode when selecting a ref already attached to a worktree", () => { expect( resolveDraftEnvModeAfterBranchChange({ nextWorktreePath: "/repo/.t3/worktrees/feature-a", @@ -50,7 +50,7 @@ describe("resolveDraftEnvModeAfterBranchChange", () => { }); describe("resolveBranchToolbarValue", () => { - it("defaults new-worktree mode to current git branch when no explicit base branch is set", () => { + it("defaults new-worktree mode to current git ref when no explicit base ref is set", () => { expect( resolveBranchToolbarValue({ envMode: "worktree", @@ -61,7 +61,7 @@ describe("resolveBranchToolbarValue", () => { ).toBe("main"); }); - it("keeps an explicitly selected worktree base branch", () => { + it("keeps an explicitly selected worktree base ref", () => { expect( resolveBranchToolbarValue({ envMode: "worktree", @@ -72,7 +72,7 @@ describe("resolveBranchToolbarValue", () => { ).toBe("feature/base"); }); - it("shows the actual checked-out branch when not selecting a new worktree base", () => { + it("shows the actual checked-out ref when not selecting a new worktree base", () => { expect( resolveBranchToolbarValue({ envMode: "local", @@ -186,8 +186,8 @@ describe("deriveLocalBranchNameFromRemoteRef", () => { }); describe("dedupeRemoteBranchesWithLocalMatches", () => { - it("hides remote refs when the matching local branch exists", () => { - const input: GitBranch[] = [ + it("hides remote refs when the matching local ref exists", () => { + const input: VcsRef[] = [ { name: "feature/demo", current: false, @@ -212,14 +212,14 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { }, ]; - expect(dedupeRemoteBranchesWithLocalMatches(input).map((branch) => branch.name)).toEqual([ + expect(dedupeRemoteBranchesWithLocalMatches(input).map((ref) => ref.name)).toEqual([ "feature/demo", "origin/feature/remote-only", ]); }); it("keeps all entries when no local match exists for a remote ref", () => { - const input: GitBranch[] = [ + const input: VcsRef[] = [ { name: "feature/local", current: false, @@ -236,14 +236,14 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { }, ]; - expect(dedupeRemoteBranchesWithLocalMatches(input).map((branch) => branch.name)).toEqual([ + expect(dedupeRemoteBranchesWithLocalMatches(input).map((ref) => ref.name)).toEqual([ "feature/local", "origin/feature/remote-only", ]); }); - it("keeps non-origin remote refs visible even when a matching local branch exists", () => { - const input: GitBranch[] = [ + it("keeps non-origin remote refs visible even when a matching local ref exists", () => { + const input: VcsRef[] = [ { name: "feature/demo", current: false, @@ -260,14 +260,14 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { }, ]; - expect(dedupeRemoteBranchesWithLocalMatches(input).map((branch) => branch.name)).toEqual([ + expect(dedupeRemoteBranchesWithLocalMatches(input).map((ref) => ref.name)).toEqual([ "feature/demo", "my-org/upstream/feature/demo", ]); }); it("keeps non-origin remote refs visible when git tracks with first-slash local naming", () => { - const input: GitBranch[] = [ + const input: VcsRef[] = [ { name: "upstream/feature", current: false, @@ -284,7 +284,7 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { }, ]; - expect(dedupeRemoteBranchesWithLocalMatches(input).map((branch) => branch.name)).toEqual([ + expect(dedupeRemoteBranchesWithLocalMatches(input).map((ref) => ref.name)).toEqual([ "upstream/feature", "my-org/upstream/feature", ]); @@ -292,12 +292,12 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { }); describe("resolveBranchSelectionTarget", () => { - it("reuses an existing secondary worktree for the selected branch", () => { + it("reuses an existing secondary worktree for the selected ref", () => { expect( resolveBranchSelectionTarget({ activeProjectCwd: "/repo", activeWorktreePath: "/repo/.t3/worktrees/feature-a", - branch: { + refName: { isDefault: false, worktreePath: "/repo/.t3/worktrees/feature-b", }, @@ -309,12 +309,12 @@ describe("resolveBranchSelectionTarget", () => { }); }); - it("switches back to the main repo when the branch already lives there", () => { + it("switches back to the main repo when the ref already lives there", () => { expect( resolveBranchSelectionTarget({ activeProjectCwd: "/repo", activeWorktreePath: "/repo/.t3/worktrees/feature-a", - branch: { + refName: { isDefault: true, worktreePath: "/repo", }, @@ -326,12 +326,12 @@ describe("resolveBranchSelectionTarget", () => { }); }); - it("checks out the default branch in the main repo when leaving a secondary worktree", () => { + it("checks out the default ref in the main repo when leaving a secondary worktree", () => { expect( resolveBranchSelectionTarget({ activeProjectCwd: "/repo", activeWorktreePath: "/repo/.t3/worktrees/feature-a", - branch: { + refName: { isDefault: true, worktreePath: null, }, @@ -343,12 +343,12 @@ describe("resolveBranchSelectionTarget", () => { }); }); - it("keeps checkout in the current worktree for non-default branches", () => { + it("keeps checkout in the current worktree for non-default refs", () => { expect( resolveBranchSelectionTarget({ activeProjectCwd: "/repo", activeWorktreePath: "/repo/.t3/worktrees/feature-a", - branch: { + refName: { isDefault: false, worktreePath: null, }, @@ -373,7 +373,7 @@ describe("shouldIncludeBranchPickerItem", () => { ).toBe(true); }); - it("keeps the synthetic create-branch item visible for arbitrary branch input", () => { + it("keeps the synthetic create-ref item visible for arbitrary ref input", () => { expect( shouldIncludeBranchPickerItem({ itemValue: "__create_new_branch__:feature/demo", @@ -384,7 +384,7 @@ describe("shouldIncludeBranchPickerItem", () => { ).toBe(true); }); - it("still filters ordinary branch items by query text", () => { + it("still filters ordinary ref items by query text", () => { expect( shouldIncludeBranchPickerItem({ itemValue: "main", diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 7adab1a2e16..31f614a2e2c 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -1,4 +1,4 @@ -import type { EnvironmentId, GitBranch, ProjectId } from "@t3tools/contracts"; +import type { EnvironmentId, VcsRef, ProjectId } from "@t3tools/contracts"; import { Schema } from "effect"; export { dedupeRemoteBranchesWithLocalMatches, @@ -100,24 +100,24 @@ export function resolveBranchToolbarValue(input: { export function resolveBranchSelectionTarget(input: { activeProjectCwd: string; activeWorktreePath: string | null; - branch: Pick; + refName: Pick; }): { checkoutCwd: string; nextWorktreePath: string | null; reuseExistingWorktree: boolean; } { - const { activeProjectCwd, activeWorktreePath, branch } = input; + const { activeProjectCwd, activeWorktreePath, refName } = input; - if (branch.worktreePath) { + if (refName.worktreePath) { return { - checkoutCwd: branch.worktreePath, - nextWorktreePath: branch.worktreePath === activeProjectCwd ? null : branch.worktreePath, + checkoutCwd: refName.worktreePath, + nextWorktreePath: refName.worktreePath === activeProjectCwd ? null : refName.worktreePath, reuseExistingWorktree: true, }; } const nextWorktreePath = - activeWorktreePath !== null && branch.isDefault ? null : activeWorktreePath; + activeWorktreePath !== null && refName.isDefault ? null : activeWorktreePath; return { checkoutCwd: nextWorktreePath ?? activeProjectCwd, diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index af5bd2360e9..2e30edfc02c 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,5 +1,5 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; -import type { EnvironmentId, GitBranch, ThreadId } from "@t3tools/contracts"; +import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { ChevronDownIcon } from "lucide-react"; @@ -21,6 +21,7 @@ import { useGitStatus } from "../lib/gitStatusState"; import { newCommandId } from "../lib/utils"; import { cn } from "../lib/utils"; import { parsePullRequestReference } from "../pullRequestReference"; +import { getSourceControlPresentation } from "../sourceControlPresentation"; import { useStore } from "../store"; import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { @@ -53,7 +54,7 @@ interface BranchToolbarBranchSelectorProps { envLocked: boolean; effectiveEnvModeOverride?: "local" | "worktree"; activeThreadBranchOverride?: string | null; - onActiveThreadBranchOverrideChange?: (branch: string | null) => void; + onActiveThreadBranchOverrideChange?: (refName: string | null) => void; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; } @@ -69,7 +70,7 @@ function getBranchTriggerLabel(input: { }): string { const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch } = input; if (!resolvedActiveBranch) { - return "Select branch"; + return "Select ref"; } if (effectiveEnvMode === "worktree" && !activeWorktreePath) { return `From ${resolvedActiveBranch}`; @@ -193,7 +194,7 @@ export function BranchToolbarBranchSelector({ ); // --------------------------------------------------------------------------- - // Git branch queries + // Git ref queries // --------------------------------------------------------------------------- const queryClient = useQueryClient(); const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); @@ -224,22 +225,27 @@ export function BranchToolbarBranchSelector({ query: deferredTrimmedBranchQuery, }), ); - const branches = useMemo( - () => branchesSearchData?.pages.flatMap((page) => page.branches) ?? [], + const refs = useMemo( + () => branchesSearchData?.pages.flatMap((page) => page.refs) ?? [], [branchesSearchData?.pages], ); const currentGitBranch = - branchStatusQuery.data?.branch ?? branches.find((branch) => branch.current)?.name ?? null; + branchStatusQuery.data?.refName ?? refs.find((refName) => refName.current)?.name ?? null; + const sourceControlPresentation = useMemo( + () => getSourceControlPresentation(branchStatusQuery.data?.sourceControlProvider), + [branchStatusQuery.data?.sourceControlProvider], + ); + const SourceControlIcon = sourceControlPresentation.Icon; const canonicalActiveBranch = resolveBranchToolbarValue({ envMode: effectiveEnvMode, activeWorktreePath, activeThreadBranch, currentGitBranch, }); - const branchNames = useMemo(() => branches.map((branch) => branch.name), [branches]); + const branchNames = useMemo(() => refs.map((refName) => refName.name), [refs]); const branchByName = useMemo( - () => new Map(branches.map((branch) => [branch.name, branch] as const)), - [branches], + () => new Map(refs.map((refName) => [refName.name, refName] as const)), + [refs], ); const normalizedDeferredBranchQuery = deferredTrimmedBranchQuery.toLowerCase(); const prReference = parsePullRequestReference(trimmedBranchQuery); @@ -289,11 +295,11 @@ export function BranchToolbarBranchSelector({ const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40; const totalBranchCount = branchesSearchData?.pages[0]?.totalCount ?? 0; const branchStatusText = isBranchesSearchPending - ? "Loading branches..." + ? "Loading refs..." : isFetchingNextPage - ? "Loading more branches..." + ? "Loading more refs..." : hasNextPage - ? `Showing ${branches.length} of ${totalBranchCount} branches` + ? `Showing ${refs.length} of ${totalBranchCount} refs` : null; // --------------------------------------------------------------------------- @@ -303,17 +309,17 @@ export function BranchToolbarBranchSelector({ startBranchActionTransition(async () => { await action().catch(() => undefined); await queryClient - .invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, branchCwd) }) + .invalidateQueries({ queryKey: gitQueryKeys.refs(environmentId, branchCwd) }) .catch(() => undefined); }); }; - const selectBranch = (branch: GitBranch) => { + const selectBranch = (refName: VcsRef) => { const api = readEnvironmentApi(environmentId); if (!api || !branchCwd || !activeProjectCwd || isBranchActionPending) return; if (isSelectingWorktreeBase) { - setThreadBranch(branch.name, null); + setThreadBranch(refName.name, null); setIsBranchMenuOpen(false); onComposerFocusRequest?.(); return; @@ -322,19 +328,19 @@ export function BranchToolbarBranchSelector({ const selectionTarget = resolveBranchSelectionTarget({ activeProjectCwd, activeWorktreePath, - branch, + refName, }); if (selectionTarget.reuseExistingWorktree) { - setThreadBranch(branch.name, selectionTarget.nextWorktreePath); + setThreadBranch(refName.name, selectionTarget.nextWorktreePath); setIsBranchMenuOpen(false); onComposerFocusRequest?.(); return; } - const selectedBranchName = branch.isRemote - ? deriveLocalBranchNameFromRemoteRef(branch.name) - : branch.name; + const selectedBranchName = refName.isRemote + ? deriveLocalBranchNameFromRemoteRef(refName.name) + : refName.name; setIsBranchMenuOpen(false); onComposerFocusRequest?.(); @@ -343,12 +349,12 @@ export function BranchToolbarBranchSelector({ const previousBranch = resolvedActiveBranch; setOptimisticBranch(selectedBranchName); try { - const checkoutResult = await api.git.checkout({ + const checkoutResult = await api.vcs.switchRef({ cwd: selectionTarget.checkoutCwd, - branch: branch.name, + refName: refName.name, }); - const nextBranchName = branch.isRemote - ? (checkoutResult.branch ?? selectedBranchName) + const nextBranchName = refName.isRemote + ? (checkoutResult.refName ?? selectedBranchName) : selectedBranchName; setOptimisticBranch(nextBranchName); setThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); @@ -357,7 +363,7 @@ export function BranchToolbarBranchSelector({ toastManager.add( stackedThreadToast({ type: "error", - title: "Failed to checkout branch.", + title: "Failed to switch ref.", description: toBranchActionErrorMessage(error), }), ); @@ -365,7 +371,7 @@ export function BranchToolbarBranchSelector({ }); }; - const createBranch = (rawName: string) => { + const createRef = (rawName: string) => { const name = rawName.trim(); const api = readEnvironmentApi(environmentId); if (!api || !branchCwd || !name || isBranchActionPending) return; @@ -377,19 +383,19 @@ export function BranchToolbarBranchSelector({ const previousBranch = resolvedActiveBranch; setOptimisticBranch(name); try { - const createBranchResult = await api.git.createBranch({ + const createBranchResult = await api.vcs.createRef({ cwd: branchCwd, - branch: name, - checkout: true, + refName: name, + switchRef: true, }); - setOptimisticBranch(createBranchResult.branch); - setThreadBranch(createBranchResult.branch, activeWorktreePath); + setOptimisticBranch(createBranchResult.refName); + setThreadBranch(createBranchResult.refName, activeWorktreePath); } catch (error) { setOptimisticBranch(previousBranch); toastManager.add( stackedThreadToast({ type: "error", - title: "Failed to create and checkout branch.", + title: "Failed to create and switch ref.", description: toBranchActionErrorMessage(error), }), ); @@ -420,7 +426,7 @@ export function BranchToolbarBranchSelector({ return; } void queryClient.invalidateQueries({ - queryKey: gitQueryKeys.branches(environmentId, branchCwd), + queryKey: gitQueryKeys.refs(environmentId, branchCwd), }); }, [branchCwd, environmentId, queryClient], @@ -482,7 +488,7 @@ export function BranchToolbarBranchSelector({ useEffect(() => { if (shouldVirtualizeBranchList) return; maybeFetchNextBranchPage(); - }, [branches.length, maybeFetchNextBranchPage, shouldVirtualizeBranchList]); + }, [refs.length, maybeFetchNextBranchPage, shouldVirtualizeBranchList]); const triggerLabel = getBranchTriggerLabel({ activeWorktreePath, @@ -508,9 +514,14 @@ export function BranchToolbarBranchSelector({ onCheckoutPullRequestRequest(prReference); }} > -
- Checkout Pull Request - {prReference} +
+ + + + Checkout {sourceControlPresentation.terminology.singular} + + {prReference} +
); @@ -522,25 +533,25 @@ export function BranchToolbarBranchSelector({ key={itemValue} index={index} value={itemValue} - onClick={() => createBranch(trimmedBranchQuery)} + onClick={() => createRef(trimmedBranchQuery)} > - Create new branch "{trimmedBranchQuery}" + Create new ref "{trimmedBranchQuery}" ); } - const branch = branchByName.get(itemValue); - if (!branch) return null; + const refName = branchByName.get(itemValue); + if (!refName) return null; const hasSecondaryWorktree = - branch.worktreePath && activeProjectCwd && branch.worktreePath !== activeProjectCwd; - const badge = branch.current + refName.worktreePath && activeProjectCwd && refName.worktreePath !== activeProjectCwd; + const badge = refName.current ? "current" : hasSecondaryWorktree ? "worktree" - : branch.isRemote + : refName.isRemote ? "remote" - : branch.isDefault + : refName.isDefault ? "default" : null; return ( @@ -549,7 +560,7 @@ export function BranchToolbarBranchSelector({ key={itemValue} index={index} value={itemValue} - onClick={() => selectBranch(branch)} + onClick={() => selectBranch(refName)} >
{itemValue} @@ -581,7 +592,7 @@ export function BranchToolbarBranchSelector({ } className={cn("min-w-0 text-muted-foreground/70 hover:text-foreground/80", className)} - disabled={(isBranchesSearchPending && branches.length === 0) || isBranchActionPending} + disabled={(isBranchesSearchPending && refs.length === 0) || isBranchActionPending} > {triggerLabel} @@ -591,14 +602,14 @@ export function BranchToolbarBranchSelector({ setBranchQuery(event.target.value)} />
- No branches found. + No refs found. {shouldVirtualizeBranchList ? ( diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 445fe193057..2a1edb91813 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -206,6 +206,7 @@ function createMockEnvironmentApi(input: { filesystem: { browse: input.browse, }, + vcs: {} as EnvironmentApi["vcs"], git: {} as EnvironmentApi["git"], orchestration: { dispatchCommand: input.dispatchCommand, @@ -951,13 +952,13 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { if (tag === WS_METHODS.serverGetConfig) { return fixture.serverConfig; } - if (tag === WS_METHODS.gitListBranches) { + if (tag === WS_METHODS.vcsListRefs) { return { isRepo: true, - hasOriginRemote: true, + hasPrimaryRemote: true, nextCursor: null, totalCount: 1, - branches: [ + refs: [ { name: "main", current: true, @@ -2287,16 +2288,16 @@ describe("ChatView timeline estimator parity (full app)", () => { branchButton.click(); const branchInput = await waitForElement( - () => document.querySelector('input[placeholder="Search branches..."]'), - "Unable to find branch search input.", + () => document.querySelector('input[placeholder="Search refs..."]'), + "Unable to find ref search input.", ); branchInput.focus(); - await page.getByPlaceholder("Search branches...").fill("1359"); + await page.getByPlaceholder("Search refs...").fill("1359"); const checkoutItem = await waitForElement( () => Array.from(document.querySelectorAll("span")).find( - (element) => element.textContent?.trim() === "Checkout Pull Request", + (element) => element.textContent?.trim() === "Checkout pull request", ) as HTMLSpanElement | null, "Unable to find checkout pull request option.", ); @@ -2425,7 +2426,7 @@ describe("ChatView timeline estimator parity (full app)", () => { { timeout: 8_000, interval: 16 }, ); - expect(wsRequests.some((request) => request._tag === WS_METHODS.gitCreateWorktree)).toBe( + expect(wsRequests.some((request) => request._tag === WS_METHODS.vcsCreateWorktree)).toBe( false, ); expect( @@ -2572,13 +2573,13 @@ describe("ChatView timeline estimator parity (full app)", () => { ), }, resolveRpc: (body) => { - if (body._tag === WS_METHODS.gitListBranches) { + if (body._tag === WS_METHODS.vcsListRefs) { return { isRepo: true, - hasOriginRemote: true, + hasPrimaryRemote: true, nextCursor: null, totalCount: 1, - branches: [ + refs: [ { name: "main", current: true, @@ -2665,13 +2666,13 @@ describe("ChatView timeline estimator parity (full app)", () => { ), }, resolveRpc: (body) => { - if (body._tag === WS_METHODS.gitListBranches) { + if (body._tag === WS_METHODS.vcsListRefs) { return { isRepo: true, - hasOriginRemote: true, + hasPrimaryRemote: true, nextCursor: null, totalCount: 2, - branches: [ + refs: [ { name: "main", current: true, @@ -2761,13 +2762,13 @@ describe("ChatView timeline estimator parity (full app)", () => { viewport: DEFAULT_VIEWPORT, snapshot: snapshotWithTwoThreads, resolveRpc: (body) => { - if (body._tag === WS_METHODS.gitListBranches) { + if (body._tag === WS_METHODS.vcsListRefs) { return { isRepo: true, - hasOriginRemote: true, + hasPrimaryRemote: true, nextCursor: null, totalCount: 2, - branches: [ + refs: [ { name: "main", current: true, @@ -3021,13 +3022,13 @@ describe("ChatView timeline estimator parity (full app)", () => { snapshot: createDraftOnlySnapshot(), initialPath: `/draft/${activeDraftId}`, resolveRpc: (body) => { - if (body._tag === WS_METHODS.gitListBranches) { + if (body._tag === WS_METHODS.vcsListRefs) { return { isRepo: true, - hasOriginRemote: true, + hasPrimaryRemote: true, nextCursor: null, totalCount: 2, - branches: [ + refs: [ { name: "main", current: true, @@ -3146,13 +3147,13 @@ describe("ChatView timeline estimator parity (full app)", () => { snapshot: createDraftOnlySnapshot(), initialPath: `/draft/${draftId}`, resolveRpc: (body) => { - if (body._tag === WS_METHODS.gitListBranches) { + if (body._tag === WS_METHODS.vcsListRefs) { return { isRepo: true, - hasOriginRemote: true, + hasPrimaryRemote: true, nextCursor: null, totalCount: branches.length, - branches, + refs: branches, }; } return undefined; @@ -3170,8 +3171,8 @@ describe("ChatView timeline estimator parity (full app)", () => { branchButton.click(); await waitForElement( - () => document.querySelector('input[placeholder="Search branches..."]'), - "Unable to find branch search input.", + () => document.querySelector('input[placeholder="Search refs..."]'), + "Unable to find ref search input.", ); const popup = await waitForElement( diff --git a/apps/web/src/components/GitActionsControl.browser.tsx b/apps/web/src/components/GitActionsControl.browser.tsx index beeb136a9c1..787e034bd1f 100644 --- a/apps/web/src/components/GitActionsControl.browser.tsx +++ b/apps/web/src/components/GitActionsControl.browser.tsx @@ -113,7 +113,15 @@ vi.mock("~/lib/gitStatusState", () => ({ resetGitStatusStateForTests: () => undefined, useGitStatus: vi.fn(() => ({ data: { - branch: BRANCH_NAME, + isRepo: true, + sourceControlProvider: { + kind: "github", + name: "GitHub", + baseUrl: "https://github.com", + }, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: BRANCH_NAME, hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: true, diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index c6a50b82c27..08c85e3649f 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -1,4 +1,4 @@ -import type { GitStatusResult } from "@t3tools/contracts"; +import type { VcsStatusResult } from "@t3tools/contracts"; import { assert, describe, it } from "vitest"; import { buildGitActionProgressStages, @@ -11,12 +11,12 @@ import { resolveThreadBranchUpdate, } from "./GitActionsControl.logic"; -function status(overrides: Partial = {}): GitStatusResult { +function status(overrides: Partial = {}): VcsStatusResult { return { isRepo: true, - hasOriginRemote: true, - isDefaultBranch: false, - branch: "feature/test", + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/test", hasWorkingTreeChanges: false, workingTree: { files: [], @@ -31,7 +31,7 @@ function status(overrides: Partial = {}): GitStatusResult { }; } -describe("when: branch is clean and has an open PR", () => { +describe("when: ref is clean and has an open PR", () => { it("resolveQuickAction opens the existing PR", () => { const quick = resolveQuickAction( status({ @@ -39,8 +39,8 @@ describe("when: branch is clean and has an open PR", () => { number: 10, title: "Open PR", url: "https://example.com/pr/10", - baseBranch: "main", - headBranch: "feature/test", + baseRef: "main", + headRef: "feature/test", state: "open", }, }), @@ -56,8 +56,8 @@ describe("when: branch is clean and has an open PR", () => { number: 11, title: "Existing PR", url: "https://example.com/pr/11", - baseBranch: "main", - headBranch: "feature/test", + baseRef: "main", + headRef: "feature/test", state: "open", }, }), @@ -150,7 +150,7 @@ describe("when: git status is unavailable", () => { }); }); -describe("when: branch is clean, ahead, and has an open PR", () => { +describe("when: ref is clean, ahead, and has an open PR", () => { it("resolveQuickAction prefers push", () => { const quick = resolveQuickAction( status({ @@ -159,8 +159,8 @@ describe("when: branch is clean, ahead, and has an open PR", () => { number: 13, title: "Open PR", url: "https://example.com/pr/13", - baseBranch: "main", - headBranch: "feature/test", + baseRef: "main", + headRef: "feature/test", state: "open", }, }), @@ -177,8 +177,8 @@ describe("when: branch is clean, ahead, and has an open PR", () => { number: 12, title: "Existing PR", url: "https://example.com/pr/12", - baseBranch: "main", - headBranch: "feature/test", + baseRef: "main", + headRef: "feature/test", state: "open", }, }), @@ -212,7 +212,7 @@ describe("when: branch is clean, ahead, and has an open PR", () => { }); }); -describe("when: branch is clean, ahead, and has no open PR", () => { +describe("when: ref is clean, ahead, and has no open PR", () => { it("resolveQuickAction pushes and creates a PR", () => { const quick = resolveQuickAction(status({ aheadCount: 2, pr: null }), false); assert.deepInclude(quick, { @@ -253,7 +253,33 @@ describe("when: branch is clean, ahead, and has no open PR", () => { }); }); -describe("when: branch is clean, up to date, and has no open PR", () => { +describe("when: source control provider uses merge requests", () => { + it("uses GitLab MR terminology in quick actions and menu items", () => { + const gitlabStatus = status({ + aheadCount: 2, + sourceControlProvider: { + kind: "gitlab", + name: "GitLab", + baseUrl: "https://gitlab.com", + }, + }); + + const quick = resolveQuickAction(gitlabStatus, false); + const items = buildMenuItems(gitlabStatus, false); + + assert.deepInclude(quick, { + kind: "run_action", + action: "create_pr", + label: "Push & create MR", + }); + assert.deepInclude(items[2], { + id: "pr", + label: "Create MR", + }); + }); +}); + +describe("when: ref is clean, up to date, and has no open PR", () => { it("resolveQuickAction returns disabled no-action state", () => { const quick = resolveQuickAction( status({ aheadCount: 0, behindCount: 0, hasWorkingTreeChanges: false, pr: null }), @@ -293,7 +319,7 @@ describe("when: branch is clean, up to date, and has no open PR", () => { }); }); -describe("when: branch is behind upstream", () => { +describe("when: ref is behind upstream", () => { it("resolveQuickAction returns pull", () => { const quick = resolveQuickAction(status({ behindCount: 2 }), false); assert.deepInclude(quick, { kind: "run_pull", label: "Pull", disabled: false }); @@ -330,11 +356,11 @@ describe("when: branch is behind upstream", () => { }); }); -describe("when: branch has diverged from upstream", () => { +describe("when: ref has diverged from upstream", () => { it("resolveQuickAction returns a disabled sync hint", () => { const quick = resolveQuickAction(status({ aheadCount: 2, behindCount: 1 }), false); assert.deepEqual(quick, { - label: "Sync branch", + label: "Sync ref", disabled: true, kind: "show_hint", hint: "Branch has diverged from upstream. Rebase/merge first.", @@ -375,8 +401,8 @@ describe("when: working tree has local changes", () => { number: 16, title: "Existing PR", url: "https://example.com/pr/16", - baseBranch: "main", - headBranch: "feature/test", + baseRef: "main", + headRef: "feature/test", state: "open", }, }), @@ -420,10 +446,10 @@ describe("when: working tree has local changes", () => { }); }); -describe("when: on default branch without open PR", () => { +describe("when: on default ref without open PR", () => { it("resolveQuickAction returns commit and push when local changes exist", () => { const quick = resolveQuickAction( - status({ branch: "main", hasWorkingTreeChanges: true }), + status({ refName: "main", hasWorkingTreeChanges: true }), false, true, ); @@ -435,9 +461,9 @@ describe("when: on default branch without open PR", () => { }); }); - it("resolveQuickAction returns push when branch is ahead", () => { + it("resolveQuickAction returns push when ref is ahead", () => { const quick = resolveQuickAction( - status({ branch: "main", aheadCount: 2, pr: null }), + status({ refName: "main", aheadCount: 2, pr: null }), false, true, ); @@ -450,7 +476,7 @@ describe("when: on default branch without open PR", () => { }); }); -describe("when: working tree has local changes and branch is behind upstream", () => { +describe("when: working tree has local changes and ref is behind upstream", () => { it("resolveQuickAction still prefers commit, push, and create PR", () => { const quick = resolveQuickAction( status({ hasWorkingTreeChanges: true, behindCount: 1 }), @@ -497,14 +523,14 @@ describe("when: working tree has local changes and branch is behind upstream", ( describe("when: HEAD is detached and there are no local changes", () => { it("resolveQuickAction shows detached head hint", () => { const quick = resolveQuickAction( - status({ branch: null, hasWorkingTreeChanges: false, hasUpstream: false }), + status({ refName: null, hasWorkingTreeChanges: false, hasUpstream: false }), false, ); assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true }); }); it("buildMenuItems keeps commit, push, and PR disabled", () => { - const items = buildMenuItems(status({ branch: null, hasWorkingTreeChanges: false }), false); + const items = buildMenuItems(status({ refName: null, hasWorkingTreeChanges: false }), false); assert.deepEqual(items, [ { id: "commit", @@ -534,7 +560,7 @@ describe("when: HEAD is detached and there are no local changes", () => { }); }); -describe("when: branch has no upstream configured", () => { +describe("when: ref has no upstream configured", () => { it("resolveQuickAction is disabled when clean, no upstream, and no local commits are ahead", () => { const quick = resolveQuickAction( status({ hasUpstream: false, pr: null, aheadCount: 0 }), @@ -557,8 +583,8 @@ describe("when: branch has no upstream configured", () => { number: 14, title: "Existing PR", url: "https://example.com/pr/14", - baseBranch: "main", - headBranch: "feature/test", + baseRef: "main", + headRef: "feature/test", state: "open", }, }), @@ -580,8 +606,8 @@ describe("when: branch has no upstream configured", () => { number: 15, title: "Existing PR", url: "https://example.com/pr/15", - baseBranch: "main", - headBranch: "feature/test", + baseRef: "main", + headRef: "feature/test", state: "open", }, }), @@ -656,7 +682,7 @@ describe("when: branch has no upstream configured", () => { assert.deepEqual(quick, { kind: "show_hint", label: "Push", - hint: 'Add an "origin" remote before pushing or creating a PR.', + hint: 'Add an "origin" remote before pushing or creating a pull request.', disabled: true, }); }); @@ -725,10 +751,10 @@ describe("when: branch has no upstream configured", () => { ]); }); - it("resolveQuickAction is disabled on default branch when no upstream exists and no commits are ahead", () => { + it("resolveQuickAction is disabled on default ref when no upstream exists and no commits are ahead", () => { const quick = resolveQuickAction( status({ - branch: "main", + refName: "main", hasUpstream: false, aheadCount: 0, pr: null, @@ -744,10 +770,10 @@ describe("when: branch has no upstream configured", () => { }); }); - it("resolveQuickAction uses push-only on default branch when no upstream exists and commits are ahead", () => { + it("resolveQuickAction uses push-only on default ref when no upstream exists and commits are ahead", () => { const quick = resolveQuickAction( status({ - branch: "main", + refName: "main", hasUpstream: false, aheadCount: 1, pr: null, @@ -763,7 +789,7 @@ describe("when: branch has no upstream configured", () => { }); }); - it("buildMenuItems still disables push and create PR when branch is behind", () => { + it("buildMenuItems still disables push and create PR when ref is behind", () => { const items = buildMenuItems( status({ hasUpstream: false, @@ -803,7 +829,7 @@ describe("when: branch has no upstream configured", () => { }); describe("requiresDefaultBranchConfirmation", () => { - it("requires confirmation for push actions on default branch", () => { + it("requires confirmation for push actions on default ref", () => { assert.isFalse(requiresDefaultBranchConfirmation("commit", true)); assert.isTrue(requiresDefaultBranchConfirmation("push", true)); assert.isTrue(requiresDefaultBranchConfirmation("create_pr", true)); @@ -823,9 +849,9 @@ describe("resolveDefaultBranchActionDialogCopy", () => { }); assert.deepEqual(copy, { - title: "Push to default branch?", + title: "Push to default ref?", description: - 'This action will push local commits on "main". You can continue on this branch or create a feature branch and run the same action there.', + 'This action will push local commits on "main". You can continue on this ref or create a feature ref and run the same action there.', continueLabel: "Push to main", }); }); @@ -838,9 +864,9 @@ describe("resolveDefaultBranchActionDialogCopy", () => { }); assert.deepEqual(copy, { - title: "Push & create PR from default branch?", + title: "Push & create PR from default ref?", description: - 'This action will push local commits and create a PR on "main". You can continue on this branch or create a feature branch and run the same action there.', + 'This action will push local commits and create a pull request on "main". You can continue on this ref or create a feature ref and run the same action there.', continueLabel: "Push & create PR", }); }); @@ -853,9 +879,9 @@ describe("resolveDefaultBranchActionDialogCopy", () => { }); assert.deepEqual(copy, { - title: "Commit, push & create PR from default branch?", + title: "Commit, push & create PR from default ref?", description: - 'This action will commit, push, and create a PR on "main". You can continue on this branch or create a feature branch and run the same action there.', + 'This action will commit, push, and create a pull request on "main". You can continue on this ref or create a feature ref and run the same action there.', continueLabel: "Commit, push & create PR", }); }); @@ -884,7 +910,7 @@ describe("buildGitActionProgressStages", () => { "Pushing to origin/feature/test...", "Preparing PR...", "Generating PR content...", - "Creating GitHub pull request...", + "Creating pull request...", ]); }); @@ -898,7 +924,7 @@ describe("buildGitActionProgressStages", () => { assert.deepEqual(stages, [ "Preparing PR...", "Generating PR content...", - "Creating GitHub pull request...", + "Creating pull request...", ]); }); @@ -928,7 +954,7 @@ describe("buildGitActionProgressStages", () => { "Pushing to origin/feature/test...", "Preparing PR...", "Generating PR content...", - "Creating GitHub pull request...", + "Creating pull request...", ]); }); }); @@ -944,7 +970,7 @@ describe("resolveThreadBranchUpdate", () => { commit: { status: "created", commitSha: "89abcdef01234567", - subject: "feat: add branch sync", + subject: "feat: add ref sync", }, push: { status: "pushed", branch: "feature/fix-toast-copy" }, pr: { status: "skipped_not_requested" }, @@ -968,7 +994,7 @@ describe("resolveThreadBranchUpdate", () => { commit: { status: "created", commitSha: "89abcdef01234567", - subject: "feat: add branch sync", + subject: "feat: add ref sync", }, push: { status: "pushed", branch: "feature/fix-toast-copy" }, pr: { status: "skipped_not_requested" }, @@ -985,8 +1011,8 @@ describe("resolveThreadBranchUpdate", () => { describe("resolveLiveThreadBranchUpdate", () => { it("returns a branch update when live git status differs from stored thread metadata", () => { const update = resolveLiveThreadBranchUpdate({ - threadBranch: "feature/old-branch", - gitStatus: status({ branch: "effect-atom" }), + threadBranch: "feature/old-ref", + gitStatus: status({ refName: "effect-atom" }), }); assert.deepEqual(update, { @@ -996,35 +1022,35 @@ describe("resolveLiveThreadBranchUpdate", () => { it("returns null when live git status is unavailable", () => { const update = resolveLiveThreadBranchUpdate({ - threadBranch: "feature/old-branch", + threadBranch: "feature/old-ref", gitStatus: null, }); assert.equal(update, null); }); - it("returns null when the stored thread branch already matches git status", () => { + it("returns null when the stored thread ref already matches git status", () => { const update = resolveLiveThreadBranchUpdate({ threadBranch: "effect-atom", - gitStatus: status({ branch: "effect-atom" }), + gitStatus: status({ refName: "effect-atom" }), }); assert.equal(update, null); }); - it("returns null when git status is detached HEAD but the thread already has a branch", () => { + it("returns null when git status is detached HEAD but the thread already has a ref", () => { const update = resolveLiveThreadBranchUpdate({ threadBranch: "effect-atom", - gitStatus: status({ branch: null }), + gitStatus: status({ refName: null }), }); assert.equal(update, null); }); - it("does not regress a semantic thread branch back to a temporary worktree branch", () => { + it("does not regress a semantic thread ref back to a temporary worktree ref", () => { const update = resolveLiveThreadBranchUpdate({ threadBranch: "t3code/github-query-rate-limit", - gitStatus: status({ branch: "t3code/bda76797" }), + gitStatus: status({ refName: "t3code/bda76797" }), }); assert.equal(update, null); @@ -1032,31 +1058,31 @@ describe("resolveLiveThreadBranchUpdate", () => { }); describe("resolveAutoFeatureBranchName", () => { - it("uses semantic preferred branch names when available", () => { - const branch = resolveAutoFeatureBranchName(["main", "feature/other"], "fix toast copy"); - assert.equal(branch, "feature/fix-toast-copy"); + it("uses semantic preferred ref names when available", () => { + const ref = resolveAutoFeatureBranchName(["main", "feature/other"], "fix toast copy"); + assert.equal(ref, "feature/fix-toast-copy"); }); - it("normalizes preferred names that already include a branch namespace", () => { - const branch = resolveAutoFeatureBranchName(["main"], "feature/refine-toolbar-actions"); - assert.equal(branch, "feature/refine-toolbar-actions"); + it("normalizes preferred names that already include a ref namespace", () => { + const ref = resolveAutoFeatureBranchName(["main"], "feature/refine-toolbar-actions"); + assert.equal(ref, "feature/refine-toolbar-actions"); }); - it("increments suffix when the preferred branch name already exists", () => { - const branch = resolveAutoFeatureBranchName( + it("increments suffix when the preferred ref name already exists", () => { + const ref = resolveAutoFeatureBranchName( ["main", "feature/fix-toast-copy", "feature/fix-toast-copy-2"], "fix toast copy", ); - assert.equal(branch, "feature/fix-toast-copy-3"); + assert.equal(ref, "feature/fix-toast-copy-3"); }); - it("treats existing branch names as case-insensitive for collision checks", () => { - const branch = resolveAutoFeatureBranchName(["Feature/Ticket-1"], "feature/ticket-1"); - assert.equal(branch, "feature/ticket-1-2"); + it("treats existing ref names as case-insensitive for collision checks", () => { + const ref = resolveAutoFeatureBranchName(["Feature/Ticket-1"], "feature/ticket-1"); + assert.equal(ref, "feature/ticket-1-2"); }); it("falls back to feature/update when no preferred name is provided", () => { - const branch = resolveAutoFeatureBranchName(["main"]); - assert.equal(branch, "feature/update"); + const ref = resolveAutoFeatureBranchName(["main"]); + assert.equal(ref, "feature/update"); }); }); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index e4e611fb87c..70ca9decb63 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -1,9 +1,14 @@ import type { GitRunStackedActionResult, GitStackedAction, - GitStatusResult, + VcsStatusResult, } from "@t3tools/contracts"; import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; +import { + DEFAULT_CHANGE_REQUEST_TERMINOLOGY, + getChangeRequestTerminology, + type ChangeRequestTerminology, +} from "../sourceControlPresentation"; export type GitActionIconName = "commit" | "push" | "pr"; @@ -38,6 +43,14 @@ export type DefaultBranchConfirmableAction = | "commit_push" | "commit_push_pr"; +function resolveChangeRequestTerminology( + gitStatus: VcsStatusResult | null, +): ChangeRequestTerminology { + return gitStatus?.sourceControlProvider + ? getChangeRequestTerminology(gitStatus.sourceControlProvider) + : DEFAULT_CHANGE_REQUEST_TERMINOLOGY; +} + export function buildGitActionProgressStages(input: { action: GitStackedAction; hasCustomCommitMessage: boolean; @@ -45,13 +58,15 @@ export function buildGitActionProgressStages(input: { pushTarget?: string; featureBranch?: boolean; shouldPushBeforePr?: boolean; + terminology?: ChangeRequestTerminology; }): string[] { - const branchStages = input.featureBranch ? ["Preparing feature branch..."] : []; + const terminology = input.terminology ?? DEFAULT_CHANGE_REQUEST_TERMINOLOGY; + const branchStages = input.featureBranch ? ["Preparing feature ref..."] : []; const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing..."; const prStages = [ - "Preparing PR...", - "Generating PR content...", - "Creating GitHub pull request...", + `Preparing ${terminology.shortLabel}...`, + `Generating ${terminology.shortLabel} content...`, + `Creating ${terminology.singular}...`, ]; if (input.action === "push") { @@ -77,17 +92,18 @@ export function buildGitActionProgressStages(input: { } export function buildMenuItems( - gitStatus: GitStatusResult | null, + gitStatus: VcsStatusResult | null, isBusy: boolean, - hasOriginRemote = true, + hasPrimaryRemote = true, ): GitActionMenuItem[] { if (!gitStatus) return []; + const terminology = resolveChangeRequestTerminology(gitStatus); - const hasBranch = gitStatus.branch !== null; + const hasBranch = gitStatus.refName !== null; const hasChanges = gitStatus.hasWorkingTreeChanges; const hasOpenPr = gitStatus.pr?.state === "open"; const isBehind = gitStatus.behindCount > 0; - const canPushWithoutUpstream = hasOriginRemote && !gitStatus.hasUpstream; + const canPushWithoutUpstream = hasPrimaryRemote && !gitStatus.hasUpstream; const canCommit = !isBusy && hasChanges; const canPush = !isBusy && @@ -126,14 +142,14 @@ export function buildMenuItems( hasOpenPr ? { id: "pr", - label: "View PR", + label: `View ${terminology.shortLabel}`, disabled: !canOpenPr, icon: "pr", kind: "open_pr", } : { id: "pr", - label: "Create PR", + label: `Create ${terminology.shortLabel}`, disabled: !canCreatePr, icon: "pr", kind: "open_dialog", @@ -143,10 +159,10 @@ export function buildMenuItems( } export function resolveQuickAction( - gitStatus: GitStatusResult | null, + gitStatus: VcsStatusResult | null, isBusy: boolean, - isDefaultBranch = false, - hasOriginRemote = true, + isDefaultRef = false, + hasPrimaryRemote = true, ): GitQuickAction { if (isBusy) { return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." }; @@ -161,31 +177,32 @@ export function resolveQuickAction( }; } - const hasBranch = gitStatus.branch !== null; + const hasBranch = gitStatus.refName !== null; const hasChanges = gitStatus.hasWorkingTreeChanges; const hasOpenPr = gitStatus.pr?.state === "open"; const isAhead = gitStatus.aheadCount > 0; const isBehind = gitStatus.behindCount > 0; const isDiverged = isAhead && isBehind; + const terminology = resolveChangeRequestTerminology(gitStatus); if (!hasBranch) { return { label: "Commit", disabled: true, kind: "show_hint", - hint: "Create and checkout a branch before pushing or opening a PR.", + hint: `Create and checkout a ref before pushing or opening a ${terminology.singular}.`, }; } if (hasChanges) { - if (!gitStatus.hasUpstream && !hasOriginRemote) { + if (!gitStatus.hasUpstream && !hasPrimaryRemote) { return { label: "Commit", disabled: false, kind: "run_action", action: "commit" }; } - if (hasOpenPr || isDefaultBranch) { + if (hasOpenPr || isDefaultRef) { return { label: "Commit & push", disabled: false, kind: "run_action", action: "commit_push" }; } return { - label: "Commit, push & PR", + label: `Commit, push & ${terminology.shortLabel}`, disabled: false, kind: "run_action", action: "commit_push_pr", @@ -193,20 +210,20 @@ export function resolveQuickAction( } if (!gitStatus.hasUpstream) { - if (!hasOriginRemote) { + if (!hasPrimaryRemote) { if (hasOpenPr && !isAhead) { - return { label: "View PR", disabled: false, kind: "open_pr" }; + return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" }; } return { label: "Push", disabled: true, kind: "show_hint", - hint: 'Add an "origin" remote before pushing or creating a PR.', + hint: `Add an "origin" remote before pushing or creating a ${terminology.singular}.`, }; } if (!isAhead) { if (hasOpenPr) { - return { label: "View PR", disabled: false, kind: "open_pr" }; + return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" }; } return { label: "Push", @@ -215,16 +232,16 @@ export function resolveQuickAction( hint: "No local commits to push.", }; } - if (hasOpenPr || isDefaultBranch) { + if (hasOpenPr || isDefaultRef) { return { label: "Push", disabled: false, kind: "run_action", - action: isDefaultBranch ? "commit_push" : "push", + action: isDefaultRef ? "commit_push" : "push", }; } return { - label: "Push & create PR", + label: `Push & create ${terminology.shortLabel}`, disabled: false, kind: "run_action", action: "create_pr", @@ -233,7 +250,7 @@ export function resolveQuickAction( if (isDiverged) { return { - label: "Sync branch", + label: "Sync ref", disabled: true, kind: "show_hint", hint: "Branch has diverged from upstream. Rebase/merge first.", @@ -249,16 +266,16 @@ export function resolveQuickAction( } if (isAhead) { - if (hasOpenPr || isDefaultBranch) { + if (hasOpenPr || isDefaultRef) { return { label: "Push", disabled: false, kind: "run_action", - action: isDefaultBranch ? "commit_push" : "push", + action: isDefaultRef ? "commit_push" : "push", }; } return { - label: "Push & create PR", + label: `Push & create ${terminology.shortLabel}`, disabled: false, kind: "run_action", action: "create_pr", @@ -266,7 +283,7 @@ export function resolveQuickAction( } if (hasOpenPr && gitStatus.hasUpstream) { - return { label: "View PR", disabled: false, kind: "open_pr" }; + return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" }; } return { @@ -279,9 +296,9 @@ export function resolveQuickAction( export function requiresDefaultBranchConfirmation( action: GitStackedAction, - isDefaultBranch: boolean, + isDefaultRef: boolean, ): boolean { - if (!isDefaultBranch) return false; + if (!isDefaultRef) return false; return ( action === "push" || action === "create_pr" || @@ -294,20 +311,22 @@ export function resolveDefaultBranchActionDialogCopy(input: { action: DefaultBranchConfirmableAction; branchName: string; includesCommit: boolean; + terminology?: ChangeRequestTerminology; }): DefaultBranchActionDialogCopy { const branchLabel = input.branchName; - const suffix = ` on "${branchLabel}". You can continue on this branch or create a feature branch and run the same action there.`; + const suffix = ` on "${branchLabel}". You can continue on this ref or create a feature ref and run the same action there.`; + const terminology = input.terminology ?? DEFAULT_CHANGE_REQUEST_TERMINOLOGY; if (input.action === "push" || input.action === "commit_push") { if (input.includesCommit) { return { - title: "Commit & push to default branch?", + title: "Commit & push to default ref?", description: `This action will commit and push changes${suffix}`, continueLabel: `Commit & push to ${branchLabel}`, }; } return { - title: "Push to default branch?", + title: "Push to default ref?", description: `This action will push local commits${suffix}`, continueLabel: `Push to ${branchLabel}`, }; @@ -315,15 +334,15 @@ export function resolveDefaultBranchActionDialogCopy(input: { if (input.includesCommit) { return { - title: "Commit, push & create PR from default branch?", - description: `This action will commit, push, and create a PR${suffix}`, - continueLabel: `Commit, push & create PR`, + title: `Commit, push & create ${terminology.shortLabel} from default ref?`, + description: `This action will commit, push, and create a ${terminology.singular}${suffix}`, + continueLabel: `Commit, push & create ${terminology.shortLabel}`, }; } return { - title: "Push & create PR from default branch?", - description: `This action will push local commits and create a PR${suffix}`, - continueLabel: "Push & create PR", + title: `Push & create ${terminology.shortLabel} from default ref?`, + description: `This action will push local commits and create a ${terminology.singular}${suffix}`, + continueLabel: `Push & create ${terminology.shortLabel}`, }; } @@ -341,31 +360,31 @@ export function resolveThreadBranchUpdate( export function resolveLiveThreadBranchUpdate(input: { threadBranch: string | null; - gitStatus: GitStatusResult | null; + gitStatus: VcsStatusResult | null; }): { branch: string | null } | null { if (!input.gitStatus) { return null; } - if (input.gitStatus.branch === null && input.threadBranch !== null) { + if (input.gitStatus.refName === null && input.threadBranch !== null) { return null; } - if (input.threadBranch === input.gitStatus.branch) { + if (input.threadBranch === input.gitStatus.refName) { return null; } if ( input.threadBranch !== null && - input.gitStatus.branch !== null && + input.gitStatus.refName !== null && !isTemporaryWorktreeBranch(input.threadBranch) && - isTemporaryWorktreeBranch(input.gitStatus.branch) + isTemporaryWorktreeBranch(input.gitStatus.refName) ) { return null; } return { - branch: input.gitStatus.branch, + branch: input.gitStatus.refName, }; } diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 6141d3546ea..b4fae359293 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -3,12 +3,11 @@ import type { GitActionProgressEvent, GitRunStackedActionResult, GitStackedAction, - GitStatusResult, + VcsStatusResult, } from "@t3tools/contracts"; import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; -import { GitHubIcon } from "./Icons"; import { buildGitActionProgressStages, buildMenuItems, @@ -52,6 +51,7 @@ import { resolvePathLinkTarget } from "~/terminal-links"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { readEnvironmentApi } from "~/environmentApi"; import { readLocalApi } from "~/localApi"; +import { getSourceControlPresentation } from "~/sourceControlPresentation"; import { useStore } from "~/store"; import { createThreadSelectorByRef } from "~/storeSelectors"; @@ -89,7 +89,7 @@ interface RunGitActionWithToastInput { commitMessage?: string; onConfirmed?: () => void; skipDefaultBranchPrompt?: boolean; - statusOverride?: GitStatusResult | null; + statusOverride?: VcsStatusResult | null; featureBranch?: boolean; progressToastId?: GitActionToastId; filePaths?: string[]; @@ -121,22 +121,23 @@ function getMenuActionDisabledReason({ item, gitStatus, isBusy, - hasOriginRemote, + hasPrimaryRemote, }: { item: GitActionMenuItem; - gitStatus: GitStatusResult | null; + gitStatus: VcsStatusResult | null; isBusy: boolean; - hasOriginRemote: boolean; + hasPrimaryRemote: boolean; }): string | null { if (!item.disabled) return null; if (isBusy) return "Git action in progress."; if (!gitStatus) return "Git status is unavailable."; - const hasBranch = gitStatus.branch !== null; + const hasBranch = gitStatus.refName !== null; const hasChanges = gitStatus.hasWorkingTreeChanges; const hasOpenPr = gitStatus.pr?.state === "open"; const isAhead = gitStatus.aheadCount > 0; const isBehind = gitStatus.behindCount > 0; + const terminology = getSourceControlPresentation(gitStatus.sourceControlProvider).terminology; if (item.id === "commit") { if (!hasChanges) { @@ -147,7 +148,7 @@ function getMenuActionDisabledReason({ if (item.id === "push") { if (!hasBranch) { - return "Detached HEAD: checkout a branch before pushing."; + return "Detached HEAD: checkout a refName before pushing."; } if (hasChanges) { return "Commit or stash local changes before pushing."; @@ -155,7 +156,7 @@ function getMenuActionDisabledReason({ if (isBehind) { return "Branch is behind upstream. Pull/rebase before pushing."; } - if (!gitStatus.hasUpstream && !hasOriginRemote) { + if (!gitStatus.hasUpstream && !hasPrimaryRemote) { return 'Add an "origin" remote before pushing.'; } if (!isAhead) { @@ -165,46 +166,58 @@ function getMenuActionDisabledReason({ } if (hasOpenPr) { - return "View PR is currently unavailable."; + return `View ${terminology.singular} is currently unavailable.`; } if (!hasBranch) { - return "Detached HEAD: checkout a branch before creating a PR."; + return `Detached HEAD: checkout a refName before creating a ${terminology.singular}.`; } if (hasChanges) { - return "Commit local changes before creating a PR."; + return `Commit local changes before creating a ${terminology.singular}.`; } - if (!gitStatus.hasUpstream && !hasOriginRemote) { - return 'Add an "origin" remote before creating a PR.'; + if (!gitStatus.hasUpstream && !hasPrimaryRemote) { + return `Add an "origin" remote before creating a ${terminology.singular}.`; } if (!isAhead) { - return "No local commits to include in a PR."; + return `No local commits to include in a ${terminology.singular}.`; } if (isBehind) { - return "Branch is behind upstream. Pull/rebase before creating a PR."; + return `Branch is behind upstream. Pull/rebase before creating a ${terminology.singular}.`; } - return "Create PR is currently unavailable."; + return `Create ${terminology.singular} is currently unavailable.`; } const COMMIT_DIALOG_TITLE = "Commit changes"; const COMMIT_DIALOG_DESCRIPTION = "Review and confirm your commit. Leave the message blank to auto-generate one."; -function GitActionItemIcon({ icon }: { icon: GitActionIconName }) { +function GitActionItemIcon({ + icon, + SourceControlIcon, +}: { + icon: GitActionIconName; + SourceControlIcon: ReturnType["Icon"]; +}) { if (icon === "commit") return ; if (icon === "push") return ; - return ; + return ; } -function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { +function GitQuickActionIcon({ + quickAction, + SourceControlIcon, +}: { + quickAction: GitQuickAction; + SourceControlIcon: ReturnType["Icon"]; +}) { const iconClassName = "size-3.5"; - if (quickAction.kind === "open_pr") return ; + if (quickAction.kind === "open_pr") return ; if (quickAction.kind === "run_pull") return ; if (quickAction.kind === "run_action") { if (quickAction.action === "commit") return ; if (quickAction.action === "push" || quickAction.action === "commit_push") { return ; } - return ; + return ; } if (quickAction.label === "Commit") return ; return ; @@ -322,9 +335,15 @@ export default function GitActionsControl({ environmentId: activeEnvironmentId, cwd: gitCwd, }); + const sourceControlPresentation = useMemo( + () => getSourceControlPresentation(gitStatus?.sourceControlProvider), + [gitStatus?.sourceControlProvider], + ); + const changeRequestTerminology = sourceControlPresentation.terminology; + const SourceControlIcon = sourceControlPresentation.Icon; // Default to true while loading so we don't flash init controls. const isRepo = gitStatus?.isRepo ?? true; - const hasOriginRemote = gitStatus?.hasOriginRemote ?? false; + const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false; const gitStatusForActions = gitStatus; const allFiles = gitStatusForActions?.workingTree.files ?? []; @@ -382,18 +401,18 @@ export default function GitActionsControl({ persistThreadBranchSync, ]); - const isDefaultBranch = useMemo(() => { - return gitStatusForActions?.isDefaultBranch ?? false; - }, [gitStatusForActions?.isDefaultBranch]); + const isDefaultRef = useMemo(() => { + return gitStatusForActions?.isDefaultRef ?? false; + }, [gitStatusForActions?.isDefaultRef]); const gitActionMenuItems = useMemo( - () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasOriginRemote), - [gitStatusForActions, hasOriginRemote, isGitActionRunning], + () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasPrimaryRemote), + [gitStatusForActions, hasPrimaryRemote, isGitActionRunning], ); const quickAction = useMemo( () => - resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultBranch, hasOriginRemote), - [gitStatusForActions, hasOriginRemote, isDefaultBranch, isGitActionRunning], + resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultRef, hasPrimaryRemote), + [gitStatusForActions, hasPrimaryRemote, isDefaultRef, isGitActionRunning], ); const quickActionDisabledReason = quickAction.disabled ? (quickAction.hint ?? "This action is currently unavailable.") @@ -403,6 +422,7 @@ export default function GitActionsControl({ action: pendingDefaultBranchAction.action, branchName: pendingDefaultBranchAction.branchName, includesCommit: pendingDefaultBranchAction.includesCommit, + terminology: changeRequestTerminology, }) : null; @@ -468,7 +488,7 @@ export default function GitActionsControl({ if (!prUrl) { toastManager.add({ type: "error", - title: "No open PR found.", + title: "No open pull request found.", data: threadToastData, }); return; @@ -477,7 +497,7 @@ export default function GitActionsControl({ toastManager.add( stackedThreadToast({ type: "error", - title: "Unable to open PR link", + title: "Unable to open pull request link", description: err instanceof Error ? err.message : "An error occurred.", ...(threadToastData !== undefined ? { data: threadToastData } : {}), }), @@ -497,8 +517,8 @@ export default function GitActionsControl({ filePaths, }: RunGitActionWithToastInput) => { const actionStatus = statusOverride ?? gitStatusForActions; - const actionBranch = actionStatus?.branch ?? null; - const actionIsDefaultBranch = featureBranch ? false : isDefaultBranch; + const actionBranch = actionStatus?.refName ?? null; + const actionIsDefaultBranch = featureBranch ? false : isDefaultRef; const actionCanCommit = action === "commit" || action === "commit_push" || action === "commit_push_pr"; const includesCommit = @@ -534,6 +554,7 @@ export default function GitActionsControl({ hasCustomCommitMessage: !!commitMessage?.trim(), hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges, featureBranch, + terminology: changeRequestTerminology, shouldPushBeforePr: action === "create_pr" && (!actionStatus?.hasUpstream || (actionStatus?.aheadCount ?? 0) > 0), @@ -775,8 +796,8 @@ export default function GitActionsControl({ title: result.status === "pulled" ? "Pulled" : "Already up to date", description: result.status === "pulled" - ? `Updated ${result.branch} from ${result.upstreamBranch ?? "upstream"}` - : `${result.branch} is already synchronized.`, + ? `Updated ${result.refName} from ${result.upstreamRef ?? "upstream"}` + : `${result.refName} is already synchronized.`, data: threadToastData, }), error: (err) => ({ @@ -889,7 +910,10 @@ export default function GitActionsControl({ /> } > - + {quickAction.label} @@ -905,7 +929,7 @@ export default function GitActionsControl({ disabled={isGitActionRunning || quickAction.disabled} onClick={runQuickAction} > - + {quickAction.label} @@ -934,7 +958,7 @@ export default function GitActionsControl({ item, gitStatus: gitStatusForActions, isBusy: isGitActionRunning, - hasOriginRemote, + hasPrimaryRemote, }); if (item.disabled && disabledReason) { return ( @@ -945,7 +969,10 @@ export default function GitActionsControl({ render={} > - + {item.label} @@ -964,18 +991,19 @@ export default function GitActionsControl({ openDialogForMenuItem(item); }} > - + {item.label} ); })} - {gitStatusForActions?.branch === null && ( + {gitStatusForActions?.refName === null && (

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

)} {gitStatusForActions && - gitStatusForActions.branch !== null && + gitStatusForActions.refName !== null && !gitStatusForActions.hasWorkingTreeChanges && gitStatusForActions.behindCount > 0 && gitStatusForActions.aheadCount === 0 && ( @@ -1013,10 +1041,12 @@ export default function GitActionsControl({ Branch - {gitStatusForActions?.branch ?? "(detached HEAD)"} + {gitStatusForActions?.refName ?? "(detached HEAD)"} - {isDefaultBranch && ( - Warning: default branch + {isDefaultRef && ( + + Warning: default refName + )}
@@ -1149,7 +1179,7 @@ export default function GitActionsControl({ disabled={noneSelected} onClick={runDialogActionOnNewBranch} > - Commit on new branch + Commit on new refName } /> @@ -1002,7 +1002,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec toastManager.add( stackedThreadToast({ type: "error", - title: "Unable to open PR link", + title: "Unable to open pull request link", description: error instanceof Error ? error.message : "An error occurred.", }), ); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 497e0f88339..5ed0c0e4c40 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -1,5 +1,5 @@ import { scopeProjectRef, scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; -import type { GitStatusResult } from "@t3tools/contracts"; +import type { VcsStatusResult } from "@t3tools/contracts"; import { CloudIcon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; import { useMemo } from "react"; import { usePrimaryEnvironmentId } from "../environments/primary"; @@ -11,12 +11,13 @@ import { useGitStatus } from "../lib/gitStatusState"; import { type AppState, selectProjectByRef, useStore } from "../store"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; +import { resolveChangeRequestPresentation } from "../sourceControlPresentation"; import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic"; import type { SidebarThreadSummary } from "../types"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; export interface PrStatusIndicator { - label: "PR open" | "PR closed" | "PR merged"; + label: string; colorClass: string; tooltip: string; url: string; @@ -28,43 +29,51 @@ export interface TerminalStatusIndicator { pulse: boolean; } -export type ThreadPr = GitStatusResult["pr"]; +export type ThreadPr = VcsStatusResult["pr"]; -export function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null { +export function prStatusIndicator( + pr: ThreadPr, + provider: VcsStatusResult["sourceControlProvider"] | null | undefined, +): PrStatusIndicator | null { if (!pr) return null; + const presentation = resolveChangeRequestPresentation(provider); if (pr.state === "open") { return { - label: "PR open", + label: `${presentation.shortName} open`, colorClass: "text-emerald-600 dark:text-emerald-300/90", - tooltip: `#${pr.number} PR open: ${pr.title}`, + tooltip: `#${pr.number} ${presentation.shortName} open: ${pr.title}`, url: pr.url, }; } if (pr.state === "closed") { return { - label: "PR closed", + label: `${presentation.shortName} closed`, colorClass: "text-zinc-500 dark:text-zinc-400/80", - tooltip: `#${pr.number} PR closed: ${pr.title}`, + tooltip: `#${pr.number} ${presentation.shortName} closed: ${pr.title}`, url: pr.url, }; } if (pr.state === "merged") { return { - label: "PR merged", + label: `${presentation.shortName} merged`, colorClass: "text-violet-600 dark:text-violet-300/90", - tooltip: `#${pr.number} PR merged: ${pr.title}`, + tooltip: `#${pr.number} ${presentation.shortName} merged: ${pr.title}`, url: pr.url, }; } return null; } +export function ChangeRequestStatusIcon({ className }: { className?: string }) { + return ; +} + export function resolveThreadPr( threadBranch: string | null, - gitStatus: GitStatusResult | null, + gitStatus: VcsStatusResult | null, ): ThreadPr | null { - if (threadBranch === null || gitStatus === null || gitStatus.branch !== threadBranch) { + if (threadBranch === null || gitStatus === null || gitStatus.refName !== threadBranch) { return null; } @@ -124,7 +133,7 @@ export function ThreadStatusLabel({ /** * Non-interactive leading status icons for a thread row in compact contexts - * like the command palette. Shows the PR state icon (if present) and the + * like the command palette. Shows the change request state icon (if present) and the * thread status dot, matching the sidebar's leading indicators. */ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummary }) { @@ -146,7 +155,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar cwd: thread.branch != null ? gitCwd : null, }); const pr = resolveThreadPr(thread.branch, gitStatus.data); - const prStatus = prStatusIndicator(pr); + const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const threadStatus = resolveThreadStatusPill({ thread: { ...thread, @@ -170,7 +179,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar /> } > - + {prStatus.tooltip} diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 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/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/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 510b332a03c..88260355d97 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -1,5 +1,5 @@ import { useCallback, type ComponentType } from "react"; -import { ArchiveIcon, ArrowLeftIcon, Link2Icon, Settings2Icon } from "lucide-react"; +import { ArchiveIcon, ArrowLeftIcon, GitBranchIcon, Link2Icon, Settings2Icon } from "lucide-react"; import { useCanGoBack, useNavigate } from "@tanstack/react-router"; import { @@ -15,6 +15,7 @@ import { export type SettingsSectionPath = | "/settings/general" + | "/settings/source-control" | "/settings/connections" | "/settings/archived"; @@ -24,6 +25,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ icon: ComponentType<{ className?: string }>; }> = [ { label: "General", to: "/settings/general", icon: Settings2Icon }, + { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon }, { label: "Connections", to: "/settings/connections", icon: Link2Icon }, { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, ]; diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx new file mode 100644 index 00000000000..c5f7909e865 --- /dev/null +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -0,0 +1,471 @@ +import { GitPullRequestIcon, RefreshCwIcon } from "lucide-react"; +import { Option } from "effect"; +import { type ReactNode, useId } from "react"; +import type { + SourceControlProviderKind, + SourceControlDiscoveryResult, + SourceControlProviderAuth, + SourceControlProviderDiscoveryItem, + VcsDiscoveryItem, +} from "@t3tools/contracts"; + +import { cn } from "../../lib/utils"; +import { + refreshSourceControlDiscovery, + useSourceControlDiscovery, +} from "../../lib/sourceControlDiscoveryState"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "../ui/empty"; +import { Skeleton } from "../ui/skeleton"; +import { Switch } from "../ui/switch"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { GitHubIcon, type Icon } from "../Icons"; +import { RedactedSensitiveText } from "./RedactedSensitiveText"; +import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; + +const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { + versionControlSystems: [], + sourceControlProviders: [], +}; + +const GitLabIcon: Icon = (props) => ( + + + + + + +); + +const AzureDevOpsIcon: Icon = (props) => { + const id = useId().replaceAll(":", ""); + const gradientA = `${id}-azure-a`; + const gradientB = `${id}-azure-b`; + const gradientC = `${id}-azure-c`; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +const BitbucketIcon: Icon = (props) => { + const id = useId().replaceAll(":", ""); + const gradientId = `${id}-bitbucket-a`; + + return ( + + + + + + + + + + ); +}; + +const SOURCE_CONTROL_PROVIDER_ICONS: Partial> = { + github: GitHubIcon, + gitlab: GitLabIcon, + "azure-devops": AzureDevOpsIcon, + bitbucket: BitbucketIcon, +}; + +function optionLabel(value: Option.Option): string | null { + return Option.getOrNull(value); +} + +function isProviderDiscoveryItem( + item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem, +): item is SourceControlProviderDiscoveryItem { + return "auth" in item; +} + +function authPresentation(auth: SourceControlProviderAuth): { + readonly label: string; + readonly badge: "warning" | null; +} { + if (auth.status === "authenticated") { + return { label: "Signed in", badge: null }; + } + if (auth.status === "unauthenticated") { + return { label: "Sign in", badge: "warning" }; + } + return { label: "Sign in", badge: null }; +} + +function RedactedAccount(props: { readonly account: string | null }) { + return ( + + ); +} + +function itemStatusDot(item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem): string { + if (!item.implemented) return "bg-muted-foreground/35"; + if (item.status !== "available") return "bg-warning"; + if (isProviderDiscoveryItem(item) && item.auth.status !== "authenticated") return "bg-warning"; + return "bg-success"; +} + +function SourceControlItemMark({ + item, +}: { + readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem; +}) { + const dotClassName = itemStatusDot(item); + const Icon = isProviderDiscoveryItem(item) ? SOURCE_CONTROL_PROVIDER_ICONS[item.kind] : null; + + if (!Icon) { + return ; + } + + return ( + + + + + ); +} + +function itemSummary({ + item, + auth, + authAccount, +}: { + readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem; + readonly auth: SourceControlProviderAuth | null; + readonly authAccount: string | null; +}) { + if (!item.implemented) { + return Support for {item.label} is coming soon.; + } + + if (item.status !== "available") { + return Not found - {item.installHint}; + } + + if (auth) { + if (auth.status === "authenticated") { + return ( + <> + Authenticated + {authAccount ? ( + <> + as + + + ) : null} + + ); + } + if (auth.status === "unauthenticated") { + return Sign in with the {item.executable} CLI to enable pull request actions.; + } + return ( + + Install and sign in with the {item.executable} CLI to enable pull request actions. + + ); + } + + return Available; +} + +function DiscoveryItemRow({ + item, +}: { + readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem; +}) { + const version = optionLabel(item.version); + const enabled = item.implemented && item.status === "available"; + const auth = isProviderDiscoveryItem(item) ? item.auth : null; + const authStatus = auth ? authPresentation(auth) : null; + const authAccount = auth ? optionLabel(auth.account) : null; + + return ( +
+
+
+
+
+ +

+ {item.label} +

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

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

+
+
+ {item.implemented ? ( + + ) : null} +
+
+
+
+ ); +} + +function SourceControlSectionSkeleton({ + title, + headerAction, +}: { + readonly title: string; + readonly headerAction?: ReactNode; +}) { + return ( + + {Array.from({ length: 2 }, (_, index) => ( +
+
+
+
+ + + +
+ +
+
+ + +
+
+
+ ))} +
+ ); +} + +function EmptySourceControlDiscovery({ + error, + isPending, + onScan, +}: { + readonly error: string | null; + readonly isPending: boolean; + readonly onScan: () => void; +}) { + const hasError = error !== null; + + return ( + + + + + + + + {hasError ? "Could not scan source control" : "No source control tools found"} + + + {hasError ? error : "Install a supported Git or pull request CLI, then scan again."} + + + + + + + + ); +} + +export function SourceControlSettingsPanel() { + const discovery = useSourceControlDiscovery(); + + const result = discovery.data ?? EMPTY_DISCOVERY_RESULT; + const hasDiscoveryItems = + result.versionControlSystems.length > 0 || result.sourceControlProviders.length > 0; + const isInitialScanPending = discovery.isPending && discovery.data === null; + const handleScan = () => { + void refreshSourceControlDiscovery(); + }; + const scanButton = ( + + + + + } + /> + Scan source control tools + + ); + + return ( + + {isInitialScanPending ? ( + <> + + + + ) : hasDiscoveryItems ? ( + <> + {result.versionControlSystems.length > 0 ? ( + + {result.versionControlSystems.map((item) => ( + + ))} + + ) : null} + + {result.sourceControlProviders.length > 0 ? ( + + {result.sourceControlProviders.map((item) => ( + + ))} + + ) : null} + + ) : ( + + )} + + ); +} diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index 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/environmentApi.ts b/apps/web/src/environmentApi.ts index 64df079df49..d2c84b1df1e 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -23,16 +23,18 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { filesystem: { browse: rpcClient.filesystem.browse, }, + vcs: { + pull: rpcClient.vcs.pull, + refreshStatus: rpcClient.vcs.refreshStatus, + onStatus: (input, callback, options) => rpcClient.vcs.onStatus(input, callback, options), + listRefs: rpcClient.vcs.listRefs, + createWorktree: rpcClient.vcs.createWorktree, + removeWorktree: rpcClient.vcs.removeWorktree, + createRef: rpcClient.vcs.createRef, + switchRef: rpcClient.vcs.switchRef, + init: rpcClient.vcs.init, + }, git: { - pull: rpcClient.git.pull, - refreshStatus: rpcClient.git.refreshStatus, - onStatus: (input, callback, options) => rpcClient.git.onStatus(input, callback, options), - listBranches: rpcClient.git.listBranches, - createWorktree: rpcClient.git.createWorktree, - removeWorktree: rpcClient.git.removeWorktree, - createBranch: rpcClient.git.createBranch, - checkout: rpcClient.git.checkout, - init: rpcClient.git.init, resolvePullRequest: rpcClient.git.resolvePullRequest, preparePullRequestThread: rpcClient.git.preparePullRequestThread, }, diff --git a/apps/web/src/environments/runtime/connection.test.ts b/apps/web/src/environments/runtime/connection.test.ts index 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/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.test.ts b/apps/web/src/lib/gitReactQuery.test.ts index 93ba66e4a24..2995207b938 100644 --- a/apps/web/src/lib/gitReactQuery.test.ts +++ b/apps/web/src/lib/gitReactQuery.test.ts @@ -11,7 +11,7 @@ vi.mock("../wsRpcClient", () => ({ })); import type { InfiniteData } from "@tanstack/react-query"; -import { EnvironmentId, type GitListBranchesResult } from "@t3tools/contracts"; +import { EnvironmentId, type VcsListRefsResult } from "@t3tools/contracts"; import { gitBranchSearchInfiniteQueryOptions, @@ -22,15 +22,15 @@ import { invalidateGitQueries, } from "./gitReactQuery"; -const BRANCH_QUERY_RESULT: GitListBranchesResult = { - branches: [], +const BRANCH_QUERY_RESULT: VcsListRefsResult = { + refs: [], isRepo: true, - hasOriginRemote: true, + hasPrimaryRemote: true, nextCursor: null, totalCount: 0, }; -const BRANCH_SEARCH_RESULT: InfiniteData = { +const BRANCH_SEARCH_RESULT: InfiniteData = { pages: [BRANCH_QUERY_RESULT], pageParams: [0], }; diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index 68d2b5bf53c..44dd705ceb5 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -19,17 +19,17 @@ const GIT_BRANCHES_PAGE_SIZE = 100; export const gitQueryKeys = { all: ["git"] as const, - branches: (environmentId: EnvironmentId | null, cwd: string | null) => - ["git", "branches", environmentId ?? null, cwd] as const, + refs: (environmentId: EnvironmentId | null, cwd: string | null) => + ["git", "refs", environmentId ?? null, cwd] as const, branchSearch: (environmentId: EnvironmentId | null, cwd: string | null, query: string) => - ["git", "branches", environmentId ?? null, cwd, "search", query] as const, + ["git", "refs", environmentId ?? null, cwd, "search", query] as const, }; export const gitMutationKeys = { init: (environmentId: EnvironmentId | null, cwd: string | null) => ["git", "mutation", "init", environmentId ?? null, cwd] as const, - checkout: (environmentId: EnvironmentId | null, cwd: string | null) => - ["git", "mutation", "checkout", environmentId ?? null, cwd] as const, + switchRef: (environmentId: EnvironmentId | null, cwd: string | null) => + ["git", "mutation", "switchRef", environmentId ?? null, cwd] as const, runStackedAction: (environmentId: EnvironmentId | null, cwd: string | null) => ["git", "mutation", "run-stacked-action", environmentId ?? null, cwd] as const, pull: (environmentId: EnvironmentId | null, cwd: string | null) => @@ -45,7 +45,7 @@ export function invalidateGitQueries( const environmentId = input?.environmentId ?? null; const cwd = input?.cwd ?? null; if (cwd !== null) { - return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, cwd) }); + return queryClient.invalidateQueries({ queryKey: gitQueryKeys.refs(environmentId, cwd) }); } return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all }); @@ -60,9 +60,12 @@ function invalidateGitBranchQueries( return Promise.resolve(); } - return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, cwd) }); + return queryClient.invalidateQueries({ queryKey: gitQueryKeys.refs(environmentId, cwd) }); } +/** + * @deprecated Use a VCS-named query helper once the UI naming migration lands. + */ export function gitBranchSearchInfiniteQueryOptions(input: { environmentId: EnvironmentId | null; cwd: string | null; @@ -75,10 +78,10 @@ export function gitBranchSearchInfiniteQueryOptions(input: { queryKey: gitQueryKeys.branchSearch(input.environmentId, input.cwd, normalizedQuery), initialPageParam: 0, queryFn: async ({ pageParam }) => { - if (!input.cwd) throw new Error("Git branches are unavailable."); - if (!input.environmentId) throw new Error("Git branches are unavailable."); + if (!input.cwd) throw new Error("Git refs are unavailable."); + if (!input.environmentId) throw new Error("Git refs are unavailable."); const api = ensureEnvironmentApi(input.environmentId); - return api.git.listBranches({ + return api.vcs.listRefs({ cwd: input.cwd, ...(normalizedQuery.length > 0 ? { query: normalizedQuery } : {}), cursor: pageParam, @@ -121,6 +124,9 @@ export function gitResolvePullRequestQueryOptions(input: { }); } +/** + * @deprecated Use a VCS-named mutation helper once the UI naming migration lands. + */ export function gitInitMutationOptions(input: { environmentId: EnvironmentId | null; cwd: string | null; @@ -131,7 +137,7 @@ export function gitInitMutationOptions(input: { mutationFn: async () => { if (!input.cwd || !input.environmentId) throw new Error("Git init is unavailable."); const api = ensureEnvironmentApi(input.environmentId); - return api.git.init({ cwd: input.cwd }); + return api.vcs.init({ cwd: input.cwd }); }, onSettled: async () => { await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); @@ -139,17 +145,20 @@ export function gitInitMutationOptions(input: { }); } +/** + * @deprecated Use a VCS-named mutation helper once the UI naming migration lands. + */ export function gitCheckoutMutationOptions(input: { environmentId: EnvironmentId | null; cwd: string | null; queryClient: QueryClient; }) { return mutationOptions({ - mutationKey: gitMutationKeys.checkout(input.environmentId, input.cwd), - mutationFn: async (branch: string) => { - if (!input.cwd || !input.environmentId) throw new Error("Git checkout is unavailable."); + mutationKey: gitMutationKeys.switchRef(input.environmentId, input.cwd), + mutationFn: async (refName: string) => { + if (!input.cwd || !input.environmentId) throw new Error("Git switchRef is unavailable."); const api = ensureEnvironmentApi(input.environmentId); - return api.git.checkout({ cwd: input.cwd, branch }); + return api.vcs.switchRef({ cwd: input.cwd, refName }); }, onSettled: async () => { await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); @@ -198,6 +207,9 @@ export function gitRunStackedActionMutationOptions(input: { }); } +/** + * @deprecated Use a VCS-named mutation helper once the UI naming migration lands. + */ export function gitPullMutationOptions(input: { environmentId: EnvironmentId | null; cwd: string | null; @@ -208,7 +220,7 @@ export function gitPullMutationOptions(input: { mutationFn: async () => { if (!input.cwd || !input.environmentId) throw new Error("Git pull is unavailable."); const api = ensureEnvironmentApi(input.environmentId); - return api.git.pull({ cwd: input.cwd }); + return api.vcs.pull({ cwd: input.cwd }); }, onSuccess: async () => { await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); @@ -216,6 +228,9 @@ export function gitPullMutationOptions(input: { }); } +/** + * @deprecated Use a VCS-named mutation helper once the UI naming migration lands. + */ export function gitCreateWorktreeMutationOptions(input: { environmentId: EnvironmentId | null; queryClient: QueryClient; @@ -223,12 +238,12 @@ export function gitCreateWorktreeMutationOptions(input: { return mutationOptions({ mutationKey: ["git", "mutation", "create-worktree", input.environmentId ?? null] as const, mutationFn: ( - args: Parameters["git"]["createWorktree"]>[0], + args: Parameters["vcs"]["createWorktree"]>[0], ) => { if (!input.environmentId) { throw new Error("Worktree creation is unavailable."); } - return ensureEnvironmentApi(input.environmentId).git.createWorktree(args); + return ensureEnvironmentApi(input.environmentId).vcs.createWorktree(args); }, onSuccess: async () => { await invalidateGitQueries(input.queryClient, { environmentId: input.environmentId }); @@ -236,6 +251,9 @@ export function gitCreateWorktreeMutationOptions(input: { }); } +/** + * @deprecated Use a VCS-named mutation helper once the UI naming migration lands. + */ export function gitRemoveWorktreeMutationOptions(input: { environmentId: EnvironmentId | null; queryClient: QueryClient; @@ -243,12 +261,12 @@ export function gitRemoveWorktreeMutationOptions(input: { return mutationOptions({ mutationKey: ["git", "mutation", "remove-worktree", input.environmentId ?? null] as const, mutationFn: ( - args: Parameters["git"]["removeWorktree"]>[0], + args: Parameters["vcs"]["removeWorktree"]>[0], ) => { if (!input.environmentId) { throw new Error("Worktree removal is unavailable."); } - return ensureEnvironmentApi(input.environmentId).git.removeWorktree(args); + return ensureEnvironmentApi(input.environmentId).vcs.removeWorktree(args); }, onSuccess: async () => { await invalidateGitQueries(input.queryClient, { environmentId: input.environmentId }); diff --git a/apps/web/src/lib/gitStatusState.test.ts b/apps/web/src/lib/gitStatusState.test.ts index 2e17cd15521..40766563fb7 100644 --- a/apps/web/src/lib/gitStatusState.test.ts +++ b/apps/web/src/lib/gitStatusState.test.ts @@ -1,4 +1,4 @@ -import { EnvironmentId, type GitStatusResult } from "@t3tools/contracts"; +import { EnvironmentId, type VcsStatusResult } from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { WsRpcClient } from "../rpc/wsRpcClient"; @@ -33,17 +33,17 @@ function registerListener(listeners: Set<(event: T) => void>, listener: (even }; } -const gitStatusListeners = new Set<(event: GitStatusResult) => void>(); +const gitStatusListeners = new Set<(event: VcsStatusResult) => void>(); const ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const OTHER_ENVIRONMENT_ID = EnvironmentId.make("environment-remote"); const TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/repo" } as const; const FRESH_TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/fresh" } as const; -const BASE_STATUS: GitStatusResult = { +const BASE_STATUS: VcsStatusResult = { isRepo: true, - hasOriginRemote: true, - isDefaultBranch: false, - branch: "feature/push-status", + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/push-status", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: true, @@ -55,21 +55,21 @@ const BASE_STATUS: GitStatusResult = { const gitClient = { refreshStatus: vi.fn(async (input: { cwd: string }) => ({ ...BASE_STATUS, - branch: `${input.cwd}-refreshed`, + refName: `${input.cwd}-refreshed`, })), - onStatus: vi.fn((input: { cwd: string }, listener: (event: GitStatusResult) => void) => + onStatus: vi.fn((input: { cwd: string }, listener: (event: VcsStatusResult) => void) => registerListener(gitStatusListeners, listener), ), }; -function emitGitStatus(event: GitStatusResult) { +function emitGitStatus(event: VcsStatusResult) { for (const listener of gitStatusListeners) { listener(event); } } function createRegisteredGitStatusClient(environmentId: EnvironmentId) { - const listeners = new Set<(event: GitStatusResult) => void>(); + const listeners = new Set<(event: VcsStatusResult) => void>(); const client = { dispose: vi.fn(async () => undefined), reconnect: vi.fn(async () => undefined), @@ -89,22 +89,24 @@ function createRegisteredGitStatusClient(environmentId: EnvironmentId) { shell: { openInEditor: vi.fn(async () => undefined), }, - git: { + vcs: { pull: vi.fn(async () => undefined), refreshStatus: vi.fn(async (input: { cwd: string }) => ({ ...BASE_STATUS, - branch: `${input.cwd}-refreshed`, + refName: `${input.cwd}-refreshed`, })), - onStatus: vi.fn((_: { cwd: string }, listener: (event: GitStatusResult) => void) => + onStatus: vi.fn((_: { cwd: string }, listener: (event: VcsStatusResult) => void) => registerListener(listeners, listener), ), - runStackedAction: vi.fn(async () => ({}) as any), - listBranches: vi.fn(async () => []), + listRefs: vi.fn(async () => []), createWorktree: vi.fn(async () => undefined), removeWorktree: vi.fn(async () => undefined), - createBranch: vi.fn(async () => undefined), - checkout: vi.fn(async () => undefined), + createRef: vi.fn(async () => undefined), + switchRef: vi.fn(async () => undefined), init: vi.fn(async () => undefined), + }, + git: { + runStackedAction: vi.fn(async () => ({}) as any), resolvePullRequest: vi.fn(async () => undefined), preparePullRequestThread: vi.fn(async () => undefined), }, @@ -155,7 +157,7 @@ function createRegisteredGitStatusClient(environmentId: EnvironmentId) { return { client, - emit: (event: GitStatusResult) => { + emit: (event: VcsStatusResult) => { for (const listener of listeners) { listener(event); } @@ -219,7 +221,7 @@ describe("gitStatusState", () => { expect(gitClient.onStatus).toHaveBeenCalledOnce(); expect(gitClient.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); - expect(refreshed).toEqual({ ...BASE_STATUS, branch: "/repo-refreshed" }); + expect(refreshed).toEqual({ ...BASE_STATUS, refName: "/repo-refreshed" }); expect(getGitStatusSnapshot(TARGET)).toEqual({ data: BASE_STATUS, error: null, @@ -231,17 +233,17 @@ describe("gitStatusState", () => { }); it("keeps git status subscriptions isolated by environment when cwds match", () => { - const localListeners = new Set<(event: GitStatusResult) => void>(); - const remoteListeners = new Set<(event: GitStatusResult) => void>(); + const localListeners = new Set<(event: VcsStatusResult) => void>(); + const remoteListeners = new Set<(event: VcsStatusResult) => void>(); const localClient = { refreshStatus: vi.fn(), - onStatus: vi.fn((_: { cwd: string }, listener: (event: GitStatusResult) => void) => + onStatus: vi.fn((_: { cwd: string }, listener: (event: VcsStatusResult) => void) => registerListener(localListeners, listener), ), }; const remoteClient = { refreshStatus: vi.fn(), - onStatus: vi.fn((_: { cwd: string }, listener: (event: GitStatusResult) => void) => + onStatus: vi.fn((_: { cwd: string }, listener: (event: VcsStatusResult) => void) => registerListener(remoteListeners, listener), ), }; @@ -254,11 +256,11 @@ describe("gitStatusState", () => { listener(BASE_STATUS); } for (const listener of remoteListeners) { - listener({ ...BASE_STATUS, branch: "remote-branch" }); + listener({ ...BASE_STATUS, refName: "remote-refName" }); } - expect(getGitStatusSnapshot(TARGET).data?.branch).toBe("feature/push-status"); - expect(getGitStatusSnapshot(remoteTarget).data?.branch).toBe("remote-branch"); + expect(getGitStatusSnapshot(TARGET).data?.refName).toBe("feature/push-status"); + expect(getGitStatusSnapshot(remoteTarget).data?.refName).toBe("remote-refName"); releaseLocal(); releaseRemote(); @@ -292,7 +294,7 @@ describe("gitStatusState", () => { const release = watchGitStatus(TARGET); firstClient.emit(BASE_STATUS); - expect(getGitStatusSnapshot(TARGET).data?.branch).toBe("feature/push-status"); + expect(getGitStatusSnapshot(TARGET).data?.refName).toBe("feature/push-status"); serviceHarness.connections.delete(ENVIRONMENT_ID); for (const listener of serviceHarness.listeners) { @@ -307,10 +309,10 @@ describe("gitStatusState", () => { }); const secondClient = createRegisteredGitStatusClient(ENVIRONMENT_ID); - secondClient.emit({ ...BASE_STATUS, branch: "reconnected-branch" }); + secondClient.emit({ ...BASE_STATUS, refName: "reconnected-refName" }); expect(getGitStatusSnapshot(TARGET)).toEqual({ - data: { ...BASE_STATUS, branch: "reconnected-branch" }, + data: { ...BASE_STATUS, refName: "reconnected-refName" }, error: null, cause: null, isPending: false, diff --git a/apps/web/src/lib/gitStatusState.ts b/apps/web/src/lib/gitStatusState.ts index 2c23ae5b826..4304dcc4aab 100644 --- a/apps/web/src/lib/gitStatusState.ts +++ b/apps/web/src/lib/gitStatusState.ts @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { type EnvironmentId, type GitManagerServiceError, - type GitStatusResult, + type VcsStatusResult, } from "@t3tools/contracts"; import { Cause } from "effect"; import { Atom } from "effect/unstable/reactivity"; @@ -16,13 +16,13 @@ import { import type { WsRpcClient } from "~/rpc/wsRpcClient"; interface GitStatusState { - readonly data: GitStatusResult | null; + readonly data: VcsStatusResult | null; readonly error: GitManagerServiceError | null; readonly cause: Cause.Cause | null; readonly isPending: boolean; } -type GitStatusClient = Pick; +type GitStatusClient = Pick; interface ResolvedGitStatusClient { readonly clientIdentity: string; readonly client: GitStatusClient; @@ -56,7 +56,7 @@ const EMPTY_GIT_STATUS_ATOM = Atom.make(EMPTY_GIT_STATUS_STATE).pipe( const NOOP: () => void = () => undefined; const watchedGitStatuses = new Map(); const knownGitStatusKeys = new Set(); -const gitStatusRefreshInFlight = new Map>(); +const gitStatusRefreshInFlight = new Map>(); const gitStatusLastRefreshAtByKey = new Map(); const GIT_STATUS_REFRESH_DEBOUNCE_MS = 1_000; @@ -83,7 +83,7 @@ function readResolvedGitStatusClient(target: GitStatusTarget): ResolvedGitStatus } const connection = readEnvironmentConnection(target.environmentId); return connection - ? { clientIdentity: connection.environmentId, client: connection.client.git } + ? { clientIdentity: connection.environmentId, client: connection.client.vcs } : null; } @@ -119,7 +119,7 @@ export function watchGitStatus(target: GitStatusTarget, client?: GitStatusClient export function refreshGitStatus( target: GitStatusTarget, client?: GitStatusClient, -): Promise { +): Promise { const targetKey = getGitStatusTargetKey(target); if (targetKey === null || target.cwd === null) { return Promise.resolve(null); @@ -245,7 +245,7 @@ function subscribeToGitStatus(targetKey: string, cwd: string, client: GitStatusC markGitStatusPending(targetKey); return client.onStatus( { cwd }, - (status: GitStatusResult) => { + (status: VcsStatusResult) => { appAtomRegistry.set(gitStatusStateAtom(targetKey), { data: status, error: null, diff --git a/apps/web/src/lib/sourceControlDiscoveryState.ts b/apps/web/src/lib/sourceControlDiscoveryState.ts new file mode 100644 index 00000000000..fad4f534908 --- /dev/null +++ b/apps/web/src/lib/sourceControlDiscoveryState.ts @@ -0,0 +1,56 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM, + EMPTY_SOURCE_CONTROL_DISCOVERY_STATE, + type SourceControlDiscoveryState, + createSourceControlDiscoveryManager, + getSourceControlDiscoveryTargetKey, + sourceControlDiscoveryStateAtom, +} from "@t3tools/client-runtime"; +import type { SourceControlDiscoveryResult } from "@t3tools/contracts"; +import { Effect } from "effect"; +import { Atom } from "effect/unstable/reactivity"; + +import { readLocalApi } from "../localApi"; +import { appAtomRegistry } from "../rpc/atomRegistry"; + +const SOURCE_CONTROL_DISCOVERY_TARGET = { key: "primary" } as const; +const SOURCE_CONTROL_DISCOVERY_STALE_TIME_MS = 30_000; +const SOURCE_CONTROL_DISCOVERY_IDLE_TTL_MS = 5 * 60_000; + +export const sourceControlDiscoveryManager = createSourceControlDiscoveryManager({ + getRegistry: () => appAtomRegistry, + getClient: () => readLocalApi()?.server ?? null, +}); + +const sourceControlDiscoveryAutoRefreshAtom = Atom.make(() => + Effect.promise(() => refreshSourceControlDiscovery()), +).pipe( + Atom.swr({ + staleTime: SOURCE_CONTROL_DISCOVERY_STALE_TIME_MS, + revalidateOnMount: true, + }), + Atom.setIdleTTL(SOURCE_CONTROL_DISCOVERY_IDLE_TTL_MS), + Atom.withLabel("source-control-discovery:auto-refresh"), +); + +export function refreshSourceControlDiscovery(): Promise { + return sourceControlDiscoveryManager.refresh(SOURCE_CONTROL_DISCOVERY_TARGET); +} + +export function resetSourceControlDiscoveryStateForTests(): void { + sourceControlDiscoveryManager.reset(); +} + +export function useSourceControlDiscovery(): SourceControlDiscoveryState { + const targetKey = getSourceControlDiscoveryTargetKey(SOURCE_CONTROL_DISCOVERY_TARGET); + + useAtomValue(sourceControlDiscoveryAutoRefreshAtom); + + const state = useAtomValue( + targetKey !== null + ? sourceControlDiscoveryStateAtom(targetKey) + : EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM, + ); + return targetKey === null ? EMPTY_SOURCE_CONTROL_DISCOVERY_STATE : state; +} diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index fbdd203e99f..b627286199c 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -3,7 +3,7 @@ import { DEFAULT_SERVER_SETTINGS, type DesktopBridge, EnvironmentId, - type GitStatusResult, + type VcsStatusResult, ProjectId, type OrchestrationShellStreamItem, ProviderDriverKind, @@ -34,7 +34,7 @@ function registerListener(listeners: Set<(event: T) => void>, listener: (even const terminalEventListeners = new Set<(event: TerminalEvent) => void>(); const shellStreamListeners = new Set<(event: OrchestrationShellStreamItem) => void>(); -const gitStatusListeners = new Set<(event: GitStatusResult) => void>(); +const gitStatusListeners = new Set<(event: VcsStatusResult) => void>(); const rpcClientMock = { dispose: vi.fn(), @@ -59,19 +59,21 @@ const rpcClientMock = { shell: { openInEditor: vi.fn(), }, - git: { + vcs: { pull: vi.fn(), refreshStatus: vi.fn(), - onStatus: vi.fn((input: { cwd: string }, listener: (event: GitStatusResult) => void) => + onStatus: vi.fn((input: { cwd: string }, listener: (event: VcsStatusResult) => void) => registerListener(gitStatusListeners, listener), ), - runStackedAction: vi.fn(), - listBranches: vi.fn(), + listRefs: vi.fn(), createWorktree: vi.fn(), removeWorktree: vi.fn(), - createBranch: vi.fn(), - checkout: vi.fn(), + createRef: vi.fn(), + switchRef: vi.fn(), init: vi.fn(), + }, + git: { + runStackedAction: vi.fn(), resolvePullRequest: vi.fn(), preparePullRequestThread: vi.fn(), }, @@ -259,11 +261,11 @@ const baseServerConfig: ServerConfig = { settings: DEFAULT_SERVER_SETTINGS, }; -const baseGitStatus: GitStatusResult = { +const baseGitStatus: VcsStatusResult = { isRepo: true, - hasOriginRemote: true, - isDefaultBranch: false, - branch: "feature/streamed", + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/streamed", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: true, @@ -351,24 +353,24 @@ describe("wsApi", () => { const api = createEnvironmentApi(rpcClientMock as never); const onStatus = vi.fn(); - api.git.onStatus({ cwd: "/repo" }, onStatus); + api.vcs.onStatus({ cwd: "/repo" }, onStatus); const gitStatus = baseGitStatus; emitEvent(gitStatusListeners, gitStatus); - expect(rpcClientMock.git.onStatus).toHaveBeenCalledWith({ cwd: "/repo" }, onStatus, undefined); + expect(rpcClientMock.vcs.onStatus).toHaveBeenCalledWith({ cwd: "/repo" }, onStatus, undefined); expect(onStatus).toHaveBeenCalledWith(gitStatus); }); it("forwards git status refreshes directly to the RPC client", async () => { - rpcClientMock.git.refreshStatus.mockResolvedValue(baseGitStatus); + rpcClientMock.vcs.refreshStatus.mockResolvedValue(baseGitStatus); const { createEnvironmentApi } = await import("./environmentApi"); const api = createEnvironmentApi(rpcClientMock as never); - await api.git.refreshStatus({ cwd: "/repo" }); + await api.vcs.refreshStatus({ cwd: "/repo" }); - expect(rpcClientMock.git.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); + expect(rpcClientMock.vcs.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); }); it("forwards shell stream subscription options to the RPC client", async () => { diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index 4401b5b778e..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"; @@ -115,6 +116,7 @@ export function createLocalApi(rpcClient: WsRpcClient): LocalApi { upsertKeybinding: rpcClient.server.upsertKeybinding, getSettings: rpcClient.server.getSettings, updateSettings: rpcClient.server.updateSettings, + discoverSourceControl: rpcClient.server.discoverSourceControl, }, }; } @@ -146,6 +148,7 @@ export async function __resetLocalApiForTests() { __resetClientSettingsPersistenceForTests(); await resetEnvironmentServiceForTests(); resetGitStatusStateForTests(); + resetSourceControlDiscoveryStateForTests(); resetRequestLatencyStateForTests(); resetSavedEnvironmentRegistryStoreForTests(); resetSavedEnvironmentRuntimeStoreForTests(); diff --git a/apps/web/src/pullRequestReference.test.ts b/apps/web/src/pullRequestReference.test.ts index 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/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 6494ecdb25f..f5844f2c941 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -13,6 +13,7 @@ import { Route as SettingsRouteImport } from './routes/settings' import { Route as PairRouteImport } from './routes/pair' import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' +import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' @@ -38,6 +39,11 @@ const ChatIndexRoute = ChatIndexRouteImport.update({ path: '/', getParentRoute: () => ChatRoute, } as any) +const SettingsSourceControlRoute = SettingsSourceControlRouteImport.update({ + id: '/source-control', + path: '/source-control', + getParentRoute: () => SettingsRoute, +} as any) const SettingsGeneralRoute = SettingsGeneralRouteImport.update({ id: '/general', path: '/general', @@ -72,6 +78,7 @@ export interface FileRoutesByFullPath { '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/general': typeof SettingsGeneralRoute + '/settings/source-control': typeof SettingsSourceControlRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute } @@ -81,6 +88,7 @@ export interface FileRoutesByTo { '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/general': typeof SettingsGeneralRoute + '/settings/source-control': typeof SettingsSourceControlRoute '/': typeof ChatIndexRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute @@ -93,6 +101,7 @@ export interface FileRoutesById { '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/general': typeof SettingsGeneralRoute + '/settings/source-control': typeof SettingsSourceControlRoute '/_chat/': typeof ChatIndexRoute '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute @@ -106,6 +115,7 @@ export interface FileRouteTypes { | '/settings/archived' | '/settings/connections' | '/settings/general' + | '/settings/source-control' | '/$environmentId/$threadId' | '/draft/$draftId' fileRoutesByTo: FileRoutesByTo @@ -115,6 +125,7 @@ export interface FileRouteTypes { | '/settings/archived' | '/settings/connections' | '/settings/general' + | '/settings/source-control' | '/' | '/$environmentId/$threadId' | '/draft/$draftId' @@ -126,6 +137,7 @@ export interface FileRouteTypes { | '/settings/archived' | '/settings/connections' | '/settings/general' + | '/settings/source-control' | '/_chat/' | '/_chat/$environmentId/$threadId' | '/_chat/draft/$draftId' @@ -167,6 +179,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatIndexRouteImport parentRoute: typeof ChatRoute } + '/settings/source-control': { + id: '/settings/source-control' + path: '/source-control' + fullPath: '/settings/source-control' + preLoaderRoute: typeof SettingsSourceControlRouteImport + parentRoute: typeof SettingsRoute + } '/settings/general': { id: '/settings/general' path: '/general' @@ -223,12 +242,14 @@ interface SettingsRouteChildren { SettingsArchivedRoute: typeof SettingsArchivedRoute SettingsConnectionsRoute: typeof SettingsConnectionsRoute SettingsGeneralRoute: typeof SettingsGeneralRoute + SettingsSourceControlRoute: typeof SettingsSourceControlRoute } const SettingsRouteChildren: SettingsRouteChildren = { SettingsArchivedRoute: SettingsArchivedRoute, SettingsConnectionsRoute: SettingsConnectionsRoute, SettingsGeneralRoute: SettingsGeneralRoute, + SettingsSourceControlRoute: SettingsSourceControlRoute, } const SettingsRouteWithChildren = SettingsRoute._addFileChildren( diff --git a/apps/web/src/routes/settings.source-control.tsx b/apps/web/src/routes/settings.source-control.tsx new file mode 100644 index 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.test.ts b/apps/web/src/rpc/wsRpcClient.test.ts index 56f39b1bd32..f8fb4cecb19 100644 --- a/apps/web/src/rpc/wsRpcClient.test.ts +++ b/apps/web/src/rpc/wsRpcClient.test.ts @@ -1,7 +1,7 @@ import type { - GitStatusLocalResult, - GitStatusRemoteResult, - GitStatusStreamEvent, + VcsStatusLocalResult, + VcsStatusRemoteResult, + VcsStatusStreamEvent, } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vitest"; @@ -18,16 +18,16 @@ vi.mock("./wsTransport", () => ({ import { createWsRpcClient } from "./wsRpcClient"; import { type WsTransport } from "./wsTransport"; -const baseLocalStatus: GitStatusLocalResult = { +const baseLocalStatus: VcsStatusLocalResult = { isRepo: true, - hasOriginRemote: true, - isDefaultBranch: false, - branch: "feature/demo", + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/demo", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, }; -const baseRemoteStatus: GitStatusRemoteResult = { +const baseRemoteStatus: VcsStatusRemoteResult = { hasUpstream: true, aheadCount: 0, behindCount: 0, @@ -35,7 +35,7 @@ const baseRemoteStatus: GitStatusRemoteResult = { }; describe("wsRpcClient", () => { - it("reduces git status stream events into flat status snapshots", () => { + it("reduces vcs status stream events into flat status snapshots", () => { const subscribe = vi.fn((_connect: unknown, listener: (value: TValue) => void) => { for (const event of [ { @@ -54,7 +54,7 @@ describe("wsRpcClient", () => { hasWorkingTreeChanges: true, }, }, - ] satisfies GitStatusStreamEvent[]) { + ] satisfies VcsStatusStreamEvent[]) { listener(event as TValue); } return () => undefined; @@ -74,7 +74,7 @@ describe("wsRpcClient", () => { const client = createWsRpcClient(transport as unknown as WsTransport); const listener = vi.fn(); - client.git.onStatus({ cwd: "/repo" }, listener); + client.vcs.onStatus({ cwd: "/repo" }, listener); expect(listener.mock.calls).toEqual([ [ diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index f1183c1e9c5..39abcb2c09c 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -2,8 +2,8 @@ import { type GitActionProgressEvent, type GitRunStackedActionInput, type GitRunStackedActionResult, - type GitStatusResult, - type GitStatusStreamEvent, + type VcsStatusResult, + type VcsStatusStreamEvent, type LocalApi, ORCHESTRATION_WS_METHODS, type ServerSettingsPatch, @@ -77,24 +77,29 @@ export interface WsRpcClient { readonly editor: Parameters[1]; }) => ReturnType; }; - readonly git: { - readonly pull: RpcUnaryMethod; - readonly refreshStatus: RpcUnaryMethod; + readonly vcs: { + readonly pull: RpcUnaryMethod; + readonly refreshStatus: RpcUnaryMethod; readonly onStatus: ( - input: RpcInput, - listener: (status: GitStatusResult) => void, + input: RpcInput, + listener: (status: VcsStatusResult) => void, options?: StreamSubscriptionOptions, ) => () => void; + readonly listRefs: RpcUnaryMethod; + readonly createWorktree: RpcUnaryMethod; + readonly removeWorktree: RpcUnaryMethod; + readonly createRef: RpcUnaryMethod; + readonly switchRef: RpcUnaryMethod; + readonly init: RpcUnaryMethod; + }; + /** + * Git-specific workflows. Local repository mechanics live under `vcs`. + */ + readonly git: { readonly runStackedAction: ( input: GitRunStackedActionInput, options?: GitRunStackedActionOptions, ) => Promise; - readonly listBranches: RpcUnaryMethod; - readonly createWorktree: RpcUnaryMethod; - readonly removeWorktree: RpcUnaryMethod; - readonly createBranch: RpcUnaryMethod; - readonly checkout: RpcUnaryMethod; - readonly init: RpcUnaryMethod; readonly resolvePullRequest: RpcUnaryMethod; readonly preparePullRequestThread: RpcUnaryMethod< typeof WS_METHODS.gitPreparePullRequestThread @@ -114,6 +119,9 @@ export interface WsRpcClient { readonly updateSettings: ( patch: ServerSettingsPatch, ) => ReturnType>; + readonly discoverSourceControl: RpcUnaryNoArgMethod< + typeof WS_METHODS.serverDiscoverSourceControl + >; readonly subscribeConfig: RpcStreamMethod; readonly subscribeLifecycle: RpcStreamMethod; readonly subscribeAuthAccess: RpcStreamMethod; @@ -161,21 +169,31 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { openInEditor: (input) => transport.request((client) => client[WS_METHODS.shellOpenInEditor](input)), }, - git: { - pull: (input) => transport.request((client) => client[WS_METHODS.gitPull](input)), + vcs: { + pull: (input) => transport.request((client) => client[WS_METHODS.vcsPull](input)), refreshStatus: (input) => - transport.request((client) => client[WS_METHODS.gitRefreshStatus](input)), + transport.request((client) => client[WS_METHODS.vcsRefreshStatus](input)), onStatus: (input, listener, options) => { - let current: GitStatusResult | null = null; + let current: VcsStatusResult | null = null; return transport.subscribe( - (client) => client[WS_METHODS.subscribeGitStatus](input), - (event: GitStatusStreamEvent) => { + (client) => client[WS_METHODS.subscribeVcsStatus](input), + (event: VcsStatusStreamEvent) => { current = applyGitStatusStreamEvent(current, event); listener(current); }, options, ); }, + listRefs: (input) => transport.request((client) => client[WS_METHODS.vcsListRefs](input)), + createWorktree: (input) => + transport.request((client) => client[WS_METHODS.vcsCreateWorktree](input)), + removeWorktree: (input) => + transport.request((client) => client[WS_METHODS.vcsRemoveWorktree](input)), + createRef: (input) => transport.request((client) => client[WS_METHODS.vcsCreateRef](input)), + switchRef: (input) => transport.request((client) => client[WS_METHODS.vcsSwitchRef](input)), + init: (input) => transport.request((client) => client[WS_METHODS.vcsInit](input)), + }, + git: { runStackedAction: async (input, options) => { let result: GitRunStackedActionResult | null = null; @@ -195,16 +213,6 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { throw new Error("Git action stream completed without a final result."); }, - listBranches: (input) => - transport.request((client) => client[WS_METHODS.gitListBranches](input)), - createWorktree: (input) => - transport.request((client) => client[WS_METHODS.gitCreateWorktree](input)), - removeWorktree: (input) => - transport.request((client) => client[WS_METHODS.gitRemoveWorktree](input)), - createBranch: (input) => - transport.request((client) => client[WS_METHODS.gitCreateBranch](input)), - checkout: (input) => transport.request((client) => client[WS_METHODS.gitCheckout](input)), - init: (input) => transport.request((client) => client[WS_METHODS.gitInit](input)), resolvePullRequest: (input) => transport.request((client) => client[WS_METHODS.gitResolvePullRequest](input)), preparePullRequestThread: (input) => @@ -219,6 +227,8 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})), updateSettings: (patch) => transport.request((client) => client[WS_METHODS.serverUpdateSettings]({ patch })), + discoverSourceControl: () => + transport.request((client) => client[WS_METHODS.serverDiscoverSourceControl]({})), subscribeConfig: (listener, options) => transport.subscribe( (client) => client[WS_METHODS.subscribeServerConfig]({}), diff --git a/apps/web/src/sourceControlPresentation.ts b/apps/web/src/sourceControlPresentation.ts new file mode 100644 index 00000000000..68271228ed6 --- /dev/null +++ b/apps/web/src/sourceControlPresentation.ts @@ -0,0 +1,62 @@ +import { GitPullRequestIcon } from "lucide-react"; +import type { ElementType } from "react"; +import type { SourceControlProviderInfo } from "@t3tools/contracts"; +export { + DEFAULT_CHANGE_REQUEST_TERMINOLOGY, + formatChangeRequestAction, + formatCreateChangeRequestPhrase, + getChangeRequestTerminology, + resolveChangeRequestPresentation, + type ChangeRequestPresentation, + type ChangeRequestTerminology, +} from "@t3tools/shared/sourceControl"; +import { + getChangeRequestTerminology, + resolveChangeRequestPresentation, + type ChangeRequestTerminology, +} from "@t3tools/shared/sourceControl"; +import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./components/Icons"; + +export interface SourceControlPresentation { + readonly providerName: string; + readonly terminology: ChangeRequestTerminology; + readonly Icon: ElementType<{ className?: string }>; +} + +export function getSourceControlPresentation( + provider: SourceControlProviderInfo | null | undefined, +): SourceControlPresentation { + const presentation = resolveChangeRequestPresentation(provider); + switch (presentation.icon) { + case "github": + return { + providerName: provider?.name || presentation.providerName, + terminology: getChangeRequestTerminology(provider), + Icon: GitHubIcon, + }; + case "gitlab": + return { + providerName: provider?.name || presentation.providerName, + terminology: getChangeRequestTerminology(provider), + Icon: GitLabIcon, + }; + case "azure-devops": + return { + providerName: provider?.name || presentation.providerName, + terminology: getChangeRequestTerminology(provider), + Icon: AzureDevOpsIcon, + }; + case "bitbucket": + return { + providerName: provider?.name || presentation.providerName, + terminology: getChangeRequestTerminology(provider), + Icon: BitbucketIcon, + }; + case "change-request": + return { + providerName: provider?.name || presentation.providerName, + terminology: getChangeRequestTerminology(provider), + Icon: GitPullRequestIcon, + }; + } +} diff --git a/apps/web/test/wsRpcHarness.ts b/apps/web/test/wsRpcHarness.ts index 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/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/git.test.ts b/packages/contracts/src/git.test.ts index ebd5324fb9f..b4f7bc213ce 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -2,14 +2,14 @@ import { describe, expect, it } from "vitest"; import { Schema } from "effect"; import { - GitCreateWorktreeInput, + VcsCreateWorktreeInput, GitPreparePullRequestThreadInput, GitRunStackedActionResult, GitRunStackedActionInput, GitResolvePullRequestResult, } from "./git.ts"; -const decodeCreateWorktreeInput = Schema.decodeUnknownSync(GitCreateWorktreeInput); +const decodeCreateWorktreeInput = Schema.decodeUnknownSync(VcsCreateWorktreeInput); const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync( GitPreparePullRequestThreadInput, ); @@ -17,16 +17,16 @@ const decodeRunStackedActionInput = Schema.decodeUnknownSync(GitRunStackedAction const decodeRunStackedActionResult = Schema.decodeUnknownSync(GitRunStackedActionResult); const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult); -describe("GitCreateWorktreeInput", () => { - it("accepts omitted newBranch for existing-branch worktrees", () => { +describe("VcsCreateWorktreeInput", () => { + it("accepts omitted newRefName for existing-refName worktrees", () => { const parsed = decodeCreateWorktreeInput({ cwd: "/repo", - branch: "feature/existing", + refName: "feature/existing", path: "/tmp/worktree", }); - expect(parsed.newBranch).toBeUndefined(); - expect(parsed.branch).toBe("feature/existing"); + expect(parsed.newRefName).toBeUndefined(); + expect(parsed.refName).toBe("feature/existing"); }); }); diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 47d74dc3567..dd12e749da4 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,5 +1,7 @@ import { Schema } from "effect"; import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { SourceControlProviderError, SourceControlProviderInfo } from "./sourceControl.ts"; +import { VcsDriverKind } from "./vcs.ts"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; const GIT_LIST_BRANCHES_MAX_LIMIT = 200; @@ -40,18 +42,10 @@ const GitPushStepStatus = Schema.Literals([ ]); const GitBranchStepStatus = Schema.Literals(["created", "skipped_not_requested"]); const GitPrStepStatus = Schema.Literals(["created", "opened_existing", "skipped_not_requested"]); -const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]); +const VcsStatusChangeRequestState = Schema.Literals(["open", "closed", "merged"]); const GitPullRequestReference = TrimmedNonEmptyStringSchema; const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]); const GitPreparePullRequestThreadMode = Schema.Literals(["local", "worktree"]); -export const GitHostingProviderKind = Schema.Literals(["github", "gitlab", "unknown"]); -export type GitHostingProviderKind = typeof GitHostingProviderKind.Type; -export const GitHostingProvider = Schema.Struct({ - kind: GitHostingProviderKind, - name: TrimmedNonEmptyStringSchema, - baseUrl: Schema.String, -}); -export type GitHostingProvider = typeof GitHostingProvider.Type; export const GitRunStackedActionToastRunAction = Schema.Struct({ kind: GitStackedAction, }); @@ -79,7 +73,7 @@ const GitRunStackedActionToast = Schema.Struct({ }); export type GitRunStackedActionToast = typeof GitRunStackedActionToast.Type; -export const GitBranch = Schema.Struct({ +export const VcsRef = Schema.Struct({ name: TrimmedNonEmptyStringSchema, isRemote: Schema.optional(Schema.Boolean), remoteName: Schema.optional(TrimmedNonEmptyStringSchema), @@ -87,11 +81,11 @@ export const GitBranch = Schema.Struct({ isDefault: Schema.Boolean, worktreePath: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), }); -export type GitBranch = typeof GitBranch.Type; +export type VcsRef = typeof VcsRef.Type; -const GitWorktree = Schema.Struct({ +const VcsWorktree = Schema.Struct({ path: TrimmedNonEmptyStringSchema, - branch: TrimmedNonEmptyStringSchema, + refName: TrimmedNonEmptyStringSchema, }); const GitResolvedPullRequest = Schema.Struct({ number: PositiveInt, @@ -105,15 +99,15 @@ export type GitResolvedPullRequest = typeof GitResolvedPullRequest.Type; // RPC Inputs -export const GitStatusInput = Schema.Struct({ +export const VcsStatusInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, }); -export type GitStatusInput = typeof GitStatusInput.Type; +export type VcsStatusInput = typeof VcsStatusInput.Type; -export const GitPullInput = Schema.Struct({ +export const VcsPullInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, }); -export type GitPullInput = typeof GitPullInput.Type; +export type VcsPullInput = typeof VcsPullInput.Type; export const GitRunStackedActionInput = Schema.Struct({ actionId: TrimmedNonEmptyStringSchema, @@ -127,7 +121,7 @@ export const GitRunStackedActionInput = Schema.Struct({ }); export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type; -export const GitListBranchesInput = Schema.Struct({ +export const VcsListRefsInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, query: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(256))), cursor: Schema.optional(NonNegativeInt), @@ -135,15 +129,15 @@ export const GitListBranchesInput = Schema.Struct({ PositiveInt.check(Schema.isLessThanOrEqualTo(GIT_LIST_BRANCHES_MAX_LIMIT)), ), }); -export type GitListBranchesInput = typeof GitListBranchesInput.Type; +export type VcsListRefsInput = typeof VcsListRefsInput.Type; -export const GitCreateWorktreeInput = Schema.Struct({ +export const VcsCreateWorktreeInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, - branch: TrimmedNonEmptyStringSchema, - newBranch: Schema.optional(TrimmedNonEmptyStringSchema), + refName: TrimmedNonEmptyStringSchema, + newRefName: Schema.optional(TrimmedNonEmptyStringSchema), path: Schema.NullOr(TrimmedNonEmptyStringSchema), }); -export type GitCreateWorktreeInput = typeof GitCreateWorktreeInput.Type; +export type VcsCreateWorktreeInput = typeof VcsCreateWorktreeInput.Type; export const GitPullRequestRefInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, @@ -159,53 +153,54 @@ export const GitPreparePullRequestThreadInput = Schema.Struct({ }); export type GitPreparePullRequestThreadInput = typeof GitPreparePullRequestThreadInput.Type; -export const GitRemoveWorktreeInput = Schema.Struct({ +export const VcsRemoveWorktreeInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, path: TrimmedNonEmptyStringSchema, force: Schema.optional(Schema.Boolean), }); -export type GitRemoveWorktreeInput = typeof GitRemoveWorktreeInput.Type; +export type VcsRemoveWorktreeInput = typeof VcsRemoveWorktreeInput.Type; -export const GitCreateBranchInput = Schema.Struct({ +export const VcsCreateRefInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, - branch: TrimmedNonEmptyStringSchema, - checkout: Schema.optional(Schema.Boolean), + refName: TrimmedNonEmptyStringSchema, + switchRef: Schema.optional(Schema.Boolean), }); -export type GitCreateBranchInput = typeof GitCreateBranchInput.Type; +export type VcsCreateRefInput = typeof VcsCreateRefInput.Type; -export const GitCreateBranchResult = Schema.Struct({ - branch: TrimmedNonEmptyStringSchema, +export const VcsCreateRefResult = Schema.Struct({ + refName: TrimmedNonEmptyStringSchema, }); -export type GitCreateBranchResult = typeof GitCreateBranchResult.Type; +export type VcsCreateRefResult = typeof VcsCreateRefResult.Type; -export const GitCheckoutInput = Schema.Struct({ +export const VcsSwitchRefInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, - branch: TrimmedNonEmptyStringSchema, + refName: TrimmedNonEmptyStringSchema, }); -export type GitCheckoutInput = typeof GitCheckoutInput.Type; +export type VcsSwitchRefInput = typeof VcsSwitchRefInput.Type; -export const GitInitInput = Schema.Struct({ +export const VcsInitInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, + kind: Schema.optional(VcsDriverKind), }); -export type GitInitInput = typeof GitInitInput.Type; +export type VcsInitInput = typeof VcsInitInput.Type; // RPC Results -const GitStatusPr = Schema.Struct({ +const VcsStatusChangeRequest = Schema.Struct({ number: PositiveInt, title: TrimmedNonEmptyStringSchema, url: Schema.String, - baseBranch: TrimmedNonEmptyStringSchema, - headBranch: TrimmedNonEmptyStringSchema, - state: GitStatusPrState, + baseRef: TrimmedNonEmptyStringSchema, + headRef: TrimmedNonEmptyStringSchema, + state: VcsStatusChangeRequestState, }); -const GitStatusLocalShape = { +const VcsStatusLocalShape = { isRepo: Schema.Boolean, - hostingProvider: Schema.optional(GitHostingProvider), - hasOriginRemote: Schema.Boolean, - isDefaultBranch: Schema.Boolean, - branch: Schema.NullOr(TrimmedNonEmptyStringSchema), + sourceControlProvider: Schema.optional(SourceControlProviderInfo), + hasPrimaryRemote: Schema.Boolean, + isDefaultRef: Schema.Boolean, + refName: Schema.NullOr(TrimmedNonEmptyStringSchema), hasWorkingTreeChanges: Schema.Boolean, workingTree: Schema.Struct({ files: Schema.Array( @@ -220,52 +215,52 @@ const GitStatusLocalShape = { }), }; -const GitStatusRemoteShape = { +const VcsStatusRemoteShape = { hasUpstream: Schema.Boolean, aheadCount: NonNegativeInt, behindCount: NonNegativeInt, - pr: Schema.NullOr(GitStatusPr), + pr: Schema.NullOr(VcsStatusChangeRequest), }; -export const GitStatusLocalResult = Schema.Struct(GitStatusLocalShape); -export type GitStatusLocalResult = typeof GitStatusLocalResult.Type; +export const VcsStatusLocalResult = Schema.Struct(VcsStatusLocalShape); +export type VcsStatusLocalResult = typeof VcsStatusLocalResult.Type; -export const GitStatusRemoteResult = Schema.Struct(GitStatusRemoteShape); -export type GitStatusRemoteResult = typeof GitStatusRemoteResult.Type; +export const VcsStatusRemoteResult = Schema.Struct(VcsStatusRemoteShape); +export type VcsStatusRemoteResult = typeof VcsStatusRemoteResult.Type; -export const GitStatusResult = Schema.Struct({ - ...GitStatusLocalShape, - ...GitStatusRemoteShape, +export const VcsStatusResult = Schema.Struct({ + ...VcsStatusLocalShape, + ...VcsStatusRemoteShape, }); -export type GitStatusResult = typeof GitStatusResult.Type; +export type VcsStatusResult = typeof VcsStatusResult.Type; -export const GitStatusStreamEvent = Schema.Union([ +export const VcsStatusStreamEvent = Schema.Union([ Schema.TaggedStruct("snapshot", { - local: GitStatusLocalResult, - remote: Schema.NullOr(GitStatusRemoteResult), + local: VcsStatusLocalResult, + remote: Schema.NullOr(VcsStatusRemoteResult), }), Schema.TaggedStruct("localUpdated", { - local: GitStatusLocalResult, + local: VcsStatusLocalResult, }), Schema.TaggedStruct("remoteUpdated", { - remote: Schema.NullOr(GitStatusRemoteResult), + remote: Schema.NullOr(VcsStatusRemoteResult), }), ]); -export type GitStatusStreamEvent = typeof GitStatusStreamEvent.Type; +export type VcsStatusStreamEvent = typeof VcsStatusStreamEvent.Type; -export const GitListBranchesResult = Schema.Struct({ - branches: Schema.Array(GitBranch), +export const VcsListRefsResult = Schema.Struct({ + refs: Schema.Array(VcsRef), isRepo: Schema.Boolean, - hasOriginRemote: Schema.Boolean, + hasPrimaryRemote: Schema.Boolean, nextCursor: NonNegativeInt.pipe(Schema.NullOr), totalCount: NonNegativeInt, }); -export type GitListBranchesResult = typeof GitListBranchesResult.Type; +export type VcsListRefsResult = typeof VcsListRefsResult.Type; -export const GitCreateWorktreeResult = Schema.Struct({ - worktree: GitWorktree, +export const VcsCreateWorktreeResult = Schema.Struct({ + worktree: VcsWorktree, }); -export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type; +export type VcsCreateWorktreeResult = typeof VcsCreateWorktreeResult.Type; export const GitResolvePullRequestResult = Schema.Struct({ pullRequest: GitResolvedPullRequest, @@ -279,10 +274,10 @@ export const GitPreparePullRequestThreadResult = Schema.Struct({ }); export type GitPreparePullRequestThreadResult = typeof GitPreparePullRequestThreadResult.Type; -export const GitCheckoutResult = Schema.Struct({ - branch: Schema.NullOr(TrimmedNonEmptyStringSchema), +export const VcsSwitchRefResult = Schema.Struct({ + refName: Schema.NullOr(TrimmedNonEmptyStringSchema), }); -export type GitCheckoutResult = typeof GitCheckoutResult.Type; +export type VcsSwitchRefResult = typeof VcsSwitchRefResult.Type; export const GitRunStackedActionResult = Schema.Struct({ action: GitStackedAction, @@ -313,12 +308,12 @@ export const GitRunStackedActionResult = Schema.Struct({ }); export type GitRunStackedActionResult = typeof GitRunStackedActionResult.Type; -export const GitPullResult = Schema.Struct({ +export const VcsPullResult = Schema.Struct({ status: Schema.Literals(["pulled", "skipped_up_to_date"]), - branch: TrimmedNonEmptyStringSchema, - upstreamBranch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), + refName: TrimmedNonEmptyStringSchema, + upstreamRef: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), }); -export type GitPullResult = typeof GitPullResult.Type; +export type VcsPullResult = typeof VcsPullResult.Type; // RPC / domain errors export class GitCommandError extends Schema.TaggedErrorClass()("GitCommandError", { @@ -370,6 +365,7 @@ export const GitManagerServiceError = Schema.Union([ GitManagerError, GitCommandError, GitHubCliError, + SourceControlProviderError, TextGenerationError, ]); export type GitManagerServiceError = typeof GitManagerServiceError.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index ad905717405..a0ccc624a5e 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -11,6 +11,8 @@ export * from "./keybindings.ts"; export * from "./server.ts"; export * from "./settings.ts"; export * from "./git.ts"; +export * from "./vcs.ts"; +export * from "./sourceControl.ts"; export * from "./orchestration.ts"; export * from "./editor.ts"; export * from "./project.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index a63c6de9626..35539a86e33 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1,22 +1,22 @@ import type { - GitCheckoutInput, - GitCheckoutResult, - GitCreateBranchInput, + VcsSwitchRefInput, + VcsSwitchRefResult, + VcsCreateRefInput, GitPreparePullRequestThreadInput, GitPreparePullRequestThreadResult, GitPullRequestRefInput, - GitCreateWorktreeInput, - GitCreateWorktreeResult, - GitInitInput, - GitListBranchesInput, - GitListBranchesResult, - GitPullInput, - GitPullResult, - GitRemoveWorktreeInput, + VcsCreateWorktreeInput, + VcsCreateWorktreeResult, + VcsInitInput, + VcsListRefsInput, + VcsListRefsResult, + VcsPullInput, + VcsPullResult, + VcsRemoveWorktreeInput, GitResolvePullRequestResult, - GitStatusInput, - GitStatusResult, - GitCreateBranchResult, + VcsStatusInput, + VcsStatusResult, + VcsCreateRefResult, } from "./git.ts"; import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts"; import type { @@ -55,6 +55,7 @@ import type { import type { EnvironmentId } from "./baseSchemas.ts"; import { EditorId } from "./editor.ts"; import { ServerSettings, type ClientSettings, type ServerSettingsPatch } from "./settings.ts"; +import type { SourceControlDiscoveryResult } from "./sourceControl.ts"; export interface ContextMenuItem { id: T; @@ -227,6 +228,7 @@ export interface LocalApi { upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; getSettings: () => Promise; updateSettings: (patch: ServerSettingsPatch) => Promise; + discoverSourceControl: () => Promise; }; } @@ -235,7 +237,7 @@ export interface LocalApi { * * These operations must always be routed with explicit environment context. * They represent remote stateful capabilities such as orchestration, terminal, - * project, and git operations. In multi-environment mode, each environment gets + * project, VCS, and provider operations. In multi-environment mode, each environment gets * its own instance of this surface, and callers should resolve it by * `environmentId` rather than reaching through the local desktop bridge. */ @@ -256,27 +258,29 @@ export interface EnvironmentApi { filesystem: { browse: (input: FilesystemBrowseInput) => Promise; }; - git: { - listBranches: (input: GitListBranchesInput) => Promise; - createWorktree: (input: GitCreateWorktreeInput) => Promise; - removeWorktree: (input: GitRemoveWorktreeInput) => Promise; - createBranch: (input: GitCreateBranchInput) => Promise; - checkout: (input: GitCheckoutInput) => Promise; - init: (input: GitInitInput) => Promise; - resolvePullRequest: (input: GitPullRequestRefInput) => Promise; - preparePullRequestThread: ( - input: GitPreparePullRequestThreadInput, - ) => Promise; - pull: (input: GitPullInput) => Promise; - refreshStatus: (input: GitStatusInput) => Promise; + vcs: { + listRefs: (input: VcsListRefsInput) => Promise; + createWorktree: (input: VcsCreateWorktreeInput) => Promise; + removeWorktree: (input: VcsRemoveWorktreeInput) => Promise; + createRef: (input: VcsCreateRefInput) => Promise; + switchRef: (input: VcsSwitchRefInput) => Promise; + init: (input: VcsInitInput) => Promise; + pull: (input: VcsPullInput) => Promise; + refreshStatus: (input: VcsStatusInput) => Promise; onStatus: ( - input: GitStatusInput, - callback: (status: GitStatusResult) => void, + input: VcsStatusInput, + callback: (status: VcsStatusResult) => void, options?: { onResubscribe?: () => void; }, ) => () => void; }; + git: { + resolvePullRequest: (input: GitPullRequestRefInput) => Promise; + preparePullRequestThread: ( + input: GitPreparePullRequestThreadInput, + ) => Promise; + }; orchestration: { dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>; getTurnDiff: (input: OrchestrationGetTurnDiffInput) => Promise; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index ca68d5cfe4e..f2da90e1907 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -11,28 +11,28 @@ import { } from "./filesystem.ts"; import { GitActionProgressEvent, - GitCheckoutInput, - GitCheckoutResult, + VcsSwitchRefInput, + VcsSwitchRefResult, GitCommandError, - GitCreateBranchInput, - GitCreateBranchResult, - GitCreateWorktreeInput, - GitCreateWorktreeResult, - GitInitInput, - GitListBranchesInput, - GitListBranchesResult, + VcsCreateRefInput, + VcsCreateRefResult, + VcsCreateWorktreeInput, + VcsCreateWorktreeResult, + VcsInitInput, + VcsListRefsInput, + VcsListRefsResult, GitManagerServiceError, GitPreparePullRequestThreadInput, GitPreparePullRequestThreadResult, - GitPullInput, + VcsPullInput, GitPullRequestRefInput, - GitPullResult, - GitRemoveWorktreeInput, + VcsPullResult, + VcsRemoveWorktreeInput, GitResolvePullRequestResult, GitRunStackedActionInput, - GitStatusInput, - GitStatusResult, - GitStatusStreamEvent, + VcsStatusInput, + VcsStatusResult, + VcsStatusStreamEvent, } from "./git.ts"; import { KeybindingsConfigError } from "./keybindings.ts"; import { @@ -77,6 +77,8 @@ import { ServerUpsertKeybindingResult, } from "./server.ts"; import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; +import { SourceControlDiscoveryResult } from "./sourceControl.ts"; +import { VcsError } from "./vcs.ts"; export const WS_METHODS = { // Project registry methods @@ -92,16 +94,18 @@ export const WS_METHODS = { // Filesystem methods filesystemBrowse: "filesystem.browse", - // Git methods - gitPull: "git.pull", - gitRefreshStatus: "git.refreshStatus", + // VCS methods + vcsPull: "vcs.pull", + vcsRefreshStatus: "vcs.refreshStatus", + vcsListRefs: "vcs.listRefs", + vcsCreateWorktree: "vcs.createWorktree", + vcsRemoveWorktree: "vcs.removeWorktree", + vcsCreateRef: "vcs.createRef", + vcsSwitchRef: "vcs.switchRef", + vcsInit: "vcs.init", + + // Git workflow methods gitRunStackedAction: "git.runStackedAction", - gitListBranches: "git.listBranches", - gitCreateWorktree: "git.createWorktree", - gitRemoveWorktree: "git.removeWorktree", - gitCreateBranch: "git.createBranch", - gitCheckout: "git.checkout", - gitInit: "git.init", gitResolvePullRequest: "git.resolvePullRequest", gitPreparePullRequestThread: "git.preparePullRequestThread", @@ -119,9 +123,10 @@ export const WS_METHODS = { serverUpsertKeybinding: "server.upsertKeybinding", serverGetSettings: "server.getSettings", serverUpdateSettings: "server.updateSettings", + serverDiscoverSourceControl: "server.discoverSourceControl", // Streaming subscriptions - subscribeGitStatus: "subscribeGitStatus", + subscribeVcsStatus: "subscribeVcsStatus", subscribeTerminalEvents: "subscribeTerminalEvents", subscribeServerConfig: "subscribeServerConfig", subscribeServerLifecycle: "subscribeServerLifecycle", @@ -165,6 +170,11 @@ export const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSetting error: ServerSettingsError, }); +export const WsServerDiscoverSourceControlRpc = Rpc.make(WS_METHODS.serverDiscoverSourceControl, { + payload: Schema.Struct({}), + success: SourceControlDiscoveryResult, +}); + export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, { payload: ProjectSearchEntriesInput, success: ProjectSearchEntriesResult, @@ -188,22 +198,22 @@ export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { error: FilesystemBrowseError, }); -export const WsSubscribeGitStatusRpc = Rpc.make(WS_METHODS.subscribeGitStatus, { - payload: GitStatusInput, - success: GitStatusStreamEvent, +export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { + payload: VcsStatusInput, + success: VcsStatusStreamEvent, error: GitManagerServiceError, stream: true, }); -export const WsGitPullRpc = Rpc.make(WS_METHODS.gitPull, { - payload: GitPullInput, - success: GitPullResult, +export const WsVcsPullRpc = Rpc.make(WS_METHODS.vcsPull, { + payload: VcsPullInput, + success: VcsPullResult, error: GitCommandError, }); -export const WsGitRefreshStatusRpc = Rpc.make(WS_METHODS.gitRefreshStatus, { - payload: GitStatusInput, - success: GitStatusResult, +export const WsVcsRefreshStatusRpc = Rpc.make(WS_METHODS.vcsRefreshStatus, { + payload: VcsStatusInput, + success: VcsStatusResult, error: GitManagerServiceError, }); @@ -226,38 +236,38 @@ export const WsGitPreparePullRequestThreadRpc = Rpc.make(WS_METHODS.gitPreparePu error: GitManagerServiceError, }); -export const WsGitListBranchesRpc = Rpc.make(WS_METHODS.gitListBranches, { - payload: GitListBranchesInput, - success: GitListBranchesResult, +export const WsVcsListRefsRpc = Rpc.make(WS_METHODS.vcsListRefs, { + payload: VcsListRefsInput, + success: VcsListRefsResult, error: GitCommandError, }); -export const WsGitCreateWorktreeRpc = Rpc.make(WS_METHODS.gitCreateWorktree, { - payload: GitCreateWorktreeInput, - success: GitCreateWorktreeResult, +export const WsVcsCreateWorktreeRpc = Rpc.make(WS_METHODS.vcsCreateWorktree, { + payload: VcsCreateWorktreeInput, + success: VcsCreateWorktreeResult, error: GitCommandError, }); -export const WsGitRemoveWorktreeRpc = Rpc.make(WS_METHODS.gitRemoveWorktree, { - payload: GitRemoveWorktreeInput, +export const WsVcsRemoveWorktreeRpc = Rpc.make(WS_METHODS.vcsRemoveWorktree, { + payload: VcsRemoveWorktreeInput, error: GitCommandError, }); -export const WsGitCreateBranchRpc = Rpc.make(WS_METHODS.gitCreateBranch, { - payload: GitCreateBranchInput, - success: GitCreateBranchResult, +export const WsVcsCreateRefRpc = Rpc.make(WS_METHODS.vcsCreateRef, { + payload: VcsCreateRefInput, + success: VcsCreateRefResult, error: GitCommandError, }); -export const WsGitCheckoutRpc = Rpc.make(WS_METHODS.gitCheckout, { - payload: GitCheckoutInput, - success: GitCheckoutResult, +export const WsVcsSwitchRefRpc = Rpc.make(WS_METHODS.vcsSwitchRef, { + payload: VcsSwitchRefInput, + success: VcsSwitchRefResult, error: GitCommandError, }); -export const WsGitInitRpc = Rpc.make(WS_METHODS.gitInit, { - payload: GitInitInput, - error: GitCommandError, +export const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, { + payload: VcsInitInput, + error: VcsError, }); export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, { @@ -370,22 +380,23 @@ export const WsRpcGroup = RpcGroup.make( WsServerUpsertKeybindingRpc, WsServerGetSettingsRpc, WsServerUpdateSettingsRpc, + WsServerDiscoverSourceControlRpc, WsProjectsSearchEntriesRpc, WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, - WsSubscribeGitStatusRpc, - WsGitPullRpc, - WsGitRefreshStatusRpc, + WsSubscribeVcsStatusRpc, + WsVcsPullRpc, + WsVcsRefreshStatusRpc, WsGitRunStackedActionRpc, WsGitResolvePullRequestRpc, WsGitPreparePullRequestThreadRpc, - WsGitListBranchesRpc, - WsGitCreateWorktreeRpc, - WsGitRemoveWorktreeRpc, - WsGitCreateBranchRpc, - WsGitCheckoutRpc, - WsGitInitRpc, + WsVcsListRefsRpc, + WsVcsCreateWorktreeRpc, + WsVcsRemoveWorktreeRpc, + WsVcsCreateRefRpc, + WsVcsSwitchRefRpc, + WsVcsInitRpc, WsTerminalOpenRpc, WsTerminalWriteRpc, WsTerminalResizeRpc, diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts new file mode 100644 index 00000000000..5776e84ff7c --- /dev/null +++ b/packages/contracts/src/sourceControl.ts @@ -0,0 +1,111 @@ +import { Schema } from "effect"; +import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { VcsDriverKind } from "./vcs.ts"; + +export const SourceControlProviderKind = Schema.Literals([ + "github", + "gitlab", + "azure-devops", + "bitbucket", + "unknown", +]); +export type SourceControlProviderKind = typeof SourceControlProviderKind.Type; + +export const SourceControlProviderInfo = Schema.Struct({ + kind: SourceControlProviderKind, + name: TrimmedNonEmptyString, + baseUrl: Schema.String, +}); +export type SourceControlProviderInfo = typeof SourceControlProviderInfo.Type; + +export const ChangeRequestState = Schema.Literals(["open", "closed", "merged"]); +export type ChangeRequestState = typeof ChangeRequestState.Type; + +export const ChangeRequest = Schema.Struct({ + provider: SourceControlProviderKind, + number: PositiveInt, + title: TrimmedNonEmptyString, + url: Schema.String, + baseRefName: TrimmedNonEmptyString, + headRefName: TrimmedNonEmptyString, + state: ChangeRequestState, + updatedAt: Schema.Option(Schema.DateTimeUtc), + isCrossRepository: Schema.optional(Schema.Boolean), + headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + headRepositoryOwnerLogin: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), +}); +export type ChangeRequest = typeof ChangeRequest.Type; + +export const SourceControlRepositoryCloneUrls = Schema.Struct({ + nameWithOwner: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + sshUrl: TrimmedNonEmptyString, +}); +export type SourceControlRepositoryCloneUrls = typeof SourceControlRepositoryCloneUrls.Type; + +export const SourceControlDiscoveryStatus = Schema.Literals(["available", "missing"]); +export type SourceControlDiscoveryStatus = typeof SourceControlDiscoveryStatus.Type; + +export const SourceControlProviderAuthStatus = Schema.Literals([ + "authenticated", + "unauthenticated", + "unknown", +]); +export type SourceControlProviderAuthStatus = typeof SourceControlProviderAuthStatus.Type; + +export const SourceControlProviderAuth = Schema.Struct({ + status: SourceControlProviderAuthStatus, + account: Schema.Option(TrimmedNonEmptyString), + host: Schema.Option(TrimmedNonEmptyString), + detail: Schema.Option(TrimmedNonEmptyString), +}); +export type SourceControlProviderAuth = typeof SourceControlProviderAuth.Type; + +const SourceControlDiscoveryItemFields = { + label: TrimmedNonEmptyString, + executable: TrimmedNonEmptyString, + implemented: Schema.Boolean, + status: SourceControlDiscoveryStatus, + version: Schema.Option(TrimmedNonEmptyString), + installHint: TrimmedNonEmptyString, + detail: Schema.Option(TrimmedNonEmptyString), +} as const; + +export const SourceControlDiscoveryItem = Schema.Struct({ + kind: Schema.String, + ...SourceControlDiscoveryItemFields, +}); +export type SourceControlDiscoveryItem = typeof SourceControlDiscoveryItem.Type; + +export const VcsDiscoveryItem = Schema.Struct({ + kind: VcsDriverKind, + ...SourceControlDiscoveryItemFields, +}); +export type VcsDiscoveryItem = typeof VcsDiscoveryItem.Type; + +export const SourceControlProviderDiscoveryItem = Schema.Struct({ + kind: SourceControlProviderKind, + ...SourceControlDiscoveryItemFields, + auth: SourceControlProviderAuth, +}); +export type SourceControlProviderDiscoveryItem = typeof SourceControlProviderDiscoveryItem.Type; + +export const SourceControlDiscoveryResult = Schema.Struct({ + versionControlSystems: Schema.Array(VcsDiscoveryItem), + sourceControlProviders: Schema.Array(SourceControlProviderDiscoveryItem), +}); +export type SourceControlDiscoveryResult = typeof SourceControlDiscoveryResult.Type; + +export class SourceControlProviderError extends Schema.TaggedErrorClass()( + "SourceControlProviderError", + { + provider: SourceControlProviderKind, + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Source control provider ${this.provider} failed in ${this.operation}: ${this.detail}`; + } +} diff --git a/packages/contracts/src/vcs.ts b/packages/contracts/src/vcs.ts new file mode 100644 index 00000000000..2dd09cf04c6 --- /dev/null +++ b/packages/contracts/src/vcs.ts @@ -0,0 +1,154 @@ +import { Schema } from "effect"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; + +export const VcsDriverKind = Schema.Literals(["git", "jj", "unknown"]); +export type VcsDriverKind = typeof VcsDriverKind.Type; + +export const VcsFreshnessSource = Schema.Literals([ + "live-local", + "cached-local", + "cached-remote", + "explicit-remote", +]); +export type VcsFreshnessSource = typeof VcsFreshnessSource.Type; + +export const VcsFreshness = Schema.Struct({ + source: VcsFreshnessSource, + observedAt: Schema.DateTimeUtc, + expiresAt: Schema.Option(Schema.DateTimeUtc), +}); +export type VcsFreshness = typeof VcsFreshness.Type; + +export const VcsDriverCapabilities = Schema.Struct({ + kind: VcsDriverKind, + supportsWorktrees: Schema.Boolean, + supportsBookmarks: Schema.Boolean, + supportsAtomicSnapshot: Schema.Boolean, + supportsPushDefaultRemote: Schema.Boolean, + ignoreClassifier: Schema.Literals(["native", "git-compatible-fallback"]), +}); +export type VcsDriverCapabilities = typeof VcsDriverCapabilities.Type; + +export const VcsRepositoryIdentity = Schema.Struct({ + kind: VcsDriverKind, + rootPath: TrimmedNonEmptyString, + metadataPath: Schema.NullOr(TrimmedNonEmptyString), + freshness: VcsFreshness, +}); +export type VcsRepositoryIdentity = typeof VcsRepositoryIdentity.Type; + +export const VcsListWorkspaceFilesResult = Schema.Struct({ + paths: Schema.Array(TrimmedNonEmptyString), + truncated: Schema.Boolean, + freshness: VcsFreshness, +}); +export type VcsListWorkspaceFilesResult = typeof VcsListWorkspaceFilesResult.Type; + +export const VcsRemote = Schema.Struct({ + name: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + pushUrl: Schema.Option(TrimmedNonEmptyString), + isPrimary: Schema.Boolean, +}); +export type VcsRemote = typeof VcsRemote.Type; + +export const VcsListRemotesResult = Schema.Struct({ + remotes: Schema.Array(VcsRemote), + freshness: VcsFreshness, +}); +export type VcsListRemotesResult = typeof VcsListRemotesResult.Type; + +export class VcsProcessSpawnError extends Schema.TaggedErrorClass()( + "VcsProcessSpawnError", + { + operation: Schema.String, + command: Schema.String, + cwd: Schema.String, + cause: Schema.Defect, + }, +) { + override get message(): string { + return `VCS process failed to spawn in ${this.operation}: ${this.command} (${this.cwd})`; + } +} + +export class VcsProcessExitError extends Schema.TaggedErrorClass()( + "VcsProcessExitError", + { + operation: Schema.String, + command: Schema.String, + cwd: Schema.String, + exitCode: Schema.Number, + detail: Schema.String, + }, +) { + override get message(): string { + return `VCS process failed in ${this.operation}: ${this.command} (${this.cwd}) exited with ${this.exitCode} - ${this.detail}`; + } +} + +export class VcsProcessTimeoutError extends Schema.TaggedErrorClass()( + "VcsProcessTimeoutError", + { + operation: Schema.String, + command: Schema.String, + cwd: Schema.String, + timeoutMs: Schema.Number, + }, +) { + override get message(): string { + return `VCS process timed out in ${this.operation}: ${this.command} (${this.cwd}) after ${this.timeoutMs}ms`; + } +} + +export class VcsOutputDecodeError extends Schema.TaggedErrorClass()( + "VcsOutputDecodeError", + { + operation: Schema.String, + command: Schema.String, + cwd: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `VCS output decode failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; + } +} + +export class VcsRepositoryDetectionError extends Schema.TaggedErrorClass()( + "VcsRepositoryDetectionError", + { + operation: Schema.String, + cwd: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `VCS repository detection failed in ${this.operation}: ${this.cwd} - ${this.detail}`; + } +} + +export class VcsUnsupportedOperationError extends Schema.TaggedErrorClass()( + "VcsUnsupportedOperationError", + { + operation: Schema.String, + kind: VcsDriverKind, + detail: Schema.String, + }, +) { + override get message(): string { + return `VCS operation is unsupported for ${this.kind} in ${this.operation}: ${this.detail}`; + } +} + +export const VcsError = Schema.Union([ + VcsProcessSpawnError, + VcsProcessExitError, + VcsProcessTimeoutError, + VcsOutputDecodeError, + VcsRepositoryDetectionError, + VcsUnsupportedOperationError, +]); +export type VcsError = typeof VcsError.Type; diff --git a/packages/shared/package.json b/packages/shared/package.json index 82085dfcaf3..da0f24b77aa 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -12,6 +12,10 @@ "types": "./src/git.ts", "import": "./src/git.ts" }, + "./sourceControl": { + "types": "./src/sourceControl.ts", + "import": "./src/sourceControl.ts" + }, "./logging": { "types": "./src/logging.ts", "import": "./src/logging.ts" diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 2160c460dc5..5eb9fd51244 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -1,4 +1,4 @@ -import type { GitStatusRemoteResult, GitStatusResult } from "@t3tools/contracts"; +import type { VcsStatusRemoteResult, VcsStatusResult } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; import { @@ -54,17 +54,17 @@ describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => { }); describe("isTemporaryWorktreeBranch", () => { - it("matches the generated temporary worktree branch format", () => { + it("matches the generated temporary worktree refName format", () => { expect(isTemporaryWorktreeBranch(buildTemporaryWorktreeBranchName())).toBe(true); }); - it("matches generated temporary worktree branches", () => { + it("matches generated temporary worktree refs", () => { expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/deadbeef`)).toBe(true); expect(isTemporaryWorktreeBranch(` ${WORKTREE_BRANCH_PREFIX}/deadbeef `)).toBe(true); expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/DEADBEEF`)).toBe(true); }); - it("rejects non-temporary branch names", () => { + it("rejects non-temporary refName names", () => { expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/feature/demo`)).toBe(false); expect(isTemporaryWorktreeBranch("main")).toBe(false); expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/deadbeef-extra`)).toBe(false); @@ -73,7 +73,7 @@ describe("isTemporaryWorktreeBranch", () => { describe("applyGitStatusStreamEvent", () => { it("treats a remote-only update as a repository when local state is missing", () => { - const remote: GitStatusRemoteResult = { + const remote: VcsStatusRemoteResult = { hasUpstream: true, aheadCount: 2, behindCount: 1, @@ -82,9 +82,9 @@ describe("applyGitStatusStreamEvent", () => { expect(applyGitStatusStreamEvent(null, { _tag: "remoteUpdated", remote })).toEqual({ isRepo: true, - hasOriginRemote: false, - isDefaultBranch: false, - branch: null, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: true, @@ -95,16 +95,16 @@ describe("applyGitStatusStreamEvent", () => { }); it("preserves local-only fields when applying a remote update", () => { - const current: GitStatusResult = { + const current: VcsStatusResult = { isRepo: true, - hostingProvider: { + sourceControlProvider: { kind: "github", name: "GitHub", baseUrl: "https://github.com", }, - hasOriginRemote: true, - isDefaultBranch: false, - branch: "feature/demo", + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/demo", hasWorkingTreeChanges: true, workingTree: { files: [{ path: "src/demo.ts", insertions: 1, deletions: 0 }], @@ -117,7 +117,7 @@ describe("applyGitStatusStreamEvent", () => { pr: null, }; - const remote: GitStatusRemoteResult = { + const remote: VcsStatusRemoteResult = { hasUpstream: true, aheadCount: 2, behindCount: 1, diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 4570ba9ecb0..02d9c61d6aa 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -1,19 +1,20 @@ import type { - GitBranch, - GitHostingProvider, - GitStatusLocalResult, - GitStatusRemoteResult, - GitStatusResult, - GitStatusStreamEvent, + VcsRef, + SourceControlProviderInfo, + VcsStatusLocalResult, + VcsStatusRemoteResult, + VcsStatusResult, + VcsStatusStreamEvent, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Random from "effect/Random"; +import { detectSourceControlProviderFromRemoteUrl } from "./sourceControl.ts"; export const WORKTREE_BRANCH_PREFIX = "t3code"; const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); /** - * Sanitize an arbitrary string into a valid, lowercase git branch fragment. + * Sanitize an arbitrary string into a valid, lowercase git refName fragment. * Strips quotes, collapses separators, limits to 64 chars. */ export function sanitizeBranchFragment(raw: string): string { @@ -35,7 +36,7 @@ export function sanitizeBranchFragment(raw: string): string { } /** - * Sanitize a string into a `feature/…` branch name. + * Sanitize a string into a `feature/…` refName name. * Preserves an existing `feature/` prefix or slash-separated namespace. */ export function sanitizeFeatureBranchName(raw: string): string { @@ -49,8 +50,8 @@ export function sanitizeFeatureBranchName(raw: string): string { const AUTO_FEATURE_BRANCH_FALLBACK = "feature/update"; /** - * Resolve a unique `feature/…` branch name that doesn't collide with - * any existing branch. Appends a numeric suffix when needed. + * Resolve a unique `feature/…` refName name that doesn't collide with + * any existing refName. Appends a numeric suffix when needed. */ export function resolveAutoFeatureBranchName( existingBranchNames: readonly string[], @@ -60,7 +61,7 @@ export function resolveAutoFeatureBranchName( const resolvedBase = sanitizeFeatureBranchName( preferred && preferred.length > 0 ? preferred : AUTO_FEATURE_BRANCH_FALLBACK, ); - const existingNames = new Set(existingBranchNames.map((branch) => branch.toLowerCase())); + const existingNames = new Set(existingBranchNames.map((refName) => refName.toLowerCase())); if (!existingNames.has(resolvedBase)) { return resolvedBase; @@ -90,8 +91,8 @@ export function buildTemporaryWorktreeBranchName(): string { return `${WORKTREE_BRANCH_PREFIX}/${token}`; } -export function isTemporaryWorktreeBranch(branch: string): boolean { - return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase()); +export function isTemporaryWorktreeBranch(refName: string): boolean { + return TEMP_WORKTREE_BRANCH_PATTERN.test(refName.trim().toLowerCase()); } /** @@ -165,98 +166,39 @@ function deriveLocalBranchNameCandidatesFromRemoteRef( } /** - * Hide `origin/*` remote refs when a matching local branch already exists. + * Hide `origin/*` remote refs when a matching local refName already exists. */ export function dedupeRemoteBranchesWithLocalMatches( - branches: ReadonlyArray, -): ReadonlyArray { + refs: ReadonlyArray, +): ReadonlyArray { const localBranchNames = new Set( - branches.filter((branch) => !branch.isRemote).map((branch) => branch.name), + refs.filter((refName) => !refName.isRemote).map((refName) => refName.name), ); - return branches.filter((branch) => { - if (!branch.isRemote) { + return refs.filter((refName) => { + if (!refName.isRemote) { return true; } - if (branch.remoteName !== "origin") { + if (refName.remoteName !== "origin") { return true; } const localBranchCandidates = deriveLocalBranchNameCandidatesFromRemoteRef( - branch.name, - branch.remoteName, + refName.name, + refName.remoteName, ); return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate)); }); } -function parseGitRemoteHost(remoteUrl: string): string | null { - const trimmed = remoteUrl.trim(); - if (trimmed.length === 0) { - return null; - } - - if (trimmed.startsWith("git@")) { - const hostWithPath = trimmed.slice("git@".length); - const separatorIndex = hostWithPath.search(/[:/]/); - if (separatorIndex <= 0) { - return null; - } - return hostWithPath.slice(0, separatorIndex).toLowerCase(); - } - - try { - return new URL(trimmed).hostname.toLowerCase(); - } catch { - return null; - } -} - -function toBaseUrl(host: string): string { - return `https://${host}`; -} - -function isGitHubHost(host: string): boolean { - return host === "github.com" || host.includes("github"); -} - -function isGitLabHost(host: string): boolean { - return host === "gitlab.com" || host.includes("gitlab"); -} - -export function detectGitHostingProviderFromRemoteUrl( +export function detectSourceControlProviderFromGitRemoteUrl( remoteUrl: string, -): GitHostingProvider | null { - const host = parseGitRemoteHost(remoteUrl); - if (!host) { - return null; - } - - if (isGitHubHost(host)) { - return { - kind: "github", - name: host === "github.com" ? "GitHub" : "GitHub Self-Hosted", - baseUrl: toBaseUrl(host), - }; - } - - if (isGitLabHost(host)) { - return { - kind: "gitlab", - name: host === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted", - baseUrl: toBaseUrl(host), - }; - } - - return { - kind: "unknown", - name: host, - baseUrl: toBaseUrl(host), - }; +): SourceControlProviderInfo | null { + return detectSourceControlProviderFromRemoteUrl(remoteUrl); } -const EMPTY_GIT_STATUS_REMOTE: GitStatusRemoteResult = { +const EMPTY_GIT_STATUS_REMOTE: VcsStatusRemoteResult = { hasUpstream: false, aheadCount: 0, behindCount: 0, @@ -264,16 +206,16 @@ const EMPTY_GIT_STATUS_REMOTE: GitStatusRemoteResult = { }; export function mergeGitStatusParts( - local: GitStatusLocalResult, - remote: GitStatusRemoteResult | null, -): GitStatusResult { + local: VcsStatusLocalResult, + remote: VcsStatusRemoteResult | null, +): VcsStatusResult { return { ...local, ...(remote ?? EMPTY_GIT_STATUS_REMOTE), }; } -function toRemoteStatusPart(status: GitStatusResult): GitStatusRemoteResult { +function toRemoteStatusPart(status: VcsStatusResult): VcsStatusRemoteResult { return { hasUpstream: status.hasUpstream, aheadCount: status.aheadCount, @@ -282,22 +224,24 @@ function toRemoteStatusPart(status: GitStatusResult): GitStatusRemoteResult { }; } -function toLocalStatusPart(status: GitStatusResult): GitStatusLocalResult { +function toLocalStatusPart(status: VcsStatusResult): VcsStatusLocalResult { return { isRepo: status.isRepo, - ...(status.hostingProvider ? { hostingProvider: status.hostingProvider } : {}), - hasOriginRemote: status.hasOriginRemote, - isDefaultBranch: status.isDefaultBranch, - branch: status.branch, + ...(status.sourceControlProvider + ? { sourceControlProvider: status.sourceControlProvider } + : {}), + hasPrimaryRemote: status.hasPrimaryRemote, + isDefaultRef: status.isDefaultRef, + refName: status.refName, hasWorkingTreeChanges: status.hasWorkingTreeChanges, workingTree: status.workingTree, }; } export function applyGitStatusStreamEvent( - current: GitStatusResult | null, - event: GitStatusStreamEvent, -): GitStatusResult { + current: VcsStatusResult | null, + event: VcsStatusStreamEvent, +): VcsStatusResult { switch (event._tag) { case "snapshot": return mergeGitStatusParts(event.local, event.remote); @@ -308,9 +252,9 @@ export function applyGitStatusStreamEvent( return mergeGitStatusParts( { isRepo: true, - hasOriginRemote: false, - isDefaultBranch: false, - branch: null, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, }, diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts new file mode 100644 index 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 new file mode 100644 index 00000000000..59257a3de7f --- /dev/null +++ b/packages/shared/src/sourceControl.ts @@ -0,0 +1,225 @@ +import type { SourceControlProviderInfo, SourceControlProviderKind } from "@t3tools/contracts"; + +export interface ChangeRequestPresentation { + readonly icon: "github" | "gitlab" | "azure-devops" | "bitbucket" | "change-request"; + readonly providerName: string; + readonly shortName: string; + readonly longName: string; + readonly pluralLongName: string; + readonly providerLongName: string; + readonly checkoutCommandExample: string; + readonly urlExample: string; +} + +export interface ChangeRequestTerminology { + readonly shortLabel: string; + readonly singular: string; +} + +export const DEFAULT_CHANGE_REQUEST_TERMINOLOGY: ChangeRequestTerminology = { + shortLabel: "PR", + singular: "pull request", +}; + +const GITHUB_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { + icon: "github", + providerName: "GitHub", + shortName: "PR", + longName: "pull request", + pluralLongName: "pull requests", + providerLongName: "GitHub pull request", + checkoutCommandExample: "gh pr checkout 123", + urlExample: "https://github.com/owner/repo/pull/42", +}; + +const GITLAB_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { + icon: "gitlab", + providerName: "GitLab", + shortName: "MR", + longName: "merge request", + pluralLongName: "merge requests", + providerLongName: "GitLab merge request", + checkoutCommandExample: "glab mr checkout 123", + urlExample: "https://gitlab.com/group/project/-/merge_requests/42", +}; + +const AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { + icon: "azure-devops", + providerName: "Azure DevOps", + shortName: "PR", + longName: "pull request", + pluralLongName: "pull requests", + providerLongName: "Azure DevOps pull request", + checkoutCommandExample: "az repos pr checkout --id 123", + urlExample: "https://dev.azure.com/org/project/_git/repo/pullrequest/42", +}; + +const BITBUCKET_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { + icon: "bitbucket", + providerName: "Bitbucket", + shortName: "PR", + longName: "pull request", + pluralLongName: "pull requests", + providerLongName: "Bitbucket pull request", + checkoutCommandExample: "bb pr checkout 123", + urlExample: "https://bitbucket.org/workspace/repo/pull-requests/42", +}; + +const GENERIC_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { + icon: "change-request", + providerName: "source control", + shortName: "change request", + longName: "change request", + pluralLongName: "change requests", + providerLongName: "change request", + checkoutCommandExample: "123", + urlExample: "#42", +}; + +export function resolveChangeRequestPresentation( + provider: SourceControlProviderInfo | null | undefined, +): ChangeRequestPresentation { + switch (provider?.kind) { + case "github": + case undefined: + return GITHUB_CHANGE_REQUEST_PRESENTATION; + case "gitlab": + return GITLAB_CHANGE_REQUEST_PRESENTATION; + case "azure-devops": + return AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION; + case "bitbucket": + return BITBUCKET_CHANGE_REQUEST_PRESENTATION; + case "unknown": + return GENERIC_CHANGE_REQUEST_PRESENTATION; + } +} + +export function resolveChangeRequestPresentationForKind( + kind: SourceControlProviderKind, +): ChangeRequestPresentation { + return resolveChangeRequestPresentation({ kind, name: "", baseUrl: "" }); +} + +export function formatChangeRequestAction( + verb: "View" | "Create", + presentation: ChangeRequestPresentation, +): string { + return `${verb} ${presentation.shortName}`; +} + +export function formatCreateChangeRequestPhrase(presentation: ChangeRequestPresentation): string { + return `create ${presentation.shortName}`; +} + +export function getChangeRequestTerminology( + provider: SourceControlProviderInfo | null | undefined, +): ChangeRequestTerminology { + if (!provider) { + return DEFAULT_CHANGE_REQUEST_TERMINOLOGY; + } + + const presentation = resolveChangeRequestPresentation(provider); + return { + shortLabel: presentation.shortName, + singular: presentation.longName, + }; +} + +export function getChangeRequestTerminologyForKind( + kind: SourceControlProviderKind, +): ChangeRequestTerminology { + const presentation = resolveChangeRequestPresentationForKind(kind); + return { + shortLabel: presentation.shortName, + singular: presentation.longName, + }; +} + +function parseRemoteHost(remoteUrl: string): string | null { + const trimmed = remoteUrl.trim(); + if (trimmed.length === 0) { + return null; + } + + if (trimmed.startsWith("git@")) { + const hostWithPath = trimmed.slice("git@".length); + const separatorIndex = hostWithPath.search(/[:/]/); + if (separatorIndex <= 0) { + return null; + } + return hostWithPath.slice(0, separatorIndex).toLowerCase(); + } + + try { + return new URL(trimmed).hostname.toLowerCase(); + } catch { + return null; + } +} + +function toBaseUrl(host: string): string { + return `https://${host}`; +} + +function isGitHubHost(host: string): boolean { + return host === "github.com" || host.includes("github"); +} + +function isGitLabHost(host: string): boolean { + return host === "gitlab.com" || host.includes("gitlab"); +} + +function isAzureDevOpsHost(host: string): boolean { + return host === "dev.azure.com" || host.endsWith(".visualstudio.com"); +} + +function isBitbucketHost(host: string): boolean { + return host === "bitbucket.org" || host.includes("bitbucket"); +} + +export function detectSourceControlProviderFromRemoteUrl( + remoteUrl: string, +): SourceControlProviderInfo | null { + const host = parseRemoteHost(remoteUrl); + if (!host) { + return null; + } + + if (isGitHubHost(host)) { + return { + kind: "github", + name: host === "github.com" ? "GitHub" : "GitHub Self-Hosted", + baseUrl: toBaseUrl(host), + }; + } + + if (isGitLabHost(host)) { + return { + kind: "gitlab", + name: host === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted", + baseUrl: toBaseUrl(host), + }; + } + + if (isAzureDevOpsHost(host)) { + return { + kind: "azure-devops", + name: "Azure DevOps", + baseUrl: toBaseUrl(host), + }; + } + + if (isBitbucketHost(host)) { + return { + kind: "bitbucket", + name: host === "bitbucket.org" ? "Bitbucket" : "Bitbucket Self-Hosted", + baseUrl: toBaseUrl(host), + }; + } + + return { + kind: "unknown", + name: host, + baseUrl: toBaseUrl(host), + }; +}