| author | |
| committer | |
| log | 6d7fe2eeb7d2731da3f7f69988056ff7a5d3b921 |
| tree | f050950a236409464878134c05aa760ee05ca44d |
| parent | 460d9c3eb977bc0341876527af7fde591133925f |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius@macmini.local>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com>148 files changed, 19497 insertions(+), 16089 deletions(-)
.plans/19-version-control-phase-1-vcs-driver-foundation.md created+216| ... | @@ -0,0 +1,216 @@ | ||
| 1 | # Version Control Phase 1: VCS Driver Foundation | ||
| 2 | |||
| 3 | ## Goal | ||
| 4 | |||
| 5 | 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. | ||
| 6 | |||
| 7 | 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. | ||
| 8 | |||
| 9 | ## Scope | ||
| 10 | |||
| 11 | - Add VCS-domain contracts in `packages/contracts/src/vcs.ts`. | ||
| 12 | - Add shared runtime parsing helpers in `packages/shared/src/vcs/*` only when they are useful to both server and web. | ||
| 13 | - Add server services under `apps/server/src/vcs`: | ||
| 14 | - `Services/VcsDriver.ts` | ||
| 15 | - `Services/VcsRepositoryResolver.ts` | ||
| 16 | - `Services/VcsProcess.ts` | ||
| 17 | - `Layers/GitVcsDriver.ts` | ||
| 18 | - `errors.ts` | ||
| 19 | - Migrate server callers from Git-specific terms where the operation is actually VCS-generic. | ||
| 20 | - Update active consumers to the new VCS APIs in the same phase; do not add backwards-compatible export shims. | ||
| 21 | - Leave source-control hosting providers out of this phase except for remote metadata needed to describe repository status. | ||
| 22 | |||
| 23 | ## Non-Goals | ||
| 24 | |||
| 25 | - No GitLab, Azure DevOps, or GitHub provider rewrite yet. | ||
| 26 | - No Jujutsu driver yet, but every interface must be designed so a Jujutsu driver does not have to pretend to be Git. | ||
| 27 | - No T3 Review implementation yet. | ||
| 28 | - No broad UI redesign. | ||
| 29 | |||
| 30 | ## Driver Model | ||
| 31 | |||
| 32 | Use provider-neutral nouns in new APIs: | ||
| 33 | |||
| 34 | - `VcsDriver`: local repository mechanics. | ||
| 35 | - `RepositoryIdentity`: detected VCS kind, root path, common metadata path when available, remotes. | ||
| 36 | - `WorkingCopyStatus`: dirty state, changed files, aggregate insertions/deletions, current branch/bookmark/change name. | ||
| 37 | - `ChangeSet`: a committed or pending unit of change, not necessarily a Git commit. | ||
| 38 | - `RefName`: branch, bookmark, tag, or provider-specific ref. | ||
| 39 | |||
| 40 | The initial driver capabilities should be explicit: | ||
| 41 | |||
| 42 | ```ts | ||
| 43 | export interface VcsDriverCapabilities { | ||
| 44 | readonly kind: "git" | "jj" | "sapling" | "unknown"; | ||
| 45 | readonly supportsWorktrees: boolean; | ||
| 46 | readonly supportsBookmarks: boolean; | ||
| 47 | readonly supportsAtomicSnapshot: boolean; | ||
| 48 | readonly supportsPushDefaultRemote: boolean; | ||
| 49 | } | ||
| 50 | ``` | ||
| 51 | |||
| 52 | 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: | ||
| 53 | |||
| 54 | - `detectRepository(cwd)` | ||
| 55 | - `status(cwd, options)` | ||
| 56 | - `listRefs(cwd, query/pagination)` | ||
| 57 | - `checkoutRef(cwd, ref)` | ||
| 58 | - `createRef(cwd, ref, from?)` | ||
| 59 | - `createWorkspace(cwd, ref, path?)` | ||
| 60 | - `removeWorkspace(path)` | ||
| 61 | - `prepareChangeContext(cwd, filePaths?)` | ||
| 62 | - `createChange(cwd, message, options)` | ||
| 63 | - `push(cwd, target?)` | ||
| 64 | - `rangeContext(cwd, base, head)` | ||
| 65 | - `listWorkspaceFiles(cwd, options)` | ||
| 66 | |||
| 67 | ## Effect Process Layer | ||
| 68 | |||
| 69 | Create a small reusable `VcsProcess` service instead of using `runProcess`. | ||
| 70 | |||
| 71 | Requirements: | ||
| 72 | |||
| 73 | - Implement with `ChildProcess` and `ChildProcessSpawner` from `effect/unstable/process`. | ||
| 74 | - Support scoped acquisition/release for long-running commands and interruption. | ||
| 75 | - Support bounded stdout/stderr collection with truncation markers. | ||
| 76 | - DO not eagerly consume full stdout/stderr, return stream apis and expose helpers for consumers so we don't consume streams to memory unnecessarily... | ||
| 77 | - Support stdin. | ||
| 78 | - Support timeout through Effect scheduling/interruption, not ad-hoc timers. | ||
| 79 | - Stream output lines to progress callbacks as Effects. | ||
| 80 | - Return a typed `ProcessOutput` value for successful execution. | ||
| 81 | - Fail with typed errors, not generic thrown exceptions. | ||
| 82 | |||
| 83 | Errors should be schema-backed tagged classes, for example: | ||
| 84 | |||
| 85 | - `VcsProcessSpawnError` | ||
| 86 | - `VcsProcessExitError` | ||
| 87 | - `VcsProcessTimeoutError` | ||
| 88 | - `VcsOutputDecodeError` | ||
| 89 | - `VcsRepositoryDetectionError` | ||
| 90 | - `VcsUnsupportedOperationError` | ||
| 91 | |||
| 92 | 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. | ||
| 93 | |||
| 94 | ## Git Driver Rewrite | ||
| 95 | |||
| 96 | Rewrite Git support against `VcsProcess`. | ||
| 97 | |||
| 98 | Carry forward current behavior from: | ||
| 99 | |||
| 100 | - `apps/server/src/git/Layers/GitCore.ts` | ||
| 101 | - `apps/server/src/git/Layers/GitCore.test.ts` | ||
| 102 | - current Git status/branch/worktree contracts | ||
| 103 | |||
| 104 | But split the implementation into smaller modules: | ||
| 105 | |||
| 106 | - command execution and hardening config | ||
| 107 | - repository detection | ||
| 108 | - status parsing | ||
| 109 | - branch/ref parsing | ||
| 110 | - worktree operations | ||
| 111 | - commit/range context generation | ||
| 112 | - push/pull operations | ||
| 113 | |||
| 114 | 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. | ||
| 115 | |||
| 116 | ## Freshness and Local Caching | ||
| 117 | |||
| 118 | 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. | ||
| 119 | |||
| 120 | Treat these as live/local: | ||
| 121 | |||
| 122 | - repository detection for the active cwd | ||
| 123 | - working copy dirty state | ||
| 124 | - staged/unstaged/untracked file summaries | ||
| 125 | - current branch/bookmark/change name | ||
| 126 | - local branch/bookmark lists | ||
| 127 | - local worktree/workspace lists | ||
| 128 | |||
| 129 | 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. | ||
| 130 | |||
| 131 | Treat these as cached or explicit-refresh only: | ||
| 132 | |||
| 133 | - remote tracking branch refreshes | ||
| 134 | - ahead/behind counts that require network fetches | ||
| 135 | - default branch discovery from a remote provider | ||
| 136 | - remote branch lists beyond locally known refs | ||
| 137 | |||
| 138 | The VCS driver should expose freshness metadata with status results: | ||
| 139 | |||
| 140 | ```ts | ||
| 141 | export interface VcsFreshness { | ||
| 142 | readonly source: "live-local" | "cached-local" | "cached-remote" | "explicit-remote"; | ||
| 143 | readonly observedAt: string; | ||
| 144 | readonly expiresAt?: string; | ||
| 145 | } | ||
| 146 | ``` | ||
| 147 | |||
| 148 | 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`. | ||
| 149 | |||
| 150 | Use Effect `Cache` for repository identity and expensive local metadata: | ||
| 151 | |||
| 152 | - key by resolved repository root plus VCS kind | ||
| 153 | - invalidate on cwd/root changes and workspace mutation operations | ||
| 154 | - use short TTLs for local status caches when filesystem events are unavailable | ||
| 155 | - never hide command failures behind stale values unless the caller explicitly accepts stale data | ||
| 156 | |||
| 157 | ## Cutover Policy | ||
| 158 | |||
| 159 | Prefer direct migration and deletion over compatibility wrappers. | ||
| 160 | |||
| 161 | Rules: | ||
| 162 | |||
| 163 | - Update consumers to call `VcsDriver`/`VcsRepositoryResolver` directly as soon as the new API exists. | ||
| 164 | - Delete migrated `GitCore` service methods and tests in the same PR that moves their consumers. | ||
| 165 | - Do not keep backwards-compatible export shims, barrel aliases, or old service names for convenience. | ||
| 166 | - Transitional modules are allowed only when a caller group is too complex or risky to migrate in the same PR. | ||
| 167 | - Every transitional module must have a narrow owner, a removal checklist, and a test proving it delegates to the new implementation. | ||
| 168 | - No new feature work may depend on transitional modules. | ||
| 169 | |||
| 170 | Expected transitional candidates: | ||
| 171 | |||
| 172 | - The highest-level `GitManager` orchestration can be migrated in slices if doing the full Commit + PR flow in one PR is too risky. | ||
| 173 | - WebSocket payload compatibility can remain only where changing it would require a coordinated UI/server protocol migration. Internal server code should still use the new VCS contracts. | ||
| 174 | |||
| 175 | ## Tests | ||
| 176 | |||
| 177 | Add integration-style tests with real temporary Git repositories for the new Git driver: | ||
| 178 | |||
| 179 | - non-repository detection | ||
| 180 | - status for clean/dirty/untracked/staged states | ||
| 181 | - branch/ref list with pagination | ||
| 182 | - checkout/create branch | ||
| 183 | - worktree create/remove | ||
| 184 | - commit context generation with file filters | ||
| 185 | - commit creation with hook progress events | ||
| 186 | - push behavior against a local bare remote | ||
| 187 | - status polling does not perform remote network refresh by default | ||
| 188 | - concurrent duplicate status requests are coalesced | ||
| 189 | - bounded output/truncation | ||
| 190 | - timeout/interruption | ||
| 191 | - typed error shape for command failure and missing executable | ||
| 192 | |||
| 193 | Move or duplicate only the tests needed to prove behavior, then delete the old service tests in the same migration slice. | ||
| 194 | |||
| 195 | ## Migration Steps | ||
| 196 | |||
| 197 | 1. Add `vcs` contracts and tagged errors. | ||
| 198 | 2. Add `VcsProcess` and unit tests around process execution semantics. | ||
| 199 | 3. Add `VcsDriver` and `VcsRepositoryResolver` service contracts. | ||
| 200 | 4. Implement `GitVcsDriver` with real Git command integration tests. | ||
| 201 | 5. Move `GitStatusBroadcaster` and branch/worktree flows to the VCS service directly. | ||
| 202 | 6. Move commit/range/push callers to the VCS service directly. | ||
| 203 | 7. Delete migrated `GitCore` internals and tests as each caller group moves. | ||
| 204 | 8. Add a transitional adapter only for any remaining `GitManager` path that is explicitly too complex to cut over safely in one PR. | ||
| 205 | 9. Remove every transitional adapter before starting Phase 2 unless the adapter is documented as blocking on the provider cutover. | ||
| 206 | |||
| 207 | ## Acceptance Criteria | ||
| 208 | |||
| 209 | - Current Git branch/status/worktree/commit behavior remains intact. | ||
| 210 | - New Git implementation does not depend on `processRunner.ts`. | ||
| 211 | - New errors are typed and inspectable by tests. | ||
| 212 | - VCS interfaces contain no GitHub/GitLab/Azure concepts. | ||
| 213 | - Active consumers use the new VCS APIs directly; any remaining transitional module has a written removal checklist and no compatibility export shim. | ||
| 214 | - Background status refresh is local-only by default and cannot hit provider rate limits. | ||
| 215 | - Jujutsu can be added by implementing a real driver instead of conforming to Git command semantics. | ||
| 216 | - `bun fmt`, `bun lint`, and `bun typecheck` pass. | ||
.plans/20-version-control-phase-2-source-control-provider-foundation.md created+268| ... | @@ -0,0 +1,268 @@ | ||
| 1 | # Version Control Phase 2: Source Control Provider Foundation | ||
| 2 | |||
| 3 | ## Goal | ||
| 4 | |||
| 5 | 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. | ||
| 6 | |||
| 7 | 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. | ||
| 8 | |||
| 9 | ## Scope | ||
| 10 | |||
| 11 | - Add provider-domain contracts in `packages/contracts/src/sourceControl.ts`. | ||
| 12 | - Add provider URL/reference parsing helpers in `packages/shared/src/sourceControl/*`. | ||
| 13 | - Add server services under `apps/server/src/sourceControl`: | ||
| 14 | - `Services/SourceControlProvider.ts` | ||
| 15 | - `Services/SourceControlProviderRegistry.ts` | ||
| 16 | - `Services/SourceControlProcess.ts` | ||
| 17 | - `Layers/GitHubSourceControlProvider.ts` | ||
| 18 | - `errors.ts` | ||
| 19 | - Migrate PR creation, PR lookup, default-branch lookup, clone URL lookup, and PR checkout through the provider layer. | ||
| 20 | - Update active consumers to the provider APIs directly; do not add backwards-compatible `GitHubCli` export shims. | ||
| 21 | - Keep GitHub as the only production provider at the end of this phase, but make GitLab and Azure implementation paths obvious and bounded. | ||
| 22 | |||
| 23 | ## Non-Goals | ||
| 24 | |||
| 25 | - No GitLab implementation in this phase, except fixtures/contracts that prove the abstraction can represent merge requests. | ||
| 26 | - No Azure DevOps implementation in this phase, except URL/reference parser test cases if cheap. | ||
| 27 | - No in-app review UI yet. | ||
| 28 | - No hard dependency on one CLI forever. The first GitHub driver may use `gh`, but the interface should support REST/GraphQL implementations later. | ||
| 29 | |||
| 30 | ## Provider Model | ||
| 31 | |||
| 32 | Use provider-neutral names: | ||
| 33 | |||
| 34 | - `SourceControlProvider`: hosted repository and change-request mechanics. | ||
| 35 | - `ChangeRequest`: GitHub pull request, GitLab merge request, Azure pull request. | ||
| 36 | - `ChangeRequestThread`: review or discussion thread. | ||
| 37 | - `ChangeRequestComment`: top-level or inline comment. | ||
| 38 | - `ProviderRepository`: owner/project/repo identity plus clone URLs. | ||
| 39 | |||
| 40 | Core provider operations: | ||
| 41 | |||
| 42 | - `detectRemote(remoteUrl)` | ||
| 43 | - `checkAuth(cwd)` | ||
| 44 | - `getRepository(cwd | remoteUrl)` | ||
| 45 | - `getDefaultTargetRef(repository)` | ||
| 46 | - `listChangeRequests(repository, filters)` | ||
| 47 | - `getChangeRequest(repository, reference)` | ||
| 48 | - `createChangeRequest(repository, input)` | ||
| 49 | - `checkoutChangeRequest(cwd, changeRequest, options)` | ||
| 50 | - `getCloneUrls(repository)` | ||
| 51 | |||
| 52 | Review-facing operations should be designed now, even if unimplemented: | ||
| 53 | |||
| 54 | - `listReviewThreads(changeRequest)` | ||
| 55 | - `createReviewComment(changeRequest, input)` | ||
| 56 | - `replyToReviewThread(thread, input)` | ||
| 57 | - `resolveReviewThread(thread)` | ||
| 58 | - `submitReview(changeRequest, input)` | ||
| 59 | |||
| 60 | Each operation should be guarded by capabilities: | ||
| 61 | |||
| 62 | ```ts | ||
| 63 | export interface SourceControlProviderCapabilities { | ||
| 64 | readonly kind: "github" | "gitlab" | "azure-devops" | "unknown"; | ||
| 65 | readonly supportsCreateChangeRequest: boolean; | ||
| 66 | readonly supportsCheckoutChangeRequest: boolean; | ||
| 67 | readonly supportsReviewThreads: boolean; | ||
| 68 | readonly supportsInlineComments: boolean; | ||
| 69 | readonly supportsDraftChangeRequests: boolean; | ||
| 70 | } | ||
| 71 | ``` | ||
| 72 | |||
| 73 | ## Provider Registry | ||
| 74 | |||
| 75 | Add a registry that resolves a provider from repository remotes and explicit user input. | ||
| 76 | |||
| 77 | Rules: | ||
| 78 | |||
| 79 | - Detection should be pure where possible and testable without spawning CLIs. | ||
| 80 | - Remote URL parsing belongs in `packages/shared`, not server-only provider layers. | ||
| 81 | - Unknown providers should return explicit unsupported-operation errors, not silently fall back to GitHub. | ||
| 82 | - Provider selection should be stable per operation and logged with enough context to debug bad remote detection. | ||
| 83 | |||
| 84 | The registry should support multiple provider implementations at runtime, not a single dispatcher file with inline provider branches. | ||
| 85 | |||
| 86 | ## Rate Limits and Provider Caching | ||
| 87 | |||
| 88 | 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. | ||
| 89 | |||
| 90 | Default behavior: | ||
| 91 | |||
| 92 | - Pure URL/remote parsing is always live because it is local. | ||
| 93 | - Provider detection from local remotes is live-local. | ||
| 94 | - Authentication checks are cached. | ||
| 95 | - Repository metadata is cached. | ||
| 96 | - Default branch metadata is cached. | ||
| 97 | - Change-request lists are cached and refreshed on explicit user actions or coarse intervals. | ||
| 98 | - Full review threads, comments, file diffs, and timeline data are fetched only when the user opens the relevant review surface or explicitly refreshes it. | ||
| 99 | - Create/update operations invalidate affected cache keys immediately after success. | ||
| 100 | |||
| 101 | The provider API should make freshness explicit: | ||
| 102 | |||
| 103 | ```ts | ||
| 104 | export interface SourceControlFreshness { | ||
| 105 | readonly source: "live-local" | "cached-provider" | "live-provider"; | ||
| 106 | readonly observedAt: string; | ||
| 107 | readonly expiresAt?: string; | ||
| 108 | readonly stale?: boolean; | ||
| 109 | } | ||
| 110 | |||
| 111 | export type ProviderRefreshPolicy = | ||
| 112 | | "cache-first" | ||
| 113 | | "stale-while-revalidate" | ||
| 114 | | "force-refresh" | ||
| 115 | | "local-only"; | ||
| 116 | ``` | ||
| 117 | |||
| 118 | 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`. | ||
| 119 | |||
| 120 | Use Effect `Cache` for provider data: | ||
| 121 | |||
| 122 | - auth status: key by provider kind, hostname, workspace identity, and account if known; TTL around minutes, not seconds | ||
| 123 | - repository metadata/default branch: key by provider repository stable ID or normalized remote URL; TTL around tens of minutes | ||
| 124 | - change-request summary lists: key by provider repository, state/filter, source ref, target ref; short TTL with stale-while-revalidate | ||
| 125 | - individual change-request summaries: key by provider repository and provider CR ID; short TTL, invalidated after create/update/comment operations | ||
| 126 | - review threads/comments/diffs: key by provider CR ID and head SHA/version when available; fetch on demand for T3 Review | ||
| 127 | |||
| 128 | Provider drivers should surface rate-limit signals when available: | ||
| 129 | |||
| 130 | - remaining quota | ||
| 131 | - reset time | ||
| 132 | - retry-after duration | ||
| 133 | - whether the limit is primary, secondary/abuse, or unknown | ||
| 134 | |||
| 135 | 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. | ||
| 136 | |||
| 137 | Avoid rate-limit footguns: | ||
| 138 | |||
| 139 | - no provider calls from render loops or fast status polling | ||
| 140 | - no listing all PRs/MRs across all repos to infer one branch state | ||
| 141 | - no silent GitHub fallback for unknown providers | ||
| 142 | - no unbounded cache cardinality for branch names or free-form search queries | ||
| 143 | - no per-thread duplicate provider refresh when multiple views observe the same repository | ||
| 144 | |||
| 145 | ## GitHub Provider Rewrite | ||
| 146 | |||
| 147 | Rewrite GitHub support as `GitHubSourceControlProvider`. | ||
| 148 | |||
| 149 | Carry forward behavior from: | ||
| 150 | |||
| 151 | - `apps/server/src/git/Layers/GitHubCli.ts` | ||
| 152 | - `apps/server/src/git/Layers/GitHubCli.test.ts` | ||
| 153 | - `apps/server/src/git/githubPullRequests.ts` | ||
| 154 | - GitHub-specific `GitManager` PR paths | ||
| 155 | |||
| 156 | Implementation requirements: | ||
| 157 | |||
| 158 | - Use `SourceControlProcess` built on `effect/unstable/process`, not `runProcess`. | ||
| 159 | - Decode `gh api` and `gh pr --json` responses with Effect Schema. | ||
| 160 | - Use typed errors for auth failure, missing CLI, command failure, output decode failure, unsupported reference, and provider mismatch. | ||
| 161 | - Keep stdout/stderr bounded. | ||
| 162 | - Avoid global mutable auth caches unless they are Effect `Cache` values with explicit keys, TTLs, and invalidation behavior. | ||
| 163 | - Parse provider rate-limit headers or CLI/API error payloads when available and map them to typed rate-limit errors. | ||
| 164 | - Keep GitHub nouns inside the GitHub driver; convert to `ChangeRequest` at the provider boundary. | ||
| 165 | |||
| 166 | ## GitManager Cutover | ||
| 167 | |||
| 168 | Refactor `GitManager` so it coordinates three independent services: | ||
| 169 | |||
| 170 | - `VcsDriver` for local repository mechanics. | ||
| 171 | - `SourceControlProviderRegistry` for hosted provider selection. | ||
| 172 | - `TextGeneration` for message/body generation. | ||
| 173 | |||
| 174 | `GitManager` should stop depending directly on GitHub services. User-visible step labels should be provider-neutral unless the selected provider is known and the label is intentionally provider-specific. | ||
| 175 | |||
| 176 | The Commit + PR flow should become: | ||
| 177 | |||
| 178 | 1. Resolve VCS repository and local status. | ||
| 179 | 2. Resolve source-control provider from remotes. | ||
| 180 | 3. Generate commit content through the existing text generation service. | ||
| 181 | 4. Create local change through `VcsDriver`. | ||
| 182 | 5. Push through `VcsDriver` or a narrow provider push helper only if the VCS requires provider-specific target syntax. | ||
| 183 | 6. Generate change-request title/body. | ||
| 184 | 7. Create the change request through `SourceControlProvider`. | ||
| 185 | |||
| 186 | ## Cutover Policy | ||
| 187 | |||
| 188 | This phase should aggressively remove old GitHub-specific internals. | ||
| 189 | |||
| 190 | Rules: | ||
| 191 | |||
| 192 | - Move each active consumer directly to `SourceControlProviderRegistry` or a concrete provider test layer. | ||
| 193 | - Delete migrated `GitHubCli` methods, tests, and GitHub-specific helper exports in the same PR that moves their final consumer. | ||
| 194 | - Do not add compatibility export shims from `apps/server/src/git` to `apps/server/src/sourceControl`. | ||
| 195 | - Transitional modules are allowed only for a bounded `GitManager` slice that cannot move safely with the rest of the provider cutover. | ||
| 196 | - Every transitional module must have an owner comment, a removal checklist, and no public exports consumed by new code. | ||
| 197 | - Provider-neutral web parsing should replace GitHub-only parsing directly; do not keep parallel parser stacks unless a route still requires both during a single PR. | ||
| 198 | |||
| 199 | ## GitLab and Azure Readiness | ||
| 200 | |||
| 201 | Use the triaged references as implementation inputs, not merge targets: | ||
| 202 | |||
| 203 | - GitLab PR #592 is useful for `glab mr` command mapping and JSON normalization. | ||
| 204 | - Azure issue #1138 defines a good first Azure slice: remote/URL detection and change-request thread setup for same-repo URLs. | ||
| 205 | |||
| 206 | The abstraction should let Phase 3 add: | ||
| 207 | |||
| 208 | - `GitLabSourceControlProvider` using `glab`. | ||
| 209 | - `AzureDevOpsSourceControlProvider` using `az repos pr` or REST APIs. | ||
| 210 | |||
| 211 | No provider should need to edit GitHub code to join the registry. | ||
| 212 | |||
| 213 | ## T3 Review Design Constraint | ||
| 214 | |||
| 215 | Do not optimize only for creation/checkout. The provider layer must be able to support a future in-app review surface. | ||
| 216 | |||
| 217 | That means contracts should include stable IDs and enough metadata for: | ||
| 218 | |||
| 219 | - file-level diffs | ||
| 220 | - inline review threads | ||
| 221 | - resolved/unresolved state | ||
| 222 | - top-level discussion comments | ||
| 223 | - pending review submission | ||
| 224 | - provider URL back-links | ||
| 225 | |||
| 226 | 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. | ||
| 227 | |||
| 228 | ## Tests | ||
| 229 | |||
| 230 | Add tests at three levels: | ||
| 231 | |||
| 232 | - Pure parser tests for GitHub, GitLab, and Azure remote URLs and change-request references. | ||
| 233 | - Provider unit tests with fake `SourceControlProcess` output and schema decode failures. | ||
| 234 | - Integration-style GitHub CLI tests only where they can run hermetically or be skipped without hiding unit coverage. | ||
| 235 | |||
| 236 | Required cases: | ||
| 237 | |||
| 238 | - GitHub PR URL, number, and branch-ish references. | ||
| 239 | - GitLab MR URL/reference parsing. | ||
| 240 | - Azure DevOps PR URL parsing for same-repo URLs. | ||
| 241 | - unknown provider returns unsupported-operation errors. | ||
| 242 | - missing CLI and auth failures produce distinct typed errors. | ||
| 243 | - invalid CLI JSON fails at decode boundary with useful context. | ||
| 244 | |||
| 245 | ## Migration Steps | ||
| 246 | |||
| 247 | 1. Add `sourceControl` contracts and provider-neutral schemas. | ||
| 248 | 2. Add shared remote/reference parser helpers and tests. | ||
| 249 | 3. Add `SourceControlProcess` and provider errors. | ||
| 250 | 4. Add provider registry with GitHub-only registration. | ||
| 251 | 5. Implement `GitHubSourceControlProvider` from scratch against the new process layer. | ||
| 252 | 6. Cut GitHub PR operations in `GitManager` over to the provider registry. | ||
| 253 | 7. Replace web PR-reference parsing with provider-neutral parser output while keeping current GitHub UX. | ||
| 254 | 8. Add provider cache metrics and tests for cache hit, stale refresh, invalidation, and rate-limit error mapping. | ||
| 255 | 9. Delete the migrated `GitHubCli` implementation, tests, and GitHub-specific helper exports unless an explicit transitional checklist remains. | ||
| 256 | |||
| 257 | ## Acceptance Criteria | ||
| 258 | |||
| 259 | - Existing GitHub Commit + PR and PR checkout flows still work. | ||
| 260 | - `GitManager` no longer imports or depends on `GitHubCli`. | ||
| 261 | - Active consumers use source-control provider APIs directly; any remaining transitional module has a written removal checklist and no compatibility export shim. | ||
| 262 | - Source-control contracts can represent GitHub PRs, GitLab MRs, and Azure DevOps PRs. | ||
| 263 | - Unknown/unsupported providers fail explicitly and visibly. | ||
| 264 | - GitHub command execution does not depend on `processRunner.ts`. | ||
| 265 | - Background provider reads are cached/coalesced and do not consume provider API quota on every status refresh. | ||
| 266 | - Rate-limit responses become typed errors with retry/reset metadata where available. | ||
| 267 | - The provider API includes the review operations needed by future T3 Review work, even if they are capability-gated. | ||
| 268 | - `bun fmt`, `bun lint`, and `bun typecheck` pass. | ||
.plans/README.md+2| ... | @@ -10,3 +10,5 @@ | ... | @@ -10,3 +10,5 @@ |
| 10 | 8. `08-precommit-format-and-lint.md` | 10 | 8. `08-precommit-format-and-lint.md` |
| 11 | 9. `09-event-state-test-expansion.md` | 11 | 9. `09-event-state-test-expansion.md` |
| 12 | 10. `10-unify-process-session-abstraction.md` | 12 | 10. `10-unify-process-session-abstraction.md` |
| 13 | 19. `19-version-control-phase-1-vcs-driver-foundation.md` | ||
| 14 | 20. `20-version-control-phase-2-source-control-provider-foundation.md` |
apps/server/integration/OrchestrationEngineHarness.integration.ts+17-14| ... | @@ -25,10 +25,7 @@ import { | ... | @@ -25,10 +25,7 @@ import { |
| 25 | 25 | ||
| 26 | import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts"; | 26 | import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts"; |
| 27 | import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts"; | 27 | import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts"; |
| 28 | import { GitCoreLive } from "../src/git/Layers/GitCore.ts"; | 28 | import { TextGeneration, type TextGenerationShape } from "../src/textGeneration/TextGeneration.ts"; |
| 29 | import { GitCore, type GitCoreShape } from "../src/git/Services/GitCore.ts"; | ||
| 30 | import { GitStatusBroadcaster } from "../src/git/Services/GitStatusBroadcaster.ts"; | ||
| 31 | import { TextGeneration, type TextGenerationShape } from "../src/git/Services/TextGeneration.ts"; | ||
| 32 | import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts"; | 29 | import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts"; |
| 33 | import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts"; | 30 | import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts"; |
| 34 | import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/ProjectionCheckpoints.ts"; | 31 | import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/ProjectionCheckpoints.ts"; |
| ... | @@ -77,6 +74,11 @@ import { | ... | @@ -77,6 +74,11 @@ import { |
| 77 | import { deriveServerPaths, ServerConfig } from "../src/config.ts"; | 74 | import { deriveServerPaths, ServerConfig } from "../src/config.ts"; |
| 78 | import { WorkspaceEntriesLive } from "../src/workspace/Layers/WorkspaceEntries.ts"; | 75 | import { WorkspaceEntriesLive } from "../src/workspace/Layers/WorkspaceEntries.ts"; |
| 79 | import { WorkspacePathsLive } from "../src/workspace/Layers/WorkspacePaths.ts"; | 76 | import { WorkspacePathsLive } from "../src/workspace/Layers/WorkspacePaths.ts"; |
| 77 | import * as GitVcsDriver from "../src/vcs/GitVcsDriver.ts"; | ||
| 78 | import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; | ||
| 79 | import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts"; | ||
| 80 | import { GitWorkflowService } from "../src/git/GitWorkflowService.ts"; | ||
| 81 | import * as VcsProcess from "../src/vcs/VcsProcess.ts"; | ||
| 80 | 82 | ||
| 81 | function runGit(cwd: string, args: ReadonlyArray<string>) { | 83 | function runGit(cwd: string, args: ReadonlyArray<string>) { |
| 82 | return execFileSync("git", args, { | 84 | return execFileSync("git", args, { |
| ... | @@ -291,7 +293,7 @@ export const makeOrchestrationIntegrationHarness = ( | ... | @@ -291,7 +293,7 @@ export const makeOrchestrationIntegrationHarness = ( |
| 291 | Layer.provide(providerEventLoggersLayer), | 293 | Layer.provide(providerEventLoggersLayer), |
| 292 | ); | 294 | ); |
| 293 | 295 | ||
| 294 | const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive)); | 296 | const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer)); |
| 295 | const projectionSnapshotQueryLayer = OrchestrationProjectionSnapshotQueryLive; | 297 | const projectionSnapshotQueryLayer = OrchestrationProjectionSnapshotQueryLive; |
| 296 | const runtimeServicesLayer = Layer.mergeAll( | 298 | const runtimeServicesLayer = Layer.mergeAll( |
| 297 | projectionSnapshotQueryLayer, | 299 | projectionSnapshotQueryLayer, |
| ... | @@ -307,31 +309,31 @@ export const makeOrchestrationIntegrationHarness = ( | ... | @@ -307,31 +309,31 @@ export const makeOrchestrationIntegrationHarness = ( |
| 307 | Layer.provideMerge(runtimeServicesLayer), | 309 | Layer.provideMerge(runtimeServicesLayer), |
| 308 | Layer.provideMerge(serverSettingsLayer), | 310 | Layer.provideMerge(serverSettingsLayer), |
| 309 | ); | 311 | ); |
| 310 | const gitCoreLayer = Layer.succeed(GitCore, { | 312 | const gitWorkflowLayer = Layer.mock(GitWorkflowService)({ |
| 311 | renameBranch: (input: Parameters<GitCoreShape["renameBranch"]>[0]) => | 313 | renameBranch: (input: Parameters<GitVcsDriver.GitVcsDriverShape["renameBranch"]>[0]) => |
| 312 | Effect.succeed({ branch: input.newBranch }), | 314 | Effect.succeed({ branch: input.newBranch }), |
| 313 | } as unknown as GitCoreShape); | 315 | }); |
| 314 | const textGenerationLayer = Layer.succeed(TextGeneration, { | 316 | const textGenerationLayer = Layer.succeed(TextGeneration, { |
| 315 | generateBranchName: () => Effect.succeed({ branch: "update" }), | 317 | generateBranchName: () => Effect.succeed({ branch: "update" }), |
| 316 | generateThreadTitle: () => Effect.succeed({ title: "New thread" }), | 318 | generateThreadTitle: () => Effect.succeed({ title: "New thread" }), |
| 317 | } as unknown as TextGenerationShape); | 319 | } as unknown as TextGenerationShape); |
| 318 | const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( | 320 | const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( |
| 319 | Layer.provideMerge(runtimeServicesLayer), | 321 | Layer.provideMerge(runtimeServicesLayer), |
| 320 | Layer.provideMerge(gitCoreLayer), | 322 | Layer.provideMerge(gitWorkflowLayer), |
| 321 | Layer.provideMerge(textGenerationLayer), | 323 | Layer.provideMerge(textGenerationLayer), |
| 322 | Layer.provideMerge(serverSettingsLayer), | 324 | Layer.provideMerge(serverSettingsLayer), |
| 323 | ); | 325 | ); |
| 324 | const checkpointReactorLayer = CheckpointReactorLive.pipe( | 326 | const checkpointReactorLayer = CheckpointReactorLive.pipe( |
| 325 | Layer.provideMerge(runtimeServicesLayer), | 327 | Layer.provideMerge(runtimeServicesLayer), |
| 326 | Layer.provideMerge( | 328 | Layer.provideMerge( |
| 327 | Layer.succeed(GitStatusBroadcaster, { | 329 | Layer.succeed(VcsStatusBroadcaster, { |
| 328 | getStatus: () => Effect.die("getStatus should not be called in this test"), | 330 | getStatus: () => Effect.die("getStatus should not be called in this test"), |
| 329 | refreshLocalStatus: () => | 331 | refreshLocalStatus: () => |
| 330 | Effect.succeed({ | 332 | Effect.succeed({ |
| 331 | isRepo: true, | 333 | isRepo: true, |
| 332 | hasOriginRemote: false, | 334 | hasPrimaryRemote: false, |
| 333 | isDefaultBranch: true, | 335 | isDefaultRef: true, |
| 334 | branch: "main", | 336 | refName: "main", |
| 335 | hasWorkingTreeChanges: false, | 337 | hasWorkingTreeChanges: false, |
| 336 | workingTree: { files: [], insertions: 0, deletions: 0 }, | 338 | workingTree: { files: [], insertions: 0, deletions: 0 }, |
| 337 | }), | 339 | }), |
| ... | @@ -342,11 +344,12 @@ export const makeOrchestrationIntegrationHarness = ( | ... | @@ -342,11 +344,12 @@ export const makeOrchestrationIntegrationHarness = ( |
| 342 | Layer.provideMerge( | 344 | Layer.provideMerge( |
| 343 | WorkspaceEntriesLive.pipe( | 345 | WorkspaceEntriesLive.pipe( |
| 344 | Layer.provide(WorkspacePathsLive), | 346 | Layer.provide(WorkspacePathsLive), |
| 345 | Layer.provideMerge(gitCoreLayer), | 347 | Layer.provideMerge(VcsDriverRegistry.layer), |
| 346 | Layer.provide(NodeServices.layer), | 348 | Layer.provide(NodeServices.layer), |
| 347 | ), | 349 | ), |
| 348 | ), | 350 | ), |
| 349 | Layer.provideMerge(WorkspacePathsLive), | 351 | Layer.provideMerge(WorkspacePathsLive), |
| 352 | Layer.provideMerge(VcsProcess.layer), | ||
| 350 | ); | 353 | ); |
| 351 | const orchestrationReactorLayer = OrchestrationReactorLive.pipe( | 354 | const orchestrationReactorLayer = OrchestrationReactorLive.pipe( |
| 352 | Layer.provideMerge(runtimeIngestionLayer), | 355 | Layer.provideMerge(runtimeIngestionLayer), |
apps/server/src/checkpointing/Errors.ts+2-5| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import { Schema } from "effect"; | 1 | import { Schema } from "effect"; |
| 2 | import type { ProjectionRepositoryError } from "../persistence/Errors.ts"; | 2 | import type { ProjectionRepositoryError } from "../persistence/Errors.ts"; |
| 3 | import { GitCommandError } from "@t3tools/contracts"; | 3 | import type { VcsError } from "@t3tools/contracts"; |
| 4 | 4 | ||
| 5 | /** | 5 | /** |
| 6 | * CheckpointUnavailableError - Expected checkpoint does not exist. | 6 | * CheckpointUnavailableError - Expected checkpoint does not exist. |
| ... | @@ -35,9 +35,6 @@ export class CheckpointInvariantError extends Schema.TaggedErrorClass<Checkpoint | ... | @@ -35,9 +35,6 @@ export class CheckpointInvariantError extends Schema.TaggedErrorClass<Checkpoint |
| 35 | } | 35 | } |
| 36 | } | 36 | } |
| 37 | 37 | ||
| 38 | export type CheckpointStoreError = | 38 | export type CheckpointStoreError = VcsError | CheckpointInvariantError | CheckpointUnavailableError; |
| 39 | | GitCommandError | ||
| 40 | | CheckpointInvariantError | ||
| 41 | | CheckpointUnavailableError; | ||
| 42 | 39 | ||
| 43 | export type CheckpointServiceError = CheckpointStoreError | ProjectionRepositoryError; | 40 | export type CheckpointServiceError = CheckpointStoreError | ProjectionRepositoryError; |
apps/server/src/checkpointing/Layers/CheckpointStore.test.ts+20-17| ... | @@ -8,24 +8,27 @@ import { describe, expect } from "vitest"; | ... | @@ -8,24 +8,27 @@ import { describe, expect } from "vitest"; |
| 8 | import { checkpointRefForThreadTurn } from "../Utils.ts"; | 8 | import { checkpointRefForThreadTurn } from "../Utils.ts"; |
| 9 | import { CheckpointStoreLive } from "./CheckpointStore.ts"; | 9 | import { CheckpointStoreLive } from "./CheckpointStore.ts"; |
| 10 | import { CheckpointStore } from "../Services/CheckpointStore.ts"; | 10 | import { CheckpointStore } from "../Services/CheckpointStore.ts"; |
| 11 | import { GitCoreLive } from "../../git/Layers/GitCore.ts"; | 11 | import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; |
| 12 | import { GitCore } from "../../git/Services/GitCore.ts"; | 12 | import * as VcsProcess from "../../vcs/VcsProcess.ts"; |
| 13 | import { GitCommandError } from "@t3tools/contracts"; | 13 | import type { VcsError } from "@t3tools/contracts"; |
| 14 | import { ServerConfig } from "../../config.ts"; | 14 | import { ServerConfig } from "../../config.ts"; |
| 15 | import { ThreadId } from "@t3tools/contracts"; | 15 | import { ThreadId } from "@t3tools/contracts"; |
| 16 | 16 | ||
| 17 | const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { | 17 | const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { |
| 18 | prefix: "t3-checkpoint-store-test-", | 18 | prefix: "t3-checkpoint-store-test-", |
| 19 | }); | 19 | }); |
| 20 | const GitCoreTestLayer = GitCoreLive.pipe( | 20 | const VcsProcessTestLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); |
| 21 | Layer.provide(ServerConfigLayer), | 21 | const VcsDriverTestLayer = VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcessTestLayer)); |
| 22 | Layer.provide(NodeServices.layer), | ||
| 23 | ); | ||
| 24 | const CheckpointStoreTestLayer = CheckpointStoreLive.pipe( | 22 | const CheckpointStoreTestLayer = CheckpointStoreLive.pipe( |
| 25 | Layer.provide(GitCoreTestLayer), | 23 | Layer.provideMerge(VcsDriverTestLayer), |
| 26 | Layer.provide(NodeServices.layer), | 24 | Layer.provideMerge(NodeServices.layer), |
| 25 | ); | ||
| 26 | const TestLayer = CheckpointStoreTestLayer.pipe( | ||
| 27 | Layer.provideMerge(VcsProcessTestLayer), | ||
| 28 | Layer.provideMerge(VcsDriverTestLayer), | ||
| 29 | Layer.provideMerge(ServerConfigLayer), | ||
| 30 | Layer.provideMerge(NodeServices.layer), | ||
| 27 | ); | 31 | ); |
| 28 | const TestLayer = Layer.mergeAll(NodeServices.layer, GitCoreTestLayer, CheckpointStoreTestLayer); | ||
| 29 | 32 | ||
| 30 | function makeTmpDir( | 33 | function makeTmpDir( |
| 31 | prefix = "checkpoint-store-test-", | 34 | prefix = "checkpoint-store-test-", |
| ... | @@ -49,11 +52,12 @@ function writeTextFile( | ... | @@ -49,11 +52,12 @@ function writeTextFile( |
| 49 | function git( | 52 | function git( |
| 50 | cwd: string, | 53 | cwd: string, |
| 51 | args: ReadonlyArray<string>, | 54 | args: ReadonlyArray<string>, |
| 52 | ): Effect.Effect<string, GitCommandError, GitCore> { | 55 | ): Effect.Effect<string, VcsError, VcsProcess.VcsProcess> { |
| 53 | return Effect.gen(function* () { | 56 | return Effect.gen(function* () { |
| 54 | const gitCore = yield* GitCore; | 57 | const process = yield* VcsProcess.VcsProcess; |
| 55 | const result = yield* gitCore.execute({ | 58 | const result = yield* process.run({ |
| 56 | operation: "CheckpointStore.test.git", | 59 | operation: "CheckpointStore.test.git", |
| 60 | command: "git", | ||
| 57 | cwd, | 61 | cwd, |
| 58 | args, | 62 | args, |
| 59 | timeoutMs: 10_000, | 63 | timeoutMs: 10_000, |
| ... | @@ -66,12 +70,11 @@ function initRepoWithCommit( | ... | @@ -66,12 +70,11 @@ function initRepoWithCommit( |
| 66 | cwd: string, | 70 | cwd: string, |
| 67 | ): Effect.Effect< | 71 | ): Effect.Effect< |
| 68 | void, | 72 | void, |
| 69 | GitCommandError | PlatformError.PlatformError, | 73 | VcsError | PlatformError.PlatformError, |
| 70 | GitCore | FileSystem.FileSystem | 74 | VcsProcess.VcsProcess | FileSystem.FileSystem |
| 71 | > { | 75 | > { |
| 72 | return Effect.gen(function* () { | 76 | return Effect.gen(function* () { |
| 73 | const core = yield* GitCore; | 77 | yield* git(cwd, ["init"]); |
| 74 | yield* core.initRepo({ cwd }); | ||
| 75 | yield* git(cwd, ["config", "user.email", "test@test.com"]); | 78 | yield* git(cwd, ["config", "user.email", "test@test.com"]); |
| 76 | yield* git(cwd, ["config", "user.name", "Test"]); | 79 | yield* git(cwd, ["config", "user.name", "Test"]); |
| 77 | yield* writeTextFile(path.join(cwd, "README.md"), "# test\n"); | 80 | yield* writeTextFile(path.join(cwd, "README.md"), "# test\n"); |
apps/server/src/checkpointing/Layers/CheckpointStore.ts+47-31| ... | @@ -14,8 +14,8 @@ import { randomUUID } from "node:crypto"; | ... | @@ -14,8 +14,8 @@ import { randomUUID } from "node:crypto"; |
| 14 | import { Effect, Layer, FileSystem, Path } from "effect"; | 14 | import { Effect, Layer, FileSystem, Path } from "effect"; |
| 15 | 15 | ||
| 16 | import { CheckpointInvariantError } from "../Errors.ts"; | 16 | import { CheckpointInvariantError } from "../Errors.ts"; |
| 17 | import { GitCommandError } from "@t3tools/contracts"; | 17 | import { VcsProcessExitError } from "@t3tools/contracts"; |
| 18 | import { GitCore } from "../../git/Services/GitCore.ts"; | 18 | import { VcsDriverRegistry } from "../../vcs/VcsDriverRegistry.ts"; |
| 19 | import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; | 19 | import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; |
| 20 | import { CheckpointRef } from "@t3tools/contracts"; | 20 | import { CheckpointRef } from "@t3tools/contracts"; |
| 21 | 21 | ||
| ... | @@ -24,10 +24,26 @@ const CHECKPOINT_DIFF_MAX_OUTPUT_BYTES = 10_000_000; | ... | @@ -24,10 +24,26 @@ const CHECKPOINT_DIFF_MAX_OUTPUT_BYTES = 10_000_000; |
| 24 | const makeCheckpointStore = Effect.gen(function* () { | 24 | const makeCheckpointStore = Effect.gen(function* () { |
| 25 | const fs = yield* FileSystem.FileSystem; | 25 | const fs = yield* FileSystem.FileSystem; |
| 26 | const path = yield* Path.Path; | 26 | const path = yield* Path.Path; |
| 27 | const git = yield* GitCore; | 27 | const vcsRegistry = yield* VcsDriverRegistry; |
| 28 | 28 | const vcs = { | |
| 29 | const resolveHeadCommit = (cwd: string): Effect.Effect<string | null, GitCommandError> => | 29 | execute: (input: { |
| 30 | git | 30 | readonly operation: string; |
| 31 | readonly cwd: string; | ||
| 32 | readonly args: ReadonlyArray<string>; | ||
| 33 | readonly stdin?: string; | ||
| 34 | readonly env?: NodeJS.ProcessEnv; | ||
| 35 | readonly allowNonZeroExit?: boolean; | ||
| 36 | readonly timeoutMs?: number; | ||
| 37 | readonly maxOutputBytes?: number; | ||
| 38 | readonly truncateOutputAtMaxBytes?: boolean; | ||
| 39 | }) => | ||
| 40 | vcsRegistry | ||
| 41 | .resolve({ cwd: input.cwd, requestedKind: "git" }) | ||
| 42 | .pipe(Effect.flatMap((handle) => handle.driver.execute(input))), | ||
| 43 | }; | ||
| 44 | |||
| 45 | const resolveHeadCommit = (cwd: string) => | ||
| 46 | vcs | ||
| 31 | .execute({ | 47 | .execute({ |
| 32 | operation: "CheckpointStore.resolveHeadCommit", | 48 | operation: "CheckpointStore.resolveHeadCommit", |
| 33 | cwd, | 49 | cwd, |
| ... | @@ -36,7 +52,7 @@ const makeCheckpointStore = Effect.gen(function* () { | ... | @@ -36,7 +52,7 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 36 | }) | 52 | }) |
| 37 | .pipe( | 53 | .pipe( |
| 38 | Effect.map((result) => { | 54 | Effect.map((result) => { |
| 39 | if (result.code !== 0) { | 55 | if (result.exitCode !== 0) { |
| 40 | return null; | 56 | return null; |
| 41 | } | 57 | } |
| 42 | const commit = result.stdout.trim(); | 58 | const commit = result.stdout.trim(); |
| ... | @@ -44,21 +60,18 @@ const makeCheckpointStore = Effect.gen(function* () { | ... | @@ -44,21 +60,18 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 44 | }), | 60 | }), |
| 45 | ); | 61 | ); |
| 46 | 62 | ||
| 47 | const hasHeadCommit = (cwd: string): Effect.Effect<boolean, GitCommandError> => | 63 | const hasHeadCommit = (cwd: string) => |
| 48 | git | 64 | vcs |
| 49 | .execute({ | 65 | .execute({ |
| 50 | operation: "CheckpointStore.hasHeadCommit", | 66 | operation: "CheckpointStore.hasHeadCommit", |
| 51 | cwd, | 67 | cwd, |
| 52 | args: ["rev-parse", "--verify", "HEAD"], | 68 | args: ["rev-parse", "--verify", "HEAD"], |
| 53 | allowNonZeroExit: true, | 69 | allowNonZeroExit: true, |
| 54 | }) | 70 | }) |
| 55 | .pipe(Effect.map((result) => result.code === 0)); | 71 | .pipe(Effect.map((result) => result.exitCode === 0)); |
| 56 | 72 | ||
| 57 | const resolveCheckpointCommit = ( | 73 | const resolveCheckpointCommit = (cwd: string, checkpointRef: CheckpointRef) => |
| 58 | cwd: string, | 74 | vcs |
| 59 | checkpointRef: CheckpointRef, | ||
| 60 | ): Effect.Effect<string | null, GitCommandError> => | ||
| 61 | git | ||
| 62 | .execute({ | 75 | .execute({ |
| 63 | operation: "CheckpointStore.resolveCheckpointCommit", | 76 | operation: "CheckpointStore.resolveCheckpointCommit", |
| 64 | cwd, | 77 | cwd, |
| ... | @@ -67,7 +80,7 @@ const makeCheckpointStore = Effect.gen(function* () { | ... | @@ -67,7 +80,7 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 67 | }) | 80 | }) |
| 68 | .pipe( | 81 | .pipe( |
| 69 | Effect.map((result) => { | 82 | Effect.map((result) => { |
| 70 | if (result.code !== 0) { | 83 | if (result.exitCode !== 0) { |
| 71 | return null; | 84 | return null; |
| 72 | } | 85 | } |
| 73 | const commit = result.stdout.trim(); | 86 | const commit = result.stdout.trim(); |
| ... | @@ -76,7 +89,7 @@ const makeCheckpointStore = Effect.gen(function* () { | ... | @@ -76,7 +89,7 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 76 | ); | 89 | ); |
| 77 | 90 | ||
| 78 | const isGitRepository: CheckpointStoreShape["isGitRepository"] = (cwd) => | 91 | const isGitRepository: CheckpointStoreShape["isGitRepository"] = (cwd) => |
| 79 | git | 92 | vcs |
| 80 | .execute({ | 93 | .execute({ |
| 81 | operation: "CheckpointStore.isGitRepository", | 94 | operation: "CheckpointStore.isGitRepository", |
| 82 | cwd, | 95 | cwd, |
| ... | @@ -84,7 +97,7 @@ const makeCheckpointStore = Effect.gen(function* () { | ... | @@ -84,7 +97,7 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 84 | allowNonZeroExit: true, | 97 | allowNonZeroExit: true, |
| 85 | }) | 98 | }) |
| 86 | .pipe( | 99 | .pipe( |
| 87 | Effect.map((result) => result.code === 0 && result.stdout.trim() === "true"), | 100 | Effect.map((result) => result.exitCode === 0 && result.stdout.trim() === "true"), |
| 88 | Effect.catch(() => Effect.succeed(false)), | 101 | Effect.catch(() => Effect.succeed(false)), |
| 89 | ); | 102 | ); |
| 90 | 103 | ||
| ... | @@ -108,7 +121,7 @@ const makeCheckpointStore = Effect.gen(function* () { | ... | @@ -108,7 +121,7 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 108 | 121 | ||
| 109 | const headExists = yield* hasHeadCommit(input.cwd); | 122 | const headExists = yield* hasHeadCommit(input.cwd); |
| 110 | if (headExists) { | 123 | if (headExists) { |
| 111 | yield* git.execute({ | 124 | yield* vcs.execute({ |
| 112 | operation, | 125 | operation, |
| 113 | cwd: input.cwd, | 126 | cwd: input.cwd, |
| 114 | args: ["read-tree", "HEAD"], | 127 | args: ["read-tree", "HEAD"], |
| ... | @@ -116,14 +129,14 @@ const makeCheckpointStore = Effect.gen(function* () { | ... | @@ -116,14 +129,14 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 116 | }); | 129 | }); |
| 117 | } | 130 | } |
| 118 | 131 | ||
| 119 | yield* git.execute({ | 132 | yield* vcs.execute({ |
| 120 | operation, | 133 | operation, |
| 121 | cwd: input.cwd, | 134 | cwd: input.cwd, |
| 122 | args: ["add", "-A", "--", "."], | 135 | args: ["add", "-A", "--", "."], |
| 123 | env: commitEnv, | 136 | env: commitEnv, |
| 124 | }); | 137 | }); |
| 125 | 138 | ||
| 126 | const writeTreeResult = yield* git.execute({ | 139 | const writeTreeResult = yield* vcs.execute({ |
| 127 | operation, | 140 | operation, |
| 128 | cwd: input.cwd, | 141 | cwd: input.cwd, |
| 129 | args: ["write-tree"], | 142 | args: ["write-tree"], |
| ... | @@ -131,16 +144,17 @@ const makeCheckpointStore = Effect.gen(function* () { | ... | @@ -131,16 +144,17 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 131 | }); | 144 | }); |
| 132 | const treeOid = writeTreeResult.stdout.trim(); | 145 | const treeOid = writeTreeResult.stdout.trim(); |
| 133 | if (treeOid.length === 0) { | 146 | if (treeOid.length === 0) { |
| 134 | return yield* new GitCommandError({ | 147 | return yield* new VcsProcessExitError({ |
| 135 | operation, | 148 | operation, |
| 136 | command: "git write-tree", | 149 | command: "git write-tree", |
| 137 | cwd: input.cwd, | 150 | cwd: input.cwd, |
| 151 | exitCode: 0, | ||
| 138 | detail: "git write-tree returned an empty tree oid.", | 152 | detail: "git write-tree returned an empty tree oid.", |
| 139 | }); | 153 | }); |
| 140 | } | 154 | } |
| 141 | 155 | ||
| 142 | const message = `t3 checkpoint ref=${input.checkpointRef}`; | 156 | const message = `t3 checkpoint ref=${input.checkpointRef}`; |
| 143 | const commitTreeResult = yield* git.execute({ | 157 | const commitTreeResult = yield* vcs.execute({ |
| 144 | operation, | 158 | operation, |
| 145 | cwd: input.cwd, | 159 | cwd: input.cwd, |
| 146 | args: ["commit-tree", treeOid, "-m", message], | 160 | args: ["commit-tree", treeOid, "-m", message], |
| ... | @@ -148,15 +162,16 @@ const makeCheckpointStore = Effect.gen(function* () { | ... | @@ -148,15 +162,16 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 148 | }); | 162 | }); |
| 149 | const commitOid = commitTreeResult.stdout.trim(); | 163 | const commitOid = commitTreeResult.stdout.trim(); |
| 150 | if (commitOid.length === 0) { | 164 | if (commitOid.length === 0) { |
| 151 | return yield* new GitCommandError({ | 165 | return yield* new VcsProcessExitError({ |
| 152 | operation, | 166 | operation, |
| 153 | command: "git commit-tree", | 167 | command: "git commit-tree", |
| 154 | cwd: input.cwd, | 168 | cwd: input.cwd, |
| 169 | exitCode: 0, | ||
| 155 | detail: "git commit-tree returned an empty commit oid.", | 170 | detail: "git commit-tree returned an empty commit oid.", |
| 156 | }); | 171 | }); |
| 157 | } | 172 | } |
| 158 | 173 | ||
| 159 | yield* git.execute({ | 174 | yield* vcs.execute({ |
| 160 | operation, | 175 | operation, |
| 161 | cwd: input.cwd, | 176 | cwd: input.cwd, |
| 162 | args: ["update-ref", input.checkpointRef, commitOid], | 177 | args: ["update-ref", input.checkpointRef, commitOid], |
| ... | @@ -197,12 +212,12 @@ const makeCheckpointStore = Effect.gen(function* () { | ... | @@ -197,12 +212,12 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 197 | return false; | 212 | return false; |
| 198 | } | 213 | } |
| 199 | 214 | ||
| 200 | yield* git.execute({ | 215 | yield* vcs.execute({ |
| 201 | operation, | 216 | operation, |
| 202 | cwd: input.cwd, | 217 | cwd: input.cwd, |
| 203 | args: ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."], | 218 | args: ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."], |
| 204 | }); | 219 | }); |
| 205 | yield* git.execute({ | 220 | yield* vcs.execute({ |
| 206 | operation, | 221 | operation, |
| 207 | cwd: input.cwd, | 222 | cwd: input.cwd, |
| 208 | args: ["clean", "-fd", "--", "."], | 223 | args: ["clean", "-fd", "--", "."], |
| ... | @@ -210,7 +225,7 @@ const makeCheckpointStore = Effect.gen(function* () { | ... | @@ -210,7 +225,7 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 210 | 225 | ||
| 211 | const headExists = yield* hasHeadCommit(input.cwd); | 226 | const headExists = yield* hasHeadCommit(input.cwd); |
| 212 | if (headExists) { | 227 | if (headExists) { |
| 213 | yield* git.execute({ | 228 | yield* vcs.execute({ |
| 214 | operation, | 229 | operation, |
| 215 | cwd: input.cwd, | 230 | cwd: input.cwd, |
| 216 | args: ["reset", "--quiet", "--", "."], | 231 | args: ["reset", "--quiet", "--", "."], |
| ... | @@ -235,15 +250,16 @@ const makeCheckpointStore = Effect.gen(function* () { | ... | @@ -235,15 +250,16 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 235 | } | 250 | } |
| 236 | 251 | ||
| 237 | if (!fromCommitOid || !toCommitOid) { | 252 | if (!fromCommitOid || !toCommitOid) { |
| 238 | return yield* new GitCommandError({ | 253 | return yield* new VcsProcessExitError({ |
| 239 | operation, | 254 | operation, |
| 240 | command: "git diff", | 255 | command: "git diff", |
| 241 | cwd: input.cwd, | 256 | cwd: input.cwd, |
| 257 | exitCode: 1, | ||
| 242 | detail: "Checkpoint ref is unavailable for diff operation.", | 258 | detail: "Checkpoint ref is unavailable for diff operation.", |
| 243 | }); | 259 | }); |
| 244 | } | 260 | } |
| 245 | 261 | ||
| 246 | const result = yield* git.execute({ | 262 | const result = yield* vcs.execute({ |
| 247 | operation, | 263 | operation, |
| 248 | cwd: input.cwd, | 264 | cwd: input.cwd, |
| 249 | args: ["diff", "--patch", "--minimal", "--no-color", fromCommitOid, toCommitOid], | 265 | args: ["diff", "--patch", "--minimal", "--no-color", fromCommitOid, toCommitOid], |
| ... | @@ -262,7 +278,7 @@ const makeCheckpointStore = Effect.gen(function* () { | ... | @@ -262,7 +278,7 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 262 | yield* Effect.forEach( | 278 | yield* Effect.forEach( |
| 263 | input.checkpointRefs, | 279 | input.checkpointRefs, |
| 264 | (checkpointRef) => | 280 | (checkpointRef) => |
| 265 | git.execute({ | 281 | vcs.execute({ |
| 266 | operation, | 282 | operation, |
| 267 | cwd: input.cwd, | 283 | cwd: input.cwd, |
| 268 | args: ["update-ref", "-d", checkpointRef], | 284 | args: ["update-ref", "-d", checkpointRef], |
apps/server/src/git/GitManager.test.ts created+3179| ... | @@ -0,0 +1,3179 @@ | ||
| 1 | import fs from "node:fs"; | ||
| 2 | import path from "node:path"; | ||
| 3 | import { spawnSync } from "node:child_process"; | ||
| 4 | |||
| 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 6 | import { it } from "@effect/vitest"; | ||
| 7 | import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; | ||
| 8 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 9 | import { expect } from "vitest"; | ||
| 10 | import type { | ||
| 11 | GitActionProgressEvent, | ||
| 12 | GitPreparePullRequestThreadInput, | ||
| 13 | ModelSelection, | ||
| 14 | ThreadId, | ||
| 15 | } from "@t3tools/contracts"; | ||
| 16 | |||
| 17 | import { GitCommandError, GitHubCliError, TextGenerationError } from "@t3tools/contracts"; | ||
| 18 | import { type GitManagerShape } from "./GitManager.ts"; | ||
| 19 | import { | ||
| 20 | type GitHubCliShape, | ||
| 21 | type GitHubPullRequestSummary, | ||
| 22 | GitHubCli, | ||
| 23 | } from "../sourceControl/GitHubCli.ts"; | ||
| 24 | import { type TextGenerationShape, TextGeneration } from "../textGeneration/TextGeneration.ts"; | ||
| 25 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | ||
| 26 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | ||
| 27 | import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; | ||
| 28 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; | ||
| 29 | import { makeGitManager } from "./GitManager.ts"; | ||
| 30 | import { ServerConfig } from "../config.ts"; | ||
| 31 | import { ServerSettingsService } from "../serverSettings.ts"; | ||
| 32 | import { | ||
| 33 | ProjectSetupScriptRunner, | ||
| 34 | type ProjectSetupScriptRunnerInput, | ||
| 35 | type ProjectSetupScriptRunnerShape, | ||
| 36 | } from "../project/Services/ProjectSetupScriptRunner.ts"; | ||
| 37 | |||
| 38 | interface FakeGhScenario { | ||
| 39 | prListSequence?: string[]; | ||
| 40 | prListByHeadSelector?: Record<string, string>; | ||
| 41 | prListSequenceByHeadSelector?: Record<string, string[]>; | ||
| 42 | createdPrUrl?: string; | ||
| 43 | defaultBranch?: string; | ||
| 44 | pullRequest?: { | ||
| 45 | number: number; | ||
| 46 | title: string; | ||
| 47 | url: string; | ||
| 48 | baseRefName: string; | ||
| 49 | headRefName: string; | ||
| 50 | state?: "open" | "closed" | "merged"; | ||
| 51 | isCrossRepository?: boolean; | ||
| 52 | headRepositoryNameWithOwner?: string | null; | ||
| 53 | headRepositoryOwnerLogin?: string | null; | ||
| 54 | }; | ||
| 55 | repositoryCloneUrls?: Record<string, { url: string; sshUrl: string }>; | ||
| 56 | failWith?: GitHubCliError; | ||
| 57 | } | ||
| 58 | |||
| 59 | function fakeGhOutput(stdout: string): VcsProcess.VcsProcessOutput { | ||
| 60 | return { | ||
| 61 | exitCode: ChildProcessSpawner.ExitCode(0), | ||
| 62 | stdout, | ||
| 63 | stderr: "", | ||
| 64 | stdoutTruncated: false, | ||
| 65 | stderrTruncated: false, | ||
| 66 | }; | ||
| 67 | } | ||
| 68 | |||
| 69 | interface FakeGitTextGeneration { | ||
| 70 | generateCommitMessage: (input: { | ||
| 71 | cwd: string; | ||
| 72 | branch: string | null; | ||
| 73 | stagedSummary: string; | ||
| 74 | stagedPatch: string; | ||
| 75 | includeBranch?: boolean; | ||
| 76 | modelSelection: ModelSelection; | ||
| 77 | }) => Effect.Effect< | ||
| 78 | { subject: string; body: string; branch?: string | undefined }, | ||
| 79 | TextGenerationError | ||
| 80 | >; | ||
| 81 | generatePrContent: (input: { | ||
| 82 | cwd: string; | ||
| 83 | baseBranch: string; | ||
| 84 | headBranch: string; | ||
| 85 | commitSummary: string; | ||
| 86 | diffSummary: string; | ||
| 87 | diffPatch: string; | ||
| 88 | modelSelection: ModelSelection; | ||
| 89 | }) => Effect.Effect<{ title: string; body: string }, TextGenerationError>; | ||
| 90 | generateBranchName: (input: { | ||
| 91 | cwd: string; | ||
| 92 | message: string; | ||
| 93 | modelSelection: ModelSelection; | ||
| 94 | }) => Effect.Effect<{ branch: string }, TextGenerationError>; | ||
| 95 | generateThreadTitle: (input: { | ||
| 96 | cwd: string; | ||
| 97 | message: string; | ||
| 98 | modelSelection: ModelSelection; | ||
| 99 | }) => Effect.Effect<{ title: string }, TextGenerationError>; | ||
| 100 | } | ||
| 101 | |||
| 102 | type FakePullRequest = NonNullable<FakeGhScenario["pullRequest"]>; | ||
| 103 | |||
| 104 | function normalizeFakePullRequestSummary(raw: unknown): GitHubPullRequestSummary | null { | ||
| 105 | if (!raw || typeof raw !== "object") { | ||
| 106 | return null; | ||
| 107 | } | ||
| 108 | |||
| 109 | const record = raw as Record<string, unknown>; | ||
| 110 | const number = record.number; | ||
| 111 | const title = record.title; | ||
| 112 | const url = record.url; | ||
| 113 | const baseRefName = record.baseRefName; | ||
| 114 | const headRefName = record.headRefName; | ||
| 115 | const headRepository = | ||
| 116 | typeof record.headRepository === "object" && record.headRepository !== null | ||
| 117 | ? (record.headRepository as Record<string, unknown>) | ||
| 118 | : null; | ||
| 119 | const headRepositoryOwner = | ||
| 120 | typeof record.headRepositoryOwner === "object" && record.headRepositoryOwner !== null | ||
| 121 | ? (record.headRepositoryOwner as Record<string, unknown>) | ||
| 122 | : null; | ||
| 123 | |||
| 124 | if ( | ||
| 125 | typeof number !== "number" || | ||
| 126 | typeof title !== "string" || | ||
| 127 | typeof url !== "string" || | ||
| 128 | typeof baseRefName !== "string" || | ||
| 129 | typeof headRefName !== "string" | ||
| 130 | ) { | ||
| 131 | return null; | ||
| 132 | } | ||
| 133 | |||
| 134 | const state = | ||
| 135 | typeof record.state === "string" | ||
| 136 | ? record.state === "OPEN" || record.state === "open" | ||
| 137 | ? "open" | ||
| 138 | : record.state === "CLOSED" || record.state === "closed" | ||
| 139 | ? "closed" | ||
| 140 | : "merged" | ||
| 141 | : undefined; | ||
| 142 | const isCrossRepository = | ||
| 143 | typeof record.isCrossRepository === "boolean" ? record.isCrossRepository : undefined; | ||
| 144 | const headRepositoryNameWithOwner = | ||
| 145 | typeof record.headRepositoryNameWithOwner === "string" | ||
| 146 | ? record.headRepositoryNameWithOwner | ||
| 147 | : typeof headRepository?.nameWithOwner === "string" | ||
| 148 | ? headRepository.nameWithOwner | ||
| 149 | : undefined; | ||
| 150 | const headRepositoryOwnerLogin = | ||
| 151 | typeof record.headRepositoryOwnerLogin === "string" | ||
| 152 | ? record.headRepositoryOwnerLogin | ||
| 153 | : typeof headRepositoryOwner?.login === "string" | ||
| 154 | ? headRepositoryOwner.login | ||
| 155 | : undefined; | ||
| 156 | |||
| 157 | return { | ||
| 158 | number, | ||
| 159 | title, | ||
| 160 | url, | ||
| 161 | baseRefName, | ||
| 162 | headRefName, | ||
| 163 | ...(state ? { state } : {}), | ||
| 164 | ...(isCrossRepository !== undefined ? { isCrossRepository } : {}), | ||
| 165 | ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), | ||
| 166 | ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), | ||
| 167 | }; | ||
| 168 | } | ||
| 169 | |||
| 170 | function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void { | ||
| 171 | const result = spawnSync("git", args, { | ||
| 172 | cwd, | ||
| 173 | encoding: "utf8", | ||
| 174 | }); | ||
| 175 | if (result.status === 0) { | ||
| 176 | return; | ||
| 177 | } | ||
| 178 | throw new GitHubCliError({ | ||
| 179 | operation: "execute", | ||
| 180 | detail: `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`, | ||
| 181 | }); | ||
| 182 | } | ||
| 183 | |||
| 184 | function isGitHubCliError(error: unknown): error is GitHubCliError { | ||
| 185 | return ( | ||
| 186 | typeof error === "object" && | ||
| 187 | error !== null && | ||
| 188 | "_tag" in error && | ||
| 189 | (error as { _tag?: unknown })._tag === "GitHubCliError" | ||
| 190 | ); | ||
| 191 | } | ||
| 192 | |||
| 193 | function makeTempDir( | ||
| 194 | prefix: string, | ||
| 195 | ): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> { | ||
| 196 | return Effect.gen(function* () { | ||
| 197 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 198 | return yield* fileSystem.makeTempDirectoryScoped({ prefix }); | ||
| 199 | }); | ||
| 200 | } | ||
| 201 | |||
| 202 | function removePath( | ||
| 203 | targetPath: string, | ||
| 204 | ): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> { | ||
| 205 | return Effect.gen(function* () { | ||
| 206 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 207 | yield* fileSystem.remove(targetPath, { recursive: true, force: true }); | ||
| 208 | }); | ||
| 209 | } | ||
| 210 | |||
| 211 | function makeDirectory( | ||
| 212 | dirPath: string, | ||
| 213 | ): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> { | ||
| 214 | return Effect.gen(function* () { | ||
| 215 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 216 | yield* fileSystem.makeDirectory(dirPath, { recursive: true }); | ||
| 217 | }); | ||
| 218 | } | ||
| 219 | |||
| 220 | function runGit( | ||
| 221 | cwd: string, | ||
| 222 | args: readonly string[], | ||
| 223 | allowNonZeroExit = false, | ||
| 224 | ): Effect.Effect< | ||
| 225 | { | ||
| 226 | readonly exitCode: GitVcsDriver.ExecuteGitResult["exitCode"]; | ||
| 227 | readonly stdout: string; | ||
| 228 | readonly stderr: string; | ||
| 229 | }, | ||
| 230 | GitCommandError, | ||
| 231 | GitVcsDriver.GitVcsDriver | ||
| 232 | > { | ||
| 233 | return Effect.gen(function* () { | ||
| 234 | const git = yield* GitVcsDriver.GitVcsDriver; | ||
| 235 | const result = yield* git.execute({ | ||
| 236 | operation: "GitManager.test.runGit", | ||
| 237 | cwd, | ||
| 238 | args, | ||
| 239 | allowNonZeroExit, | ||
| 240 | }); | ||
| 241 | return { | ||
| 242 | exitCode: result.exitCode, | ||
| 243 | stdout: result.stdout, | ||
| 244 | stderr: result.stderr, | ||
| 245 | }; | ||
| 246 | }); | ||
| 247 | } | ||
| 248 | |||
| 249 | function initRepo( | ||
| 250 | cwd: string, | ||
| 251 | ): Effect.Effect< | ||
| 252 | void, | ||
| 253 | PlatformError.PlatformError | GitCommandError, | ||
| 254 | FileSystem.FileSystem | Scope.Scope | GitVcsDriver.GitVcsDriver | ||
| 255 | > { | ||
| 256 | return Effect.gen(function* () { | ||
| 257 | const fs = yield* FileSystem.FileSystem; | ||
| 258 | yield* runGit(cwd, ["init", "--initial-branch=main"]); | ||
| 259 | yield* runGit(cwd, ["config", "user.email", "test@example.com"]); | ||
| 260 | yield* runGit(cwd, ["config", "user.name", "Test User"]); | ||
| 261 | yield* fs.writeFileString(path.join(cwd, "README.md"), "hello\n"); | ||
| 262 | yield* runGit(cwd, ["add", "README.md"]); | ||
| 263 | yield* runGit(cwd, ["commit", "-m", "Initial commit"]); | ||
| 264 | }); | ||
| 265 | } | ||
| 266 | |||
| 267 | function createBareRemote(): Effect.Effect< | ||
| 268 | string, | ||
| 269 | PlatformError.PlatformError | GitCommandError, | ||
| 270 | FileSystem.FileSystem | Scope.Scope | GitVcsDriver.GitVcsDriver | ||
| 271 | > { | ||
| 272 | return Effect.gen(function* () { | ||
| 273 | const remoteDir = yield* makeTempDir("t3code-git-remote-"); | ||
| 274 | yield* runGit(remoteDir, ["init", "--bare"]); | ||
| 275 | return remoteDir; | ||
| 276 | }); | ||
| 277 | } | ||
| 278 | |||
| 279 | function configureRemote( | ||
| 280 | cwd: string, | ||
| 281 | remoteName: string, | ||
| 282 | remotePath: string, | ||
| 283 | fetchNamespace: string, | ||
| 284 | ): Effect.Effect<void, GitCommandError, GitVcsDriver.GitVcsDriver> { | ||
| 285 | return Effect.gen(function* () { | ||
| 286 | yield* runGit(cwd, ["config", `remote.${remoteName}.url`, remotePath]); | ||
| 287 | yield* runGit(cwd, [ | ||
| 288 | "config", | ||
| 289 | "--replace-all", | ||
| 290 | `remote.${remoteName}.fetch`, | ||
| 291 | `+refs/heads/*:refs/remotes/${fetchNamespace}/*`, | ||
| 292 | ]); | ||
| 293 | }); | ||
| 294 | } | ||
| 295 | |||
| 296 | function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): TextGenerationShape { | ||
| 297 | const implementation: FakeGitTextGeneration = { | ||
| 298 | generateCommitMessage: (input) => | ||
| 299 | Effect.succeed({ | ||
| 300 | subject: "Implement stacked git actions", | ||
| 301 | body: "", | ||
| 302 | ...(input.includeBranch ? { branch: "feature/implement-stacked-git-actions" } : {}), | ||
| 303 | }), | ||
| 304 | generatePrContent: () => | ||
| 305 | Effect.succeed({ | ||
| 306 | title: "Add stacked git actions", | ||
| 307 | body: "## Summary\n- Add stacked git workflow\n\n## Testing\n- Not run", | ||
| 308 | }), | ||
| 309 | generateBranchName: () => | ||
| 310 | Effect.succeed({ | ||
| 311 | branch: "update-workflow", | ||
| 312 | }), | ||
| 313 | generateThreadTitle: () => | ||
| 314 | Effect.succeed({ | ||
| 315 | title: "Update workflow", | ||
| 316 | }), | ||
| 317 | ...overrides, | ||
| 318 | }; | ||
| 319 | |||
| 320 | return { | ||
| 321 | generateCommitMessage: (input) => | ||
| 322 | implementation.generateCommitMessage(input).pipe( | ||
| 323 | Effect.mapError( | ||
| 324 | (cause) => | ||
| 325 | new TextGenerationError({ | ||
| 326 | operation: "generateCommitMessage", | ||
| 327 | detail: "fake text generation failed", | ||
| 328 | ...(cause !== undefined ? { cause } : {}), | ||
| 329 | }), | ||
| 330 | ), | ||
| 331 | ), | ||
| 332 | generatePrContent: (input) => | ||
| 333 | implementation.generatePrContent(input).pipe( | ||
| 334 | Effect.mapError( | ||
| 335 | (cause) => | ||
| 336 | new TextGenerationError({ | ||
| 337 | operation: "generatePrContent", | ||
| 338 | detail: "fake text generation failed", | ||
| 339 | ...(cause !== undefined ? { cause } : {}), | ||
| 340 | }), | ||
| 341 | ), | ||
| 342 | ), | ||
| 343 | generateBranchName: (input) => | ||
| 344 | implementation.generateBranchName(input).pipe( | ||
| 345 | Effect.mapError( | ||
| 346 | (cause) => | ||
| 347 | new TextGenerationError({ | ||
| 348 | operation: "generateBranchName", | ||
| 349 | detail: "fake text generation failed", | ||
| 350 | ...(cause !== undefined ? { cause } : {}), | ||
| 351 | }), | ||
| 352 | ), | ||
| 353 | ), | ||
| 354 | generateThreadTitle: (input) => | ||
| 355 | implementation.generateThreadTitle(input).pipe( | ||
| 356 | Effect.mapError( | ||
| 357 | (cause) => | ||
| 358 | new TextGenerationError({ | ||
| 359 | operation: "generateThreadTitle", | ||
| 360 | detail: "fake text generation failed", | ||
| 361 | ...(cause !== undefined ? { cause } : {}), | ||
| 362 | }), | ||
| 363 | ), | ||
| 364 | ), | ||
| 365 | }; | ||
| 366 | } | ||
| 367 | |||
| 368 | function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { | ||
| 369 | service: GitHubCliShape; | ||
| 370 | ghCalls: string[]; | ||
| 371 | } { | ||
| 372 | const prListQueue = [...(scenario.prListSequence ?? [])]; | ||
| 373 | const prListQueueByHeadSelector = new Map( | ||
| 374 | Object.entries(scenario.prListSequenceByHeadSelector ?? {}).map(([headSelector, values]) => [ | ||
| 375 | headSelector, | ||
| 376 | [...values], | ||
| 377 | ]), | ||
| 378 | ); | ||
| 379 | const ghCalls: string[] = []; | ||
| 380 | |||
| 381 | const execute: GitHubCliShape["execute"] = (input) => { | ||
| 382 | const args = [...input.args]; | ||
| 383 | ghCalls.push(args.join(" ")); | ||
| 384 | |||
| 385 | if (scenario.failWith) { | ||
| 386 | return Effect.fail(scenario.failWith); | ||
| 387 | } | ||
| 388 | |||
| 389 | if (args[0] === "pr" && args[1] === "list") { | ||
| 390 | const headSelectorIndex = args.findIndex((value) => value === "--head"); | ||
| 391 | const headSelector = | ||
| 392 | headSelectorIndex >= 0 && headSelectorIndex < args.length - 1 | ||
| 393 | ? args[headSelectorIndex + 1] | ||
| 394 | : undefined; | ||
| 395 | const mappedQueue = | ||
| 396 | typeof headSelector === "string" | ||
| 397 | ? prListQueueByHeadSelector.get(headSelector)?.shift() | ||
| 398 | : undefined; | ||
| 399 | const mappedStdout = | ||
| 400 | typeof headSelector === "string" | ||
| 401 | ? scenario.prListByHeadSelector?.[headSelector] | ||
| 402 | : undefined; | ||
| 403 | const stdout = (mappedQueue ?? mappedStdout ?? prListQueue.shift() ?? "[]") + "\n"; | ||
| 404 | return Effect.succeed(fakeGhOutput(stdout)); | ||
| 405 | } | ||
| 406 | |||
| 407 | if (args[0] === "pr" && args[1] === "create") { | ||
| 408 | return Effect.succeed( | ||
| 409 | fakeGhOutput( | ||
| 410 | (scenario.createdPrUrl ?? "https://github.com/pingdotgg/codething-mvp/pull/101") + "\n", | ||
| 411 | ), | ||
| 412 | ); | ||
| 413 | } | ||
| 414 | |||
| 415 | if (args[0] === "pr" && args[1] === "view") { | ||
| 416 | const pullRequest: FakePullRequest = scenario.pullRequest ?? { | ||
| 417 | number: 101, | ||
| 418 | title: "Pull request", | ||
| 419 | url: "https://github.com/pingdotgg/codething-mvp/pull/101", | ||
| 420 | baseRefName: "main", | ||
| 421 | headRefName: "feature/pull-request", | ||
| 422 | state: "open", | ||
| 423 | }; | ||
| 424 | return Effect.succeed( | ||
| 425 | fakeGhOutput( | ||
| 426 | JSON.stringify({ | ||
| 427 | ...pullRequest, | ||
| 428 | ...(pullRequest.headRepositoryNameWithOwner | ||
| 429 | ? { | ||
| 430 | headRepository: { | ||
| 431 | nameWithOwner: pullRequest.headRepositoryNameWithOwner, | ||
| 432 | }, | ||
| 433 | } | ||
| 434 | : {}), | ||
| 435 | ...(pullRequest.headRepositoryOwnerLogin | ||
| 436 | ? { | ||
| 437 | headRepositoryOwner: { | ||
| 438 | login: pullRequest.headRepositoryOwnerLogin, | ||
| 439 | }, | ||
| 440 | } | ||
| 441 | : {}), | ||
| 442 | }) + "\n", | ||
| 443 | ), | ||
| 444 | ); | ||
| 445 | } | ||
| 446 | |||
| 447 | if (args[0] === "pr" && args[1] === "checkout") { | ||
| 448 | return Effect.try({ | ||
| 449 | try: () => { | ||
| 450 | const headBranch = scenario.pullRequest?.headRefName; | ||
| 451 | if (headBranch) { | ||
| 452 | const existingBranch = spawnSync( | ||
| 453 | "git", | ||
| 454 | ["show-ref", "--verify", "--quiet", `refs/heads/${headBranch}`], | ||
| 455 | { | ||
| 456 | cwd: input.cwd, | ||
| 457 | encoding: "utf8", | ||
| 458 | }, | ||
| 459 | ); | ||
| 460 | if (existingBranch.status === 0) { | ||
| 461 | runGitSyncForFakeGh(input.cwd, ["checkout", headBranch]); | ||
| 462 | } else { | ||
| 463 | runGitSyncForFakeGh(input.cwd, ["checkout", "-b", headBranch]); | ||
| 464 | } | ||
| 465 | } | ||
| 466 | return fakeGhOutput(""); | ||
| 467 | }, | ||
| 468 | catch: (error) => | ||
| 469 | isGitHubCliError(error) | ||
| 470 | ? error | ||
| 471 | : new GitHubCliError({ | ||
| 472 | operation: "execute", | ||
| 473 | detail: | ||
| 474 | error instanceof Error | ||
| 475 | ? `Failed to simulate gh checkout: ${error.message}` | ||
| 476 | : "Failed to simulate gh checkout.", | ||
| 477 | }), | ||
| 478 | }); | ||
| 479 | } | ||
| 480 | |||
| 481 | if (args[0] === "repo" && args[1] === "view") { | ||
| 482 | const repository = args[2]; | ||
| 483 | if (typeof repository === "string" && args.includes("nameWithOwner,url,sshUrl")) { | ||
| 484 | const cloneUrls = scenario.repositoryCloneUrls?.[repository]; | ||
| 485 | if (!cloneUrls) { | ||
| 486 | return Effect.fail( | ||
| 487 | new GitHubCliError({ | ||
| 488 | operation: "execute", | ||
| 489 | detail: `Unexpected repository lookup: ${repository}`, | ||
| 490 | }), | ||
| 491 | ); | ||
| 492 | } | ||
| 493 | return Effect.succeed( | ||
| 494 | fakeGhOutput( | ||
| 495 | JSON.stringify({ | ||
| 496 | nameWithOwner: repository, | ||
| 497 | url: cloneUrls.url, | ||
| 498 | sshUrl: cloneUrls.sshUrl, | ||
| 499 | }) + "\n", | ||
| 500 | ), | ||
| 501 | ); | ||
| 502 | } | ||
| 503 | return Effect.succeed(fakeGhOutput(`${scenario.defaultBranch ?? "main"}\n`)); | ||
| 504 | } | ||
| 505 | |||
| 506 | return Effect.fail( | ||
| 507 | new GitHubCliError({ | ||
| 508 | operation: "execute", | ||
| 509 | detail: `Unexpected gh command: ${args.join(" ")}`, | ||
| 510 | }), | ||
| 511 | ); | ||
| 512 | }; | ||
| 513 | |||
| 514 | return { | ||
| 515 | service: { | ||
| 516 | execute, | ||
| 517 | listOpenPullRequests: (input) => | ||
| 518 | execute({ | ||
| 519 | cwd: input.cwd, | ||
| 520 | args: [ | ||
| 521 | "pr", | ||
| 522 | "list", | ||
| 523 | "--head", | ||
| 524 | input.headSelector, | ||
| 525 | "--state", | ||
| 526 | "open", | ||
| 527 | "--limit", | ||
| 528 | String(input.limit ?? 1), | ||
| 529 | "--json", | ||
| 530 | "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", | ||
| 531 | ], | ||
| 532 | }).pipe( | ||
| 533 | Effect.map((result) => JSON.parse(result.stdout) as unknown[]), | ||
| 534 | Effect.map((raw) => | ||
| 535 | raw | ||
| 536 | .map((entry) => normalizeFakePullRequestSummary(entry)) | ||
| 537 | .filter((entry): entry is GitHubPullRequestSummary => entry !== null), | ||
| 538 | ), | ||
| 539 | ), | ||
| 540 | createPullRequest: (input) => | ||
| 541 | execute({ | ||
| 542 | cwd: input.cwd, | ||
| 543 | args: [ | ||
| 544 | "pr", | ||
| 545 | "create", | ||
| 546 | "--base", | ||
| 547 | input.baseBranch, | ||
| 548 | "--head", | ||
| 549 | input.headSelector, | ||
| 550 | "--title", | ||
| 551 | input.title, | ||
| 552 | "--body-file", | ||
| 553 | input.bodyFile, | ||
| 554 | ], | ||
| 555 | }).pipe(Effect.asVoid), | ||
| 556 | getDefaultBranch: (input) => | ||
| 557 | execute({ | ||
| 558 | cwd: input.cwd, | ||
| 559 | args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], | ||
| 560 | }).pipe( | ||
| 561 | Effect.map((result) => { | ||
| 562 | const value = result.stdout.trim(); | ||
| 563 | return value.length > 0 ? value : null; | ||
| 564 | }), | ||
| 565 | ), | ||
| 566 | getPullRequest: (input) => | ||
| 567 | execute({ | ||
| 568 | cwd: input.cwd, | ||
| 569 | args: [ | ||
| 570 | "pr", | ||
| 571 | "view", | ||
| 572 | input.reference, | ||
| 573 | "--json", | ||
| 574 | "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", | ||
| 575 | ], | ||
| 576 | }).pipe(Effect.map((result) => JSON.parse(result.stdout) as GitHubPullRequestSummary)), | ||
| 577 | getRepositoryCloneUrls: (input) => | ||
| 578 | execute({ | ||
| 579 | cwd: input.cwd, | ||
| 580 | args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], | ||
| 581 | }).pipe(Effect.map((result) => JSON.parse(result.stdout))), | ||
| 582 | checkoutPullRequest: (input) => | ||
| 583 | execute({ | ||
| 584 | cwd: input.cwd, | ||
| 585 | args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], | ||
| 586 | }).pipe(Effect.asVoid), | ||
| 587 | }, | ||
| 588 | ghCalls, | ||
| 589 | }; | ||
| 590 | } | ||
| 591 | |||
| 592 | function runStackedAction( | ||
| 593 | manager: GitManagerShape, | ||
| 594 | input: { | ||
| 595 | cwd: string; | ||
| 596 | action: "commit" | "push" | "create_pr" | "commit_push" | "commit_push_pr"; | ||
| 597 | actionId?: string; | ||
| 598 | commitMessage?: string; | ||
| 599 | featureBranch?: boolean; | ||
| 600 | filePaths?: readonly string[]; | ||
| 601 | }, | ||
| 602 | options?: Parameters<GitManagerShape["runStackedAction"]>[1], | ||
| 603 | ) { | ||
| 604 | return manager.runStackedAction( | ||
| 605 | { | ||
| 606 | ...input, | ||
| 607 | actionId: input.actionId ?? "test-action-id", | ||
| 608 | }, | ||
| 609 | options, | ||
| 610 | ); | ||
| 611 | } | ||
| 612 | |||
| 613 | function resolvePullRequest(manager: GitManagerShape, input: { cwd: string; reference: string }) { | ||
| 614 | return manager.resolvePullRequest(input); | ||
| 615 | } | ||
| 616 | |||
| 617 | function preparePullRequestThread( | ||
| 618 | manager: GitManagerShape, | ||
| 619 | input: GitPreparePullRequestThreadInput, | ||
| 620 | ) { | ||
| 621 | return manager.preparePullRequestThread(input); | ||
| 622 | } | ||
| 623 | |||
| 624 | function makeManager(input?: { | ||
| 625 | ghScenario?: FakeGhScenario; | ||
| 626 | textGeneration?: Partial<FakeGitTextGeneration>; | ||
| 627 | setupScriptRunner?: ProjectSetupScriptRunnerShape; | ||
| 628 | }) { | ||
| 629 | const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); | ||
| 630 | const textGeneration = createTextGeneration(input?.textGeneration); | ||
| 631 | const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { | ||
| 632 | prefix: "t3-git-manager-test-", | ||
| 633 | }); | ||
| 634 | |||
| 635 | const serverSettingsLayer = ServerSettingsService.layerTest(); | ||
| 636 | |||
| 637 | const vcsDriverLayer = GitVcsDriver.layer.pipe( | ||
| 638 | Layer.provideMerge(VcsProcess.layer), | ||
| 639 | Layer.provideMerge(NodeServices.layer), | ||
| 640 | Layer.provideMerge(ServerConfigLayer), | ||
| 641 | ); | ||
| 642 | const sourceControlRegistryLayer = Layer.effect( | ||
| 643 | SourceControlProviderRegistry.SourceControlProviderRegistry, | ||
| 644 | GitHubSourceControlProvider.make().pipe( | ||
| 645 | Effect.map((provider) => | ||
| 646 | SourceControlProviderRegistry.SourceControlProviderRegistry.of({ | ||
| 647 | get: () => Effect.succeed(provider), | ||
| 648 | resolveHandle: () => Effect.succeed({ provider, context: null }), | ||
| 649 | resolve: () => Effect.succeed(provider), | ||
| 650 | }), | ||
| 651 | ), | ||
| 652 | Effect.provide(Layer.succeed(GitHubCli, gitHubCli)), | ||
| 653 | ), | ||
| 654 | ); | ||
| 655 | |||
| 656 | const managerLayer = Layer.mergeAll( | ||
| 657 | Layer.succeed(TextGeneration, textGeneration), | ||
| 658 | Layer.succeed( | ||
| 659 | ProjectSetupScriptRunner, | ||
| 660 | input?.setupScriptRunner ?? { | ||
| 661 | runForThread: () => Effect.succeed({ status: "no-script" as const }), | ||
| 662 | }, | ||
| 663 | ), | ||
| 664 | vcsDriverLayer, | ||
| 665 | serverSettingsLayer, | ||
| 666 | ).pipe(Layer.provideMerge(sourceControlRegistryLayer), Layer.provideMerge(NodeServices.layer)); | ||
| 667 | |||
| 668 | return makeGitManager().pipe( | ||
| 669 | Effect.provide(managerLayer), | ||
| 670 | Effect.map((manager) => ({ manager, ghCalls })), | ||
| 671 | ); | ||
| 672 | } | ||
| 673 | |||
| 674 | const asThreadId = (threadId: string) => threadId as ThreadId; | ||
| 675 | |||
| 676 | const GitManagerTestLayer = GitVcsDriver.layer.pipe( | ||
| 677 | Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })), | ||
| 678 | Layer.provideMerge(VcsProcess.layer), | ||
| 679 | Layer.provideMerge(NodeServices.layer), | ||
| 680 | ); | ||
| 681 | |||
| 682 | it.layer(GitManagerTestLayer)("GitManager", (it) => { | ||
| 683 | it.effect("status includes PR metadata when branch already has an open PR", () => | ||
| 684 | Effect.gen(function* () { | ||
| 685 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 686 | yield* initRepo(repoDir); | ||
| 687 | yield* runGit(repoDir, ["checkout", "-b", "feature/status-open-pr"]); | ||
| 688 | const remoteDir = yield* createBareRemote(); | ||
| 689 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 690 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-open-pr"]); | ||
| 691 | |||
| 692 | const { manager } = yield* makeManager({ | ||
| 693 | ghScenario: { | ||
| 694 | prListSequence: [ | ||
| 695 | JSON.stringify([ | ||
| 696 | { | ||
| 697 | number: 13, | ||
| 698 | title: "Existing PR", | ||
| 699 | url: "https://github.com/pingdotgg/codething-mvp/pull/13", | ||
| 700 | baseRefName: "main", | ||
| 701 | headRefName: "feature/status-open-pr", | ||
| 702 | }, | ||
| 703 | ]), | ||
| 704 | ], | ||
| 705 | }, | ||
| 706 | }); | ||
| 707 | |||
| 708 | const status = yield* manager.status({ cwd: repoDir }); | ||
| 709 | expect(status.isRepo).toBe(true); | ||
| 710 | expect(status.hasPrimaryRemote).toBe(true); | ||
| 711 | expect(status.isDefaultRef).toBe(false); | ||
| 712 | expect(status.refName).toBe("feature/status-open-pr"); | ||
| 713 | expect(status.pr).toEqual({ | ||
| 714 | number: 13, | ||
| 715 | title: "Existing PR", | ||
| 716 | url: "https://github.com/pingdotgg/codething-mvp/pull/13", | ||
| 717 | baseRef: "main", | ||
| 718 | headRef: "feature/status-open-pr", | ||
| 719 | state: "open", | ||
| 720 | }); | ||
| 721 | }), | ||
| 722 | ); | ||
| 723 | |||
| 724 | it.effect("status trims PR metadata returned by gh before publishing it", () => | ||
| 725 | Effect.gen(function* () { | ||
| 726 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 727 | yield* initRepo(repoDir); | ||
| 728 | yield* runGit(repoDir, ["checkout", "-b", "feature/status-trimmed-pr"]); | ||
| 729 | const remoteDir = yield* createBareRemote(); | ||
| 730 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 731 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-trimmed-pr"]); | ||
| 732 | |||
| 733 | const { manager } = yield* makeManager({ | ||
| 734 | ghScenario: { | ||
| 735 | prListSequence: [ | ||
| 736 | JSON.stringify([ | ||
| 737 | { | ||
| 738 | number: 14, | ||
| 739 | title: " Existing PR title \n", | ||
| 740 | url: " https://github.com/pingdotgg/codething-mvp/pull/14 ", | ||
| 741 | baseRefName: " main ", | ||
| 742 | headRefName: "\tfeature/status-trimmed-pr\t", | ||
| 743 | }, | ||
| 744 | ]), | ||
| 745 | ], | ||
| 746 | }, | ||
| 747 | }); | ||
| 748 | |||
| 749 | const status = yield* manager.status({ cwd: repoDir }); | ||
| 750 | |||
| 751 | expect(status.pr).toEqual({ | ||
| 752 | number: 14, | ||
| 753 | title: "Existing PR title", | ||
| 754 | url: "https://github.com/pingdotgg/codething-mvp/pull/14", | ||
| 755 | baseRef: "main", | ||
| 756 | headRef: "feature/status-trimmed-pr", | ||
| 757 | state: "open", | ||
| 758 | }); | ||
| 759 | }), | ||
| 760 | ); | ||
| 761 | |||
| 762 | it.effect("status ignores invalid gh pr list entries and keeps valid ones", () => | ||
| 763 | Effect.gen(function* () { | ||
| 764 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 765 | yield* initRepo(repoDir); | ||
| 766 | yield* runGit(repoDir, ["checkout", "-b", "feature/status-valid-pr-entry"]); | ||
| 767 | const remoteDir = yield* createBareRemote(); | ||
| 768 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 769 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-valid-pr-entry"]); | ||
| 770 | |||
| 771 | const { manager } = yield* makeManager({ | ||
| 772 | ghScenario: { | ||
| 773 | prListSequence: [ | ||
| 774 | JSON.stringify([ | ||
| 775 | { | ||
| 776 | number: 0, | ||
| 777 | title: "invalid", | ||
| 778 | url: "https://github.com/pingdotgg/codething-mvp/pull/0", | ||
| 779 | baseRefName: "main", | ||
| 780 | headRefName: "feature/invalid", | ||
| 781 | }, | ||
| 782 | { | ||
| 783 | number: 15, | ||
| 784 | title: " Valid PR title ", | ||
| 785 | url: " https://github.com/pingdotgg/codething-mvp/pull/15 ", | ||
| 786 | baseRefName: " main ", | ||
| 787 | headRefName: "\tfeature/status-valid-pr-entry\t", | ||
| 788 | headRepository: { | ||
| 789 | nameWithOwner: " ", | ||
| 790 | }, | ||
| 791 | headRepositoryOwner: { | ||
| 792 | login: " ", | ||
| 793 | }, | ||
| 794 | }, | ||
| 795 | ]), | ||
| 796 | ], | ||
| 797 | }, | ||
| 798 | }); | ||
| 799 | |||
| 800 | const status = yield* manager.status({ cwd: repoDir }); | ||
| 801 | |||
| 802 | expect(status.pr).toEqual({ | ||
| 803 | number: 15, | ||
| 804 | title: "Valid PR title", | ||
| 805 | url: "https://github.com/pingdotgg/codething-mvp/pull/15", | ||
| 806 | baseRef: "main", | ||
| 807 | headRef: "feature/status-valid-pr-entry", | ||
| 808 | state: "open", | ||
| 809 | }); | ||
| 810 | }), | ||
| 811 | ); | ||
| 812 | |||
| 813 | it.effect("status preserves lowercase merged and closed PR states from gh json", () => | ||
| 814 | Effect.gen(function* () { | ||
| 815 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 816 | yield* initRepo(repoDir); | ||
| 817 | yield* runGit(repoDir, ["checkout", "-b", "feature/status-lowercase-state"]); | ||
| 818 | const remoteDir = yield* createBareRemote(); | ||
| 819 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 820 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-lowercase-state"]); | ||
| 821 | |||
| 822 | const { manager } = yield* makeManager({ | ||
| 823 | ghScenario: { | ||
| 824 | prListSequence: [ | ||
| 825 | JSON.stringify([ | ||
| 826 | { | ||
| 827 | number: 16, | ||
| 828 | title: "Closed PR", | ||
| 829 | url: "https://github.com/pingdotgg/codething-mvp/pull/16", | ||
| 830 | baseRefName: "main", | ||
| 831 | headRefName: "feature/status-lowercase-state", | ||
| 832 | state: "closed", | ||
| 833 | updatedAt: "2026-01-01T00:00:00.000Z", | ||
| 834 | }, | ||
| 835 | { | ||
| 836 | number: 17, | ||
| 837 | title: "Merged PR", | ||
| 838 | url: "https://github.com/pingdotgg/codething-mvp/pull/17", | ||
| 839 | baseRefName: "main", | ||
| 840 | headRefName: "feature/status-lowercase-state", | ||
| 841 | state: "merged", | ||
| 842 | updatedAt: "2026-01-02T00:00:00.000Z", | ||
| 843 | }, | ||
| 844 | ]), | ||
| 845 | ], | ||
| 846 | }, | ||
| 847 | }); | ||
| 848 | |||
| 849 | const status = yield* manager.status({ cwd: repoDir }); | ||
| 850 | |||
| 851 | expect(status.pr).toEqual({ | ||
| 852 | number: 17, | ||
| 853 | title: "Merged PR", | ||
| 854 | url: "https://github.com/pingdotgg/codething-mvp/pull/17", | ||
| 855 | baseRef: "main", | ||
| 856 | headRef: "feature/status-lowercase-state", | ||
| 857 | state: "merged", | ||
| 858 | }); | ||
| 859 | }), | ||
| 860 | ); | ||
| 861 | |||
| 862 | it.effect("status returns an explicit non-repo result for non-git directories", () => | ||
| 863 | Effect.gen(function* () { | ||
| 864 | const cwd = yield* makeTempDir("t3code-git-manager-non-repo-"); | ||
| 865 | const { manager } = yield* makeManager(); | ||
| 866 | |||
| 867 | const status = yield* manager.status({ cwd }); | ||
| 868 | |||
| 869 | expect(status).toEqual({ | ||
| 870 | isRepo: false, | ||
| 871 | hasPrimaryRemote: false, | ||
| 872 | isDefaultRef: false, | ||
| 873 | refName: null, | ||
| 874 | hasWorkingTreeChanges: false, | ||
| 875 | workingTree: { | ||
| 876 | files: [], | ||
| 877 | insertions: 0, | ||
| 878 | deletions: 0, | ||
| 879 | }, | ||
| 880 | hasUpstream: false, | ||
| 881 | aheadCount: 0, | ||
| 882 | behindCount: 0, | ||
| 883 | pr: null, | ||
| 884 | }); | ||
| 885 | }), | ||
| 886 | ); | ||
| 887 | |||
| 888 | it.effect("status returns an explicit non-repo result for deleted directories", () => | ||
| 889 | Effect.gen(function* () { | ||
| 890 | const rootDir = yield* makeTempDir("t3code-git-manager-missing-dir-"); | ||
| 891 | const cwd = path.join(rootDir, "deleted-repo"); | ||
| 892 | yield* makeDirectory(cwd); | ||
| 893 | yield* removePath(cwd); | ||
| 894 | const { manager } = yield* makeManager(); | ||
| 895 | |||
| 896 | const status = yield* manager.status({ cwd }); | ||
| 897 | |||
| 898 | expect(status).toEqual({ | ||
| 899 | isRepo: false, | ||
| 900 | hasPrimaryRemote: false, | ||
| 901 | isDefaultRef: false, | ||
| 902 | refName: null, | ||
| 903 | hasWorkingTreeChanges: false, | ||
| 904 | workingTree: { | ||
| 905 | files: [], | ||
| 906 | insertions: 0, | ||
| 907 | deletions: 0, | ||
| 908 | }, | ||
| 909 | hasUpstream: false, | ||
| 910 | aheadCount: 0, | ||
| 911 | behindCount: 0, | ||
| 912 | pr: null, | ||
| 913 | }); | ||
| 914 | }), | ||
| 915 | ); | ||
| 916 | |||
| 917 | it.effect("status briefly caches repeated lookups for the same cwd", () => | ||
| 918 | Effect.gen(function* () { | ||
| 919 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 920 | yield* initRepo(repoDir); | ||
| 921 | yield* runGit(repoDir, ["checkout", "-b", "feature/status-cache"]); | ||
| 922 | const remoteDir = yield* createBareRemote(); | ||
| 923 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 924 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-cache"]); | ||
| 925 | |||
| 926 | const existingPr = { | ||
| 927 | number: 113, | ||
| 928 | title: "Cached PR", | ||
| 929 | url: "https://github.com/pingdotgg/codething-mvp/pull/113", | ||
| 930 | baseRefName: "main", | ||
| 931 | headRefName: "feature/status-cache", | ||
| 932 | }; | ||
| 933 | const { manager, ghCalls } = yield* makeManager({ | ||
| 934 | ghScenario: { | ||
| 935 | prListSequence: [JSON.stringify([existingPr]), JSON.stringify([existingPr])], | ||
| 936 | }, | ||
| 937 | }); | ||
| 938 | |||
| 939 | const first = yield* manager.status({ cwd: repoDir }); | ||
| 940 | const second = yield* manager.status({ cwd: repoDir }); | ||
| 941 | |||
| 942 | expect(first.pr?.number).toBe(113); | ||
| 943 | expect(second.pr?.number).toBe(113); | ||
| 944 | expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1); | ||
| 945 | }), | ||
| 946 | ); | ||
| 947 | |||
| 948 | it.effect( | ||
| 949 | "status ignores unrelated fork PRs when the current branch tracks the same repository", | ||
| 950 | () => | ||
| 951 | Effect.gen(function* () { | ||
| 952 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 953 | yield* initRepo(repoDir); | ||
| 954 | const remoteDir = yield* createBareRemote(); | ||
| 955 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 956 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); | ||
| 957 | |||
| 958 | const { manager } = yield* makeManager({ | ||
| 959 | ghScenario: { | ||
| 960 | prListSequence: [ | ||
| 961 | JSON.stringify([ | ||
| 962 | { | ||
| 963 | number: 1661, | ||
| 964 | title: "Fork PR from main", | ||
| 965 | url: "https://github.com/pingdotgg/t3code/pull/1661", | ||
| 966 | baseRefName: "main", | ||
| 967 | headRefName: "main", | ||
| 968 | state: "OPEN", | ||
| 969 | updatedAt: "2026-04-01T15:00:00Z", | ||
| 970 | isCrossRepository: true, | ||
| 971 | headRepository: { | ||
| 972 | nameWithOwner: "lnieuwenhuis/t3code", | ||
| 973 | }, | ||
| 974 | headRepositoryOwner: { | ||
| 975 | login: "lnieuwenhuis", | ||
| 976 | }, | ||
| 977 | }, | ||
| 978 | ]), | ||
| 979 | ], | ||
| 980 | }, | ||
| 981 | }); | ||
| 982 | |||
| 983 | const status = yield* manager.status({ cwd: repoDir }); | ||
| 984 | expect(status.refName).toBe("main"); | ||
| 985 | expect(status.pr).toBeNull(); | ||
| 986 | }), | ||
| 987 | ); | ||
| 988 | |||
| 989 | it.effect( | ||
| 990 | "status detects cross-repo PRs from the upstream remote URL owner", | ||
| 991 | () => | ||
| 992 | Effect.gen(function* () { | ||
| 993 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 994 | yield* initRepo(repoDir); | ||
| 995 | const forkDir = yield* createBareRemote(); | ||
| 996 | yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); | ||
| 997 | yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); | ||
| 998 | fs.writeFileSync(path.join(repoDir, "fork-pr.txt"), "fork pr\n"); | ||
| 999 | yield* runGit(repoDir, ["add", "fork-pr.txt"]); | ||
| 1000 | yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]); | ||
| 1001 | yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); | ||
| 1002 | yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-488/statemachine"]); | ||
| 1003 | yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); | ||
| 1004 | yield* runGit(repoDir, [ | ||
| 1005 | "config", | ||
| 1006 | "remote.fork-seed.url", | ||
| 1007 | "git@github.com:jasonLaster/codething-mvp.git", | ||
| 1008 | ]); | ||
| 1009 | |||
| 1010 | const { manager, ghCalls } = yield* makeManager({ | ||
| 1011 | ghScenario: { | ||
| 1012 | prListSequence: [ | ||
| 1013 | JSON.stringify([]), | ||
| 1014 | JSON.stringify([]), | ||
| 1015 | JSON.stringify([ | ||
| 1016 | { | ||
| 1017 | number: 488, | ||
| 1018 | title: "Rebase this PR on latest main", | ||
| 1019 | url: "https://github.com/pingdotgg/codething-mvp/pull/488", | ||
| 1020 | baseRefName: "main", | ||
| 1021 | headRefName: "statemachine", | ||
| 1022 | state: "OPEN", | ||
| 1023 | updatedAt: "2026-03-10T07:00:00Z", | ||
| 1024 | isCrossRepository: true, | ||
| 1025 | headRepository: { | ||
| 1026 | nameWithOwner: "jasonLaster/codething-mvp", | ||
| 1027 | }, | ||
| 1028 | headRepositoryOwner: { | ||
| 1029 | login: "jasonLaster", | ||
| 1030 | }, | ||
| 1031 | }, | ||
| 1032 | ]), | ||
| 1033 | ], | ||
| 1034 | }, | ||
| 1035 | }); | ||
| 1036 | |||
| 1037 | const status = yield* manager.status({ cwd: repoDir }); | ||
| 1038 | expect(status.refName).toBe("t3code/pr-488/statemachine"); | ||
| 1039 | expect(status.pr).toEqual({ | ||
| 1040 | number: 488, | ||
| 1041 | title: "Rebase this PR on latest main", | ||
| 1042 | url: "https://github.com/pingdotgg/codething-mvp/pull/488", | ||
| 1043 | baseRef: "main", | ||
| 1044 | headRef: "statemachine", | ||
| 1045 | state: "open", | ||
| 1046 | }); | ||
| 1047 | expect(ghCalls).toContain( | ||
| 1048 | "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | ||
| 1049 | ); | ||
| 1050 | }), | ||
| 1051 | 20_000, | ||
| 1052 | ); | ||
| 1053 | |||
| 1054 | it.effect( | ||
| 1055 | "status ignores synthetic local branch aliases when the upstream remote name contains slashes", | ||
| 1056 | () => | ||
| 1057 | Effect.gen(function* () { | ||
| 1058 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1059 | yield* initRepo(repoDir); | ||
| 1060 | const originDir = yield* createBareRemote(); | ||
| 1061 | const upstreamDir = yield* createBareRemote(); | ||
| 1062 | yield* configureRemote(repoDir, "origin", originDir, "origin"); | ||
| 1063 | yield* configureRemote(repoDir, "my-org/upstream", upstreamDir, "my-org/upstream"); | ||
| 1064 | |||
| 1065 | yield* runGit(repoDir, ["checkout", "-b", "effect-atom"]); | ||
| 1066 | yield* runGit(repoDir, ["push", "-u", "origin", "effect-atom"]); | ||
| 1067 | yield* runGit(repoDir, ["push", "-u", "my-org/upstream", "effect-atom"]); | ||
| 1068 | yield* runGit(repoDir, [ | ||
| 1069 | "config", | ||
| 1070 | "remote.origin.url", | ||
| 1071 | "git@github.com:pingdotgg/codething-mvp.git", | ||
| 1072 | ]); | ||
| 1073 | yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]); | ||
| 1074 | yield* runGit(repoDir, [ | ||
| 1075 | "config", | ||
| 1076 | "remote.my-org/upstream.url", | ||
| 1077 | "git@github.com:pingdotgg/codething-mvp.git", | ||
| 1078 | ]); | ||
| 1079 | yield* runGit(repoDir, ["config", "remote.my-org/upstream.pushurl", upstreamDir]); | ||
| 1080 | yield* runGit(repoDir, ["checkout", "main"]); | ||
| 1081 | yield* runGit(repoDir, ["branch", "-D", "effect-atom"]); | ||
| 1082 | yield* runGit(repoDir, ["checkout", "--track", "my-org/upstream/effect-atom"]); | ||
| 1083 | |||
| 1084 | const { manager, ghCalls } = yield* makeManager({ | ||
| 1085 | ghScenario: { | ||
| 1086 | prListByHeadSelector: { | ||
| 1087 | "effect-atom": JSON.stringify([ | ||
| 1088 | { | ||
| 1089 | number: 1618, | ||
| 1090 | title: "Correct PR", | ||
| 1091 | url: "https://github.com/pingdotgg/t3code/pull/1618", | ||
| 1092 | baseRefName: "main", | ||
| 1093 | headRefName: "effect-atom", | ||
| 1094 | state: "OPEN", | ||
| 1095 | updatedAt: "2026-03-01T10:00:00Z", | ||
| 1096 | }, | ||
| 1097 | ]), | ||
| 1098 | "upstream/effect-atom": JSON.stringify([ | ||
| 1099 | { | ||
| 1100 | number: 1518, | ||
| 1101 | title: "Wrong PR", | ||
| 1102 | url: "https://github.com/pingdotgg/t3code/pull/1518", | ||
| 1103 | baseRefName: "main", | ||
| 1104 | headRefName: "upstream/effect-atom", | ||
| 1105 | state: "OPEN", | ||
| 1106 | updatedAt: "2026-04-01T10:00:00Z", | ||
| 1107 | }, | ||
| 1108 | ]), | ||
| 1109 | "pingdotgg:effect-atom": JSON.stringify([]), | ||
| 1110 | "my-org/upstream:effect-atom": JSON.stringify([]), | ||
| 1111 | "pingdotgg:upstream/effect-atom": JSON.stringify([ | ||
| 1112 | { | ||
| 1113 | number: 1518, | ||
| 1114 | title: "Wrong PR", | ||
| 1115 | url: "https://github.com/pingdotgg/t3code/pull/1518", | ||
| 1116 | baseRefName: "main", | ||
| 1117 | headRefName: "upstream/effect-atom", | ||
| 1118 | state: "OPEN", | ||
| 1119 | updatedAt: "2026-04-01T10:00:00Z", | ||
| 1120 | }, | ||
| 1121 | ]), | ||
| 1122 | "my-org/upstream:upstream/effect-atom": JSON.stringify([ | ||
| 1123 | { | ||
| 1124 | number: 1518, | ||
| 1125 | title: "Wrong PR", | ||
| 1126 | url: "https://github.com/pingdotgg/t3code/pull/1518", | ||
| 1127 | baseRefName: "main", | ||
| 1128 | headRefName: "upstream/effect-atom", | ||
| 1129 | state: "OPEN", | ||
| 1130 | updatedAt: "2026-04-01T10:00:00Z", | ||
| 1131 | }, | ||
| 1132 | ]), | ||
| 1133 | }, | ||
| 1134 | }, | ||
| 1135 | }); | ||
| 1136 | |||
| 1137 | const status = yield* manager.status({ cwd: repoDir }); | ||
| 1138 | expect(status.refName).toBe("upstream/effect-atom"); | ||
| 1139 | expect(status.pr).toEqual({ | ||
| 1140 | number: 1618, | ||
| 1141 | title: "Correct PR", | ||
| 1142 | url: "https://github.com/pingdotgg/t3code/pull/1618", | ||
| 1143 | baseRef: "main", | ||
| 1144 | headRef: "effect-atom", | ||
| 1145 | state: "open", | ||
| 1146 | }); | ||
| 1147 | expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe( | ||
| 1148 | false, | ||
| 1149 | ); | ||
| 1150 | expect( | ||
| 1151 | ghCalls.some((call) => call.includes("pr list --head pingdotgg:upstream/effect-atom ")), | ||
| 1152 | ).toBe(false); | ||
| 1153 | expect( | ||
| 1154 | ghCalls.some((call) => | ||
| 1155 | call.includes("pr list --head my-org/upstream:upstream/effect-atom "), | ||
| 1156 | ), | ||
| 1157 | ).toBe(false); | ||
| 1158 | }), | ||
| 1159 | 20_000, | ||
| 1160 | ); | ||
| 1161 | |||
| 1162 | it.effect("status returns merged PR state when latest PR was merged", () => | ||
| 1163 | Effect.gen(function* () { | ||
| 1164 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1165 | yield* initRepo(repoDir); | ||
| 1166 | yield* runGit(repoDir, ["checkout", "-b", "feature/status-merged-pr"]); | ||
| 1167 | |||
| 1168 | const { manager } = yield* makeManager({ | ||
| 1169 | ghScenario: { | ||
| 1170 | prListSequence: [ | ||
| 1171 | JSON.stringify([ | ||
| 1172 | { | ||
| 1173 | number: 22, | ||
| 1174 | title: "Merged PR", | ||
| 1175 | url: "https://github.com/pingdotgg/codething-mvp/pull/22", | ||
| 1176 | baseRefName: "main", | ||
| 1177 | headRefName: "feature/status-merged-pr", | ||
| 1178 | state: "MERGED", | ||
| 1179 | mergedAt: "2026-01-30T10:00:00Z", | ||
| 1180 | updatedAt: "2026-01-30T10:00:00Z", | ||
| 1181 | }, | ||
| 1182 | ]), | ||
| 1183 | ], | ||
| 1184 | }, | ||
| 1185 | }); | ||
| 1186 | |||
| 1187 | const status = yield* manager.status({ cwd: repoDir }); | ||
| 1188 | expect(status.refName).toBe("feature/status-merged-pr"); | ||
| 1189 | expect(status.pr).toEqual({ | ||
| 1190 | number: 22, | ||
| 1191 | title: "Merged PR", | ||
| 1192 | url: "https://github.com/pingdotgg/codething-mvp/pull/22", | ||
| 1193 | baseRef: "main", | ||
| 1194 | headRef: "feature/status-merged-pr", | ||
| 1195 | state: "merged", | ||
| 1196 | }); | ||
| 1197 | }), | ||
| 1198 | ); | ||
| 1199 | |||
| 1200 | it.effect("status hides merged PRs on the default branch", () => | ||
| 1201 | Effect.gen(function* () { | ||
| 1202 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1203 | yield* initRepo(repoDir); | ||
| 1204 | |||
| 1205 | const { manager } = yield* makeManager({ | ||
| 1206 | ghScenario: { | ||
| 1207 | prListSequence: [ | ||
| 1208 | JSON.stringify([ | ||
| 1209 | { | ||
| 1210 | number: 23, | ||
| 1211 | title: "Merged PR", | ||
| 1212 | url: "https://github.com/pingdotgg/codething-mvp/pull/23", | ||
| 1213 | baseRefName: "feature/status-default-branch-target", | ||
| 1214 | headRefName: "main", | ||
| 1215 | state: "MERGED", | ||
| 1216 | mergedAt: "2026-01-30T10:00:00Z", | ||
| 1217 | updatedAt: "2026-01-30T10:00:00Z", | ||
| 1218 | }, | ||
| 1219 | ]), | ||
| 1220 | ], | ||
| 1221 | }, | ||
| 1222 | }); | ||
| 1223 | |||
| 1224 | const status = yield* manager.status({ cwd: repoDir }); | ||
| 1225 | expect(status.refName).toBe("main"); | ||
| 1226 | expect(status.pr).toBeNull(); | ||
| 1227 | }), | ||
| 1228 | ); | ||
| 1229 | |||
| 1230 | it.effect("status prefers open PR when merged PR has newer updatedAt", () => | ||
| 1231 | Effect.gen(function* () { | ||
| 1232 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1233 | yield* initRepo(repoDir); | ||
| 1234 | yield* runGit(repoDir, ["checkout", "-b", "feature/status-open-over-merged"]); | ||
| 1235 | |||
| 1236 | const { manager } = yield* makeManager({ | ||
| 1237 | ghScenario: { | ||
| 1238 | prListSequence: [ | ||
| 1239 | JSON.stringify([ | ||
| 1240 | { | ||
| 1241 | number: 45, | ||
| 1242 | title: "Merged PR", | ||
| 1243 | url: "https://github.com/pingdotgg/codething-mvp/pull/45", | ||
| 1244 | baseRefName: "main", | ||
| 1245 | headRefName: "feature/status-open-over-merged", | ||
| 1246 | state: "MERGED", | ||
| 1247 | mergedAt: "2026-01-31T10:00:00Z", | ||
| 1248 | updatedAt: "2026-02-01T10:00:00Z", | ||
| 1249 | }, | ||
| 1250 | { | ||
| 1251 | number: 46, | ||
| 1252 | title: "Open PR", | ||
| 1253 | url: "https://github.com/pingdotgg/codething-mvp/pull/46", | ||
| 1254 | baseRefName: "main", | ||
| 1255 | headRefName: "feature/status-open-over-merged", | ||
| 1256 | state: "OPEN", | ||
| 1257 | updatedAt: "2026-01-30T10:00:00Z", | ||
| 1258 | }, | ||
| 1259 | ]), | ||
| 1260 | ], | ||
| 1261 | }, | ||
| 1262 | }); | ||
| 1263 | |||
| 1264 | const status = yield* manager.status({ cwd: repoDir }); | ||
| 1265 | expect(status.refName).toBe("feature/status-open-over-merged"); | ||
| 1266 | expect(status.pr).toEqual({ | ||
| 1267 | number: 46, | ||
| 1268 | title: "Open PR", | ||
| 1269 | url: "https://github.com/pingdotgg/codething-mvp/pull/46", | ||
| 1270 | baseRef: "main", | ||
| 1271 | headRef: "feature/status-open-over-merged", | ||
| 1272 | state: "open", | ||
| 1273 | }); | ||
| 1274 | }), | ||
| 1275 | ); | ||
| 1276 | |||
| 1277 | it.effect("status is resilient to gh lookup failures and returns pr null", () => | ||
| 1278 | Effect.gen(function* () { | ||
| 1279 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1280 | yield* initRepo(repoDir); | ||
| 1281 | yield* runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]); | ||
| 1282 | const remoteDir = yield* createBareRemote(); | ||
| 1283 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 1284 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-no-gh"]); | ||
| 1285 | |||
| 1286 | const { manager } = yield* makeManager({ | ||
| 1287 | ghScenario: { | ||
| 1288 | failWith: new GitHubCliError({ | ||
| 1289 | operation: "execute", | ||
| 1290 | detail: "GitHub CLI (`gh`) is required but not available on PATH.", | ||
| 1291 | }), | ||
| 1292 | }, | ||
| 1293 | }); | ||
| 1294 | |||
| 1295 | const status = yield* manager.status({ cwd: repoDir }); | ||
| 1296 | expect(status.refName).toBe("feature/status-no-gh"); | ||
| 1297 | expect(status.pr).toBeNull(); | ||
| 1298 | }), | ||
| 1299 | ); | ||
| 1300 | |||
| 1301 | it.effect("creates a commit when working tree is dirty", () => | ||
| 1302 | Effect.gen(function* () { | ||
| 1303 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1304 | yield* initRepo(repoDir); | ||
| 1305 | fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nworld\n"); | ||
| 1306 | |||
| 1307 | const { manager } = yield* makeManager(); | ||
| 1308 | const result = yield* runStackedAction(manager, { | ||
| 1309 | cwd: repoDir, | ||
| 1310 | action: "commit", | ||
| 1311 | }); | ||
| 1312 | |||
| 1313 | expect(result.branch.status).toBe("skipped_not_requested"); | ||
| 1314 | expect(result.commit.status).toBe("created"); | ||
| 1315 | expect(result.push.status).toBe("skipped_not_requested"); | ||
| 1316 | expect(result.pr.status).toBe("skipped_not_requested"); | ||
| 1317 | expect(result.toast).toMatchObject({ | ||
| 1318 | description: "Implement stacked git actions", | ||
| 1319 | cta: { | ||
| 1320 | kind: "run_action", | ||
| 1321 | label: "Push", | ||
| 1322 | action: { | ||
| 1323 | kind: "push", | ||
| 1324 | }, | ||
| 1325 | }, | ||
| 1326 | }); | ||
| 1327 | expect(result.toast.title).toMatch(/^Committed [0-9a-f]{7}$/); | ||
| 1328 | expect( | ||
| 1329 | yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe( | ||
| 1330 | Effect.map((result) => result.stdout.trim()), | ||
| 1331 | ), | ||
| 1332 | ).toBe("Implement stacked git actions"); | ||
| 1333 | }), | ||
| 1334 | ); | ||
| 1335 | |||
| 1336 | it.effect("uses custom commit message when provided", () => | ||
| 1337 | Effect.gen(function* () { | ||
| 1338 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1339 | yield* initRepo(repoDir); | ||
| 1340 | fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom\n"); | ||
| 1341 | let generatedCount = 0; | ||
| 1342 | |||
| 1343 | const { manager } = yield* makeManager({ | ||
| 1344 | textGeneration: { | ||
| 1345 | generateCommitMessage: (input) => | ||
| 1346 | Effect.sync(() => { | ||
| 1347 | generatedCount += 1; | ||
| 1348 | return { | ||
| 1349 | subject: "this should not be used", | ||
| 1350 | body: "", | ||
| 1351 | ...(input.includeBranch ? { branch: "feature/unused" } : {}), | ||
| 1352 | }; | ||
| 1353 | }), | ||
| 1354 | }, | ||
| 1355 | }); | ||
| 1356 | const result = yield* runStackedAction(manager, { | ||
| 1357 | cwd: repoDir, | ||
| 1358 | action: "commit", | ||
| 1359 | commitMessage: "feat: custom summary line\n\n- details from user", | ||
| 1360 | }); | ||
| 1361 | |||
| 1362 | expect(result.branch.status).toBe("skipped_not_requested"); | ||
| 1363 | expect(result.commit.status).toBe("created"); | ||
| 1364 | expect(result.commit.subject).toBe("feat: custom summary line"); | ||
| 1365 | expect(generatedCount).toBe(0); | ||
| 1366 | expect( | ||
| 1367 | yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe( | ||
| 1368 | Effect.map((result) => result.stdout.trim()), | ||
| 1369 | ), | ||
| 1370 | ).toBe("feat: custom summary line"); | ||
| 1371 | expect( | ||
| 1372 | yield* runGit(repoDir, ["log", "-1", "--pretty=%b"]).pipe( | ||
| 1373 | Effect.map((result) => result.stdout.trim()), | ||
| 1374 | ), | ||
| 1375 | ).toContain("- details from user"); | ||
| 1376 | }), | ||
| 1377 | ); | ||
| 1378 | |||
| 1379 | it.effect("commits only selected files when filePaths is provided", () => | ||
| 1380 | Effect.gen(function* () { | ||
| 1381 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1382 | yield* initRepo(repoDir); | ||
| 1383 | fs.writeFileSync(path.join(repoDir, "a.txt"), "file a\n"); | ||
| 1384 | fs.writeFileSync(path.join(repoDir, "b.txt"), "file b\n"); | ||
| 1385 | |||
| 1386 | const { manager } = yield* makeManager(); | ||
| 1387 | const result = yield* runStackedAction(manager, { | ||
| 1388 | cwd: repoDir, | ||
| 1389 | action: "commit", | ||
| 1390 | filePaths: ["a.txt"], | ||
| 1391 | }); | ||
| 1392 | |||
| 1393 | expect(result.commit.status).toBe("created"); | ||
| 1394 | |||
| 1395 | // b.txt should remain in the working tree | ||
| 1396 | const statusStdout = yield* runGit(repoDir, ["status", "--porcelain"]).pipe( | ||
| 1397 | Effect.map((r) => r.stdout), | ||
| 1398 | ); | ||
| 1399 | expect(statusStdout).toContain("b.txt"); | ||
| 1400 | expect(statusStdout).not.toContain("a.txt"); | ||
| 1401 | }), | ||
| 1402 | ); | ||
| 1403 | |||
| 1404 | it.effect("creates feature branch, commits, and pushes with featureBranch option", () => | ||
| 1405 | Effect.gen(function* () { | ||
| 1406 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1407 | yield* initRepo(repoDir); | ||
| 1408 | const remoteDir = yield* createBareRemote(); | ||
| 1409 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 1410 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); | ||
| 1411 | fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nfeature-branch\n"); | ||
| 1412 | let generatedCount = 0; | ||
| 1413 | |||
| 1414 | const { manager } = yield* makeManager({ | ||
| 1415 | textGeneration: { | ||
| 1416 | generateCommitMessage: (input) => | ||
| 1417 | Effect.sync(() => { | ||
| 1418 | generatedCount += 1; | ||
| 1419 | return { | ||
| 1420 | subject: "Implement stacked git actions", | ||
| 1421 | body: "", | ||
| 1422 | ...(input.includeBranch ? { branch: "feature/implement-stacked-git-actions" } : {}), | ||
| 1423 | }; | ||
| 1424 | }), | ||
| 1425 | }, | ||
| 1426 | }); | ||
| 1427 | const result = yield* runStackedAction(manager, { | ||
| 1428 | cwd: repoDir, | ||
| 1429 | action: "commit_push", | ||
| 1430 | featureBranch: true, | ||
| 1431 | }); | ||
| 1432 | |||
| 1433 | expect(result.branch.status).toBe("created"); | ||
| 1434 | expect(result.branch.name).toBe("feature/implement-stacked-git-actions"); | ||
| 1435 | expect(result.commit.status).toBe("created"); | ||
| 1436 | expect(result.push.status).toBe("pushed"); | ||
| 1437 | expect(result.toast).toMatchObject({ | ||
| 1438 | description: "Implement stacked git actions", | ||
| 1439 | cta: { | ||
| 1440 | kind: "run_action", | ||
| 1441 | label: "Create PR", | ||
| 1442 | action: { | ||
| 1443 | kind: "create_pr", | ||
| 1444 | }, | ||
| 1445 | }, | ||
| 1446 | }); | ||
| 1447 | expect(result.toast.title).toMatch( | ||
| 1448 | /^Pushed [0-9a-f]{7} to origin\/feature\/implement-stacked-git-actions$/, | ||
| 1449 | ); | ||
| 1450 | expect( | ||
| 1451 | yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "HEAD"]).pipe( | ||
| 1452 | Effect.map((result) => result.stdout.trim()), | ||
| 1453 | ), | ||
| 1454 | ).toBe("feature/implement-stacked-git-actions"); | ||
| 1455 | |||
| 1456 | const mainSha = yield* runGit(repoDir, ["rev-parse", "main"]).pipe( | ||
| 1457 | Effect.map((r) => r.stdout.trim()), | ||
| 1458 | ); | ||
| 1459 | const mergeBase = yield* runGit(repoDir, ["merge-base", "main", "HEAD"]).pipe( | ||
| 1460 | Effect.map((r) => r.stdout.trim()), | ||
| 1461 | ); | ||
| 1462 | expect(mergeBase).toBe(mainSha); | ||
| 1463 | expect(generatedCount).toBe(1); | ||
| 1464 | }), | ||
| 1465 | ); | ||
| 1466 | |||
| 1467 | it.effect("featureBranch uses custom commit message and derives branch name", () => | ||
| 1468 | Effect.gen(function* () { | ||
| 1469 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1470 | yield* initRepo(repoDir); | ||
| 1471 | fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom-feature\n"); | ||
| 1472 | let generatedCount = 0; | ||
| 1473 | |||
| 1474 | const { manager } = yield* makeManager({ | ||
| 1475 | textGeneration: { | ||
| 1476 | generateCommitMessage: (input) => | ||
| 1477 | Effect.sync(() => { | ||
| 1478 | generatedCount += 1; | ||
| 1479 | return { | ||
| 1480 | subject: "unused", | ||
| 1481 | body: "", | ||
| 1482 | ...(input.includeBranch ? { branch: "feature/unused" } : {}), | ||
| 1483 | }; | ||
| 1484 | }), | ||
| 1485 | }, | ||
| 1486 | }); | ||
| 1487 | const result = yield* runStackedAction(manager, { | ||
| 1488 | cwd: repoDir, | ||
| 1489 | action: "commit", | ||
| 1490 | featureBranch: true, | ||
| 1491 | commitMessage: "feat: custom summary line\n\n- details from user", | ||
| 1492 | }); | ||
| 1493 | |||
| 1494 | expect(result.branch.status).toBe("created"); | ||
| 1495 | expect(result.branch.name).toBe("feature/feat-custom-summary-line"); | ||
| 1496 | expect(result.commit.status).toBe("created"); | ||
| 1497 | expect(result.commit.subject).toBe("feat: custom summary line"); | ||
| 1498 | expect(generatedCount).toBe(0); | ||
| 1499 | |||
| 1500 | const mainSha = yield* runGit(repoDir, ["rev-parse", "main"]).pipe( | ||
| 1501 | Effect.map((r) => r.stdout.trim()), | ||
| 1502 | ); | ||
| 1503 | const mergeBase = yield* runGit(repoDir, ["merge-base", "main", result.branch.name!]).pipe( | ||
| 1504 | Effect.map((r) => r.stdout.trim()), | ||
| 1505 | ); | ||
| 1506 | expect(mergeBase).toBe(mainSha); | ||
| 1507 | }), | ||
| 1508 | ); | ||
| 1509 | |||
| 1510 | it.effect("skips commit when there are no uncommitted changes", () => | ||
| 1511 | Effect.gen(function* () { | ||
| 1512 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1513 | yield* initRepo(repoDir); | ||
| 1514 | |||
| 1515 | const { manager } = yield* makeManager(); | ||
| 1516 | const result = yield* runStackedAction(manager, { | ||
| 1517 | cwd: repoDir, | ||
| 1518 | action: "commit", | ||
| 1519 | }); | ||
| 1520 | |||
| 1521 | expect(result.branch.status).toBe("skipped_not_requested"); | ||
| 1522 | expect(result.commit.status).toBe("skipped_no_changes"); | ||
| 1523 | expect(result.push.status).toBe("skipped_not_requested"); | ||
| 1524 | expect(result.pr.status).toBe("skipped_not_requested"); | ||
| 1525 | }), | ||
| 1526 | ); | ||
| 1527 | |||
| 1528 | it.effect("featureBranch returns error when worktree is clean", () => | ||
| 1529 | Effect.gen(function* () { | ||
| 1530 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1531 | yield* initRepo(repoDir); | ||
| 1532 | |||
| 1533 | const { manager } = yield* makeManager(); | ||
| 1534 | const errorMessage = yield* runStackedAction(manager, { | ||
| 1535 | cwd: repoDir, | ||
| 1536 | action: "commit", | ||
| 1537 | featureBranch: true, | ||
| 1538 | }).pipe( | ||
| 1539 | Effect.flip, | ||
| 1540 | Effect.map((error) => error.message), | ||
| 1541 | ); | ||
| 1542 | |||
| 1543 | expect(errorMessage).toContain("no changes to commit"); | ||
| 1544 | }), | ||
| 1545 | ); | ||
| 1546 | |||
| 1547 | it.effect("commits and pushes with upstream auto-setup when needed", () => | ||
| 1548 | Effect.gen(function* () { | ||
| 1549 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1550 | yield* initRepo(repoDir); | ||
| 1551 | yield* runGit(repoDir, ["checkout", "-b", "feature/stacked-flow"]); | ||
| 1552 | const remoteDir = yield* createBareRemote(); | ||
| 1553 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 1554 | fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n"); | ||
| 1555 | |||
| 1556 | const { manager } = yield* makeManager(); | ||
| 1557 | const result = yield* runStackedAction(manager, { | ||
| 1558 | cwd: repoDir, | ||
| 1559 | action: "commit_push", | ||
| 1560 | }); | ||
| 1561 | |||
| 1562 | expect(result.branch.status).toBe("skipped_not_requested"); | ||
| 1563 | expect(result.commit.status).toBe("created"); | ||
| 1564 | expect(result.push.status).toBe("pushed"); | ||
| 1565 | expect(result.push.setUpstream).toBe(true); | ||
| 1566 | expect( | ||
| 1567 | yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe( | ||
| 1568 | Effect.map((result) => result.stdout.trim()), | ||
| 1569 | ), | ||
| 1570 | ).toBe("origin/feature/stacked-flow"); | ||
| 1571 | }), | ||
| 1572 | ); | ||
| 1573 | |||
| 1574 | it.effect( | ||
| 1575 | "pushes and creates PR from a no-upstream branch when local commits are ahead of base", | ||
| 1576 | () => | ||
| 1577 | Effect.gen(function* () { | ||
| 1578 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1579 | yield* initRepo(repoDir); | ||
| 1580 | yield* runGit(repoDir, ["checkout", "-b", "feature/no-upstream-pr"]); | ||
| 1581 | const remoteDir = yield* createBareRemote(); | ||
| 1582 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 1583 | fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n"); | ||
| 1584 | |||
| 1585 | const { manager, ghCalls } = yield* makeManager({ | ||
| 1586 | ghScenario: { | ||
| 1587 | prListSequence: [ | ||
| 1588 | "[]", | ||
| 1589 | JSON.stringify([ | ||
| 1590 | { | ||
| 1591 | number: 77, | ||
| 1592 | title: "Add no-upstream PR flow", | ||
| 1593 | url: "https://github.com/pingdotgg/codething-mvp/pull/77", | ||
| 1594 | baseRefName: "main", | ||
| 1595 | headRefName: "feature/no-upstream-pr", | ||
| 1596 | }, | ||
| 1597 | ]), | ||
| 1598 | ], | ||
| 1599 | }, | ||
| 1600 | }); | ||
| 1601 | |||
| 1602 | const result = yield* runStackedAction(manager, { | ||
| 1603 | cwd: repoDir, | ||
| 1604 | action: "commit_push_pr", | ||
| 1605 | }); | ||
| 1606 | |||
| 1607 | expect(result.branch.status).toBe("skipped_not_requested"); | ||
| 1608 | expect(result.commit.status).toBe("created"); | ||
| 1609 | expect(result.push.status).toBe("pushed"); | ||
| 1610 | expect(result.push.setUpstream).toBe(true); | ||
| 1611 | expect(result.pr.status).toBe("created"); | ||
| 1612 | expect( | ||
| 1613 | yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe( | ||
| 1614 | Effect.map((result) => result.stdout.trim()), | ||
| 1615 | ), | ||
| 1616 | ).toBe("origin/feature/no-upstream-pr"); | ||
| 1617 | expect( | ||
| 1618 | ghCalls.some((call) => | ||
| 1619 | call.includes("pr create --base main --head feature/no-upstream-pr"), | ||
| 1620 | ), | ||
| 1621 | ).toBe(true); | ||
| 1622 | }), | ||
| 1623 | ); | ||
| 1624 | |||
| 1625 | it.effect("skips push when branch is already up to date", () => | ||
| 1626 | Effect.gen(function* () { | ||
| 1627 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1628 | yield* initRepo(repoDir); | ||
| 1629 | yield* runGit(repoDir, ["checkout", "-b", "feature/up-to-date"]); | ||
| 1630 | const remoteDir = yield* createBareRemote(); | ||
| 1631 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 1632 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/up-to-date"]); | ||
| 1633 | |||
| 1634 | const { manager } = yield* makeManager(); | ||
| 1635 | const result = yield* runStackedAction(manager, { | ||
| 1636 | cwd: repoDir, | ||
| 1637 | action: "commit_push", | ||
| 1638 | }); | ||
| 1639 | |||
| 1640 | expect(result.branch.status).toBe("skipped_not_requested"); | ||
| 1641 | expect(result.commit.status).toBe("skipped_no_changes"); | ||
| 1642 | expect(result.push.status).toBe("skipped_up_to_date"); | ||
| 1643 | }), | ||
| 1644 | ); | ||
| 1645 | |||
| 1646 | it.effect("pushes existing clean commits without rerunning commit logic", () => | ||
| 1647 | Effect.gen(function* () { | ||
| 1648 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1649 | yield* initRepo(repoDir); | ||
| 1650 | yield* runGit(repoDir, ["checkout", "-b", "feature/push-only"]); | ||
| 1651 | const remoteDir = yield* createBareRemote(); | ||
| 1652 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 1653 | fs.writeFileSync(path.join(repoDir, "push-only.txt"), "push only\n"); | ||
| 1654 | yield* runGit(repoDir, ["add", "push-only.txt"]); | ||
| 1655 | yield* runGit(repoDir, ["commit", "-m", "Push only branch"]); | ||
| 1656 | |||
| 1657 | const { manager } = yield* makeManager(); | ||
| 1658 | const result = yield* runStackedAction(manager, { | ||
| 1659 | cwd: repoDir, | ||
| 1660 | action: "push", | ||
| 1661 | }); | ||
| 1662 | |||
| 1663 | expect(result.commit.status).toBe("skipped_not_requested"); | ||
| 1664 | expect(result.push.status).toBe("pushed"); | ||
| 1665 | expect(result.pr.status).toBe("skipped_not_requested"); | ||
| 1666 | expect( | ||
| 1667 | yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe( | ||
| 1668 | Effect.map((output) => output.stdout.trim()), | ||
| 1669 | ), | ||
| 1670 | ).toBe("origin/feature/push-only"); | ||
| 1671 | }), | ||
| 1672 | ); | ||
| 1673 | |||
| 1674 | it.effect("create_pr pushes a clean branch before creating the PR when needed", () => | ||
| 1675 | Effect.gen(function* () { | ||
| 1676 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1677 | yield* initRepo(repoDir); | ||
| 1678 | yield* runGit(repoDir, ["checkout", "-b", "feature/create-pr-only"]); | ||
| 1679 | const remoteDir = yield* createBareRemote(); | ||
| 1680 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 1681 | fs.writeFileSync(path.join(repoDir, "create-pr-only.txt"), "create pr\n"); | ||
| 1682 | yield* runGit(repoDir, ["add", "create-pr-only.txt"]); | ||
| 1683 | yield* runGit(repoDir, ["commit", "-m", "Create PR only branch"]); | ||
| 1684 | |||
| 1685 | const { manager, ghCalls } = yield* makeManager({ | ||
| 1686 | ghScenario: { | ||
| 1687 | prListSequence: [ | ||
| 1688 | "[]", | ||
| 1689 | JSON.stringify([ | ||
| 1690 | { | ||
| 1691 | number: 303, | ||
| 1692 | title: "Create PR only branch", | ||
| 1693 | url: "https://github.com/pingdotgg/codething-mvp/pull/303", | ||
| 1694 | baseRefName: "main", | ||
| 1695 | headRefName: "feature/create-pr-only", | ||
| 1696 | }, | ||
| 1697 | ]), | ||
| 1698 | ], | ||
| 1699 | }, | ||
| 1700 | }); | ||
| 1701 | |||
| 1702 | const result = yield* runStackedAction(manager, { | ||
| 1703 | cwd: repoDir, | ||
| 1704 | action: "create_pr", | ||
| 1705 | }); | ||
| 1706 | |||
| 1707 | expect(result.commit.status).toBe("skipped_not_requested"); | ||
| 1708 | expect(result.push.status).toBe("pushed"); | ||
| 1709 | expect(result.push.setUpstream).toBe(true); | ||
| 1710 | expect(result.pr.status).toBe("created"); | ||
| 1711 | expect(result.pr.number).toBe(303); | ||
| 1712 | expect( | ||
| 1713 | ghCalls.some((call) => | ||
| 1714 | call.includes("pr create --base main --head feature/create-pr-only"), | ||
| 1715 | ), | ||
| 1716 | ).toBe(true); | ||
| 1717 | }), | ||
| 1718 | ); | ||
| 1719 | |||
| 1720 | it.effect("create_pr falls back to main when source control provider detection fails", () => | ||
| 1721 | Effect.gen(function* () { | ||
| 1722 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1723 | yield* initRepo(repoDir); | ||
| 1724 | yield* runGit(repoDir, ["checkout", "-b", "feature/provider-fallback"]); | ||
| 1725 | fs.writeFileSync(path.join(repoDir, "provider-fallback.txt"), "fallback\n"); | ||
| 1726 | yield* runGit(repoDir, ["add", "provider-fallback.txt"]); | ||
| 1727 | yield* runGit(repoDir, ["commit", "-m", "Provider fallback"]); | ||
| 1728 | const remoteDir = yield* createBareRemote(); | ||
| 1729 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 1730 | |||
| 1731 | const { manager, ghCalls } = yield* makeManager({ | ||
| 1732 | ghScenario: { | ||
| 1733 | prListSequence: [ | ||
| 1734 | "[]", | ||
| 1735 | JSON.stringify([ | ||
| 1736 | { | ||
| 1737 | number: 404, | ||
| 1738 | title: "Provider fallback", | ||
| 1739 | url: "https://github.com/pingdotgg/codething-mvp/pull/404", | ||
| 1740 | baseRefName: "main", | ||
| 1741 | headRefName: "feature/provider-fallback", | ||
| 1742 | }, | ||
| 1743 | ]), | ||
| 1744 | ], | ||
| 1745 | }, | ||
| 1746 | }); | ||
| 1747 | |||
| 1748 | const result = yield* runStackedAction(manager, { | ||
| 1749 | cwd: repoDir, | ||
| 1750 | action: "create_pr", | ||
| 1751 | }); | ||
| 1752 | |||
| 1753 | expect(result.pr.status).toBe("created"); | ||
| 1754 | expect(result.pr.number).toBe(404); | ||
| 1755 | expect( | ||
| 1756 | ghCalls.some((call) => | ||
| 1757 | call.includes("pr create --base main --head feature/provider-fallback"), | ||
| 1758 | ), | ||
| 1759 | ).toBe(true); | ||
| 1760 | }), | ||
| 1761 | ); | ||
| 1762 | |||
| 1763 | it.effect("returns existing PR metadata for commit/push/pr action", () => | ||
| 1764 | Effect.gen(function* () { | ||
| 1765 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1766 | yield* initRepo(repoDir); | ||
| 1767 | yield* runGit(repoDir, ["checkout", "-b", "feature/existing-pr"]); | ||
| 1768 | const remoteDir = yield* createBareRemote(); | ||
| 1769 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 1770 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/existing-pr"]); | ||
| 1771 | |||
| 1772 | const { manager, ghCalls } = yield* makeManager({ | ||
| 1773 | ghScenario: { | ||
| 1774 | prListSequence: [ | ||
| 1775 | JSON.stringify([ | ||
| 1776 | { | ||
| 1777 | number: 42, | ||
| 1778 | title: "Existing PR", | ||
| 1779 | url: "https://github.com/pingdotgg/codething-mvp/pull/42", | ||
| 1780 | baseRefName: "main", | ||
| 1781 | headRefName: "feature/existing-pr", | ||
| 1782 | }, | ||
| 1783 | ]), | ||
| 1784 | ], | ||
| 1785 | }, | ||
| 1786 | }); | ||
| 1787 | const result = yield* runStackedAction(manager, { | ||
| 1788 | cwd: repoDir, | ||
| 1789 | action: "commit_push_pr", | ||
| 1790 | }); | ||
| 1791 | |||
| 1792 | expect(result.branch.status).toBe("skipped_not_requested"); | ||
| 1793 | expect(result.pr.status).toBe("opened_existing"); | ||
| 1794 | expect(result.pr.number).toBe(42); | ||
| 1795 | expect(result.toast).toEqual({ | ||
| 1796 | title: "Opened PR #42", | ||
| 1797 | description: "Existing PR", | ||
| 1798 | cta: { | ||
| 1799 | kind: "open_pr", | ||
| 1800 | label: "View PR", | ||
| 1801 | url: "https://github.com/pingdotgg/codething-mvp/pull/42", | ||
| 1802 | }, | ||
| 1803 | }); | ||
| 1804 | expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); | ||
| 1805 | }), | ||
| 1806 | ); | ||
| 1807 | |||
| 1808 | it.effect( | ||
| 1809 | "returns existing cross-repo PR metadata using the fork owner selector", | ||
| 1810 | () => | ||
| 1811 | Effect.gen(function* () { | ||
| 1812 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1813 | yield* initRepo(repoDir); | ||
| 1814 | yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); | ||
| 1815 | const forkDir = yield* createBareRemote(); | ||
| 1816 | yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); | ||
| 1817 | yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); | ||
| 1818 | yield* runGit(repoDir, [ | ||
| 1819 | "config", | ||
| 1820 | "remote.fork-seed.url", | ||
| 1821 | "git@github.com:octocat/codething-mvp.git", | ||
| 1822 | ]); | ||
| 1823 | |||
| 1824 | const { manager, ghCalls } = yield* makeManager({ | ||
| 1825 | ghScenario: { | ||
| 1826 | prListSequence: [ | ||
| 1827 | JSON.stringify([]), | ||
| 1828 | JSON.stringify([ | ||
| 1829 | { | ||
| 1830 | number: 142, | ||
| 1831 | title: "Existing fork PR", | ||
| 1832 | url: "https://github.com/pingdotgg/codething-mvp/pull/142", | ||
| 1833 | baseRefName: "main", | ||
| 1834 | headRefName: "statemachine", | ||
| 1835 | state: "OPEN", | ||
| 1836 | isCrossRepository: true, | ||
| 1837 | headRepository: { | ||
| 1838 | nameWithOwner: "octocat/codething-mvp", | ||
| 1839 | }, | ||
| 1840 | headRepositoryOwner: { | ||
| 1841 | login: "octocat", | ||
| 1842 | }, | ||
| 1843 | }, | ||
| 1844 | ]), | ||
| 1845 | ], | ||
| 1846 | }, | ||
| 1847 | }); | ||
| 1848 | |||
| 1849 | const result = yield* runStackedAction(manager, { | ||
| 1850 | cwd: repoDir, | ||
| 1851 | action: "commit_push_pr", | ||
| 1852 | }); | ||
| 1853 | |||
| 1854 | expect(result.pr.status).toBe("opened_existing"); | ||
| 1855 | expect(result.pr.number).toBe(142); | ||
| 1856 | expect( | ||
| 1857 | ghCalls.some((call) => | ||
| 1858 | call.includes("pr list --head octocat:statemachine --state open --limit 1"), | ||
| 1859 | ), | ||
| 1860 | ).toBe(true); | ||
| 1861 | expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); | ||
| 1862 | }), | ||
| 1863 | 12_000, | ||
| 1864 | ); | ||
| 1865 | |||
| 1866 | it.effect( | ||
| 1867 | "returns the correct existing PR when a slash remote checks out to a synthetic local alias", | ||
| 1868 | () => | ||
| 1869 | Effect.gen(function* () { | ||
| 1870 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1871 | yield* initRepo(repoDir); | ||
| 1872 | const originDir = yield* createBareRemote(); | ||
| 1873 | const upstreamDir = yield* createBareRemote(); | ||
| 1874 | yield* configureRemote(repoDir, "origin", originDir, "origin"); | ||
| 1875 | yield* configureRemote(repoDir, "my-org/upstream", upstreamDir, "my-org/upstream"); | ||
| 1876 | |||
| 1877 | yield* runGit(repoDir, ["checkout", "-b", "effect-atom"]); | ||
| 1878 | yield* runGit(repoDir, ["push", "-u", "origin", "effect-atom"]); | ||
| 1879 | yield* runGit(repoDir, ["push", "-u", "my-org/upstream", "effect-atom"]); | ||
| 1880 | yield* runGit(repoDir, [ | ||
| 1881 | "config", | ||
| 1882 | "remote.origin.url", | ||
| 1883 | "git@github.com:pingdotgg/codething-mvp.git", | ||
| 1884 | ]); | ||
| 1885 | yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]); | ||
| 1886 | yield* runGit(repoDir, [ | ||
| 1887 | "config", | ||
| 1888 | "remote.my-org/upstream.url", | ||
| 1889 | "git@github.com:pingdotgg/codething-mvp.git", | ||
| 1890 | ]); | ||
| 1891 | yield* runGit(repoDir, ["config", "remote.my-org/upstream.pushurl", upstreamDir]); | ||
| 1892 | yield* runGit(repoDir, ["checkout", "main"]); | ||
| 1893 | yield* runGit(repoDir, ["branch", "-D", "effect-atom"]); | ||
| 1894 | yield* runGit(repoDir, ["checkout", "--track", "my-org/upstream/effect-atom"]); | ||
| 1895 | fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); | ||
| 1896 | yield* runGit(repoDir, ["add", "changes.txt"]); | ||
| 1897 | yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); | ||
| 1898 | |||
| 1899 | const { manager, ghCalls } = yield* makeManager({ | ||
| 1900 | ghScenario: { | ||
| 1901 | prListByHeadSelector: { | ||
| 1902 | "effect-atom": JSON.stringify([ | ||
| 1903 | { | ||
| 1904 | number: 1618, | ||
| 1905 | title: "Correct PR", | ||
| 1906 | url: "https://github.com/pingdotgg/t3code/pull/1618", | ||
| 1907 | baseRefName: "main", | ||
| 1908 | headRefName: "effect-atom", | ||
| 1909 | }, | ||
| 1910 | ]), | ||
| 1911 | "upstream/effect-atom": JSON.stringify([ | ||
| 1912 | { | ||
| 1913 | number: 1518, | ||
| 1914 | title: "Wrong PR", | ||
| 1915 | url: "https://github.com/pingdotgg/t3code/pull/1518", | ||
| 1916 | baseRefName: "main", | ||
| 1917 | headRefName: "upstream/effect-atom", | ||
| 1918 | }, | ||
| 1919 | ]), | ||
| 1920 | "pingdotgg:effect-atom": JSON.stringify([]), | ||
| 1921 | "my-org/upstream:effect-atom": JSON.stringify([]), | ||
| 1922 | "pingdotgg:upstream/effect-atom": JSON.stringify([ | ||
| 1923 | { | ||
| 1924 | number: 1518, | ||
| 1925 | title: "Wrong PR", | ||
| 1926 | url: "https://github.com/pingdotgg/t3code/pull/1518", | ||
| 1927 | baseRefName: "main", | ||
| 1928 | headRefName: "upstream/effect-atom", | ||
| 1929 | }, | ||
| 1930 | ]), | ||
| 1931 | "my-org/upstream:upstream/effect-atom": JSON.stringify([ | ||
| 1932 | { | ||
| 1933 | number: 1518, | ||
| 1934 | title: "Wrong PR", | ||
| 1935 | url: "https://github.com/pingdotgg/t3code/pull/1518", | ||
| 1936 | baseRefName: "main", | ||
| 1937 | headRefName: "upstream/effect-atom", | ||
| 1938 | }, | ||
| 1939 | ]), | ||
| 1940 | }, | ||
| 1941 | }, | ||
| 1942 | }); | ||
| 1943 | |||
| 1944 | const result = yield* runStackedAction(manager, { | ||
| 1945 | cwd: repoDir, | ||
| 1946 | action: "commit_push_pr", | ||
| 1947 | }); | ||
| 1948 | |||
| 1949 | expect(result.pr.status).toBe("opened_existing"); | ||
| 1950 | expect(result.pr.number).toBe(1618); | ||
| 1951 | expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe( | ||
| 1952 | false, | ||
| 1953 | ); | ||
| 1954 | }), | ||
| 1955 | 20_000, | ||
| 1956 | ); | ||
| 1957 | |||
| 1958 | it.effect( | ||
| 1959 | "prefers owner-qualified selectors before bare branch names for cross-repo PRs", | ||
| 1960 | () => | ||
| 1961 | Effect.gen(function* () { | ||
| 1962 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1963 | yield* initRepo(repoDir); | ||
| 1964 | yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); | ||
| 1965 | const forkDir = yield* createBareRemote(); | ||
| 1966 | yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); | ||
| 1967 | yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); | ||
| 1968 | yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-142/statemachine"]); | ||
| 1969 | yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); | ||
| 1970 | yield* runGit(repoDir, [ | ||
| 1971 | "config", | ||
| 1972 | "remote.fork-seed.url", | ||
| 1973 | "git@github.com:octocat/codething-mvp.git", | ||
| 1974 | ]); | ||
| 1975 | |||
| 1976 | const { manager, ghCalls } = yield* makeManager({ | ||
| 1977 | ghScenario: { | ||
| 1978 | prListByHeadSelector: { | ||
| 1979 | "t3code/pr-142/statemachine": JSON.stringify([]), | ||
| 1980 | statemachine: JSON.stringify([ | ||
| 1981 | { | ||
| 1982 | number: 41, | ||
| 1983 | title: "Unrelated same-repo PR", | ||
| 1984 | url: "https://github.com/pingdotgg/codething-mvp/pull/41", | ||
| 1985 | baseRefName: "main", | ||
| 1986 | headRefName: "statemachine", | ||
| 1987 | }, | ||
| 1988 | ]), | ||
| 1989 | "octocat:statemachine": JSON.stringify([ | ||
| 1990 | { | ||
| 1991 | number: 142, | ||
| 1992 | title: "Existing fork PR", | ||
| 1993 | url: "https://github.com/pingdotgg/codething-mvp/pull/142", | ||
| 1994 | baseRefName: "main", | ||
| 1995 | headRefName: "statemachine", | ||
| 1996 | state: "OPEN", | ||
| 1997 | isCrossRepository: true, | ||
| 1998 | headRepository: { | ||
| 1999 | nameWithOwner: "octocat/codething-mvp", | ||
| 2000 | }, | ||
| 2001 | headRepositoryOwner: { | ||
| 2002 | login: "octocat", | ||
| 2003 | }, | ||
| 2004 | }, | ||
| 2005 | ]), | ||
| 2006 | "fork-seed:statemachine": JSON.stringify([]), | ||
| 2007 | }, | ||
| 2008 | }, | ||
| 2009 | }); | ||
| 2010 | |||
| 2011 | const result = yield* runStackedAction(manager, { | ||
| 2012 | cwd: repoDir, | ||
| 2013 | action: "commit_push_pr", | ||
| 2014 | }); | ||
| 2015 | |||
| 2016 | expect(result.pr.status).toBe("opened_existing"); | ||
| 2017 | expect(result.pr.number).toBe(142); | ||
| 2018 | |||
| 2019 | const ownerSelectorCallIndex = ghCalls.findIndex((call) => | ||
| 2020 | call.includes("pr list --head octocat:statemachine --state open --limit 1"), | ||
| 2021 | ); | ||
| 2022 | expect(ownerSelectorCallIndex).toBeGreaterThanOrEqual(0); | ||
| 2023 | expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); | ||
| 2024 | }), | ||
| 2025 | 12_000, | ||
| 2026 | ); | ||
| 2027 | |||
| 2028 | it.effect( | ||
| 2029 | "stops probing head selectors after finding an existing PR", | ||
| 2030 | () => | ||
| 2031 | Effect.gen(function* () { | ||
| 2032 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2033 | yield* initRepo(repoDir); | ||
| 2034 | yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); | ||
| 2035 | const forkDir = yield* createBareRemote(); | ||
| 2036 | yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); | ||
| 2037 | yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); | ||
| 2038 | yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-142/statemachine"]); | ||
| 2039 | yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); | ||
| 2040 | yield* runGit(repoDir, [ | ||
| 2041 | "config", | ||
| 2042 | "remote.fork-seed.url", | ||
| 2043 | "git@github.com:octocat/codething-mvp.git", | ||
| 2044 | ]); | ||
| 2045 | |||
| 2046 | const { manager, ghCalls } = yield* makeManager({ | ||
| 2047 | ghScenario: { | ||
| 2048 | prListByHeadSelector: { | ||
| 2049 | "octocat:statemachine": JSON.stringify([ | ||
| 2050 | { | ||
| 2051 | number: 142, | ||
| 2052 | title: "Existing fork PR", | ||
| 2053 | url: "https://github.com/pingdotgg/codething-mvp/pull/142", | ||
| 2054 | baseRefName: "main", | ||
| 2055 | headRefName: "statemachine", | ||
| 2056 | state: "OPEN", | ||
| 2057 | isCrossRepository: true, | ||
| 2058 | headRepository: { | ||
| 2059 | nameWithOwner: "octocat/codething-mvp", | ||
| 2060 | }, | ||
| 2061 | headRepositoryOwner: { | ||
| 2062 | login: "octocat", | ||
| 2063 | }, | ||
| 2064 | }, | ||
| 2065 | ]), | ||
| 2066 | "fork-seed:statemachine": JSON.stringify([]), | ||
| 2067 | "t3code/pr-142/statemachine": JSON.stringify([]), | ||
| 2068 | statemachine: JSON.stringify([]), | ||
| 2069 | }, | ||
| 2070 | }, | ||
| 2071 | }); | ||
| 2072 | |||
| 2073 | const result = yield* runStackedAction(manager, { | ||
| 2074 | cwd: repoDir, | ||
| 2075 | action: "commit_push_pr", | ||
| 2076 | }); | ||
| 2077 | |||
| 2078 | expect(result.pr.status).toBe("opened_existing"); | ||
| 2079 | expect(result.pr.number).toBe(142); | ||
| 2080 | |||
| 2081 | const openLookupCalls = ghCalls.filter((call) => call.includes("--state open --limit 1")); | ||
| 2082 | expect(openLookupCalls).toHaveLength(1); | ||
| 2083 | expect(openLookupCalls[0]).toContain( | ||
| 2084 | "pr list --head octocat:statemachine --state open --limit 1", | ||
| 2085 | ); | ||
| 2086 | }), | ||
| 2087 | 12_000, | ||
| 2088 | ); | ||
| 2089 | |||
| 2090 | it.effect("creates PR when one does not already exist", () => | ||
| 2091 | Effect.gen(function* () { | ||
| 2092 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2093 | yield* initRepo(repoDir); | ||
| 2094 | yield* runGit(repoDir, ["checkout", "-b", "feature-create-pr"]); | ||
| 2095 | const remoteDir = yield* createBareRemote(); | ||
| 2096 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 2097 | fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); | ||
| 2098 | yield* runGit(repoDir, ["add", "changes.txt"]); | ||
| 2099 | yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); | ||
| 2100 | yield* runGit(repoDir, ["push", "-u", "origin", "feature-create-pr"]); | ||
| 2101 | yield* runGit(repoDir, ["config", "branch.feature-create-pr.gh-merge-base", "main"]); | ||
| 2102 | |||
| 2103 | const { manager, ghCalls } = yield* makeManager({ | ||
| 2104 | ghScenario: { | ||
| 2105 | prListSequence: [ | ||
| 2106 | "[]", | ||
| 2107 | JSON.stringify([ | ||
| 2108 | { | ||
| 2109 | number: 88, | ||
| 2110 | title: "Add stacked git actions", | ||
| 2111 | url: "https://github.com/pingdotgg/codething-mvp/pull/88", | ||
| 2112 | baseRefName: "main", | ||
| 2113 | headRefName: "feature-create-pr", | ||
| 2114 | }, | ||
| 2115 | ]), | ||
| 2116 | ], | ||
| 2117 | }, | ||
| 2118 | }); | ||
| 2119 | const result = yield* runStackedAction(manager, { | ||
| 2120 | cwd: repoDir, | ||
| 2121 | action: "commit_push_pr", | ||
| 2122 | }); | ||
| 2123 | |||
| 2124 | expect(result.branch.status).toBe("skipped_not_requested"); | ||
| 2125 | expect(result.pr.status).toBe("created"); | ||
| 2126 | expect(result.pr.number).toBe(88); | ||
| 2127 | expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); | ||
| 2128 | expect( | ||
| 2129 | ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")), | ||
| 2130 | ).toBe(true); | ||
| 2131 | expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); | ||
| 2132 | }), | ||
| 2133 | ); | ||
| 2134 | |||
| 2135 | it.effect( | ||
| 2136 | "creates a new PR instead of reusing an unrelated fork PR with the same head branch", | ||
| 2137 | () => | ||
| 2138 | Effect.gen(function* () { | ||
| 2139 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2140 | yield* initRepo(repoDir); | ||
| 2141 | yield* runGit(repoDir, ["checkout", "-b", "feature/no-fork-match"]); | ||
| 2142 | const remoteDir = yield* createBareRemote(); | ||
| 2143 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 2144 | fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); | ||
| 2145 | yield* runGit(repoDir, ["add", "changes.txt"]); | ||
| 2146 | yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); | ||
| 2147 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/no-fork-match"]); | ||
| 2148 | |||
| 2149 | const { manager, ghCalls } = yield* makeManager({ | ||
| 2150 | ghScenario: { | ||
| 2151 | prListSequence: [ | ||
| 2152 | JSON.stringify([ | ||
| 2153 | { | ||
| 2154 | number: 1661, | ||
| 2155 | title: "Fork PR with same branch name", | ||
| 2156 | url: "https://github.com/pingdotgg/t3code/pull/1661", | ||
| 2157 | baseRefName: "main", | ||
| 2158 | headRefName: "feature/no-fork-match", | ||
| 2159 | state: "OPEN", | ||
| 2160 | isCrossRepository: true, | ||
| 2161 | headRepository: { | ||
| 2162 | nameWithOwner: "lnieuwenhuis/t3code", | ||
| 2163 | }, | ||
| 2164 | headRepositoryOwner: { | ||
| 2165 | login: "lnieuwenhuis", | ||
| 2166 | }, | ||
| 2167 | }, | ||
| 2168 | ]), | ||
| 2169 | JSON.stringify([ | ||
| 2170 | { | ||
| 2171 | number: 188, | ||
| 2172 | title: "Add stacked git actions", | ||
| 2173 | url: "https://github.com/pingdotgg/codething-mvp/pull/188", | ||
| 2174 | baseRefName: "main", | ||
| 2175 | headRefName: "feature/no-fork-match", | ||
| 2176 | state: "OPEN", | ||
| 2177 | isCrossRepository: false, | ||
| 2178 | }, | ||
| 2179 | ]), | ||
| 2180 | ], | ||
| 2181 | }, | ||
| 2182 | }); | ||
| 2183 | const result = yield* runStackedAction(manager, { | ||
| 2184 | cwd: repoDir, | ||
| 2185 | action: "commit_push_pr", | ||
| 2186 | }); | ||
| 2187 | |||
| 2188 | expect(result.pr.status).toBe("created"); | ||
| 2189 | expect(result.pr.number).toBe(188); | ||
| 2190 | expect(result.toast).toEqual({ | ||
| 2191 | title: "Created PR #188", | ||
| 2192 | description: "Add stacked git actions", | ||
| 2193 | cta: { | ||
| 2194 | kind: "open_pr", | ||
| 2195 | label: "View PR", | ||
| 2196 | url: "https://github.com/pingdotgg/codething-mvp/pull/188", | ||
| 2197 | }, | ||
| 2198 | }); | ||
| 2199 | expect( | ||
| 2200 | ghCalls.some((call) => | ||
| 2201 | call.includes("pr create --base main --head feature/no-fork-match"), | ||
| 2202 | ), | ||
| 2203 | ).toBe(true); | ||
| 2204 | }), | ||
| 2205 | ); | ||
| 2206 | |||
| 2207 | it.effect("creates cross-repo PRs with the fork owner selector and default base branch", () => | ||
| 2208 | Effect.gen(function* () { | ||
| 2209 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2210 | yield* initRepo(repoDir); | ||
| 2211 | const forkDir = yield* createBareRemote(); | ||
| 2212 | yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); | ||
| 2213 | yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); | ||
| 2214 | fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); | ||
| 2215 | yield* runGit(repoDir, ["add", "changes.txt"]); | ||
| 2216 | yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); | ||
| 2217 | yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); | ||
| 2218 | yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-91/statemachine"]); | ||
| 2219 | yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); | ||
| 2220 | yield* runGit(repoDir, [ | ||
| 2221 | "config", | ||
| 2222 | "remote.fork-seed.url", | ||
| 2223 | "git@github.com:octocat/codething-mvp.git", | ||
| 2224 | ]); | ||
| 2225 | |||
| 2226 | const { manager, ghCalls } = yield* makeManager({ | ||
| 2227 | ghScenario: { | ||
| 2228 | prListSequenceByHeadSelector: { | ||
| 2229 | "octocat:statemachine": [ | ||
| 2230 | JSON.stringify([]), | ||
| 2231 | JSON.stringify([ | ||
| 2232 | { | ||
| 2233 | number: 188, | ||
| 2234 | title: "Add stacked git actions", | ||
| 2235 | url: "https://github.com/pingdotgg/codething-mvp/pull/188", | ||
| 2236 | baseRefName: "main", | ||
| 2237 | headRefName: "statemachine", | ||
| 2238 | state: "OPEN", | ||
| 2239 | isCrossRepository: true, | ||
| 2240 | headRepository: { | ||
| 2241 | nameWithOwner: "octocat/codething-mvp", | ||
| 2242 | }, | ||
| 2243 | headRepositoryOwner: { | ||
| 2244 | login: "octocat", | ||
| 2245 | }, | ||
| 2246 | }, | ||
| 2247 | ]), | ||
| 2248 | ], | ||
| 2249 | "fork-seed:statemachine": [JSON.stringify([])], | ||
| 2250 | statemachine: [JSON.stringify([])], | ||
| 2251 | }, | ||
| 2252 | }, | ||
| 2253 | }); | ||
| 2254 | |||
| 2255 | const result = yield* runStackedAction(manager, { | ||
| 2256 | cwd: repoDir, | ||
| 2257 | action: "commit_push_pr", | ||
| 2258 | }); | ||
| 2259 | |||
| 2260 | expect(result.pr.status).toBe("created"); | ||
| 2261 | expect(result.pr.number).toBe(188); | ||
| 2262 | expect( | ||
| 2263 | ghCalls.some((call) => call.includes("pr create --base main --head octocat:statemachine")), | ||
| 2264 | ).toBe(true); | ||
| 2265 | expect( | ||
| 2266 | ghCalls.some((call) => | ||
| 2267 | call.includes("pr create --base statemachine --head octocat:statemachine"), | ||
| 2268 | ), | ||
| 2269 | ).toBe(false); | ||
| 2270 | }), | ||
| 2271 | ); | ||
| 2272 | |||
| 2273 | it.effect("rejects push/pr actions from detached HEAD", () => | ||
| 2274 | Effect.gen(function* () { | ||
| 2275 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2276 | yield* initRepo(repoDir); | ||
| 2277 | yield* runGit(repoDir, ["checkout", "--detach", "HEAD"]); | ||
| 2278 | |||
| 2279 | const { manager } = yield* makeManager(); | ||
| 2280 | const errorMessage = yield* runStackedAction(manager, { | ||
| 2281 | cwd: repoDir, | ||
| 2282 | action: "commit_push", | ||
| 2283 | }).pipe( | ||
| 2284 | Effect.flip, | ||
| 2285 | Effect.map((error) => error.message), | ||
| 2286 | ); | ||
| 2287 | expect(errorMessage).toContain("detached HEAD"); | ||
| 2288 | }), | ||
| 2289 | ); | ||
| 2290 | |||
| 2291 | it.effect("surfaces missing gh binary errors", () => | ||
| 2292 | Effect.gen(function* () { | ||
| 2293 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2294 | yield* initRepo(repoDir); | ||
| 2295 | yield* runGit(repoDir, ["checkout", "-b", "feature/gh-missing"]); | ||
| 2296 | const remoteDir = yield* createBareRemote(); | ||
| 2297 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 2298 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/gh-missing"]); | ||
| 2299 | |||
| 2300 | const { manager } = yield* makeManager({ | ||
| 2301 | ghScenario: { | ||
| 2302 | failWith: new GitHubCliError({ | ||
| 2303 | operation: "execute", | ||
| 2304 | detail: "GitHub CLI (`gh`) is required but not available on PATH.", | ||
| 2305 | }), | ||
| 2306 | }, | ||
| 2307 | }); | ||
| 2308 | |||
| 2309 | const errorMessage = yield* runStackedAction(manager, { | ||
| 2310 | cwd: repoDir, | ||
| 2311 | action: "commit_push_pr", | ||
| 2312 | }).pipe( | ||
| 2313 | Effect.flip, | ||
| 2314 | Effect.map((error) => error.message), | ||
| 2315 | ); | ||
| 2316 | expect(errorMessage).toContain("GitHub CLI (`gh`) is required"); | ||
| 2317 | }), | ||
| 2318 | ); | ||
| 2319 | |||
| 2320 | it.effect("surfaces gh auth errors with guidance", () => | ||
| 2321 | Effect.gen(function* () { | ||
| 2322 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2323 | yield* initRepo(repoDir); | ||
| 2324 | yield* runGit(repoDir, ["checkout", "-b", "feature/gh-auth"]); | ||
| 2325 | const remoteDir = yield* createBareRemote(); | ||
| 2326 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 2327 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/gh-auth"]); | ||
| 2328 | |||
| 2329 | const { manager } = yield* makeManager({ | ||
| 2330 | ghScenario: { | ||
| 2331 | failWith: new GitHubCliError({ | ||
| 2332 | operation: "execute", | ||
| 2333 | detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", | ||
| 2334 | }), | ||
| 2335 | }, | ||
| 2336 | }); | ||
| 2337 | |||
| 2338 | const errorMessage = yield* runStackedAction(manager, { | ||
| 2339 | cwd: repoDir, | ||
| 2340 | action: "commit_push_pr", | ||
| 2341 | }).pipe( | ||
| 2342 | Effect.flip, | ||
| 2343 | Effect.map((error) => error.message), | ||
| 2344 | ); | ||
| 2345 | expect(errorMessage).toContain("gh auth login"); | ||
| 2346 | }), | ||
| 2347 | ); | ||
| 2348 | |||
| 2349 | it.effect("resolves pull requests from #number references", () => | ||
| 2350 | Effect.gen(function* () { | ||
| 2351 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2352 | yield* initRepo(repoDir); | ||
| 2353 | |||
| 2354 | const { manager, ghCalls } = yield* makeManager({ | ||
| 2355 | ghScenario: { | ||
| 2356 | pullRequest: { | ||
| 2357 | number: 42, | ||
| 2358 | title: "Resolve PR", | ||
| 2359 | url: "https://github.com/pingdotgg/codething-mvp/pull/42", | ||
| 2360 | baseRefName: "main", | ||
| 2361 | headRefName: "feature/resolve-pr", | ||
| 2362 | state: "open", | ||
| 2363 | }, | ||
| 2364 | }, | ||
| 2365 | }); | ||
| 2366 | |||
| 2367 | const result = yield* resolvePullRequest(manager, { | ||
| 2368 | cwd: repoDir, | ||
| 2369 | reference: "#42", | ||
| 2370 | }); | ||
| 2371 | |||
| 2372 | expect(result.pullRequest).toEqual({ | ||
| 2373 | number: 42, | ||
| 2374 | title: "Resolve PR", | ||
| 2375 | url: "https://github.com/pingdotgg/codething-mvp/pull/42", | ||
| 2376 | baseBranch: "main", | ||
| 2377 | headBranch: "feature/resolve-pr", | ||
| 2378 | state: "open", | ||
| 2379 | }); | ||
| 2380 | expect(ghCalls.some((call) => call.startsWith("pr view 42 "))).toBe(true); | ||
| 2381 | }), | ||
| 2382 | ); | ||
| 2383 | |||
| 2384 | it.effect("prepares pull request threads in local mode by checking out the PR branch", () => | ||
| 2385 | Effect.gen(function* () { | ||
| 2386 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2387 | yield* initRepo(repoDir); | ||
| 2388 | yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local"]); | ||
| 2389 | fs.writeFileSync(path.join(repoDir, "local.txt"), "local\n"); | ||
| 2390 | yield* runGit(repoDir, ["add", "local.txt"]); | ||
| 2391 | yield* runGit(repoDir, ["commit", "-m", "Local PR branch"]); | ||
| 2392 | |||
| 2393 | const { manager, ghCalls } = yield* makeManager({ | ||
| 2394 | ghScenario: { | ||
| 2395 | pullRequest: { | ||
| 2396 | number: 64, | ||
| 2397 | title: "Local PR", | ||
| 2398 | url: "https://github.com/pingdotgg/codething-mvp/pull/64", | ||
| 2399 | baseRefName: "main", | ||
| 2400 | headRefName: "feature/pr-local", | ||
| 2401 | state: "open", | ||
| 2402 | }, | ||
| 2403 | }, | ||
| 2404 | }); | ||
| 2405 | |||
| 2406 | const result = yield* preparePullRequestThread(manager, { | ||
| 2407 | cwd: repoDir, | ||
| 2408 | reference: "#64", | ||
| 2409 | mode: "local", | ||
| 2410 | }); | ||
| 2411 | |||
| 2412 | expect(result.branch).toBe("feature/pr-local"); | ||
| 2413 | expect(result.worktreePath).toBeNull(); | ||
| 2414 | const branch = (yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim(); | ||
| 2415 | expect(branch).toBe("feature/pr-local"); | ||
| 2416 | expect(ghCalls).toContain("pr checkout 64 --force"); | ||
| 2417 | }), | ||
| 2418 | ); | ||
| 2419 | |||
| 2420 | it.effect("prepares pull request threads in worktree mode on the PR head branch", () => | ||
| 2421 | Effect.gen(function* () { | ||
| 2422 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2423 | yield* initRepo(repoDir); | ||
| 2424 | const remoteDir = yield* createBareRemote(); | ||
| 2425 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 2426 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); | ||
| 2427 | yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree"]); | ||
| 2428 | fs.writeFileSync(path.join(repoDir, "worktree.txt"), "worktree\n"); | ||
| 2429 | yield* runGit(repoDir, ["add", "worktree.txt"]); | ||
| 2430 | yield* runGit(repoDir, ["commit", "-m", "PR worktree branch"]); | ||
| 2431 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree"]); | ||
| 2432 | yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/77/head"]); | ||
| 2433 | yield* runGit(repoDir, ["checkout", "main"]); | ||
| 2434 | |||
| 2435 | const { manager } = yield* makeManager({ | ||
| 2436 | ghScenario: { | ||
| 2437 | pullRequest: { | ||
| 2438 | number: 77, | ||
| 2439 | title: "Worktree PR", | ||
| 2440 | url: "https://github.com/pingdotgg/codething-mvp/pull/77", | ||
| 2441 | baseRefName: "main", | ||
| 2442 | headRefName: "feature/pr-worktree", | ||
| 2443 | state: "open", | ||
| 2444 | }, | ||
| 2445 | }, | ||
| 2446 | }); | ||
| 2447 | |||
| 2448 | const result = yield* preparePullRequestThread(manager, { | ||
| 2449 | cwd: repoDir, | ||
| 2450 | reference: "77", | ||
| 2451 | mode: "worktree", | ||
| 2452 | }); | ||
| 2453 | |||
| 2454 | expect(result.branch).toBe("feature/pr-worktree"); | ||
| 2455 | expect(result.worktreePath).not.toBeNull(); | ||
| 2456 | expect(fs.existsSync(result.worktreePath as string)).toBe(true); | ||
| 2457 | const worktreeBranch = (yield* runGit(result.worktreePath as string, [ | ||
| 2458 | "branch", | ||
| 2459 | "--show-current", | ||
| 2460 | ])).stdout.trim(); | ||
| 2461 | expect(worktreeBranch).toBe("feature/pr-worktree"); | ||
| 2462 | }), | ||
| 2463 | ); | ||
| 2464 | |||
| 2465 | it.effect("launches setup only when creating a new PR worktree", () => | ||
| 2466 | Effect.gen(function* () { | ||
| 2467 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2468 | yield* initRepo(repoDir); | ||
| 2469 | const remoteDir = yield* createBareRemote(); | ||
| 2470 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 2471 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); | ||
| 2472 | yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree-setup"]); | ||
| 2473 | fs.writeFileSync(path.join(repoDir, "setup.txt"), "setup\n"); | ||
| 2474 | yield* runGit(repoDir, ["add", "setup.txt"]); | ||
| 2475 | yield* runGit(repoDir, ["commit", "-m", "PR worktree setup branch"]); | ||
| 2476 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree-setup"]); | ||
| 2477 | yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/177/head"]); | ||
| 2478 | yield* runGit(repoDir, ["checkout", "main"]); | ||
| 2479 | |||
| 2480 | const setupCalls: ProjectSetupScriptRunnerInput[] = []; | ||
| 2481 | const { manager } = yield* makeManager({ | ||
| 2482 | ghScenario: { | ||
| 2483 | pullRequest: { | ||
| 2484 | number: 177, | ||
| 2485 | title: "Worktree setup PR", | ||
| 2486 | url: "https://github.com/pingdotgg/codething-mvp/pull/177", | ||
| 2487 | baseRefName: "main", | ||
| 2488 | headRefName: "feature/pr-worktree-setup", | ||
| 2489 | state: "open", | ||
| 2490 | }, | ||
| 2491 | }, | ||
| 2492 | setupScriptRunner: { | ||
| 2493 | runForThread: (setupInput) => | ||
| 2494 | Effect.sync(() => { | ||
| 2495 | setupCalls.push(setupInput); | ||
| 2496 | return { status: "no-script" as const }; | ||
| 2497 | }), | ||
| 2498 | }, | ||
| 2499 | }); | ||
| 2500 | |||
| 2501 | const result = yield* preparePullRequestThread(manager, { | ||
| 2502 | cwd: repoDir, | ||
| 2503 | reference: "177", | ||
| 2504 | mode: "worktree", | ||
| 2505 | threadId: asThreadId("thread-pr-setup"), | ||
| 2506 | }); | ||
| 2507 | |||
| 2508 | expect(result.worktreePath).not.toBeNull(); | ||
| 2509 | expect(setupCalls).toHaveLength(1); | ||
| 2510 | expect(setupCalls[0]).toEqual({ | ||
| 2511 | threadId: "thread-pr-setup", | ||
| 2512 | projectCwd: repoDir, | ||
| 2513 | worktreePath: result.worktreePath as string, | ||
| 2514 | }); | ||
| 2515 | }), | ||
| 2516 | ); | ||
| 2517 | |||
| 2518 | it.effect("preserves fork upstream tracking when preparing a worktree PR thread", () => | ||
| 2519 | Effect.gen(function* () { | ||
| 2520 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2521 | yield* initRepo(repoDir); | ||
| 2522 | const originDir = yield* createBareRemote(); | ||
| 2523 | const forkDir = yield* createBareRemote(); | ||
| 2524 | yield* runGit(repoDir, ["remote", "add", "origin", originDir]); | ||
| 2525 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); | ||
| 2526 | yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); | ||
| 2527 | yield* runGit(repoDir, ["checkout", "-b", "feature/pr-fork"]); | ||
| 2528 | fs.writeFileSync(path.join(repoDir, "fork.txt"), "fork\n"); | ||
| 2529 | yield* runGit(repoDir, ["add", "fork.txt"]); | ||
| 2530 | yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]); | ||
| 2531 | yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-fork"]); | ||
| 2532 | yield* runGit(repoDir, ["checkout", "main"]); | ||
| 2533 | |||
| 2534 | const { manager } = yield* makeManager({ | ||
| 2535 | ghScenario: { | ||
| 2536 | pullRequest: { | ||
| 2537 | number: 81, | ||
| 2538 | title: "Fork PR", | ||
| 2539 | url: "https://github.com/pingdotgg/codething-mvp/pull/81", | ||
| 2540 | baseRefName: "main", | ||
| 2541 | headRefName: "feature/pr-fork", | ||
| 2542 | state: "open", | ||
| 2543 | isCrossRepository: true, | ||
| 2544 | headRepositoryNameWithOwner: "octocat/codething-mvp", | ||
| 2545 | headRepositoryOwnerLogin: "octocat", | ||
| 2546 | }, | ||
| 2547 | repositoryCloneUrls: { | ||
| 2548 | "octocat/codething-mvp": { | ||
| 2549 | url: forkDir, | ||
| 2550 | sshUrl: forkDir, | ||
| 2551 | }, | ||
| 2552 | }, | ||
| 2553 | }, | ||
| 2554 | }); | ||
| 2555 | |||
| 2556 | const result = yield* preparePullRequestThread(manager, { | ||
| 2557 | cwd: repoDir, | ||
| 2558 | reference: "81", | ||
| 2559 | mode: "worktree", | ||
| 2560 | }); | ||
| 2561 | |||
| 2562 | expect(result.worktreePath).not.toBeNull(); | ||
| 2563 | const upstreamRef = (yield* runGit(result.worktreePath as string, [ | ||
| 2564 | "rev-parse", | ||
| 2565 | "--abbrev-ref", | ||
| 2566 | "@{upstream}", | ||
| 2567 | ])).stdout.trim(); | ||
| 2568 | expect(upstreamRef).toBe("fork-seed/feature/pr-fork"); | ||
| 2569 | expect(upstreamRef.startsWith("origin/")).toBe(false); | ||
| 2570 | expect( | ||
| 2571 | (yield* runGit(result.worktreePath as string, [ | ||
| 2572 | "config", | ||
| 2573 | "--get", | ||
| 2574 | "remote.fork-seed.url", | ||
| 2575 | ])).stdout.trim(), | ||
| 2576 | ).toBe(forkDir); | ||
| 2577 | }), | ||
| 2578 | ); | ||
| 2579 | |||
| 2580 | it.effect("preserves fork upstream tracking when preparing a local PR thread", () => | ||
| 2581 | Effect.gen(function* () { | ||
| 2582 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2583 | yield* initRepo(repoDir); | ||
| 2584 | const originDir = yield* createBareRemote(); | ||
| 2585 | const forkDir = yield* createBareRemote(); | ||
| 2586 | yield* runGit(repoDir, ["remote", "add", "origin", originDir]); | ||
| 2587 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); | ||
| 2588 | yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); | ||
| 2589 | yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-fork"]); | ||
| 2590 | fs.writeFileSync(path.join(repoDir, "local-fork.txt"), "local fork\n"); | ||
| 2591 | yield* runGit(repoDir, ["add", "local-fork.txt"]); | ||
| 2592 | yield* runGit(repoDir, ["commit", "-m", "Local fork PR branch"]); | ||
| 2593 | yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-local-fork"]); | ||
| 2594 | yield* runGit(repoDir, ["checkout", "main"]); | ||
| 2595 | yield* runGit(repoDir, ["branch", "-D", "feature/pr-local-fork"]); | ||
| 2596 | |||
| 2597 | const { manager } = yield* makeManager({ | ||
| 2598 | ghScenario: { | ||
| 2599 | pullRequest: { | ||
| 2600 | number: 82, | ||
| 2601 | title: "Local Fork PR", | ||
| 2602 | url: "https://github.com/pingdotgg/codething-mvp/pull/82", | ||
| 2603 | baseRefName: "main", | ||
| 2604 | headRefName: "feature/pr-local-fork", | ||
| 2605 | state: "open", | ||
| 2606 | isCrossRepository: true, | ||
| 2607 | headRepositoryNameWithOwner: "octocat/codething-mvp", | ||
| 2608 | headRepositoryOwnerLogin: "octocat", | ||
| 2609 | }, | ||
| 2610 | repositoryCloneUrls: { | ||
| 2611 | "octocat/codething-mvp": { | ||
| 2612 | url: forkDir, | ||
| 2613 | sshUrl: forkDir, | ||
| 2614 | }, | ||
| 2615 | }, | ||
| 2616 | }, | ||
| 2617 | }); | ||
| 2618 | |||
| 2619 | const result = yield* preparePullRequestThread(manager, { | ||
| 2620 | cwd: repoDir, | ||
| 2621 | reference: "82", | ||
| 2622 | mode: "local", | ||
| 2623 | }); | ||
| 2624 | |||
| 2625 | expect(result.worktreePath).toBeNull(); | ||
| 2626 | expect(result.branch).toBe("feature/pr-local-fork"); | ||
| 2627 | expect( | ||
| 2628 | (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), | ||
| 2629 | ).toBe("fork-seed/feature/pr-local-fork"); | ||
| 2630 | }), | ||
| 2631 | ); | ||
| 2632 | |||
| 2633 | it.effect("derives fork repository identity from PR URL when GitHub omits nameWithOwner", () => | ||
| 2634 | Effect.gen(function* () { | ||
| 2635 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2636 | yield* initRepo(repoDir); | ||
| 2637 | const originDir = yield* createBareRemote(); | ||
| 2638 | const forkDir = yield* createBareRemote(); | ||
| 2639 | yield* runGit(repoDir, ["remote", "add", "origin", originDir]); | ||
| 2640 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); | ||
| 2641 | yield* runGit(repoDir, ["remote", "add", "binbandit-seed", forkDir]); | ||
| 2642 | yield* runGit(repoDir, ["checkout", "-b", "fix/git-action-default-without-origin"]); | ||
| 2643 | fs.writeFileSync(path.join(repoDir, "derived-fork.txt"), "derived fork\n"); | ||
| 2644 | yield* runGit(repoDir, ["add", "derived-fork.txt"]); | ||
| 2645 | yield* runGit(repoDir, ["commit", "-m", "Derived fork PR branch"]); | ||
| 2646 | yield* runGit(repoDir, [ | ||
| 2647 | "push", | ||
| 2648 | "-u", | ||
| 2649 | "binbandit-seed", | ||
| 2650 | "fix/git-action-default-without-origin", | ||
| 2651 | ]); | ||
| 2652 | yield* runGit(repoDir, ["checkout", "main"]); | ||
| 2653 | yield* runGit(repoDir, ["branch", "-D", "fix/git-action-default-without-origin"]); | ||
| 2654 | |||
| 2655 | const { manager } = yield* makeManager({ | ||
| 2656 | ghScenario: { | ||
| 2657 | pullRequest: { | ||
| 2658 | number: 642, | ||
| 2659 | title: "fix: use commit as the default git action without origin", | ||
| 2660 | url: "https://github.com/pingdotgg/t3code/pull/642", | ||
| 2661 | baseRefName: "main", | ||
| 2662 | headRefName: "fix/git-action-default-without-origin", | ||
| 2663 | state: "open", | ||
| 2664 | isCrossRepository: true, | ||
| 2665 | headRepositoryOwnerLogin: "binbandit", | ||
| 2666 | }, | ||
| 2667 | repositoryCloneUrls: { | ||
| 2668 | "binbandit/t3code": { | ||
| 2669 | url: forkDir, | ||
| 2670 | sshUrl: forkDir, | ||
| 2671 | }, | ||
| 2672 | }, | ||
| 2673 | }, | ||
| 2674 | }); | ||
| 2675 | |||
| 2676 | const result = yield* preparePullRequestThread(manager, { | ||
| 2677 | cwd: repoDir, | ||
| 2678 | reference: "642", | ||
| 2679 | mode: "local", | ||
| 2680 | }); | ||
| 2681 | |||
| 2682 | expect(result.branch).toBe("fix/git-action-default-without-origin"); | ||
| 2683 | expect(result.worktreePath).toBeNull(); | ||
| 2684 | expect( | ||
| 2685 | (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), | ||
| 2686 | ).toBe("binbandit-seed/fix/git-action-default-without-origin"); | ||
| 2687 | }), | ||
| 2688 | ); | ||
| 2689 | |||
| 2690 | it.effect("reuses an existing dedicated worktree for the PR head branch", () => | ||
| 2691 | Effect.gen(function* () { | ||
| 2692 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2693 | yield* initRepo(repoDir); | ||
| 2694 | yield* runGit(repoDir, ["checkout", "-b", "feature/pr-existing-worktree"]); | ||
| 2695 | fs.writeFileSync(path.join(repoDir, "existing.txt"), "existing\n"); | ||
| 2696 | yield* runGit(repoDir, ["add", "existing.txt"]); | ||
| 2697 | yield* runGit(repoDir, ["commit", "-m", "Existing worktree branch"]); | ||
| 2698 | yield* runGit(repoDir, ["checkout", "main"]); | ||
| 2699 | const worktreePath = path.join(repoDir, "..", `pr-existing-${Date.now()}`); | ||
| 2700 | yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-existing-worktree"]); | ||
| 2701 | |||
| 2702 | const setupCalls: ProjectSetupScriptRunnerInput[] = []; | ||
| 2703 | const { manager } = yield* makeManager({ | ||
| 2704 | ghScenario: { | ||
| 2705 | pullRequest: { | ||
| 2706 | number: 78, | ||
| 2707 | title: "Existing worktree PR", | ||
| 2708 | url: "https://github.com/pingdotgg/codething-mvp/pull/78", | ||
| 2709 | baseRefName: "main", | ||
| 2710 | headRefName: "feature/pr-existing-worktree", | ||
| 2711 | state: "open", | ||
| 2712 | }, | ||
| 2713 | }, | ||
| 2714 | setupScriptRunner: { | ||
| 2715 | runForThread: (setupInput) => | ||
| 2716 | Effect.sync(() => { | ||
| 2717 | setupCalls.push(setupInput); | ||
| 2718 | return { status: "no-script" as const }; | ||
| 2719 | }), | ||
| 2720 | }, | ||
| 2721 | }); | ||
| 2722 | |||
| 2723 | const result = yield* preparePullRequestThread(manager, { | ||
| 2724 | cwd: repoDir, | ||
| 2725 | reference: "78", | ||
| 2726 | mode: "worktree", | ||
| 2727 | threadId: asThreadId("thread-pr-existing-worktree"), | ||
| 2728 | }); | ||
| 2729 | |||
| 2730 | expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe( | ||
| 2731 | fs.realpathSync.native(worktreePath), | ||
| 2732 | ); | ||
| 2733 | expect(result.branch).toBe("feature/pr-existing-worktree"); | ||
| 2734 | expect(setupCalls).toHaveLength(0); | ||
| 2735 | }), | ||
| 2736 | ); | ||
| 2737 | |||
| 2738 | it.effect( | ||
| 2739 | "does not block fork PR worktree prep when the fork head branch collides with root main", | ||
| 2740 | () => | ||
| 2741 | Effect.gen(function* () { | ||
| 2742 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2743 | yield* initRepo(repoDir); | ||
| 2744 | const originDir = yield* createBareRemote(); | ||
| 2745 | const forkDir = yield* createBareRemote(); | ||
| 2746 | yield* runGit(repoDir, ["remote", "add", "origin", originDir]); | ||
| 2747 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); | ||
| 2748 | yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); | ||
| 2749 | yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]); | ||
| 2750 | fs.writeFileSync(path.join(repoDir, "fork-main.txt"), "fork main\n"); | ||
| 2751 | yield* runGit(repoDir, ["add", "fork-main.txt"]); | ||
| 2752 | yield* runGit(repoDir, ["commit", "-m", "Fork main branch"]); | ||
| 2753 | yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]); | ||
| 2754 | yield* runGit(repoDir, ["checkout", "main"]); | ||
| 2755 | const mainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim(); | ||
| 2756 | |||
| 2757 | const { manager } = yield* makeManager({ | ||
| 2758 | ghScenario: { | ||
| 2759 | pullRequest: { | ||
| 2760 | number: 91, | ||
| 2761 | title: "Fork main PR", | ||
| 2762 | url: "https://github.com/pingdotgg/codething-mvp/pull/91", | ||
| 2763 | baseRefName: "main", | ||
| 2764 | headRefName: "main", | ||
| 2765 | state: "open", | ||
| 2766 | isCrossRepository: true, | ||
| 2767 | headRepositoryNameWithOwner: "octocat/codething-mvp", | ||
| 2768 | headRepositoryOwnerLogin: "octocat", | ||
| 2769 | }, | ||
| 2770 | repositoryCloneUrls: { | ||
| 2771 | "octocat/codething-mvp": { | ||
| 2772 | url: forkDir, | ||
| 2773 | sshUrl: forkDir, | ||
| 2774 | }, | ||
| 2775 | }, | ||
| 2776 | }, | ||
| 2777 | }); | ||
| 2778 | |||
| 2779 | const result = yield* preparePullRequestThread(manager, { | ||
| 2780 | cwd: repoDir, | ||
| 2781 | reference: "91", | ||
| 2782 | mode: "worktree", | ||
| 2783 | }); | ||
| 2784 | |||
| 2785 | expect(result.branch).toBe("t3code/pr-91/main"); | ||
| 2786 | expect(result.worktreePath).not.toBeNull(); | ||
| 2787 | expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main"); | ||
| 2788 | expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(mainBefore); | ||
| 2789 | expect( | ||
| 2790 | (yield* runGit(result.worktreePath as string, [ | ||
| 2791 | "branch", | ||
| 2792 | "--show-current", | ||
| 2793 | ])).stdout.trim(), | ||
| 2794 | ).toBe("t3code/pr-91/main"); | ||
| 2795 | }), | ||
| 2796 | ); | ||
| 2797 | |||
| 2798 | it.effect( | ||
| 2799 | "does not overwrite an existing local main branch when preparing a fork PR worktree", | ||
| 2800 | () => | ||
| 2801 | Effect.gen(function* () { | ||
| 2802 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2803 | yield* initRepo(repoDir); | ||
| 2804 | const originDir = yield* createBareRemote(); | ||
| 2805 | const forkDir = yield* createBareRemote(); | ||
| 2806 | yield* runGit(repoDir, ["remote", "add", "origin", originDir]); | ||
| 2807 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); | ||
| 2808 | yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); | ||
| 2809 | yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]); | ||
| 2810 | fs.writeFileSync(path.join(repoDir, "fork-main-second.txt"), "fork main second\n"); | ||
| 2811 | yield* runGit(repoDir, ["add", "fork-main-second.txt"]); | ||
| 2812 | yield* runGit(repoDir, ["commit", "-m", "Fork main second branch"]); | ||
| 2813 | yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]); | ||
| 2814 | yield* runGit(repoDir, ["checkout", "main"]); | ||
| 2815 | const localMainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim(); | ||
| 2816 | yield* runGit(repoDir, ["checkout", "-b", "feature/root-branch"]); | ||
| 2817 | |||
| 2818 | const { manager } = yield* makeManager({ | ||
| 2819 | ghScenario: { | ||
| 2820 | pullRequest: { | ||
| 2821 | number: 92, | ||
| 2822 | title: "Fork main overwrite PR", | ||
| 2823 | url: "https://github.com/pingdotgg/codething-mvp/pull/92", | ||
| 2824 | baseRefName: "main", | ||
| 2825 | headRefName: "main", | ||
| 2826 | state: "open", | ||
| 2827 | isCrossRepository: true, | ||
| 2828 | headRepositoryNameWithOwner: "octocat/codething-mvp", | ||
| 2829 | headRepositoryOwnerLogin: "octocat", | ||
| 2830 | }, | ||
| 2831 | repositoryCloneUrls: { | ||
| 2832 | "octocat/codething-mvp": { | ||
| 2833 | url: forkDir, | ||
| 2834 | sshUrl: forkDir, | ||
| 2835 | }, | ||
| 2836 | }, | ||
| 2837 | }, | ||
| 2838 | }); | ||
| 2839 | |||
| 2840 | const result = yield* preparePullRequestThread(manager, { | ||
| 2841 | cwd: repoDir, | ||
| 2842 | reference: "92", | ||
| 2843 | mode: "worktree", | ||
| 2844 | }); | ||
| 2845 | |||
| 2846 | expect(result.branch).toBe("t3code/pr-92/main"); | ||
| 2847 | expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(localMainBefore); | ||
| 2848 | expect( | ||
| 2849 | (yield* runGit(result.worktreePath as string, [ | ||
| 2850 | "rev-parse", | ||
| 2851 | "--abbrev-ref", | ||
| 2852 | "@{upstream}", | ||
| 2853 | ])).stdout.trim(), | ||
| 2854 | ).toBe("fork-seed/main"); | ||
| 2855 | }), | ||
| 2856 | ); | ||
| 2857 | |||
| 2858 | it.effect("reuses an existing PR worktree and restores fork upstream tracking", () => | ||
| 2859 | Effect.gen(function* () { | ||
| 2860 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2861 | yield* initRepo(repoDir); | ||
| 2862 | const originDir = yield* createBareRemote(); | ||
| 2863 | const forkDir = yield* createBareRemote(); | ||
| 2864 | yield* runGit(repoDir, ["remote", "add", "origin", originDir]); | ||
| 2865 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); | ||
| 2866 | yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); | ||
| 2867 | yield* runGit(repoDir, ["checkout", "-b", "feature/pr-reused-fork"]); | ||
| 2868 | fs.writeFileSync(path.join(repoDir, "reused-fork.txt"), "reused fork\n"); | ||
| 2869 | yield* runGit(repoDir, ["add", "reused-fork.txt"]); | ||
| 2870 | yield* runGit(repoDir, ["commit", "-m", "Reused fork PR branch"]); | ||
| 2871 | yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-reused-fork"]); | ||
| 2872 | yield* runGit(repoDir, ["checkout", "main"]); | ||
| 2873 | const worktreePath = path.join(repoDir, "..", `pr-reused-fork-${Date.now()}`); | ||
| 2874 | yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-fork"]); | ||
| 2875 | yield* runGit(worktreePath, ["branch", "--unset-upstream"], true); | ||
| 2876 | |||
| 2877 | const { manager } = yield* makeManager({ | ||
| 2878 | ghScenario: { | ||
| 2879 | pullRequest: { | ||
| 2880 | number: 83, | ||
| 2881 | title: "Reused Fork PR", | ||
| 2882 | url: "https://github.com/pingdotgg/codething-mvp/pull/83", | ||
| 2883 | baseRefName: "main", | ||
| 2884 | headRefName: "feature/pr-reused-fork", | ||
| 2885 | state: "open", | ||
| 2886 | isCrossRepository: true, | ||
| 2887 | headRepositoryNameWithOwner: "octocat/codething-mvp", | ||
| 2888 | headRepositoryOwnerLogin: "octocat", | ||
| 2889 | }, | ||
| 2890 | repositoryCloneUrls: { | ||
| 2891 | "octocat/codething-mvp": { | ||
| 2892 | url: forkDir, | ||
| 2893 | sshUrl: forkDir, | ||
| 2894 | }, | ||
| 2895 | }, | ||
| 2896 | }, | ||
| 2897 | }); | ||
| 2898 | |||
| 2899 | const result = yield* preparePullRequestThread(manager, { | ||
| 2900 | cwd: repoDir, | ||
| 2901 | reference: "83", | ||
| 2902 | mode: "worktree", | ||
| 2903 | }); | ||
| 2904 | |||
| 2905 | expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe( | ||
| 2906 | fs.realpathSync.native(worktreePath), | ||
| 2907 | ); | ||
| 2908 | expect( | ||
| 2909 | (yield* runGit(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), | ||
| 2910 | ).toBe("fork-seed/feature/pr-reused-fork"); | ||
| 2911 | }), | ||
| 2912 | ); | ||
| 2913 | |||
| 2914 | it.effect("does not fail PR worktree prep when setup terminal startup fails", () => | ||
| 2915 | Effect.gen(function* () { | ||
| 2916 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2917 | yield* initRepo(repoDir); | ||
| 2918 | const remoteDir = yield* createBareRemote(); | ||
| 2919 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 2920 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); | ||
| 2921 | yield* runGit(repoDir, ["checkout", "-b", "feature/pr-setup-failure"]); | ||
| 2922 | fs.writeFileSync(path.join(repoDir, "setup-failure.txt"), "setup failure\n"); | ||
| 2923 | yield* runGit(repoDir, ["add", "setup-failure.txt"]); | ||
| 2924 | yield* runGit(repoDir, ["commit", "-m", "PR setup failure branch"]); | ||
| 2925 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-setup-failure"]); | ||
| 2926 | yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/184/head"]); | ||
| 2927 | yield* runGit(repoDir, ["checkout", "main"]); | ||
| 2928 | |||
| 2929 | const { manager } = yield* makeManager({ | ||
| 2930 | ghScenario: { | ||
| 2931 | pullRequest: { | ||
| 2932 | number: 184, | ||
| 2933 | title: "Setup failure PR", | ||
| 2934 | url: "https://github.com/pingdotgg/codething-mvp/pull/184", | ||
| 2935 | baseRefName: "main", | ||
| 2936 | headRefName: "feature/pr-setup-failure", | ||
| 2937 | state: "open", | ||
| 2938 | }, | ||
| 2939 | }, | ||
| 2940 | setupScriptRunner: { | ||
| 2941 | runForThread: () => Effect.fail(new Error("terminal start failed")), | ||
| 2942 | }, | ||
| 2943 | }); | ||
| 2944 | |||
| 2945 | const result = yield* preparePullRequestThread(manager, { | ||
| 2946 | cwd: repoDir, | ||
| 2947 | reference: "184", | ||
| 2948 | mode: "worktree", | ||
| 2949 | threadId: asThreadId("thread-pr-setup-failure"), | ||
| 2950 | }); | ||
| 2951 | |||
| 2952 | expect(result.branch).toBe("feature/pr-setup-failure"); | ||
| 2953 | expect(result.worktreePath).not.toBeNull(); | ||
| 2954 | expect(fs.existsSync(result.worktreePath as string)).toBe(true); | ||
| 2955 | }), | ||
| 2956 | ); | ||
| 2957 | |||
| 2958 | it.effect("rejects worktree prep when the PR head branch is checked out in the main repo", () => | ||
| 2959 | Effect.gen(function* () { | ||
| 2960 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2961 | yield* initRepo(repoDir); | ||
| 2962 | yield* runGit(repoDir, ["checkout", "-b", "feature/pr-root-only"]); | ||
| 2963 | |||
| 2964 | const { manager } = yield* makeManager({ | ||
| 2965 | ghScenario: { | ||
| 2966 | pullRequest: { | ||
| 2967 | number: 79, | ||
| 2968 | title: "Root-only PR", | ||
| 2969 | url: "https://github.com/pingdotgg/codething-mvp/pull/79", | ||
| 2970 | baseRefName: "main", | ||
| 2971 | headRefName: "feature/pr-root-only", | ||
| 2972 | state: "open", | ||
| 2973 | }, | ||
| 2974 | }, | ||
| 2975 | }); | ||
| 2976 | |||
| 2977 | const errorMessage = yield* preparePullRequestThread(manager, { | ||
| 2978 | cwd: repoDir, | ||
| 2979 | reference: "79", | ||
| 2980 | mode: "worktree", | ||
| 2981 | }).pipe( | ||
| 2982 | Effect.flip, | ||
| 2983 | Effect.map((error) => error.message), | ||
| 2984 | ); | ||
| 2985 | |||
| 2986 | expect(errorMessage).toContain("already checked out in the main repo"); | ||
| 2987 | }), | ||
| 2988 | ); | ||
| 2989 | |||
| 2990 | it.effect("emits ordered progress events for commit hooks", () => | ||
| 2991 | Effect.gen(function* () { | ||
| 2992 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2993 | yield* initRepo(repoDir); | ||
| 2994 | fs.writeFileSync(path.join(repoDir, "hooked.txt"), "hooked\n"); | ||
| 2995 | fs.writeFileSync( | ||
| 2996 | path.join(repoDir, ".git", "hooks", "pre-commit"), | ||
| 2997 | '#!/bin/sh\necho "hook: start" >&2\nsleep 1\necho "hook: end" >&2\n', | ||
| 2998 | { mode: 0o755 }, | ||
| 2999 | ); | ||
| 3000 | |||
| 3001 | const { manager } = yield* makeManager(); | ||
| 3002 | const events: GitActionProgressEvent[] = []; | ||
| 3003 | |||
| 3004 | const result = yield* runStackedAction( | ||
| 3005 | manager, | ||
| 3006 | { | ||
| 3007 | cwd: repoDir, | ||
| 3008 | action: "commit", | ||
| 3009 | }, | ||
| 3010 | { | ||
| 3011 | actionId: "action-1", | ||
| 3012 | progressReporter: { | ||
| 3013 | publish: (event) => | ||
| 3014 | Effect.sync(() => { | ||
| 3015 | events.push(event); | ||
| 3016 | }), | ||
| 3017 | }, | ||
| 3018 | }, | ||
| 3019 | ); | ||
| 3020 | |||
| 3021 | expect(result.commit.status).toBe("created"); | ||
| 3022 | expect(events.map((event) => event.kind)).toContain("action_started"); | ||
| 3023 | expect(events).toEqual( | ||
| 3024 | expect.arrayContaining([ | ||
| 3025 | expect.objectContaining({ | ||
| 3026 | kind: "phase_started", | ||
| 3027 | phase: "commit", | ||
| 3028 | }), | ||
| 3029 | expect.objectContaining({ | ||
| 3030 | kind: "hook_started", | ||
| 3031 | hookName: "pre-commit", | ||
| 3032 | }), | ||
| 3033 | expect.objectContaining({ | ||
| 3034 | kind: "hook_output", | ||
| 3035 | text: "hook: start", | ||
| 3036 | }), | ||
| 3037 | expect.objectContaining({ | ||
| 3038 | kind: "hook_output", | ||
| 3039 | text: "hook: end", | ||
| 3040 | }), | ||
| 3041 | expect.objectContaining({ | ||
| 3042 | kind: "hook_finished", | ||
| 3043 | hookName: "pre-commit", | ||
| 3044 | }), | ||
| 3045 | expect.objectContaining({ | ||
| 3046 | kind: "action_finished", | ||
| 3047 | }), | ||
| 3048 | ]), | ||
| 3049 | ); | ||
| 3050 | }), | ||
| 3051 | ); | ||
| 3052 | |||
| 3053 | it.effect("emits action_failed when a commit hook rejects", () => | ||
| 3054 | Effect.gen(function* () { | ||
| 3055 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 3056 | yield* initRepo(repoDir); | ||
| 3057 | fs.writeFileSync(path.join(repoDir, "hook-failure.txt"), "broken\n"); | ||
| 3058 | fs.writeFileSync( | ||
| 3059 | path.join(repoDir, ".git", "hooks", "pre-commit"), | ||
| 3060 | '#!/bin/sh\necho "hook: fail" >&2\nexit 1\n', | ||
| 3061 | { mode: 0o755 }, | ||
| 3062 | ); | ||
| 3063 | |||
| 3064 | const { manager } = yield* makeManager(); | ||
| 3065 | const events: GitActionProgressEvent[] = []; | ||
| 3066 | |||
| 3067 | const errorMessage = yield* runStackedAction( | ||
| 3068 | manager, | ||
| 3069 | { | ||
| 3070 | cwd: repoDir, | ||
| 3071 | action: "commit", | ||
| 3072 | }, | ||
| 3073 | { | ||
| 3074 | actionId: "action-2", | ||
| 3075 | progressReporter: { | ||
| 3076 | publish: (event) => | ||
| 3077 | Effect.sync(() => { | ||
| 3078 | events.push(event); | ||
| 3079 | }), | ||
| 3080 | }, | ||
| 3081 | }, | ||
| 3082 | ).pipe( | ||
| 3083 | Effect.flip, | ||
| 3084 | Effect.map((error) => error.message), | ||
| 3085 | ); | ||
| 3086 | |||
| 3087 | expect(errorMessage).toContain("hook: fail"); | ||
| 3088 | expect(events).toEqual( | ||
| 3089 | expect.arrayContaining([ | ||
| 3090 | expect.objectContaining({ | ||
| 3091 | kind: "hook_started", | ||
| 3092 | hookName: "pre-commit", | ||
| 3093 | }), | ||
| 3094 | expect.objectContaining({ | ||
| 3095 | kind: "action_failed", | ||
| 3096 | phase: "commit", | ||
| 3097 | }), | ||
| 3098 | ]), | ||
| 3099 | ); | ||
| 3100 | }), | ||
| 3101 | ); | ||
| 3102 | |||
| 3103 | it.effect("create_pr emits only the PR phase when the branch is already pushed", () => | ||
| 3104 | Effect.gen(function* () { | ||
| 3105 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 3106 | yield* initRepo(repoDir); | ||
| 3107 | yield* runGit(repoDir, ["checkout", "-b", "feature/pr-only-follow-up"]); | ||
| 3108 | const remoteDir = yield* createBareRemote(); | ||
| 3109 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 3110 | fs.writeFileSync(path.join(repoDir, "pr-only.txt"), "pr only\n"); | ||
| 3111 | yield* runGit(repoDir, ["add", "pr-only.txt"]); | ||
| 3112 | yield* runGit(repoDir, ["commit", "-m", "PR only branch"]); | ||
| 3113 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-only-follow-up"]); | ||
| 3114 | |||
| 3115 | const { manager } = yield* makeManager({ | ||
| 3116 | ghScenario: { | ||
| 3117 | prListSequence: [ | ||
| 3118 | JSON.stringify([]), | ||
| 3119 | JSON.stringify([ | ||
| 3120 | { | ||
| 3121 | number: 201, | ||
| 3122 | title: "PR only branch", | ||
| 3123 | url: "https://github.com/pingdotgg/codething-mvp/pull/201", | ||
| 3124 | baseRefName: "main", | ||
| 3125 | headRefName: "feature/pr-only-follow-up", | ||
| 3126 | state: "OPEN", | ||
| 3127 | isCrossRepository: false, | ||
| 3128 | }, | ||
| 3129 | ]), | ||
| 3130 | ], | ||
| 3131 | }, | ||
| 3132 | }); | ||
| 3133 | const events: GitActionProgressEvent[] = []; | ||
| 3134 | |||
| 3135 | const result = yield* runStackedAction( | ||
| 3136 | manager, | ||
| 3137 | { | ||
| 3138 | cwd: repoDir, | ||
| 3139 | action: "create_pr", | ||
| 3140 | }, | ||
| 3141 | { | ||
| 3142 | actionId: "action-pr-only", | ||
| 3143 | progressReporter: { | ||
| 3144 | publish: (event) => | ||
| 3145 | Effect.sync(() => { | ||
| 3146 | events.push(event); | ||
| 3147 | }), | ||
| 3148 | }, | ||
| 3149 | }, | ||
| 3150 | ); | ||
| 3151 | |||
| 3152 | expect(result.commit.status).toBe("skipped_not_requested"); | ||
| 3153 | expect(result.push.status).toBe("skipped_not_requested"); | ||
| 3154 | expect(result.pr.status).toBe("created"); | ||
| 3155 | expect( | ||
| 3156 | events.filter( | ||
| 3157 | (event): event is Extract<GitActionProgressEvent, { kind: "phase_started" }> => | ||
| 3158 | event.kind === "phase_started", | ||
| 3159 | ), | ||
| 3160 | ).toEqual([ | ||
| 3161 | expect.objectContaining({ | ||
| 3162 | kind: "phase_started", | ||
| 3163 | phase: "pr", | ||
| 3164 | label: "Preparing PR...", | ||
| 3165 | }), | ||
| 3166 | expect.objectContaining({ | ||
| 3167 | kind: "phase_started", | ||
| 3168 | phase: "pr", | ||
| 3169 | label: "Generating PR content...", | ||
| 3170 | }), | ||
| 3171 | expect.objectContaining({ | ||
| 3172 | kind: "phase_started", | ||
| 3173 | phase: "pr", | ||
| 3174 | label: "Creating pull request...", | ||
| 3175 | }), | ||
| 3176 | ]); | ||
| 3177 | }), | ||
| 3178 | ); | ||
| 3179 | }); | ||
apps/server/src/git/GitManager.ts created+1769| ... | @@ -0,0 +1,1769 @@ | ||
| 1 | import { randomUUID } from "node:crypto"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | Array as Arr, | ||
| 5 | Cache, | ||
| 6 | Context, | ||
| 7 | DateTime, | ||
| 8 | Duration, | ||
| 9 | Effect, | ||
| 10 | Exit, | ||
| 11 | FileSystem, | ||
| 12 | Layer, | ||
| 13 | Option, | ||
| 14 | Order, | ||
| 15 | Path, | ||
| 16 | Ref, | ||
| 17 | } from "effect"; | ||
| 18 | import { | ||
| 19 | GitActionProgressEvent, | ||
| 20 | GitActionProgressPhase, | ||
| 21 | GitCommandError, | ||
| 22 | GitPreparePullRequestThreadInput, | ||
| 23 | GitPreparePullRequestThreadResult, | ||
| 24 | GitPullRequestRefInput, | ||
| 25 | GitResolvePullRequestResult, | ||
| 26 | GitRunStackedActionInput, | ||
| 27 | GitRunStackedActionResult, | ||
| 28 | GitStackedAction, | ||
| 29 | VcsStatusInput, | ||
| 30 | type VcsStatusLocalResult, | ||
| 31 | type VcsStatusRemoteResult, | ||
| 32 | VcsStatusResult, | ||
| 33 | ModelSelection, | ||
| 34 | } from "@t3tools/contracts"; | ||
| 35 | import { | ||
| 36 | detectSourceControlProviderFromGitRemoteUrl, | ||
| 37 | mergeGitStatusParts, | ||
| 38 | resolveAutoFeatureBranchName, | ||
| 39 | sanitizeBranchFragment, | ||
| 40 | sanitizeFeatureBranchName, | ||
| 41 | } from "@t3tools/shared/git"; | ||
| 42 | import { | ||
| 43 | getChangeRequestTerminologyForKind, | ||
| 44 | type ChangeRequestTerminology, | ||
| 45 | } from "@t3tools/shared/sourceControl"; | ||
| 46 | |||
| 47 | import { GitManagerError } from "@t3tools/contracts"; | ||
| 48 | import { TextGeneration } from "../textGeneration/TextGeneration.ts"; | ||
| 49 | import { ProjectSetupScriptRunner } from "../project/Services/ProjectSetupScriptRunner.ts"; | ||
| 50 | import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; | ||
| 51 | import { ServerSettingsService } from "../serverSettings.ts"; | ||
| 52 | import type { GitManagerServiceError } from "@t3tools/contracts"; | ||
| 53 | import { GitVcsDriver, type GitStatusDetails } from "../vcs/GitVcsDriver.ts"; | ||
| 54 | import { SourceControlProviderRegistry } from "../sourceControl/SourceControlProviderRegistry.ts"; | ||
| 55 | import type { ChangeRequest } from "@t3tools/contracts"; | ||
| 56 | |||
| 57 | export interface GitActionProgressReporter { | ||
| 58 | readonly publish: (event: GitActionProgressEvent) => Effect.Effect<void, never>; | ||
| 59 | } | ||
| 60 | |||
| 61 | export interface GitRunStackedActionOptions { | ||
| 62 | readonly actionId?: string; | ||
| 63 | readonly progressReporter?: GitActionProgressReporter; | ||
| 64 | } | ||
| 65 | |||
| 66 | export interface GitManagerShape { | ||
| 67 | readonly status: ( | ||
| 68 | input: VcsStatusInput, | ||
| 69 | ) => Effect.Effect<VcsStatusResult, GitManagerServiceError>; | ||
| 70 | readonly localStatus: ( | ||
| 71 | input: VcsStatusInput, | ||
| 72 | ) => Effect.Effect<VcsStatusLocalResult, GitManagerServiceError>; | ||
| 73 | readonly remoteStatus: ( | ||
| 74 | input: VcsStatusInput, | ||
| 75 | ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>; | ||
| 76 | readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>; | ||
| 77 | readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>; | ||
| 78 | readonly invalidateStatus: (cwd: string) => Effect.Effect<void, never>; | ||
| 79 | readonly resolvePullRequest: ( | ||
| 80 | input: GitPullRequestRefInput, | ||
| 81 | ) => Effect.Effect<GitResolvePullRequestResult, GitManagerServiceError>; | ||
| 82 | readonly preparePullRequestThread: ( | ||
| 83 | input: GitPreparePullRequestThreadInput, | ||
| 84 | ) => Effect.Effect<GitPreparePullRequestThreadResult, GitManagerServiceError>; | ||
| 85 | readonly runStackedAction: ( | ||
| 86 | input: GitRunStackedActionInput, | ||
| 87 | options?: GitRunStackedActionOptions, | ||
| 88 | ) => Effect.Effect<GitRunStackedActionResult, GitManagerServiceError>; | ||
| 89 | } | ||
| 90 | |||
| 91 | export class GitManager extends Context.Service<GitManager, GitManagerShape>()( | ||
| 92 | "t3/git/GitManager", | ||
| 93 | ) {} | ||
| 94 | |||
| 95 | const COMMIT_TIMEOUT_MS = 10 * 60_000; | ||
| 96 | const MAX_PROGRESS_TEXT_LENGTH = 500; | ||
| 97 | const SHORT_SHA_LENGTH = 7; | ||
| 98 | const TOAST_DESCRIPTION_MAX = 72; | ||
| 99 | const STATUS_RESULT_CACHE_TTL = Duration.seconds(1); | ||
| 100 | const STATUS_RESULT_CACHE_CAPACITY = 2_048; | ||
| 101 | type StripProgressContext<T> = T extends any ? Omit<T, "actionId" | "cwd" | "action"> : never; | ||
| 102 | type GitActionProgressPayload = StripProgressContext<GitActionProgressEvent>; | ||
| 103 | type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect<void, never>; | ||
| 104 | |||
| 105 | function isNotGitRepositoryError(error: GitCommandError): boolean { | ||
| 106 | return error.message.toLowerCase().includes("not a git repository"); | ||
| 107 | } | ||
| 108 | |||
| 109 | interface OpenPrInfo { | ||
| 110 | number: number; | ||
| 111 | title: string; | ||
| 112 | url: string; | ||
| 113 | baseRefName: string; | ||
| 114 | headRefName: string; | ||
| 115 | } | ||
| 116 | |||
| 117 | interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { | ||
| 118 | state: "open" | "closed" | "merged"; | ||
| 119 | updatedAt: Option.Option<DateTime.Utc>; | ||
| 120 | } | ||
| 121 | |||
| 122 | const pullRequestUpdatedAtDescOrder: Order.Order<PullRequestInfo> = Order.mapInput( | ||
| 123 | Order.flip(Option.makeOrder(DateTime.Order)), | ||
| 124 | (pullRequest) => pullRequest.updatedAt, | ||
| 125 | ); | ||
| 126 | |||
| 127 | interface ResolvedPullRequest { | ||
| 128 | number: number; | ||
| 129 | title: string; | ||
| 130 | url: string; | ||
| 131 | baseBranch: string; | ||
| 132 | headBranch: string; | ||
| 133 | state: "open" | "closed" | "merged"; | ||
| 134 | } | ||
| 135 | |||
| 136 | interface PullRequestHeadRemoteInfo { | ||
| 137 | isCrossRepository?: boolean | undefined; | ||
| 138 | headRepositoryNameWithOwner?: string | null | undefined; | ||
| 139 | headRepositoryOwnerLogin?: string | null | undefined; | ||
| 140 | } | ||
| 141 | |||
| 142 | interface BranchHeadContext { | ||
| 143 | localBranch: string; | ||
| 144 | headBranch: string; | ||
| 145 | headSelectors: ReadonlyArray<string>; | ||
| 146 | preferredHeadSelector: string; | ||
| 147 | remoteName: string | null; | ||
| 148 | headRepositoryNameWithOwner: string | null; | ||
| 149 | headRepositoryOwnerLogin: string | null; | ||
| 150 | isCrossRepository: boolean; | ||
| 151 | } | ||
| 152 | |||
| 153 | function parseRepositoryNameFromPullRequestUrl(url: string): string | null { | ||
| 154 | const trimmed = url.trim(); | ||
| 155 | const match = /^https:\/\/github\.com\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed); | ||
| 156 | const repositoryName = match?.[1]?.trim() ?? ""; | ||
| 157 | return repositoryName.length > 0 ? repositoryName : null; | ||
| 158 | } | ||
| 159 | |||
| 160 | function resolveHeadRepositoryNameWithOwner( | ||
| 161 | pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, | ||
| 162 | ): string | null { | ||
| 163 | const explicitRepository = pullRequest.headRepositoryNameWithOwner?.trim() ?? ""; | ||
| 164 | if (explicitRepository.length > 0) { | ||
| 165 | return explicitRepository; | ||
| 166 | } | ||
| 167 | |||
| 168 | if (!pullRequest.isCrossRepository) { | ||
| 169 | return null; | ||
| 170 | } | ||
| 171 | |||
| 172 | const ownerLogin = pullRequest.headRepositoryOwnerLogin?.trim() ?? ""; | ||
| 173 | const repositoryName = parseRepositoryNameFromPullRequestUrl(pullRequest.url); | ||
| 174 | if (ownerLogin.length === 0 || !repositoryName) { | ||
| 175 | return null; | ||
| 176 | } | ||
| 177 | |||
| 178 | return `${ownerLogin}/${repositoryName}`; | ||
| 179 | } | ||
| 180 | |||
| 181 | function resolvePullRequestWorktreeLocalBranchName( | ||
| 182 | pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, | ||
| 183 | ): string { | ||
| 184 | if (!pullRequest.isCrossRepository) { | ||
| 185 | return pullRequest.headBranch; | ||
| 186 | } | ||
| 187 | |||
| 188 | const sanitizedHeadBranch = sanitizeBranchFragment(pullRequest.headBranch).trim(); | ||
| 189 | const suffix = sanitizedHeadBranch.length > 0 ? sanitizedHeadBranch : "head"; | ||
| 190 | return `t3code/pr-${pullRequest.number}/${suffix}`; | ||
| 191 | } | ||
| 192 | |||
| 193 | function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { | ||
| 194 | const trimmed = url?.trim() ?? ""; | ||
| 195 | if (trimmed.length === 0) { | ||
| 196 | return null; | ||
| 197 | } | ||
| 198 | |||
| 199 | const match = | ||
| 200 | /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( | ||
| 201 | trimmed, | ||
| 202 | ); | ||
| 203 | const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; | ||
| 204 | return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null; | ||
| 205 | } | ||
| 206 | |||
| 207 | function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null { | ||
| 208 | const trimmed = nameWithOwner?.trim() ?? ""; | ||
| 209 | if (trimmed.length === 0) { | ||
| 210 | return null; | ||
| 211 | } | ||
| 212 | const [ownerLogin] = trimmed.split("/"); | ||
| 213 | const normalizedOwnerLogin = ownerLogin?.trim() ?? ""; | ||
| 214 | return normalizedOwnerLogin.length > 0 ? normalizedOwnerLogin : null; | ||
| 215 | } | ||
| 216 | |||
| 217 | function normalizeOptionalString(value: string | null | undefined): string | null { | ||
| 218 | const trimmed = value?.trim() ?? ""; | ||
| 219 | return trimmed.length > 0 ? trimmed : null; | ||
| 220 | } | ||
| 221 | |||
| 222 | function normalizeOptionalRepositoryNameWithOwner(value: string | null | undefined): string | null { | ||
| 223 | const normalized = normalizeOptionalString(value); | ||
| 224 | return normalized ? normalized.toLowerCase() : null; | ||
| 225 | } | ||
| 226 | |||
| 227 | function normalizeOptionalOwnerLogin(value: string | null | undefined): string | null { | ||
| 228 | const normalized = normalizeOptionalString(value); | ||
| 229 | return normalized ? normalized.toLowerCase() : null; | ||
| 230 | } | ||
| 231 | |||
| 232 | function resolvePullRequestHeadRepositoryNameWithOwner( | ||
| 233 | pr: PullRequestHeadRemoteInfo & { url: string }, | ||
| 234 | ) { | ||
| 235 | const explicitRepository = normalizeOptionalString(pr.headRepositoryNameWithOwner); | ||
| 236 | if (explicitRepository) { | ||
| 237 | return explicitRepository; | ||
| 238 | } | ||
| 239 | |||
| 240 | if (!pr.isCrossRepository) { | ||
| 241 | return null; | ||
| 242 | } | ||
| 243 | |||
| 244 | const ownerLogin = normalizeOptionalString(pr.headRepositoryOwnerLogin); | ||
| 245 | const repositoryName = parseRepositoryNameFromPullRequestUrl(pr.url); | ||
| 246 | if (!ownerLogin || !repositoryName) { | ||
| 247 | return null; | ||
| 248 | } | ||
| 249 | |||
| 250 | return `${ownerLogin}/${repositoryName}`; | ||
| 251 | } | ||
| 252 | |||
| 253 | function matchesBranchHeadContext( | ||
| 254 | pr: PullRequestInfo, | ||
| 255 | headContext: Pick< | ||
| 256 | BranchHeadContext, | ||
| 257 | "headBranch" | "headRepositoryNameWithOwner" | "headRepositoryOwnerLogin" | "isCrossRepository" | ||
| 258 | >, | ||
| 259 | ): boolean { | ||
| 260 | if (pr.headRefName !== headContext.headBranch) { | ||
| 261 | return false; | ||
| 262 | } | ||
| 263 | |||
| 264 | const expectedHeadRepository = normalizeOptionalRepositoryNameWithOwner( | ||
| 265 | headContext.headRepositoryNameWithOwner, | ||
| 266 | ); | ||
| 267 | const expectedHeadOwner = | ||
| 268 | normalizeOptionalOwnerLogin(headContext.headRepositoryOwnerLogin) ?? | ||
| 269 | parseRepositoryOwnerLogin(expectedHeadRepository); | ||
| 270 | const prHeadRepository = normalizeOptionalRepositoryNameWithOwner( | ||
| 271 | resolvePullRequestHeadRepositoryNameWithOwner(pr), | ||
| 272 | ); | ||
| 273 | const prHeadOwner = | ||
| 274 | normalizeOptionalOwnerLogin(pr.headRepositoryOwnerLogin) ?? | ||
| 275 | parseRepositoryOwnerLogin(prHeadRepository); | ||
| 276 | |||
| 277 | if (headContext.isCrossRepository) { | ||
| 278 | if (pr.isCrossRepository === false) { | ||
| 279 | return false; | ||
| 280 | } | ||
| 281 | if ((expectedHeadRepository || expectedHeadOwner) && !prHeadRepository && !prHeadOwner) { | ||
| 282 | return false; | ||
| 283 | } | ||
| 284 | if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) { | ||
| 285 | return false; | ||
| 286 | } | ||
| 287 | if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) { | ||
| 288 | return false; | ||
| 289 | } | ||
| 290 | return true; | ||
| 291 | } | ||
| 292 | |||
| 293 | if (pr.isCrossRepository === true) { | ||
| 294 | return false; | ||
| 295 | } | ||
| 296 | if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) { | ||
| 297 | return false; | ||
| 298 | } | ||
| 299 | if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) { | ||
| 300 | return false; | ||
| 301 | } | ||
| 302 | return true; | ||
| 303 | } | ||
| 304 | |||
| 305 | function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { | ||
| 306 | return { | ||
| 307 | number: summary.number, | ||
| 308 | title: summary.title, | ||
| 309 | url: summary.url, | ||
| 310 | baseRefName: summary.baseRefName, | ||
| 311 | headRefName: summary.headRefName, | ||
| 312 | state: summary.state ?? "open", | ||
| 313 | updatedAt: summary.updatedAt, | ||
| 314 | ...(summary.isCrossRepository !== undefined | ||
| 315 | ? { isCrossRepository: summary.isCrossRepository } | ||
| 316 | : {}), | ||
| 317 | ...(summary.headRepositoryNameWithOwner !== undefined | ||
| 318 | ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } | ||
| 319 | : {}), | ||
| 320 | ...(summary.headRepositoryOwnerLogin !== undefined | ||
| 321 | ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } | ||
| 322 | : {}), | ||
| 323 | }; | ||
| 324 | } | ||
| 325 | |||
| 326 | function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError { | ||
| 327 | return new GitManagerError({ | ||
| 328 | operation, | ||
| 329 | detail, | ||
| 330 | ...(cause !== undefined ? { cause } : {}), | ||
| 331 | }); | ||
| 332 | } | ||
| 333 | |||
| 334 | function limitContext(value: string, maxChars: number): string { | ||
| 335 | if (value.length <= maxChars) return value; | ||
| 336 | return `${value.slice(0, maxChars)}\n\n[truncated]`; | ||
| 337 | } | ||
| 338 | |||
| 339 | function shortenSha(sha: string | undefined): string | null { | ||
| 340 | if (!sha) return null; | ||
| 341 | return sha.slice(0, SHORT_SHA_LENGTH); | ||
| 342 | } | ||
| 343 | |||
| 344 | function truncateText( | ||
| 345 | value: string | undefined, | ||
| 346 | maxLength = TOAST_DESCRIPTION_MAX, | ||
| 347 | ): string | undefined { | ||
| 348 | if (!value) return undefined; | ||
| 349 | if (value.length <= maxLength) return value; | ||
| 350 | if (maxLength <= 3) return "...".slice(0, maxLength); | ||
| 351 | return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; | ||
| 352 | } | ||
| 353 | |||
| 354 | function withDescription(title: string, description: string | undefined) { | ||
| 355 | return description ? { title, description } : { title }; | ||
| 356 | } | ||
| 357 | |||
| 358 | function summarizeGitActionResult( | ||
| 359 | result: Pick<GitRunStackedActionResult, "commit" | "push" | "pr">, | ||
| 360 | terms: ChangeRequestTerminology, | ||
| 361 | ): { | ||
| 362 | title: string; | ||
| 363 | description?: string; | ||
| 364 | } { | ||
| 365 | if (result.pr.status === "created" || result.pr.status === "opened_existing") { | ||
| 366 | const prNumber = result.pr.number ? ` #${result.pr.number}` : ""; | ||
| 367 | const title = `${result.pr.status === "created" ? "Created" : "Opened"} ${terms.shortLabel}${prNumber}`; | ||
| 368 | return withDescription(title, truncateText(result.pr.title)); | ||
| 369 | } | ||
| 370 | |||
| 371 | if (result.push.status === "pushed") { | ||
| 372 | const shortSha = shortenSha(result.commit.commitSha); | ||
| 373 | const branch = result.push.upstreamBranch ?? result.push.branch; | ||
| 374 | const pushedCommitPart = shortSha ? ` ${shortSha}` : ""; | ||
| 375 | const branchPart = branch ? ` to ${branch}` : ""; | ||
| 376 | return withDescription( | ||
| 377 | `Pushed${pushedCommitPart}${branchPart}`, | ||
| 378 | truncateText(result.commit.subject), | ||
| 379 | ); | ||
| 380 | } | ||
| 381 | |||
| 382 | if (result.commit.status === "created") { | ||
| 383 | const shortSha = shortenSha(result.commit.commitSha); | ||
| 384 | const title = shortSha ? `Committed ${shortSha}` : "Committed changes"; | ||
| 385 | return withDescription(title, truncateText(result.commit.subject)); | ||
| 386 | } | ||
| 387 | |||
| 388 | return { title: "Done" }; | ||
| 389 | } | ||
| 390 | |||
| 391 | function sanitizeCommitMessage(generated: { | ||
| 392 | subject: string; | ||
| 393 | body: string; | ||
| 394 | branch?: string | undefined; | ||
| 395 | }): { | ||
| 396 | subject: string; | ||
| 397 | body: string; | ||
| 398 | branch?: string | undefined; | ||
| 399 | } { | ||
| 400 | const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? ""; | ||
| 401 | const subject = rawSubject.replace(/[.]+$/g, "").trim(); | ||
| 402 | const safeSubject = subject.length > 0 ? subject.slice(0, 72).trimEnd() : "Update project files"; | ||
| 403 | return { | ||
| 404 | subject: safeSubject, | ||
| 405 | body: generated.body.trim(), | ||
| 406 | ...(generated.branch !== undefined ? { branch: generated.branch } : {}), | ||
| 407 | }; | ||
| 408 | } | ||
| 409 | |||
| 410 | function sanitizeProgressText(value: string): string | null { | ||
| 411 | const trimmed = value.trim(); | ||
| 412 | if (trimmed.length === 0) { | ||
| 413 | return null; | ||
| 414 | } | ||
| 415 | if (trimmed.length <= MAX_PROGRESS_TEXT_LENGTH) { | ||
| 416 | return trimmed; | ||
| 417 | } | ||
| 418 | return trimmed.slice(0, MAX_PROGRESS_TEXT_LENGTH).trimEnd(); | ||
| 419 | } | ||
| 420 | |||
| 421 | interface CommitAndBranchSuggestion { | ||
| 422 | subject: string; | ||
| 423 | body: string; | ||
| 424 | branch?: string | undefined; | ||
| 425 | commitMessage: string; | ||
| 426 | } | ||
| 427 | |||
| 428 | function isCommitAction( | ||
| 429 | action: GitStackedAction, | ||
| 430 | ): action is "commit" | "commit_push" | "commit_push_pr" { | ||
| 431 | return action === "commit" || action === "commit_push" || action === "commit_push_pr"; | ||
| 432 | } | ||
| 433 | |||
| 434 | function formatCommitMessage(subject: string, body: string): string { | ||
| 435 | const trimmedBody = body.trim(); | ||
| 436 | if (trimmedBody.length === 0) { | ||
| 437 | return subject; | ||
| 438 | } | ||
| 439 | return `${subject}\n\n${trimmedBody}`; | ||
| 440 | } | ||
| 441 | |||
| 442 | function parseCustomCommitMessage(raw: string): { subject: string; body: string } | null { | ||
| 443 | const normalized = raw.replace(/\r\n/g, "\n").trim(); | ||
| 444 | if (normalized.length === 0) { | ||
| 445 | return null; | ||
| 446 | } | ||
| 447 | |||
| 448 | const [firstLine, ...rest] = normalized.split("\n"); | ||
| 449 | const subject = firstLine?.trim() ?? ""; | ||
| 450 | if (subject.length === 0) { | ||
| 451 | return null; | ||
| 452 | } | ||
| 453 | |||
| 454 | return { | ||
| 455 | subject, | ||
| 456 | body: rest.join("\n").trim(), | ||
| 457 | }; | ||
| 458 | } | ||
| 459 | |||
| 460 | function appendUnique(values: string[], next: string | null | undefined): void { | ||
| 461 | const trimmed = next?.trim() ?? ""; | ||
| 462 | if (trimmed.length === 0 || values.includes(trimmed)) { | ||
| 463 | return; | ||
| 464 | } | ||
| 465 | values.push(trimmed); | ||
| 466 | } | ||
| 467 | |||
| 468 | function toStatusPr(pr: PullRequestInfo): { | ||
| 469 | number: number; | ||
| 470 | title: string; | ||
| 471 | url: string; | ||
| 472 | baseRef: string; | ||
| 473 | headRef: string; | ||
| 474 | state: "open" | "closed" | "merged"; | ||
| 475 | } { | ||
| 476 | return { | ||
| 477 | number: pr.number, | ||
| 478 | title: pr.title, | ||
| 479 | url: pr.url, | ||
| 480 | baseRef: pr.baseRefName, | ||
| 481 | headRef: pr.headRefName, | ||
| 482 | state: pr.state, | ||
| 483 | }; | ||
| 484 | } | ||
| 485 | |||
| 486 | function normalizePullRequestReference(reference: string): string { | ||
| 487 | const trimmed = reference.trim(); | ||
| 488 | const hashNumber = /^#(\d+)$/.exec(trimmed); | ||
| 489 | return hashNumber?.[1] ?? trimmed; | ||
| 490 | } | ||
| 491 | |||
| 492 | function toResolvedPullRequest(pr: { | ||
| 493 | number: number; | ||
| 494 | title: string; | ||
| 495 | url: string; | ||
| 496 | baseRefName: string; | ||
| 497 | headRefName: string; | ||
| 498 | state?: "open" | "closed" | "merged"; | ||
| 499 | }): ResolvedPullRequest { | ||
| 500 | return { | ||
| 501 | number: pr.number, | ||
| 502 | title: pr.title, | ||
| 503 | url: pr.url, | ||
| 504 | baseBranch: pr.baseRefName, | ||
| 505 | headBranch: pr.headRefName, | ||
| 506 | state: pr.state ?? "open", | ||
| 507 | }; | ||
| 508 | } | ||
| 509 | |||
| 510 | function shouldPreferSshRemote(url: string | null): boolean { | ||
| 511 | if (!url) return false; | ||
| 512 | const trimmed = url.trim(); | ||
| 513 | return trimmed.startsWith("git@") || trimmed.startsWith("ssh://"); | ||
| 514 | } | ||
| 515 | |||
| 516 | function toPullRequestHeadRemoteInfo(pr: { | ||
| 517 | isCrossRepository?: boolean | undefined; | ||
| 518 | headRepositoryNameWithOwner?: string | null | undefined; | ||
| 519 | headRepositoryOwnerLogin?: string | null | undefined; | ||
| 520 | }): PullRequestHeadRemoteInfo { | ||
| 521 | return { | ||
| 522 | ...(pr.isCrossRepository !== undefined ? { isCrossRepository: pr.isCrossRepository } : {}), | ||
| 523 | ...(pr.headRepositoryNameWithOwner !== undefined | ||
| 524 | ? { headRepositoryNameWithOwner: pr.headRepositoryNameWithOwner } | ||
| 525 | : {}), | ||
| 526 | ...(pr.headRepositoryOwnerLogin !== undefined | ||
| 527 | ? { headRepositoryOwnerLogin: pr.headRepositoryOwnerLogin } | ||
| 528 | : {}), | ||
| 529 | }; | ||
| 530 | } | ||
| 531 | |||
| 532 | export const makeGitManager = Effect.fn("makeGitManager")(function* () { | ||
| 533 | const gitCore = yield* GitVcsDriver; | ||
| 534 | const sourceControlProviders = yield* SourceControlProviderRegistry; | ||
| 535 | const textGeneration = yield* TextGeneration; | ||
| 536 | const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; | ||
| 537 | |||
| 538 | const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); | ||
| 539 | const serverSettingsService = yield* ServerSettingsService; | ||
| 540 | |||
| 541 | const createProgressEmitter = ( | ||
| 542 | input: { cwd: string; action: GitStackedAction }, | ||
| 543 | options?: GitRunStackedActionOptions, | ||
| 544 | ) => { | ||
| 545 | const actionId = options?.actionId ?? randomUUID(); | ||
| 546 | const reporter = options?.progressReporter; | ||
| 547 | |||
| 548 | const emit = (event: GitActionProgressPayload) => | ||
| 549 | reporter | ||
| 550 | ? reporter.publish({ | ||
| 551 | actionId, | ||
| 552 | cwd: input.cwd, | ||
| 553 | action: input.action, | ||
| 554 | ...event, | ||
| 555 | } as GitActionProgressEvent) | ||
| 556 | : Effect.void; | ||
| 557 | |||
| 558 | return { | ||
| 559 | actionId, | ||
| 560 | emit, | ||
| 561 | }; | ||
| 562 | }; | ||
| 563 | |||
| 564 | const configurePullRequestHeadUpstreamBase = Effect.fn("configurePullRequestHeadUpstream")( | ||
| 565 | function* ( | ||
| 566 | cwd: string, | ||
| 567 | pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, | ||
| 568 | localBranch = pullRequest.headBranch, | ||
| 569 | ) { | ||
| 570 | const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; | ||
| 571 | if (repositoryNameWithOwner.length === 0) { | ||
| 572 | return; | ||
| 573 | } | ||
| 574 | |||
| 575 | const cloneUrls = yield* (yield* sourceControlProvider(cwd)).getRepositoryCloneUrls({ | ||
| 576 | cwd, | ||
| 577 | repository: repositoryNameWithOwner, | ||
| 578 | }); | ||
| 579 | const originRemoteUrl = yield* gitCore.readConfigValue(cwd, "remote.origin.url"); | ||
| 580 | const remoteUrl = shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url; | ||
| 581 | const preferredRemoteName = | ||
| 582 | pullRequest.headRepositoryOwnerLogin?.trim() || | ||
| 583 | repositoryNameWithOwner.split("/")[0]?.trim() || | ||
| 584 | "fork"; | ||
| 585 | const remoteName = yield* gitCore.ensureRemote({ | ||
| 586 | cwd, | ||
| 587 | preferredName: preferredRemoteName, | ||
| 588 | url: remoteUrl, | ||
| 589 | }); | ||
| 590 | |||
| 591 | yield* gitCore.setBranchUpstream({ | ||
| 592 | cwd, | ||
| 593 | branch: localBranch, | ||
| 594 | remoteName, | ||
| 595 | remoteBranch: pullRequest.headBranch, | ||
| 596 | }); | ||
| 597 | }, | ||
| 598 | ); | ||
| 599 | |||
| 600 | const configurePullRequestHeadUpstream = ( | ||
| 601 | cwd: string, | ||
| 602 | pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, | ||
| 603 | localBranch = pullRequest.headBranch, | ||
| 604 | ) => | ||
| 605 | configurePullRequestHeadUpstreamBase(cwd, pullRequest, localBranch).pipe( | ||
| 606 | Effect.catch((error) => | ||
| 607 | Effect.logWarning( | ||
| 608 | `GitManager.configurePullRequestHeadUpstream: failed to configure upstream for ${localBranch} -> ${pullRequest.headBranch} in ${cwd}: ${error.message}`, | ||
| 609 | ).pipe(Effect.asVoid), | ||
| 610 | ), | ||
| 611 | ); | ||
| 612 | |||
| 613 | const materializePullRequestHeadBranchBase = Effect.fn("materializePullRequestHeadBranch")( | ||
| 614 | function* ( | ||
| 615 | cwd: string, | ||
| 616 | pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, | ||
| 617 | localBranch = pullRequest.headBranch, | ||
| 618 | ) { | ||
| 619 | const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; | ||
| 620 | |||
| 621 | if (repositoryNameWithOwner.length === 0) { | ||
| 622 | yield* gitCore.fetchPullRequestBranch({ | ||
| 623 | cwd, | ||
| 624 | prNumber: pullRequest.number, | ||
| 625 | branch: localBranch, | ||
| 626 | }); | ||
| 627 | return; | ||
| 628 | } | ||
| 629 | |||
| 630 | const cloneUrls = yield* (yield* sourceControlProvider(cwd)).getRepositoryCloneUrls({ | ||
| 631 | cwd, | ||
| 632 | repository: repositoryNameWithOwner, | ||
| 633 | }); | ||
| 634 | const originRemoteUrl = yield* gitCore.readConfigValue(cwd, "remote.origin.url"); | ||
| 635 | const remoteUrl = shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url; | ||
| 636 | const preferredRemoteName = | ||
| 637 | pullRequest.headRepositoryOwnerLogin?.trim() || | ||
| 638 | repositoryNameWithOwner.split("/")[0]?.trim() || | ||
| 639 | "fork"; | ||
| 640 | const remoteName = yield* gitCore.ensureRemote({ | ||
| 641 | cwd, | ||
| 642 | preferredName: preferredRemoteName, | ||
| 643 | url: remoteUrl, | ||
| 644 | }); | ||
| 645 | |||
| 646 | yield* gitCore.fetchRemoteBranch({ | ||
| 647 | cwd, | ||
| 648 | remoteName, | ||
| 649 | remoteBranch: pullRequest.headBranch, | ||
| 650 | localBranch, | ||
| 651 | }); | ||
| 652 | yield* gitCore.setBranchUpstream({ | ||
| 653 | cwd, | ||
| 654 | branch: localBranch, | ||
| 655 | remoteName, | ||
| 656 | remoteBranch: pullRequest.headBranch, | ||
| 657 | }); | ||
| 658 | }, | ||
| 659 | ); | ||
| 660 | |||
| 661 | const materializePullRequestHeadBranch = ( | ||
| 662 | cwd: string, | ||
| 663 | pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, | ||
| 664 | localBranch = pullRequest.headBranch, | ||
| 665 | ) => | ||
| 666 | materializePullRequestHeadBranchBase(cwd, pullRequest, localBranch).pipe( | ||
| 667 | Effect.catch(() => | ||
| 668 | gitCore.fetchPullRequestBranch({ | ||
| 669 | cwd, | ||
| 670 | prNumber: pullRequest.number, | ||
| 671 | branch: localBranch, | ||
| 672 | }), | ||
| 673 | ), | ||
| 674 | ); | ||
| 675 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 676 | const path = yield* Path.Path; | ||
| 677 | |||
| 678 | const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; | ||
| 679 | const canonicalizeExistingPath = (value: string) => | ||
| 680 | fileSystem.realPath(value).pipe(Effect.catch(() => Effect.succeed(value))); | ||
| 681 | const normalizeStatusCacheKey = canonicalizeExistingPath; | ||
| 682 | const nonRepositoryStatusDetails = { | ||
| 683 | isRepo: false, | ||
| 684 | hasOriginRemote: false, | ||
| 685 | isDefaultBranch: false, | ||
| 686 | branch: null, | ||
| 687 | upstreamRef: null, | ||
| 688 | hasWorkingTreeChanges: false, | ||
| 689 | workingTree: { files: [], insertions: 0, deletions: 0 }, | ||
| 690 | hasUpstream: false, | ||
| 691 | aheadCount: 0, | ||
| 692 | behindCount: 0, | ||
| 693 | } satisfies GitStatusDetails; | ||
| 694 | const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) { | ||
| 695 | const details = yield* gitCore | ||
| 696 | .statusDetailsLocal(cwd) | ||
| 697 | .pipe( | ||
| 698 | Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)), | ||
| 699 | ); | ||
| 700 | const hostingProvider = details.isRepo | ||
| 701 | ? yield* resolveHostingProvider(cwd, details.branch) | ||
| 702 | : null; | ||
| 703 | |||
| 704 | return { | ||
| 705 | isRepo: details.isRepo, | ||
| 706 | ...(hostingProvider ? { sourceControlProvider: hostingProvider } : {}), | ||
| 707 | hasPrimaryRemote: details.hasOriginRemote, | ||
| 708 | isDefaultRef: details.isDefaultBranch, | ||
| 709 | refName: details.branch, | ||
| 710 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, | ||
| 711 | workingTree: details.workingTree, | ||
| 712 | } satisfies VcsStatusLocalResult; | ||
| 713 | }); | ||
| 714 | const localStatusResultCache = yield* Cache.makeWith(readLocalStatus, { | ||
| 715 | capacity: STATUS_RESULT_CACHE_CAPACITY, | ||
| 716 | timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), | ||
| 717 | }); | ||
| 718 | const invalidateLocalStatusResultCache = (cwd: string) => | ||
| 719 | normalizeStatusCacheKey(cwd).pipe( | ||
| 720 | Effect.flatMap((cacheKey) => Cache.invalidate(localStatusResultCache, cacheKey)), | ||
| 721 | ); | ||
| 722 | const readRemoteStatus = Effect.fn("readRemoteStatus")(function* (cwd: string) { | ||
| 723 | const details = yield* gitCore | ||
| 724 | .statusDetails(cwd) | ||
| 725 | .pipe(Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(null))); | ||
| 726 | if (details === null || !details.isRepo) { | ||
| 727 | return null; | ||
| 728 | } | ||
| 729 | |||
| 730 | const pr = | ||
| 731 | details.branch !== null | ||
| 732 | ? yield* findLatestPr(cwd, { | ||
| 733 | branch: details.branch, | ||
| 734 | upstreamRef: details.upstreamRef, | ||
| 735 | }).pipe( | ||
| 736 | Effect.map((latest) => { | ||
| 737 | if (!latest) return null; | ||
| 738 | // On the default branch, only surface open PRs. | ||
| 739 | // Merged/closed matches are usually reverse-merge history, not the thread's PR context. | ||
| 740 | if (details.isDefaultBranch && latest.state !== "open") return null; | ||
| 741 | return toStatusPr(latest); | ||
| 742 | }), | ||
| 743 | Effect.catch(() => Effect.succeed(null)), | ||
| 744 | ) | ||
| 745 | : null; | ||
| 746 | |||
| 747 | return { | ||
| 748 | hasUpstream: details.hasUpstream, | ||
| 749 | aheadCount: details.aheadCount, | ||
| 750 | behindCount: details.behindCount, | ||
| 751 | pr, | ||
| 752 | } satisfies VcsStatusRemoteResult; | ||
| 753 | }); | ||
| 754 | const remoteStatusResultCache = yield* Cache.makeWith(readRemoteStatus, { | ||
| 755 | capacity: STATUS_RESULT_CACHE_CAPACITY, | ||
| 756 | timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), | ||
| 757 | }); | ||
| 758 | const invalidateRemoteStatusResultCache = (cwd: string) => | ||
| 759 | normalizeStatusCacheKey(cwd).pipe( | ||
| 760 | Effect.flatMap((cacheKey) => Cache.invalidate(remoteStatusResultCache, cacheKey)), | ||
| 761 | ); | ||
| 762 | |||
| 763 | const readConfigValueNullable = (cwd: string, key: string) => | ||
| 764 | gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null))); | ||
| 765 | |||
| 766 | const resolveHostingProvider = Effect.fn("resolveHostingProvider")(function* ( | ||
| 767 | cwd: string, | ||
| 768 | branch: string | null, | ||
| 769 | ) { | ||
| 770 | const preferredRemoteName = | ||
| 771 | branch === null | ||
| 772 | ? "origin" | ||
| 773 | : ((yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)) ?? "origin"); | ||
| 774 | const remoteUrl = | ||
| 775 | (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ?? | ||
| 776 | (yield* readConfigValueNullable(cwd, "remote.origin.url")); | ||
| 777 | |||
| 778 | return remoteUrl ? detectSourceControlProviderFromGitRemoteUrl(remoteUrl) : null; | ||
| 779 | }); | ||
| 780 | |||
| 781 | const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* ( | ||
| 782 | cwd: string, | ||
| 783 | remoteName: string | null, | ||
| 784 | ) { | ||
| 785 | if (!remoteName) { | ||
| 786 | return { | ||
| 787 | repositoryNameWithOwner: null, | ||
| 788 | ownerLogin: null, | ||
| 789 | }; | ||
| 790 | } | ||
| 791 | |||
| 792 | const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`); | ||
| 793 | const repositoryNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); | ||
| 794 | return { | ||
| 795 | repositoryNameWithOwner, | ||
| 796 | ownerLogin: parseRepositoryOwnerLogin(repositoryNameWithOwner), | ||
| 797 | }; | ||
| 798 | }); | ||
| 799 | |||
| 800 | const resolveBranchHeadContext = Effect.fn("resolveBranchHeadContext")(function* ( | ||
| 801 | cwd: string, | ||
| 802 | details: { branch: string; upstreamRef: string | null }, | ||
| 803 | ) { | ||
| 804 | const remoteName = yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`); | ||
| 805 | const headBranchFromUpstream = details.upstreamRef | ||
| 806 | ? extractBranchNameFromRemoteRef(details.upstreamRef, { remoteName }) | ||
| 807 | : ""; | ||
| 808 | const headBranch = headBranchFromUpstream.length > 0 ? headBranchFromUpstream : details.branch; | ||
| 809 | const shouldProbeLocalBranchSelector = | ||
| 810 | headBranchFromUpstream.length === 0 || headBranch === details.branch; | ||
| 811 | |||
| 812 | const [remoteRepository, originRepository] = yield* Effect.all( | ||
| 813 | [ | ||
| 814 | resolveRemoteRepositoryContext(cwd, remoteName), | ||
| 815 | resolveRemoteRepositoryContext(cwd, "origin"), | ||
| 816 | ], | ||
| 817 | { concurrency: "unbounded" }, | ||
| 818 | ); | ||
| 819 | |||
| 820 | const isCrossRepository = | ||
| 821 | remoteRepository.repositoryNameWithOwner !== null && | ||
| 822 | originRepository.repositoryNameWithOwner !== null | ||
| 823 | ? remoteRepository.repositoryNameWithOwner.toLowerCase() !== | ||
| 824 | originRepository.repositoryNameWithOwner.toLowerCase() | ||
| 825 | : remoteName !== null && | ||
| 826 | remoteName !== "origin" && | ||
| 827 | remoteRepository.repositoryNameWithOwner !== null; | ||
| 828 | |||
| 829 | const ownerHeadSelector = | ||
| 830 | remoteRepository.ownerLogin && headBranch.length > 0 | ||
| 831 | ? `${remoteRepository.ownerLogin}:${headBranch}` | ||
| 832 | : null; | ||
| 833 | const remoteAliasHeadSelector = | ||
| 834 | remoteName && headBranch.length > 0 ? `${remoteName}:${headBranch}` : null; | ||
| 835 | const shouldProbeRemoteOwnedSelectors = | ||
| 836 | isCrossRepository || (remoteName !== null && remoteName !== "origin"); | ||
| 837 | |||
| 838 | const headSelectors: string[] = []; | ||
| 839 | if (isCrossRepository && shouldProbeRemoteOwnedSelectors) { | ||
| 840 | appendUnique(headSelectors, ownerHeadSelector); | ||
| 841 | appendUnique( | ||
| 842 | headSelectors, | ||
| 843 | remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null, | ||
| 844 | ); | ||
| 845 | } | ||
| 846 | if (shouldProbeLocalBranchSelector) { | ||
| 847 | appendUnique(headSelectors, details.branch); | ||
| 848 | } | ||
| 849 | appendUnique(headSelectors, headBranch !== details.branch ? headBranch : null); | ||
| 850 | if (!isCrossRepository && shouldProbeRemoteOwnedSelectors) { | ||
| 851 | appendUnique(headSelectors, ownerHeadSelector); | ||
| 852 | appendUnique( | ||
| 853 | headSelectors, | ||
| 854 | remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null, | ||
| 855 | ); | ||
| 856 | } | ||
| 857 | |||
| 858 | return { | ||
| 859 | localBranch: details.branch, | ||
| 860 | headBranch, | ||
| 861 | headSelectors, | ||
| 862 | preferredHeadSelector: | ||
| 863 | ownerHeadSelector && isCrossRepository ? ownerHeadSelector : headBranch, | ||
| 864 | remoteName, | ||
| 865 | headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner, | ||
| 866 | headRepositoryOwnerLogin: remoteRepository.ownerLogin, | ||
| 867 | isCrossRepository, | ||
| 868 | } satisfies BranchHeadContext; | ||
| 869 | }); | ||
| 870 | |||
| 871 | const findOpenPr = Effect.fn("findOpenPr")(function* ( | ||
| 872 | cwd: string, | ||
| 873 | headContext: Pick< | ||
| 874 | BranchHeadContext, | ||
| 875 | | "headBranch" | ||
| 876 | | "headSelectors" | ||
| 877 | | "headRepositoryNameWithOwner" | ||
| 878 | | "headRepositoryOwnerLogin" | ||
| 879 | | "isCrossRepository" | ||
| 880 | >, | ||
| 881 | ) { | ||
| 882 | for (const headSelector of headContext.headSelectors) { | ||
| 883 | const pullRequests = yield* (yield* sourceControlProvider(cwd)).listChangeRequests({ | ||
| 884 | cwd, | ||
| 885 | headSelector, | ||
| 886 | state: "open", | ||
| 887 | limit: 1, | ||
| 888 | }); | ||
| 889 | const normalizedPullRequests = pullRequests.map(toPullRequestInfo); | ||
| 890 | |||
| 891 | const firstPullRequest = normalizedPullRequests.find((pullRequest) => | ||
| 892 | matchesBranchHeadContext(pullRequest, headContext), | ||
| 893 | ); | ||
| 894 | if (firstPullRequest) { | ||
| 895 | return { | ||
| 896 | number: firstPullRequest.number, | ||
| 897 | title: firstPullRequest.title, | ||
| 898 | url: firstPullRequest.url, | ||
| 899 | baseRefName: firstPullRequest.baseRefName, | ||
| 900 | headRefName: firstPullRequest.headRefName, | ||
| 901 | state: "open", | ||
| 902 | updatedAt: Option.none(), | ||
| 903 | } satisfies PullRequestInfo; | ||
| 904 | } | ||
| 905 | } | ||
| 906 | |||
| 907 | return null; | ||
| 908 | }); | ||
| 909 | |||
| 910 | const findLatestPr = Effect.fn("findLatestPr")(function* ( | ||
| 911 | cwd: string, | ||
| 912 | details: { branch: string; upstreamRef: string | null }, | ||
| 913 | ) { | ||
| 914 | const headContext = yield* resolveBranchHeadContext(cwd, details); | ||
| 915 | const parsedByNumber = new Map<number, PullRequestInfo>(); | ||
| 916 | |||
| 917 | for (const headSelector of headContext.headSelectors) { | ||
| 918 | const pullRequests = yield* (yield* sourceControlProvider(cwd)).listChangeRequests({ | ||
| 919 | cwd, | ||
| 920 | headSelector, | ||
| 921 | state: "all", | ||
| 922 | limit: 20, | ||
| 923 | }); | ||
| 924 | |||
| 925 | for (const pr of pullRequests.map(toPullRequestInfo)) { | ||
| 926 | if (!matchesBranchHeadContext(pr, headContext)) { | ||
| 927 | continue; | ||
| 928 | } | ||
| 929 | parsedByNumber.set(pr.number, pr); | ||
| 930 | } | ||
| 931 | } | ||
| 932 | |||
| 933 | const parsed = Arr.sort(parsedByNumber.values(), pullRequestUpdatedAtDescOrder); | ||
| 934 | |||
| 935 | const latestOpenPr = parsed.find((pr) => pr.state === "open"); | ||
| 936 | if (latestOpenPr) { | ||
| 937 | return latestOpenPr; | ||
| 938 | } | ||
| 939 | return parsed[0] ?? null; | ||
| 940 | }); | ||
| 941 | |||
| 942 | const buildCompletionToast = Effect.fn("buildCompletionToast")(function* ( | ||
| 943 | cwd: string, | ||
| 944 | result: Pick<GitRunStackedActionResult, "action" | "branch" | "commit" | "push" | "pr">, | ||
| 945 | ) { | ||
| 946 | const terms = yield* sourceControlProvider(cwd).pipe( | ||
| 947 | Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)), | ||
| 948 | Effect.catch(() => Effect.succeed(getChangeRequestTerminologyForKind("unknown"))), | ||
| 949 | ); | ||
| 950 | const summary = summarizeGitActionResult(result, terms); | ||
| 951 | let latestOpenPr: PullRequestInfo | null = null; | ||
| 952 | let currentBranchIsDefault = false; | ||
| 953 | let finalBranchContext: { | ||
| 954 | branch: string; | ||
| 955 | upstreamRef: string | null; | ||
| 956 | hasUpstream: boolean; | ||
| 957 | } | null = null; | ||
| 958 | |||
| 959 | if (result.action !== "commit") { | ||
| 960 | const finalStatus = yield* gitCore.statusDetails(cwd); | ||
| 961 | if (finalStatus.branch) { | ||
| 962 | finalBranchContext = { | ||
| 963 | branch: finalStatus.branch, | ||
| 964 | upstreamRef: finalStatus.upstreamRef, | ||
| 965 | hasUpstream: finalStatus.hasUpstream, | ||
| 966 | }; | ||
| 967 | currentBranchIsDefault = finalStatus.isDefaultBranch; | ||
| 968 | } | ||
| 969 | } | ||
| 970 | |||
| 971 | const explicitResultPr = | ||
| 972 | (result.pr.status === "created" || result.pr.status === "opened_existing") && result.pr.url | ||
| 973 | ? { | ||
| 974 | url: result.pr.url, | ||
| 975 | state: "open" as const, | ||
| 976 | } | ||
| 977 | : null; | ||
| 978 | const shouldLookupExistingOpenPr = | ||
| 979 | (result.action === "commit_push" || result.action === "push") && | ||
| 980 | result.push.status === "pushed" && | ||
| 981 | result.branch.status !== "created" && | ||
| 982 | !currentBranchIsDefault && | ||
| 983 | explicitResultPr === null && | ||
| 984 | finalBranchContext?.hasUpstream === true; | ||
| 985 | |||
| 986 | if (shouldLookupExistingOpenPr && finalBranchContext) { | ||
| 987 | latestOpenPr = yield* resolveBranchHeadContext(cwd, { | ||
| 988 | branch: finalBranchContext.branch, | ||
| 989 | upstreamRef: finalBranchContext.upstreamRef, | ||
| 990 | }).pipe( | ||
| 991 | Effect.flatMap((headContext) => findOpenPr(cwd, headContext)), | ||
| 992 | Effect.catch(() => Effect.succeed(null)), | ||
| 993 | ); | ||
| 994 | } | ||
| 995 | |||
| 996 | const openPr = latestOpenPr ?? explicitResultPr; | ||
| 997 | |||
| 998 | const cta = | ||
| 999 | result.action === "commit" && result.commit.status === "created" | ||
| 1000 | ? { | ||
| 1001 | kind: "run_action" as const, | ||
| 1002 | label: "Push", | ||
| 1003 | action: { kind: "push" as const }, | ||
| 1004 | } | ||
| 1005 | : (result.action === "push" || | ||
| 1006 | result.action === "create_pr" || | ||
| 1007 | result.action === "commit_push" || | ||
| 1008 | result.action === "commit_push_pr") && | ||
| 1009 | openPr?.url && | ||
| 1010 | (!currentBranchIsDefault || | ||
| 1011 | result.pr.status === "created" || | ||
| 1012 | result.pr.status === "opened_existing") | ||
| 1013 | ? { | ||
| 1014 | kind: "open_pr" as const, | ||
| 1015 | label: `View ${terms.shortLabel}`, | ||
| 1016 | url: openPr.url, | ||
| 1017 | } | ||
| 1018 | : (result.action === "push" || result.action === "commit_push") && | ||
| 1019 | result.push.status === "pushed" && | ||
| 1020 | !currentBranchIsDefault | ||
| 1021 | ? { | ||
| 1022 | kind: "run_action" as const, | ||
| 1023 | label: `Create ${terms.shortLabel}`, | ||
| 1024 | action: { kind: "create_pr" as const }, | ||
| 1025 | } | ||
| 1026 | : { | ||
| 1027 | kind: "none" as const, | ||
| 1028 | }; | ||
| 1029 | |||
| 1030 | return { | ||
| 1031 | ...summary, | ||
| 1032 | cta, | ||
| 1033 | }; | ||
| 1034 | }); | ||
| 1035 | |||
| 1036 | const resolveBaseBranch = Effect.fn("resolveBaseBranch")(function* ( | ||
| 1037 | cwd: string, | ||
| 1038 | branch: string, | ||
| 1039 | upstreamRef: string | null, | ||
| 1040 | headContext: Pick<BranchHeadContext, "isCrossRepository" | "remoteName">, | ||
| 1041 | ) { | ||
| 1042 | const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`); | ||
| 1043 | if (configured) return configured; | ||
| 1044 | |||
| 1045 | if (upstreamRef && !headContext.isCrossRepository) { | ||
| 1046 | const upstreamBranch = extractBranchNameFromRemoteRef(upstreamRef, { | ||
| 1047 | remoteName: headContext.remoteName, | ||
| 1048 | }); | ||
| 1049 | if (upstreamBranch.length > 0 && upstreamBranch !== branch) { | ||
| 1050 | return upstreamBranch; | ||
| 1051 | } | ||
| 1052 | } | ||
| 1053 | |||
| 1054 | const defaultFromProvider = yield* sourceControlProvider(cwd).pipe( | ||
| 1055 | Effect.flatMap((provider) => provider.getDefaultBranch({ cwd })), | ||
| 1056 | Effect.catch(() => Effect.succeed(null)), | ||
| 1057 | ); | ||
| 1058 | if (defaultFromProvider) { | ||
| 1059 | return defaultFromProvider; | ||
| 1060 | } | ||
| 1061 | |||
| 1062 | return "main"; | ||
| 1063 | }); | ||
| 1064 | |||
| 1065 | const resolveCommitAndBranchSuggestion = Effect.fn("resolveCommitAndBranchSuggestion")( | ||
| 1066 | function* (input: { | ||
| 1067 | cwd: string; | ||
| 1068 | branch: string | null; | ||
| 1069 | commitMessage?: string; | ||
| 1070 | /** When true, also produce a semantic feature branch name. */ | ||
| 1071 | includeBranch?: boolean; | ||
| 1072 | filePaths?: readonly string[]; | ||
| 1073 | modelSelection: ModelSelection; | ||
| 1074 | }) { | ||
| 1075 | const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths); | ||
| 1076 | if (!context) { | ||
| 1077 | return null; | ||
| 1078 | } | ||
| 1079 | |||
| 1080 | const customCommit = parseCustomCommitMessage(input.commitMessage ?? ""); | ||
| 1081 | if (customCommit) { | ||
| 1082 | return { | ||
| 1083 | subject: customCommit.subject, | ||
| 1084 | body: customCommit.body, | ||
| 1085 | ...(input.includeBranch | ||
| 1086 | ? { branch: sanitizeFeatureBranchName(customCommit.subject) } | ||
| 1087 | : {}), | ||
| 1088 | commitMessage: formatCommitMessage(customCommit.subject, customCommit.body), | ||
| 1089 | }; | ||
| 1090 | } | ||
| 1091 | |||
| 1092 | const generated = yield* textGeneration | ||
| 1093 | .generateCommitMessage({ | ||
| 1094 | cwd: input.cwd, | ||
| 1095 | branch: input.branch, | ||
| 1096 | stagedSummary: limitContext(context.stagedSummary, 8_000), | ||
| 1097 | stagedPatch: limitContext(context.stagedPatch, 50_000), | ||
| 1098 | ...(input.includeBranch ? { includeBranch: true } : {}), | ||
| 1099 | modelSelection: input.modelSelection, | ||
| 1100 | }) | ||
| 1101 | .pipe(Effect.map((result) => sanitizeCommitMessage(result))); | ||
| 1102 | |||
| 1103 | return { | ||
| 1104 | subject: generated.subject, | ||
| 1105 | body: generated.body, | ||
| 1106 | ...(generated.branch !== undefined ? { branch: generated.branch } : {}), | ||
| 1107 | commitMessage: formatCommitMessage(generated.subject, generated.body), | ||
| 1108 | }; | ||
| 1109 | }, | ||
| 1110 | ); | ||
| 1111 | |||
| 1112 | const runCommitStep = Effect.fn("runCommitStep")(function* ( | ||
| 1113 | modelSelection: ModelSelection, | ||
| 1114 | cwd: string, | ||
| 1115 | action: "commit" | "commit_push" | "commit_push_pr", | ||
| 1116 | branch: string | null, | ||
| 1117 | commitMessage?: string, | ||
| 1118 | preResolvedSuggestion?: CommitAndBranchSuggestion, | ||
| 1119 | filePaths?: readonly string[], | ||
| 1120 | progressReporter?: GitActionProgressReporter, | ||
| 1121 | actionId?: string, | ||
| 1122 | ) { | ||
| 1123 | const emit = (event: GitActionProgressPayload) => | ||
| 1124 | progressReporter && actionId | ||
| 1125 | ? progressReporter.publish({ | ||
| 1126 | actionId, | ||
| 1127 | cwd, | ||
| 1128 | action, | ||
| 1129 | ...event, | ||
| 1130 | } as GitActionProgressEvent) | ||
| 1131 | : Effect.void; | ||
| 1132 | |||
| 1133 | let suggestion: CommitAndBranchSuggestion | null | undefined = preResolvedSuggestion; | ||
| 1134 | if (!suggestion) { | ||
| 1135 | const needsGeneration = !commitMessage?.trim(); | ||
| 1136 | if (needsGeneration) { | ||
| 1137 | yield* emit({ | ||
| 1138 | kind: "phase_started", | ||
| 1139 | phase: "commit", | ||
| 1140 | label: "Generating commit message...", | ||
| 1141 | }); | ||
| 1142 | } | ||
| 1143 | suggestion = yield* resolveCommitAndBranchSuggestion({ | ||
| 1144 | cwd, | ||
| 1145 | branch, | ||
| 1146 | ...(commitMessage ? { commitMessage } : {}), | ||
| 1147 | ...(filePaths ? { filePaths } : {}), | ||
| 1148 | modelSelection, | ||
| 1149 | }); | ||
| 1150 | } | ||
| 1151 | if (!suggestion) { | ||
| 1152 | return { status: "skipped_no_changes" as const }; | ||
| 1153 | } | ||
| 1154 | |||
| 1155 | yield* emit({ | ||
| 1156 | kind: "phase_started", | ||
| 1157 | phase: "commit", | ||
| 1158 | label: "Committing...", | ||
| 1159 | }); | ||
| 1160 | |||
| 1161 | let currentHookName: string | null = null; | ||
| 1162 | const commitProgress = | ||
| 1163 | progressReporter && actionId | ||
| 1164 | ? { | ||
| 1165 | onOutputLine: ({ stream, text }: { stream: "stdout" | "stderr"; text: string }) => { | ||
| 1166 | const sanitized = sanitizeProgressText(text); | ||
| 1167 | if (!sanitized) { | ||
| 1168 | return Effect.void; | ||
| 1169 | } | ||
| 1170 | return emit({ | ||
| 1171 | kind: "hook_output", | ||
| 1172 | hookName: currentHookName, | ||
| 1173 | stream, | ||
| 1174 | text: sanitized, | ||
| 1175 | }); | ||
| 1176 | }, | ||
| 1177 | onHookStarted: (hookName: string) => { | ||
| 1178 | currentHookName = hookName; | ||
| 1179 | return emit({ | ||
| 1180 | kind: "hook_started", | ||
| 1181 | hookName, | ||
| 1182 | }); | ||
| 1183 | }, | ||
| 1184 | onHookFinished: ({ | ||
| 1185 | hookName, | ||
| 1186 | exitCode, | ||
| 1187 | durationMs, | ||
| 1188 | }: { | ||
| 1189 | hookName: string; | ||
| 1190 | exitCode: number | null; | ||
| 1191 | durationMs: number | null; | ||
| 1192 | }) => { | ||
| 1193 | if (currentHookName === hookName) { | ||
| 1194 | currentHookName = null; | ||
| 1195 | } | ||
| 1196 | return emit({ | ||
| 1197 | kind: "hook_finished", | ||
| 1198 | hookName, | ||
| 1199 | exitCode, | ||
| 1200 | durationMs, | ||
| 1201 | }); | ||
| 1202 | }, | ||
| 1203 | } | ||
| 1204 | : null; | ||
| 1205 | const { commitSha } = yield* gitCore.commit(cwd, suggestion.subject, suggestion.body, { | ||
| 1206 | timeoutMs: COMMIT_TIMEOUT_MS, | ||
| 1207 | ...(commitProgress ? { progress: commitProgress } : {}), | ||
| 1208 | }); | ||
| 1209 | if (currentHookName !== null) { | ||
| 1210 | yield* emit({ | ||
| 1211 | kind: "hook_finished", | ||
| 1212 | hookName: currentHookName, | ||
| 1213 | exitCode: 0, | ||
| 1214 | durationMs: null, | ||
| 1215 | }); | ||
| 1216 | currentHookName = null; | ||
| 1217 | } | ||
| 1218 | return { | ||
| 1219 | status: "created" as const, | ||
| 1220 | commitSha, | ||
| 1221 | subject: suggestion.subject, | ||
| 1222 | }; | ||
| 1223 | }); | ||
| 1224 | |||
| 1225 | const runPrStep = Effect.fn("runPrStep")(function* ( | ||
| 1226 | modelSelection: ModelSelection, | ||
| 1227 | cwd: string, | ||
| 1228 | fallbackBranch: string | null, | ||
| 1229 | emit: GitActionProgressEmitter, | ||
| 1230 | ) { | ||
| 1231 | const provider = yield* sourceControlProvider(cwd); | ||
| 1232 | const terms = getChangeRequestTerminologyForKind(provider.kind); | ||
| 1233 | const details = yield* gitCore.statusDetails(cwd); | ||
| 1234 | const branch = details.branch ?? fallbackBranch; | ||
| 1235 | if (!branch) { | ||
| 1236 | return yield* gitManagerError( | ||
| 1237 | "runPrStep", | ||
| 1238 | "Cannot create a pull request from detached HEAD.", | ||
| 1239 | ); | ||
| 1240 | } | ||
| 1241 | if (!details.hasUpstream) { | ||
| 1242 | return yield* gitManagerError( | ||
| 1243 | "runPrStep", | ||
| 1244 | "Current branch has not been pushed. Push before creating a PR.", | ||
| 1245 | ); | ||
| 1246 | } | ||
| 1247 | |||
| 1248 | const headContext = yield* resolveBranchHeadContext(cwd, { | ||
| 1249 | branch, | ||
| 1250 | upstreamRef: details.upstreamRef, | ||
| 1251 | }); | ||
| 1252 | |||
| 1253 | const existing = yield* findOpenPr(cwd, headContext); | ||
| 1254 | if (existing) { | ||
| 1255 | return { | ||
| 1256 | status: "opened_existing" as const, | ||
| 1257 | url: existing.url, | ||
| 1258 | number: existing.number, | ||
| 1259 | baseBranch: existing.baseRefName, | ||
| 1260 | headBranch: existing.headRefName, | ||
| 1261 | title: existing.title, | ||
| 1262 | }; | ||
| 1263 | } | ||
| 1264 | |||
| 1265 | const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef, headContext); | ||
| 1266 | yield* emit({ | ||
| 1267 | kind: "phase_started", | ||
| 1268 | phase: "pr", | ||
| 1269 | label: `Generating ${terms.shortLabel} content...`, | ||
| 1270 | }); | ||
| 1271 | const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch); | ||
| 1272 | |||
| 1273 | const generated = yield* textGeneration.generatePrContent({ | ||
| 1274 | cwd, | ||
| 1275 | baseBranch, | ||
| 1276 | headBranch: headContext.headBranch, | ||
| 1277 | commitSummary: limitContext(rangeContext.commitSummary, 20_000), | ||
| 1278 | diffSummary: limitContext(rangeContext.diffSummary, 20_000), | ||
| 1279 | diffPatch: limitContext(rangeContext.diffPatch, 60_000), | ||
| 1280 | modelSelection, | ||
| 1281 | }); | ||
| 1282 | |||
| 1283 | const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${randomUUID()}.md`); | ||
| 1284 | yield* fileSystem | ||
| 1285 | .writeFileString(bodyFile, generated.body) | ||
| 1286 | .pipe( | ||
| 1287 | Effect.mapError((cause) => | ||
| 1288 | gitManagerError("runPrStep", "Failed to write pull request body temp file.", cause), | ||
| 1289 | ), | ||
| 1290 | ); | ||
| 1291 | yield* emit({ | ||
| 1292 | kind: "phase_started", | ||
| 1293 | phase: "pr", | ||
| 1294 | label: `Creating ${terms.singular}...`, | ||
| 1295 | }); | ||
| 1296 | yield* provider | ||
| 1297 | .createChangeRequest({ | ||
| 1298 | cwd, | ||
| 1299 | baseRefName: baseBranch, | ||
| 1300 | headSelector: headContext.preferredHeadSelector, | ||
| 1301 | title: generated.title, | ||
| 1302 | bodyFile, | ||
| 1303 | }) | ||
| 1304 | .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void)))); | ||
| 1305 | |||
| 1306 | const created = yield* findOpenPr(cwd, headContext); | ||
| 1307 | if (!created) { | ||
| 1308 | return { | ||
| 1309 | status: "created" as const, | ||
| 1310 | baseBranch, | ||
| 1311 | headBranch: headContext.headBranch, | ||
| 1312 | title: generated.title, | ||
| 1313 | }; | ||
| 1314 | } | ||
| 1315 | |||
| 1316 | return { | ||
| 1317 | status: "created" as const, | ||
| 1318 | url: created.url, | ||
| 1319 | number: created.number, | ||
| 1320 | baseBranch: created.baseRefName, | ||
| 1321 | headBranch: created.headRefName, | ||
| 1322 | title: created.title, | ||
| 1323 | }; | ||
| 1324 | }); | ||
| 1325 | |||
| 1326 | const localStatus: GitManagerShape["localStatus"] = Effect.fn("localStatus")(function* (input) { | ||
| 1327 | const cacheKey = yield* normalizeStatusCacheKey(input.cwd); | ||
| 1328 | return yield* Cache.get(localStatusResultCache, cacheKey); | ||
| 1329 | }); | ||
| 1330 | const remoteStatus: GitManagerShape["remoteStatus"] = Effect.fn("remoteStatus")( | ||
| 1331 | function* (input) { | ||
| 1332 | const cacheKey = yield* normalizeStatusCacheKey(input.cwd); | ||
| 1333 | return yield* Cache.get(remoteStatusResultCache, cacheKey); | ||
| 1334 | }, | ||
| 1335 | ); | ||
| 1336 | const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) { | ||
| 1337 | const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)]); | ||
| 1338 | return mergeGitStatusParts(local, remote); | ||
| 1339 | }); | ||
| 1340 | const invalidateLocalStatus: GitManagerShape["invalidateLocalStatus"] = Effect.fn( | ||
| 1341 | "invalidateLocalStatus", | ||
| 1342 | )(function* (cwd) { | ||
| 1343 | yield* invalidateLocalStatusResultCache(cwd); | ||
| 1344 | }); | ||
| 1345 | const invalidateRemoteStatus: GitManagerShape["invalidateRemoteStatus"] = Effect.fn( | ||
| 1346 | "invalidateRemoteStatus", | ||
| 1347 | )(function* (cwd) { | ||
| 1348 | yield* invalidateRemoteStatusResultCache(cwd); | ||
| 1349 | }); | ||
| 1350 | const invalidateStatus: GitManagerShape["invalidateStatus"] = Effect.fn("invalidateStatus")( | ||
| 1351 | function* (cwd) { | ||
| 1352 | yield* invalidateLocalStatusResultCache(cwd); | ||
| 1353 | yield* invalidateRemoteStatusResultCache(cwd); | ||
| 1354 | }, | ||
| 1355 | ); | ||
| 1356 | |||
| 1357 | const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")( | ||
| 1358 | function* (input) { | ||
| 1359 | const pullRequest = yield* (yield* sourceControlProvider(input.cwd)) | ||
| 1360 | .getChangeRequest({ | ||
| 1361 | cwd: input.cwd, | ||
| 1362 | reference: normalizePullRequestReference(input.reference), | ||
| 1363 | }) | ||
| 1364 | .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved))); | ||
| 1365 | |||
| 1366 | return { pullRequest }; | ||
| 1367 | }, | ||
| 1368 | ); | ||
| 1369 | |||
| 1370 | const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fn( | ||
| 1371 | "preparePullRequestThread", | ||
| 1372 | )(function* (input) { | ||
| 1373 | const maybeRunSetupScript = (worktreePath: string) => { | ||
| 1374 | if (!input.threadId) { | ||
| 1375 | return Effect.void; | ||
| 1376 | } | ||
| 1377 | return projectSetupScriptRunner | ||
| 1378 | .runForThread({ | ||
| 1379 | threadId: input.threadId, | ||
| 1380 | projectCwd: input.cwd, | ||
| 1381 | worktreePath, | ||
| 1382 | }) | ||
| 1383 | .pipe( | ||
| 1384 | Effect.catch((error) => | ||
| 1385 | Effect.logWarning( | ||
| 1386 | `GitManager.preparePullRequestThread: failed to launch worktree setup script for thread ${input.threadId} in ${worktreePath}: ${error.message}`, | ||
| 1387 | ).pipe(Effect.asVoid), | ||
| 1388 | ), | ||
| 1389 | ); | ||
| 1390 | }; | ||
| 1391 | return yield* Effect.gen(function* () { | ||
| 1392 | const normalizedReference = normalizePullRequestReference(input.reference); | ||
| 1393 | const rootWorktreePath = yield* canonicalizeExistingPath(input.cwd); | ||
| 1394 | const pullRequestSummary = yield* (yield* sourceControlProvider(input.cwd)).getChangeRequest({ | ||
| 1395 | cwd: input.cwd, | ||
| 1396 | reference: normalizedReference, | ||
| 1397 | }); | ||
| 1398 | const pullRequest = toResolvedPullRequest(pullRequestSummary); | ||
| 1399 | |||
| 1400 | if (input.mode === "local") { | ||
| 1401 | yield* (yield* sourceControlProvider(input.cwd)).checkoutChangeRequest({ | ||
| 1402 | cwd: input.cwd, | ||
| 1403 | reference: normalizedReference, | ||
| 1404 | force: true, | ||
| 1405 | }); | ||
| 1406 | const details = yield* gitCore.statusDetails(input.cwd); | ||
| 1407 | yield* configurePullRequestHeadUpstream( | ||
| 1408 | input.cwd, | ||
| 1409 | { | ||
| 1410 | ...pullRequest, | ||
| 1411 | ...toPullRequestHeadRemoteInfo(pullRequestSummary), | ||
| 1412 | }, | ||
| 1413 | details.branch ?? pullRequest.headBranch, | ||
| 1414 | ); | ||
| 1415 | return { | ||
| 1416 | pullRequest, | ||
| 1417 | branch: details.branch ?? pullRequest.headBranch, | ||
| 1418 | worktreePath: null, | ||
| 1419 | }; | ||
| 1420 | } | ||
| 1421 | |||
| 1422 | const ensureExistingWorktreeUpstream = Effect.fn("ensureExistingWorktreeUpstream")(function* ( | ||
| 1423 | worktreePath: string, | ||
| 1424 | ) { | ||
| 1425 | const details = yield* gitCore.statusDetails(worktreePath); | ||
| 1426 | yield* configurePullRequestHeadUpstream( | ||
| 1427 | worktreePath, | ||
| 1428 | { | ||
| 1429 | ...pullRequest, | ||
| 1430 | ...toPullRequestHeadRemoteInfo(pullRequestSummary), | ||
| 1431 | }, | ||
| 1432 | details.branch ?? pullRequest.headBranch, | ||
| 1433 | ); | ||
| 1434 | }); | ||
| 1435 | |||
| 1436 | const pullRequestWithRemoteInfo = { | ||
| 1437 | ...pullRequest, | ||
| 1438 | ...toPullRequestHeadRemoteInfo(pullRequestSummary), | ||
| 1439 | } as const; | ||
| 1440 | const localPullRequestBranch = | ||
| 1441 | resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo); | ||
| 1442 | |||
| 1443 | const findLocalHeadBranch = Effect.fn("findLocalHeadBranch")(function* (cwd: string) { | ||
| 1444 | const result = yield* gitCore.listRefs({ cwd }); | ||
| 1445 | const localBranch = result.refs.find( | ||
| 1446 | (branch) => !branch.isRemote && branch.name === localPullRequestBranch, | ||
| 1447 | ); | ||
| 1448 | if (localBranch) { | ||
| 1449 | return localBranch; | ||
| 1450 | } | ||
| 1451 | if (localPullRequestBranch === pullRequest.headBranch) { | ||
| 1452 | return null; | ||
| 1453 | } | ||
| 1454 | |||
| 1455 | for (const branch of result.refs) { | ||
| 1456 | if (branch.isRemote || branch.name !== pullRequest.headBranch || !branch.worktreePath) { | ||
| 1457 | continue; | ||
| 1458 | } | ||
| 1459 | |||
| 1460 | const worktreePath = yield* canonicalizeExistingPath(branch.worktreePath); | ||
| 1461 | if (worktreePath !== rootWorktreePath) { | ||
| 1462 | return branch; | ||
| 1463 | } | ||
| 1464 | } | ||
| 1465 | |||
| 1466 | return null; | ||
| 1467 | }); | ||
| 1468 | |||
| 1469 | const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd); | ||
| 1470 | const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath | ||
| 1471 | ? yield* canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath) | ||
| 1472 | : null; | ||
| 1473 | if ( | ||
| 1474 | existingBranchBeforeFetch?.worktreePath && | ||
| 1475 | existingBranchBeforeFetchPath !== rootWorktreePath | ||
| 1476 | ) { | ||
| 1477 | yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath); | ||
| 1478 | return { | ||
| 1479 | pullRequest, | ||
| 1480 | branch: localPullRequestBranch, | ||
| 1481 | worktreePath: existingBranchBeforeFetch.worktreePath, | ||
| 1482 | }; | ||
| 1483 | } | ||
| 1484 | if (existingBranchBeforeFetchPath === rootWorktreePath) { | ||
| 1485 | return yield* gitManagerError( | ||
| 1486 | "preparePullRequestThread", | ||
| 1487 | "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", | ||
| 1488 | ); | ||
| 1489 | } | ||
| 1490 | |||
| 1491 | yield* materializePullRequestHeadBranch( | ||
| 1492 | input.cwd, | ||
| 1493 | pullRequestWithRemoteInfo, | ||
| 1494 | localPullRequestBranch, | ||
| 1495 | ); | ||
| 1496 | |||
| 1497 | const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd); | ||
| 1498 | const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath | ||
| 1499 | ? yield* canonicalizeExistingPath(existingBranchAfterFetch.worktreePath) | ||
| 1500 | : null; | ||
| 1501 | if ( | ||
| 1502 | existingBranchAfterFetch?.worktreePath && | ||
| 1503 | existingBranchAfterFetchPath !== rootWorktreePath | ||
| 1504 | ) { | ||
| 1505 | yield* ensureExistingWorktreeUpstream(existingBranchAfterFetch.worktreePath); | ||
| 1506 | return { | ||
| 1507 | pullRequest, | ||
| 1508 | branch: localPullRequestBranch, | ||
| 1509 | worktreePath: existingBranchAfterFetch.worktreePath, | ||
| 1510 | }; | ||
| 1511 | } | ||
| 1512 | if (existingBranchAfterFetchPath === rootWorktreePath) { | ||
| 1513 | return yield* gitManagerError( | ||
| 1514 | "preparePullRequestThread", | ||
| 1515 | "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", | ||
| 1516 | ); | ||
| 1517 | } | ||
| 1518 | |||
| 1519 | const worktree = yield* gitCore.createWorktree({ | ||
| 1520 | cwd: input.cwd, | ||
| 1521 | refName: localPullRequestBranch, | ||
| 1522 | path: null, | ||
| 1523 | }); | ||
| 1524 | yield* ensureExistingWorktreeUpstream(worktree.worktree.path); | ||
| 1525 | yield* maybeRunSetupScript(worktree.worktree.path); | ||
| 1526 | |||
| 1527 | return { | ||
| 1528 | pullRequest, | ||
| 1529 | branch: worktree.worktree.refName, | ||
| 1530 | worktreePath: worktree.worktree.path, | ||
| 1531 | }; | ||
| 1532 | }).pipe(Effect.ensuring(invalidateStatus(input.cwd))); | ||
| 1533 | }); | ||
| 1534 | |||
| 1535 | const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* ( | ||
| 1536 | modelSelection: ModelSelection, | ||
| 1537 | cwd: string, | ||
| 1538 | branch: string | null, | ||
| 1539 | commitMessage?: string, | ||
| 1540 | filePaths?: readonly string[], | ||
| 1541 | ) { | ||
| 1542 | const suggestion = yield* resolveCommitAndBranchSuggestion({ | ||
| 1543 | cwd, | ||
| 1544 | branch, | ||
| 1545 | ...(commitMessage ? { commitMessage } : {}), | ||
| 1546 | ...(filePaths ? { filePaths } : {}), | ||
| 1547 | includeBranch: true, | ||
| 1548 | modelSelection, | ||
| 1549 | }); | ||
| 1550 | if (!suggestion) { | ||
| 1551 | return yield* gitManagerError( | ||
| 1552 | "runFeatureBranchStep", | ||
| 1553 | "Cannot create a feature branch because there are no changes to commit.", | ||
| 1554 | ); | ||
| 1555 | } | ||
| 1556 | |||
| 1557 | const preferredBranch = suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject); | ||
| 1558 | const existingBranchNames = yield* gitCore.listLocalBranchNames(cwd); | ||
| 1559 | const resolvedBranch = resolveAutoFeatureBranchName(existingBranchNames, preferredBranch); | ||
| 1560 | |||
| 1561 | yield* gitCore.createRef({ cwd, refName: resolvedBranch }); | ||
| 1562 | yield* Effect.scoped(gitCore.switchRef({ cwd, refName: resolvedBranch })); | ||
| 1563 | |||
| 1564 | return { | ||
| 1565 | branchStep: { status: "created" as const, name: resolvedBranch }, | ||
| 1566 | resolvedCommitMessage: suggestion.commitMessage, | ||
| 1567 | resolvedCommitSuggestion: suggestion, | ||
| 1568 | }; | ||
| 1569 | }); | ||
| 1570 | |||
| 1571 | const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fn("runStackedAction")( | ||
| 1572 | function* (input, options) { | ||
| 1573 | const progress = createProgressEmitter(input, options); | ||
| 1574 | const currentPhase = yield* Ref.make<Option.Option<GitActionProgressPhase>>(Option.none()); | ||
| 1575 | |||
| 1576 | const runAction = Effect.fn("runStackedAction.runAction")(function* (): Effect.fn.Return< | ||
| 1577 | GitRunStackedActionResult, | ||
| 1578 | GitManagerServiceError | ||
| 1579 | > { | ||
| 1580 | const initialStatus = yield* gitCore.statusDetails(input.cwd); | ||
| 1581 | const wantsCommit = isCommitAction(input.action); | ||
| 1582 | const wantsPush = | ||
| 1583 | input.action === "push" || | ||
| 1584 | input.action === "commit_push" || | ||
| 1585 | input.action === "commit_push_pr" || | ||
| 1586 | (input.action === "create_pr" && | ||
| 1587 | (!initialStatus.hasUpstream || initialStatus.aheadCount > 0)); | ||
| 1588 | const wantsPr = input.action === "create_pr" || input.action === "commit_push_pr"; | ||
| 1589 | |||
| 1590 | if (input.featureBranch && !wantsCommit) { | ||
| 1591 | return yield* gitManagerError( | ||
| 1592 | "runStackedAction", | ||
| 1593 | "Feature-branch checkout is only supported for commit actions.", | ||
| 1594 | ); | ||
| 1595 | } | ||
| 1596 | if (input.action === "push" && initialStatus.hasWorkingTreeChanges) { | ||
| 1597 | return yield* gitManagerError( | ||
| 1598 | "runStackedAction", | ||
| 1599 | "Commit or stash local changes before pushing.", | ||
| 1600 | ); | ||
| 1601 | } | ||
| 1602 | if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) { | ||
| 1603 | return yield* gitManagerError( | ||
| 1604 | "runStackedAction", | ||
| 1605 | "Commit local changes before creating a PR.", | ||
| 1606 | ); | ||
| 1607 | } | ||
| 1608 | |||
| 1609 | const phases: GitActionProgressPhase[] = [ | ||
| 1610 | ...(input.featureBranch ? (["branch"] as const) : []), | ||
| 1611 | ...(wantsCommit ? (["commit"] as const) : []), | ||
| 1612 | ...(wantsPush ? (["push"] as const) : []), | ||
| 1613 | ...(wantsPr ? (["pr"] as const) : []), | ||
| 1614 | ]; | ||
| 1615 | |||
| 1616 | yield* progress.emit({ | ||
| 1617 | kind: "action_started", | ||
| 1618 | phases, | ||
| 1619 | }); | ||
| 1620 | |||
| 1621 | if (!input.featureBranch && wantsPush && !initialStatus.branch) { | ||
| 1622 | return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD."); | ||
| 1623 | } | ||
| 1624 | if (!input.featureBranch && wantsPr && !initialStatus.branch) { | ||
| 1625 | return yield* gitManagerError( | ||
| 1626 | "runStackedAction", | ||
| 1627 | "Cannot create a pull request from detached HEAD.", | ||
| 1628 | ); | ||
| 1629 | } | ||
| 1630 | |||
| 1631 | let branchStep: { status: "created" | "skipped_not_requested"; name?: string }; | ||
| 1632 | let commitMessageForStep = input.commitMessage; | ||
| 1633 | let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined; | ||
| 1634 | |||
| 1635 | const modelSelection = yield* serverSettingsService.getSettings.pipe( | ||
| 1636 | Effect.map((settings) => settings.textGenerationModelSelection), | ||
| 1637 | Effect.mapError((cause) => | ||
| 1638 | gitManagerError("runStackedAction", "Failed to get server settings.", cause), | ||
| 1639 | ), | ||
| 1640 | ); | ||
| 1641 | |||
| 1642 | if (input.featureBranch) { | ||
| 1643 | yield* Ref.set(currentPhase, Option.some("branch")); | ||
| 1644 | yield* progress.emit({ | ||
| 1645 | kind: "phase_started", | ||
| 1646 | phase: "branch", | ||
| 1647 | label: "Preparing feature branch...", | ||
| 1648 | }); | ||
| 1649 | const result = yield* runFeatureBranchStep( | ||
| 1650 | modelSelection, | ||
| 1651 | input.cwd, | ||
| 1652 | initialStatus.branch, | ||
| 1653 | input.commitMessage, | ||
| 1654 | input.filePaths, | ||
| 1655 | ); | ||
| 1656 | branchStep = result.branchStep; | ||
| 1657 | commitMessageForStep = result.resolvedCommitMessage; | ||
| 1658 | preResolvedCommitSuggestion = result.resolvedCommitSuggestion; | ||
| 1659 | } else { | ||
| 1660 | branchStep = { status: "skipped_not_requested" as const }; | ||
| 1661 | } | ||
| 1662 | |||
| 1663 | const currentBranch = branchStep.name ?? initialStatus.branch; | ||
| 1664 | const commitAction = isCommitAction(input.action) ? input.action : null; | ||
| 1665 | const changeRequestTerms = wantsPr | ||
| 1666 | ? yield* sourceControlProvider(input.cwd).pipe( | ||
| 1667 | Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)), | ||
| 1668 | Effect.catch(() => Effect.succeed(getChangeRequestTerminologyForKind("unknown"))), | ||
| 1669 | ) | ||
| 1670 | : null; | ||
| 1671 | |||
| 1672 | const commit = commitAction | ||
| 1673 | ? yield* Ref.set(currentPhase, Option.some("commit")).pipe( | ||
| 1674 | Effect.flatMap(() => | ||
| 1675 | runCommitStep( | ||
| 1676 | modelSelection, | ||
| 1677 | input.cwd, | ||
| 1678 | commitAction, | ||
| 1679 | currentBranch, | ||
| 1680 | commitMessageForStep, | ||
| 1681 | preResolvedCommitSuggestion, | ||
| 1682 | input.filePaths, | ||
| 1683 | options?.progressReporter, | ||
| 1684 | progress.actionId, | ||
| 1685 | ), | ||
| 1686 | ), | ||
| 1687 | ) | ||
| 1688 | : { status: "skipped_not_requested" as const }; | ||
| 1689 | |||
| 1690 | const push = wantsPush | ||
| 1691 | ? yield* progress | ||
| 1692 | .emit({ | ||
| 1693 | kind: "phase_started", | ||
| 1694 | phase: "push", | ||
| 1695 | label: "Pushing...", | ||
| 1696 | }) | ||
| 1697 | .pipe( | ||
| 1698 | Effect.tap(() => Ref.set(currentPhase, Option.some("push"))), | ||
| 1699 | Effect.flatMap(() => gitCore.pushCurrentBranch(input.cwd, currentBranch)), | ||
| 1700 | ) | ||
| 1701 | : { status: "skipped_not_requested" as const }; | ||
| 1702 | |||
| 1703 | const pr = wantsPr | ||
| 1704 | ? yield* progress | ||
| 1705 | .emit({ | ||
| 1706 | kind: "phase_started", | ||
| 1707 | phase: "pr", | ||
| 1708 | label: `Preparing ${changeRequestTerms?.shortLabel ?? "PR"}...`, | ||
| 1709 | }) | ||
| 1710 | .pipe( | ||
| 1711 | Effect.tap(() => Ref.set(currentPhase, Option.some("pr"))), | ||
| 1712 | Effect.flatMap(() => | ||
| 1713 | runPrStep(modelSelection, input.cwd, currentBranch, progress.emit), | ||
| 1714 | ), | ||
| 1715 | ) | ||
| 1716 | : { status: "skipped_not_requested" as const }; | ||
| 1717 | |||
| 1718 | const toast = yield* buildCompletionToast(input.cwd, { | ||
| 1719 | action: input.action, | ||
| 1720 | branch: branchStep, | ||
| 1721 | commit, | ||
| 1722 | push, | ||
| 1723 | pr, | ||
| 1724 | }); | ||
| 1725 | |||
| 1726 | const result = { | ||
| 1727 | action: input.action, | ||
| 1728 | branch: branchStep, | ||
| 1729 | commit, | ||
| 1730 | push, | ||
| 1731 | pr, | ||
| 1732 | toast, | ||
| 1733 | }; | ||
| 1734 | yield* progress.emit({ | ||
| 1735 | kind: "action_finished", | ||
| 1736 | result, | ||
| 1737 | }); | ||
| 1738 | return result; | ||
| 1739 | }); | ||
| 1740 | |||
| 1741 | return yield* runAction().pipe( | ||
| 1742 | Effect.ensuring(invalidateStatus(input.cwd)), | ||
| 1743 | Effect.tapError((error) => | ||
| 1744 | Effect.flatMap(Ref.get(currentPhase), (phase) => | ||
| 1745 | progress.emit({ | ||
| 1746 | kind: "action_failed", | ||
| 1747 | phase: Option.getOrNull(phase), | ||
| 1748 | message: error.message, | ||
| 1749 | }), | ||
| 1750 | ), | ||
| 1751 | ), | ||
| 1752 | ); | ||
| 1753 | }, | ||
| 1754 | ); | ||
| 1755 | |||
| 1756 | return { | ||
| 1757 | localStatus, | ||
| 1758 | remoteStatus, | ||
| 1759 | status, | ||
| 1760 | invalidateLocalStatus, | ||
| 1761 | invalidateRemoteStatus, | ||
| 1762 | invalidateStatus, | ||
| 1763 | resolvePullRequest, | ||
| 1764 | preparePullRequestThread, | ||
| 1765 | runStackedAction, | ||
| 1766 | } satisfies GitManagerShape; | ||
| 1767 | }); | ||
| 1768 | |||
| 1769 | export const layer = Layer.effect(GitManager, makeGitManager()); | ||
apps/server/src/git/GitWorkflowService.test.ts created+132| ... | @@ -0,0 +1,132 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Layer } from "effect"; | ||
| 3 | import { describe, vi } from "vitest"; | ||
| 4 | |||
| 5 | import { GitManager } from "./GitManager.ts"; | ||
| 6 | import { GitWorkflowService, layer as GitWorkflowServiceLayer } from "./GitWorkflowService.ts"; | ||
| 7 | import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; | ||
| 8 | import { VcsDriverRegistry, type VcsDriverRegistryShape } from "../vcs/VcsDriverRegistry.ts"; | ||
| 9 | |||
| 10 | function makeLayer(input: { readonly detect: VcsDriverRegistryShape["detect"] }) { | ||
| 11 | return GitWorkflowServiceLayer.pipe( | ||
| 12 | Layer.provide( | ||
| 13 | Layer.mock(VcsDriverRegistry)({ | ||
| 14 | detect: input.detect, | ||
| 15 | }), | ||
| 16 | ), | ||
| 17 | Layer.provide(Layer.mock(GitVcsDriver)({})), | ||
| 18 | Layer.provide(Layer.mock(GitManager)({})), | ||
| 19 | ); | ||
| 20 | } | ||
| 21 | |||
| 22 | describe("GitWorkflowService", () => { | ||
| 23 | it.effect("returns an empty local status when no VCS repository is detected", () => | ||
| 24 | Effect.gen(function* () { | ||
| 25 | const workflow = yield* GitWorkflowService; | ||
| 26 | const status = yield* workflow.localStatus({ cwd: "/not-a-repo" }); | ||
| 27 | |||
| 28 | assert.deepStrictEqual(status, { | ||
| 29 | isRepo: false, | ||
| 30 | hasPrimaryRemote: false, | ||
| 31 | isDefaultRef: false, | ||
| 32 | refName: null, | ||
| 33 | hasWorkingTreeChanges: false, | ||
| 34 | workingTree: { | ||
| 35 | files: [], | ||
| 36 | insertions: 0, | ||
| 37 | deletions: 0, | ||
| 38 | }, | ||
| 39 | }); | ||
| 40 | }).pipe( | ||
| 41 | Effect.provide( | ||
| 42 | makeLayer({ | ||
| 43 | detect: () => Effect.succeed(null), | ||
| 44 | }), | ||
| 45 | ), | ||
| 46 | ), | ||
| 47 | ); | ||
| 48 | |||
| 49 | it.effect("returns an empty full status when no VCS repository is detected", () => | ||
| 50 | Effect.gen(function* () { | ||
| 51 | const workflow = yield* GitWorkflowService; | ||
| 52 | const status = yield* workflow.status({ cwd: "/not-a-repo" }); | ||
| 53 | |||
| 54 | assert.deepStrictEqual(status, { | ||
| 55 | isRepo: false, | ||
| 56 | hasPrimaryRemote: false, | ||
| 57 | isDefaultRef: false, | ||
| 58 | refName: null, | ||
| 59 | hasWorkingTreeChanges: false, | ||
| 60 | workingTree: { | ||
| 61 | files: [], | ||
| 62 | insertions: 0, | ||
| 63 | deletions: 0, | ||
| 64 | }, | ||
| 65 | hasUpstream: false, | ||
| 66 | aheadCount: 0, | ||
| 67 | behindCount: 0, | ||
| 68 | pr: null, | ||
| 69 | }); | ||
| 70 | }).pipe( | ||
| 71 | Effect.provide( | ||
| 72 | makeLayer({ | ||
| 73 | detect: () => Effect.succeed(null), | ||
| 74 | }), | ||
| 75 | ), | ||
| 76 | ), | ||
| 77 | ); | ||
| 78 | |||
| 79 | it.effect("does not call GitManager status methods when no VCS repository is detected", () => { | ||
| 80 | const localStatus = vi.fn(); | ||
| 81 | const remoteStatus = vi.fn(); | ||
| 82 | const status = vi.fn(); | ||
| 83 | |||
| 84 | const testLayer = GitWorkflowServiceLayer.pipe( | ||
| 85 | Layer.provide( | ||
| 86 | Layer.mock(VcsDriverRegistry)({ | ||
| 87 | detect: () => Effect.succeed(null), | ||
| 88 | }), | ||
| 89 | ), | ||
| 90 | Layer.provide(Layer.mock(GitVcsDriver)({})), | ||
| 91 | Layer.provide( | ||
| 92 | Layer.mock(GitManager)({ | ||
| 93 | localStatus, | ||
| 94 | remoteStatus, | ||
| 95 | status, | ||
| 96 | }), | ||
| 97 | ), | ||
| 98 | ); | ||
| 99 | |||
| 100 | return Effect.gen(function* () { | ||
| 101 | const workflow = yield* GitWorkflowService; | ||
| 102 | yield* workflow.localStatus({ cwd: "/not-a-repo" }); | ||
| 103 | yield* workflow.remoteStatus({ cwd: "/not-a-repo" }); | ||
| 104 | yield* workflow.status({ cwd: "/not-a-repo" }); | ||
| 105 | |||
| 106 | assert.equal(localStatus.mock.calls.length, 0); | ||
| 107 | assert.equal(remoteStatus.mock.calls.length, 0); | ||
| 108 | assert.equal(status.mock.calls.length, 0); | ||
| 109 | }).pipe(Effect.provide(testLayer)); | ||
| 110 | }); | ||
| 111 | |||
| 112 | it.effect("returns an empty ref list when no VCS repository is detected", () => | ||
| 113 | Effect.gen(function* () { | ||
| 114 | const workflow = yield* GitWorkflowService; | ||
| 115 | const refs = yield* workflow.listRefs({ cwd: "/not-a-repo" }); | ||
| 116 | |||
| 117 | assert.deepStrictEqual(refs, { | ||
| 118 | refs: [], | ||
| 119 | isRepo: false, | ||
| 120 | hasPrimaryRemote: false, | ||
| 121 | nextCursor: null, | ||
| 122 | totalCount: 0, | ||
| 123 | }); | ||
| 124 | }).pipe( | ||
| 125 | Effect.provide( | ||
| 126 | makeLayer({ | ||
| 127 | detect: () => Effect.succeed(null), | ||
| 128 | }), | ||
| 129 | ), | ||
| 130 | ), | ||
| 131 | ); | ||
| 132 | }); | ||
apps/server/src/git/GitWorkflowService.ts created+313| ... | @@ -0,0 +1,313 @@ | ||
| 1 | import { Context, Effect, Layer } from "effect"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | GitManagerError, | ||
| 5 | GitCommandError, | ||
| 6 | type VcsSwitchRefInput, | ||
| 7 | type VcsSwitchRefResult, | ||
| 8 | type VcsCreateRefInput, | ||
| 9 | type VcsCreateRefResult, | ||
| 10 | type VcsCreateWorktreeInput, | ||
| 11 | type VcsCreateWorktreeResult, | ||
| 12 | type VcsListRefsInput, | ||
| 13 | type VcsListRefsResult, | ||
| 14 | type GitManagerServiceError, | ||
| 15 | type GitPreparePullRequestThreadInput, | ||
| 16 | type GitPreparePullRequestThreadResult, | ||
| 17 | type GitPullRequestRefInput, | ||
| 18 | type VcsPullResult, | ||
| 19 | type VcsRemoveWorktreeInput, | ||
| 20 | type GitResolvePullRequestResult, | ||
| 21 | type GitRunStackedActionInput, | ||
| 22 | type GitRunStackedActionResult, | ||
| 23 | type VcsStatusInput, | ||
| 24 | type VcsStatusLocalResult, | ||
| 25 | type VcsStatusRemoteResult, | ||
| 26 | type VcsStatusResult, | ||
| 27 | } from "@t3tools/contracts"; | ||
| 28 | |||
| 29 | import { GitManager, type GitRunStackedActionOptions } from "./GitManager.ts"; | ||
| 30 | import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; | ||
| 31 | import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; | ||
| 32 | |||
| 33 | export interface GitWorkflowServiceShape { | ||
| 34 | readonly status: ( | ||
| 35 | input: VcsStatusInput, | ||
| 36 | ) => Effect.Effect<VcsStatusResult, GitManagerServiceError>; | ||
| 37 | readonly localStatus: ( | ||
| 38 | input: VcsStatusInput, | ||
| 39 | ) => Effect.Effect<VcsStatusLocalResult, GitManagerServiceError>; | ||
| 40 | readonly remoteStatus: ( | ||
| 41 | input: VcsStatusInput, | ||
| 42 | ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>; | ||
| 43 | readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>; | ||
| 44 | readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>; | ||
| 45 | readonly invalidateStatus: (cwd: string) => Effect.Effect<void, never>; | ||
| 46 | readonly pullCurrentBranch: (cwd: string) => Effect.Effect<VcsPullResult, GitCommandError>; | ||
| 47 | readonly runStackedAction: ( | ||
| 48 | input: GitRunStackedActionInput, | ||
| 49 | options?: GitRunStackedActionOptions, | ||
| 50 | ) => Effect.Effect<GitRunStackedActionResult, GitManagerServiceError>; | ||
| 51 | readonly resolvePullRequest: ( | ||
| 52 | input: GitPullRequestRefInput, | ||
| 53 | ) => Effect.Effect<GitResolvePullRequestResult, GitManagerServiceError>; | ||
| 54 | readonly preparePullRequestThread: ( | ||
| 55 | input: GitPreparePullRequestThreadInput, | ||
| 56 | ) => Effect.Effect<GitPreparePullRequestThreadResult, GitManagerServiceError>; | ||
| 57 | readonly listRefs: (input: VcsListRefsInput) => Effect.Effect<VcsListRefsResult, GitCommandError>; | ||
| 58 | readonly createWorktree: ( | ||
| 59 | input: VcsCreateWorktreeInput, | ||
| 60 | ) => Effect.Effect<VcsCreateWorktreeResult, GitCommandError>; | ||
| 61 | readonly removeWorktree: (input: VcsRemoveWorktreeInput) => Effect.Effect<void, GitCommandError>; | ||
| 62 | readonly createRef: ( | ||
| 63 | input: VcsCreateRefInput, | ||
| 64 | ) => Effect.Effect<VcsCreateRefResult, GitCommandError>; | ||
| 65 | readonly switchRef: ( | ||
| 66 | input: VcsSwitchRefInput, | ||
| 67 | ) => Effect.Effect<VcsSwitchRefResult, GitCommandError>; | ||
| 68 | readonly renameBranch: (input: { | ||
| 69 | readonly cwd: string; | ||
| 70 | readonly oldBranch: string; | ||
| 71 | readonly newBranch: string; | ||
| 72 | }) => Effect.Effect<{ readonly branch: string }, GitManagerServiceError>; | ||
| 73 | } | ||
| 74 | |||
| 75 | export class GitWorkflowService extends Context.Service< | ||
| 76 | GitWorkflowService, | ||
| 77 | GitWorkflowServiceShape | ||
| 78 | >()("t3/git/GitWorkflowService") {} | ||
| 79 | |||
| 80 | const unsupportedGitWorkflow = (operation: string, cwd: string, detail: string) => | ||
| 81 | new GitManagerError({ | ||
| 82 | operation, | ||
| 83 | detail: `${detail} (${cwd})`, | ||
| 84 | }); | ||
| 85 | |||
| 86 | const unsupportedGitCommand = (operation: string, cwd: string, detail: string) => | ||
| 87 | new GitCommandError({ | ||
| 88 | operation, | ||
| 89 | command: "vcs-route", | ||
| 90 | cwd, | ||
| 91 | detail, | ||
| 92 | }); | ||
| 93 | |||
| 94 | function nonRepositoryLocalStatus(): VcsStatusLocalResult { | ||
| 95 | return { | ||
| 96 | isRepo: false, | ||
| 97 | hasPrimaryRemote: false, | ||
| 98 | isDefaultRef: false, | ||
| 99 | refName: null, | ||
| 100 | hasWorkingTreeChanges: false, | ||
| 101 | workingTree: { | ||
| 102 | files: [], | ||
| 103 | insertions: 0, | ||
| 104 | deletions: 0, | ||
| 105 | }, | ||
| 106 | }; | ||
| 107 | } | ||
| 108 | |||
| 109 | function nonRepositoryStatus(): VcsStatusResult { | ||
| 110 | return { | ||
| 111 | ...nonRepositoryLocalStatus(), | ||
| 112 | hasUpstream: false, | ||
| 113 | aheadCount: 0, | ||
| 114 | behindCount: 0, | ||
| 115 | pr: null, | ||
| 116 | }; | ||
| 117 | } | ||
| 118 | |||
| 119 | function nonRepositoryListRefs(): VcsListRefsResult { | ||
| 120 | return { | ||
| 121 | refs: [], | ||
| 122 | isRepo: false, | ||
| 123 | hasPrimaryRemote: false, | ||
| 124 | nextCursor: null, | ||
| 125 | totalCount: 0, | ||
| 126 | }; | ||
| 127 | } | ||
| 128 | |||
| 129 | export const make = Effect.fn("makeGitWorkflowService")(function* () { | ||
| 130 | const registry = yield* VcsDriverRegistry; | ||
| 131 | const git = yield* GitVcsDriver; | ||
| 132 | const gitManager = yield* GitManager; | ||
| 133 | |||
| 134 | const ensureGit = Effect.fn("GitWorkflowService.ensureGit")(function* ( | ||
| 135 | operation: string, | ||
| 136 | cwd: string, | ||
| 137 | ) { | ||
| 138 | const handle = yield* registry | ||
| 139 | .resolve({ cwd }) | ||
| 140 | .pipe( | ||
| 141 | Effect.mapError((error) => | ||
| 142 | unsupportedGitWorkflow( | ||
| 143 | operation, | ||
| 144 | cwd, | ||
| 145 | error instanceof Error ? error.message : String(error), | ||
| 146 | ), | ||
| 147 | ), | ||
| 148 | ); | ||
| 149 | if (handle.kind !== "git") { | ||
| 150 | return yield* unsupportedGitWorkflow( | ||
| 151 | operation, | ||
| 152 | cwd, | ||
| 153 | `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}.`, | ||
| 154 | ); | ||
| 155 | } | ||
| 156 | }); | ||
| 157 | |||
| 158 | const ensureGitCommand = Effect.fn("GitWorkflowService.ensureGitCommand")(function* ( | ||
| 159 | operation: string, | ||
| 160 | cwd: string, | ||
| 161 | ) { | ||
| 162 | const handle = yield* registry | ||
| 163 | .resolve({ cwd }) | ||
| 164 | .pipe( | ||
| 165 | Effect.mapError((error) => | ||
| 166 | unsupportedGitCommand( | ||
| 167 | operation, | ||
| 168 | cwd, | ||
| 169 | error instanceof Error ? error.message : String(error), | ||
| 170 | ), | ||
| 171 | ), | ||
| 172 | ); | ||
| 173 | if (handle.kind !== "git") { | ||
| 174 | return yield* unsupportedGitCommand( | ||
| 175 | operation, | ||
| 176 | cwd, | ||
| 177 | `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, | ||
| 178 | ); | ||
| 179 | } | ||
| 180 | }); | ||
| 181 | |||
| 182 | const detectGitRepositoryForStatus = Effect.fn("GitWorkflowService.detectGitRepositoryForStatus")( | ||
| 183 | function* (operation: string, cwd: string) { | ||
| 184 | const handle = yield* registry | ||
| 185 | .detect({ cwd }) | ||
| 186 | .pipe( | ||
| 187 | Effect.mapError((error) => | ||
| 188 | unsupportedGitWorkflow( | ||
| 189 | operation, | ||
| 190 | cwd, | ||
| 191 | error instanceof Error ? error.message : String(error), | ||
| 192 | ), | ||
| 193 | ), | ||
| 194 | ); | ||
| 195 | if (!handle) { | ||
| 196 | return false; | ||
| 197 | } | ||
| 198 | if (handle.kind !== "git") { | ||
| 199 | return yield* unsupportedGitWorkflow( | ||
| 200 | operation, | ||
| 201 | cwd, | ||
| 202 | `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}.`, | ||
| 203 | ); | ||
| 204 | } | ||
| 205 | return true; | ||
| 206 | }, | ||
| 207 | ); | ||
| 208 | |||
| 209 | const detectGitRepositoryForCommand = Effect.fn( | ||
| 210 | "GitWorkflowService.detectGitRepositoryForCommand", | ||
| 211 | )(function* (operation: string, cwd: string) { | ||
| 212 | const handle = yield* registry | ||
| 213 | .detect({ cwd }) | ||
| 214 | .pipe( | ||
| 215 | Effect.mapError((error) => | ||
| 216 | unsupportedGitCommand( | ||
| 217 | operation, | ||
| 218 | cwd, | ||
| 219 | error instanceof Error ? error.message : String(error), | ||
| 220 | ), | ||
| 221 | ), | ||
| 222 | ); | ||
| 223 | if (!handle) { | ||
| 224 | return false; | ||
| 225 | } | ||
| 226 | if (handle.kind !== "git") { | ||
| 227 | return yield* unsupportedGitCommand( | ||
| 228 | operation, | ||
| 229 | cwd, | ||
| 230 | `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, | ||
| 231 | ); | ||
| 232 | } | ||
| 233 | return true; | ||
| 234 | }); | ||
| 235 | |||
| 236 | const routeGitManager = | ||
| 237 | <Input extends { readonly cwd: string }, Output>( | ||
| 238 | operation: string, | ||
| 239 | run: (input: Input) => Effect.Effect<Output, GitManagerServiceError>, | ||
| 240 | ) => | ||
| 241 | (input: Input) => | ||
| 242 | ensureGit(operation, input.cwd).pipe(Effect.andThen(run(input))); | ||
| 243 | |||
| 244 | return GitWorkflowService.of({ | ||
| 245 | status: (input) => | ||
| 246 | detectGitRepositoryForStatus("GitWorkflowService.status", input.cwd).pipe( | ||
| 247 | Effect.flatMap((isGitRepository) => | ||
| 248 | isGitRepository ? gitManager.status(input) : Effect.succeed(nonRepositoryStatus()), | ||
| 249 | ), | ||
| 250 | ), | ||
| 251 | localStatus: (input) => | ||
| 252 | detectGitRepositoryForStatus("GitWorkflowService.localStatus", input.cwd).pipe( | ||
| 253 | Effect.flatMap((isGitRepository) => | ||
| 254 | isGitRepository | ||
| 255 | ? gitManager.localStatus(input) | ||
| 256 | : Effect.succeed(nonRepositoryLocalStatus()), | ||
| 257 | ), | ||
| 258 | ), | ||
| 259 | remoteStatus: (input) => | ||
| 260 | detectGitRepositoryForStatus("GitWorkflowService.remoteStatus", input.cwd).pipe( | ||
| 261 | Effect.flatMap((isGitRepository) => | ||
| 262 | isGitRepository ? gitManager.remoteStatus(input) : Effect.succeed(null), | ||
| 263 | ), | ||
| 264 | ), | ||
| 265 | invalidateLocalStatus: gitManager.invalidateLocalStatus, | ||
| 266 | invalidateRemoteStatus: gitManager.invalidateRemoteStatus, | ||
| 267 | invalidateStatus: gitManager.invalidateStatus, | ||
| 268 | pullCurrentBranch: (cwd) => | ||
| 269 | ensureGitCommand("GitWorkflowService.pullCurrentBranch", cwd).pipe( | ||
| 270 | Effect.andThen(git.pullCurrentBranch(cwd)), | ||
| 271 | ), | ||
| 272 | runStackedAction: (input, options) => | ||
| 273 | ensureGit("GitWorkflowService.runStackedAction", input.cwd).pipe( | ||
| 274 | Effect.andThen(gitManager.runStackedAction(input, options)), | ||
| 275 | ), | ||
| 276 | resolvePullRequest: routeGitManager( | ||
| 277 | "GitWorkflowService.resolvePullRequest", | ||
| 278 | gitManager.resolvePullRequest, | ||
| 279 | ), | ||
| 280 | preparePullRequestThread: routeGitManager( | ||
| 281 | "GitWorkflowService.preparePullRequestThread", | ||
| 282 | gitManager.preparePullRequestThread, | ||
| 283 | ), | ||
| 284 | listRefs: (input) => | ||
| 285 | detectGitRepositoryForCommand("GitWorkflowService.listRefs", input.cwd).pipe( | ||
| 286 | Effect.flatMap((isGitRepository) => | ||
| 287 | isGitRepository ? git.listRefs(input) : Effect.succeed(nonRepositoryListRefs()), | ||
| 288 | ), | ||
| 289 | ), | ||
| 290 | createWorktree: (input) => | ||
| 291 | ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe( | ||
| 292 | Effect.andThen(git.createWorktree(input)), | ||
| 293 | ), | ||
| 294 | removeWorktree: (input) => | ||
| 295 | ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe( | ||
| 296 | Effect.andThen(git.removeWorktree(input)), | ||
| 297 | ), | ||
| 298 | createRef: (input) => | ||
| 299 | ensureGitCommand("GitWorkflowService.createRef", input.cwd).pipe( | ||
| 300 | Effect.andThen(git.createRef(input)), | ||
| 301 | ), | ||
| 302 | switchRef: (input) => | ||
| 303 | ensureGitCommand("GitWorkflowService.switchRef", input.cwd).pipe( | ||
| 304 | Effect.andThen(Effect.scoped(git.switchRef(input))), | ||
| 305 | ), | ||
| 306 | renameBranch: (input) => | ||
| 307 | ensureGit("GitWorkflowService.renameBranch", input.cwd).pipe( | ||
| 308 | Effect.andThen(git.renameBranch(input)), | ||
| 309 | ), | ||
| 310 | }); | ||
| 311 | }); | ||
| 312 | |||
| 313 | export const layer = Layer.effect(GitWorkflowService, make()); | ||
apps/server/src/git/Layers/ClaudeTextGeneration.test.ts deleted-339| ... | @@ -1,339 +0,0 @@ | ||
| 1 | import { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts"; | ||
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 3 | import { it } from "@effect/vitest"; | ||
| 4 | import { Effect, FileSystem, Layer, Path, Schema } from "effect"; | ||
| 5 | import { createModelSelection } from "@t3tools/shared/model"; | ||
| 6 | import { expect } from "vitest"; | ||
| 7 | |||
| 8 | import { ServerConfig } from "../../config.ts"; | ||
| 9 | import { type TextGenerationShape } from "../Services/TextGeneration.ts"; | ||
| 10 | import { sanitizeThreadTitle } from "../Utils.ts"; | ||
| 11 | import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts"; | ||
| 12 | |||
| 13 | const ClaudeTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { | ||
| 14 | prefix: "t3code-claude-text-generation-test-", | ||
| 15 | }).pipe(Layer.provideMerge(NodeServices.layer)); | ||
| 16 | |||
| 17 | function makeFakeClaudeBinary(dir: string) { | ||
| 18 | return Effect.gen(function* () { | ||
| 19 | const fs = yield* FileSystem.FileSystem; | ||
| 20 | const path = yield* Path.Path; | ||
| 21 | const binDir = path.join(dir, "bin"); | ||
| 22 | const claudePath = path.join(binDir, "claude"); | ||
| 23 | yield* fs.makeDirectory(binDir, { recursive: true }); | ||
| 24 | |||
| 25 | yield* fs.writeFileString( | ||
| 26 | claudePath, | ||
| 27 | [ | ||
| 28 | "#!/bin/sh", | ||
| 29 | 'args="$*"', | ||
| 30 | 'stdin_content="$(cat)"', | ||
| 31 | 'if [ -n "$T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN" ]; then', | ||
| 32 | ' printf "%s" "$args" | grep -F -- "$T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN" >/dev/null || {', | ||
| 33 | ' printf "%s\\n" "args missing expected content" >&2', | ||
| 34 | " exit 2", | ||
| 35 | " }", | ||
| 36 | "fi", | ||
| 37 | 'if [ -n "$T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN" ]; then', | ||
| 38 | ' if printf "%s" "$args" | grep -F -- "$T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN" >/dev/null; then', | ||
| 39 | ' printf "%s\\n" "args contained forbidden content" >&2', | ||
| 40 | " exit 3", | ||
| 41 | " fi", | ||
| 42 | "fi", | ||
| 43 | 'if [ -n "$T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN" ]; then', | ||
| 44 | ' printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN" >/dev/null || {', | ||
| 45 | ' printf "%s\\n" "stdin missing expected content" >&2', | ||
| 46 | " exit 4", | ||
| 47 | " }", | ||
| 48 | "fi", | ||
| 49 | 'if [ -n "$T3_FAKE_CLAUDE_HOME_MUST_BE" ] && [ "$HOME" != "$T3_FAKE_CLAUDE_HOME_MUST_BE" ]; then', | ||
| 50 | ' printf "%s\\n" "HOME was $HOME" >&2', | ||
| 51 | " exit 5", | ||
| 52 | "fi", | ||
| 53 | 'if [ -n "$T3_FAKE_CLAUDE_STDERR" ]; then', | ||
| 54 | ' printf "%s\\n" "$T3_FAKE_CLAUDE_STDERR" >&2', | ||
| 55 | "fi", | ||
| 56 | 'printf "%s" "$T3_FAKE_CLAUDE_OUTPUT"', | ||
| 57 | 'exit "${T3_FAKE_CLAUDE_EXIT_CODE:-0}"', | ||
| 58 | "", | ||
| 59 | ].join("\n"), | ||
| 60 | ); | ||
| 61 | yield* fs.chmod(claudePath, 0o755); | ||
| 62 | return binDir; | ||
| 63 | }); | ||
| 64 | } | ||
| 65 | |||
| 66 | function withFakeClaudeEnv<A, E, R>( | ||
| 67 | input: { | ||
| 68 | output: string; | ||
| 69 | exitCode?: number; | ||
| 70 | stderr?: string; | ||
| 71 | argsMustContain?: string; | ||
| 72 | argsMustNotContain?: string; | ||
| 73 | stdinMustContain?: string; | ||
| 74 | homeMustBe?: string; | ||
| 75 | claudeConfig?: Partial<ClaudeSettings>; | ||
| 76 | }, | ||
| 77 | effectFn: (textGeneration: TextGenerationShape) => Effect.Effect<A, E, R>, | ||
| 78 | ) { | ||
| 79 | return Effect.gen(function* () { | ||
| 80 | const fs = yield* FileSystem.FileSystem; | ||
| 81 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-claude-text-" }); | ||
| 82 | const binDir = yield* makeFakeClaudeBinary(tempDir); | ||
| 83 | const previousPath = process.env.PATH; | ||
| 84 | const previousOutput = process.env.T3_FAKE_CLAUDE_OUTPUT; | ||
| 85 | const previousExitCode = process.env.T3_FAKE_CLAUDE_EXIT_CODE; | ||
| 86 | const previousStderr = process.env.T3_FAKE_CLAUDE_STDERR; | ||
| 87 | const previousArgsMustContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; | ||
| 88 | const previousArgsMustNotContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; | ||
| 89 | const previousStdinMustContain = process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; | ||
| 90 | const previousHomeMustBe = process.env.T3_FAKE_CLAUDE_HOME_MUST_BE; | ||
| 91 | |||
| 92 | yield* Effect.acquireRelease( | ||
| 93 | Effect.sync(() => { | ||
| 94 | process.env.PATH = `${binDir}:${previousPath ?? ""}`; | ||
| 95 | process.env.T3_FAKE_CLAUDE_OUTPUT = input.output; | ||
| 96 | |||
| 97 | if (input.exitCode !== undefined) { | ||
| 98 | process.env.T3_FAKE_CLAUDE_EXIT_CODE = String(input.exitCode); | ||
| 99 | } else { | ||
| 100 | delete process.env.T3_FAKE_CLAUDE_EXIT_CODE; | ||
| 101 | } | ||
| 102 | |||
| 103 | if (input.stderr !== undefined) { | ||
| 104 | process.env.T3_FAKE_CLAUDE_STDERR = input.stderr; | ||
| 105 | } else { | ||
| 106 | delete process.env.T3_FAKE_CLAUDE_STDERR; | ||
| 107 | } | ||
| 108 | |||
| 109 | if (input.argsMustContain !== undefined) { | ||
| 110 | process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = input.argsMustContain; | ||
| 111 | } else { | ||
| 112 | delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; | ||
| 113 | } | ||
| 114 | |||
| 115 | if (input.argsMustNotContain !== undefined) { | ||
| 116 | process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = input.argsMustNotContain; | ||
| 117 | } else { | ||
| 118 | delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; | ||
| 119 | } | ||
| 120 | |||
| 121 | if (input.stdinMustContain !== undefined) { | ||
| 122 | process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = input.stdinMustContain; | ||
| 123 | } else { | ||
| 124 | delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; | ||
| 125 | } | ||
| 126 | |||
| 127 | if (input.homeMustBe !== undefined) { | ||
| 128 | process.env.T3_FAKE_CLAUDE_HOME_MUST_BE = input.homeMustBe; | ||
| 129 | } else { | ||
| 130 | delete process.env.T3_FAKE_CLAUDE_HOME_MUST_BE; | ||
| 131 | } | ||
| 132 | }), | ||
| 133 | () => | ||
| 134 | Effect.sync(() => { | ||
| 135 | process.env.PATH = previousPath; | ||
| 136 | |||
| 137 | if (previousOutput === undefined) { | ||
| 138 | delete process.env.T3_FAKE_CLAUDE_OUTPUT; | ||
| 139 | } else { | ||
| 140 | process.env.T3_FAKE_CLAUDE_OUTPUT = previousOutput; | ||
| 141 | } | ||
| 142 | |||
| 143 | if (previousExitCode === undefined) { | ||
| 144 | delete process.env.T3_FAKE_CLAUDE_EXIT_CODE; | ||
| 145 | } else { | ||
| 146 | process.env.T3_FAKE_CLAUDE_EXIT_CODE = previousExitCode; | ||
| 147 | } | ||
| 148 | |||
| 149 | if (previousStderr === undefined) { | ||
| 150 | delete process.env.T3_FAKE_CLAUDE_STDERR; | ||
| 151 | } else { | ||
| 152 | process.env.T3_FAKE_CLAUDE_STDERR = previousStderr; | ||
| 153 | } | ||
| 154 | |||
| 155 | if (previousArgsMustContain === undefined) { | ||
| 156 | delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; | ||
| 157 | } else { | ||
| 158 | process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = previousArgsMustContain; | ||
| 159 | } | ||
| 160 | |||
| 161 | if (previousArgsMustNotContain === undefined) { | ||
| 162 | delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; | ||
| 163 | } else { | ||
| 164 | process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = previousArgsMustNotContain; | ||
| 165 | } | ||
| 166 | |||
| 167 | if (previousStdinMustContain === undefined) { | ||
| 168 | delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; | ||
| 169 | } else { | ||
| 170 | process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = previousStdinMustContain; | ||
| 171 | } | ||
| 172 | |||
| 173 | if (previousHomeMustBe === undefined) { | ||
| 174 | delete process.env.T3_FAKE_CLAUDE_HOME_MUST_BE; | ||
| 175 | } else { | ||
| 176 | process.env.T3_FAKE_CLAUDE_HOME_MUST_BE = previousHomeMustBe; | ||
| 177 | } | ||
| 178 | }), | ||
| 179 | ); | ||
| 180 | |||
| 181 | const config = Schema.decodeSync(ClaudeSettings)(input.claudeConfig ?? {}); | ||
| 182 | const textGeneration = yield* makeClaudeTextGeneration(config); | ||
| 183 | return yield* effectFn(textGeneration); | ||
| 184 | }).pipe(Effect.scoped); | ||
| 185 | } | ||
| 186 | |||
| 187 | it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => { | ||
| 188 | it.effect("forwards Claude thinking settings for Haiku without passing effort", () => | ||
| 189 | withFakeClaudeEnv( | ||
| 190 | { | ||
| 191 | output: JSON.stringify({ | ||
| 192 | structured_output: { | ||
| 193 | subject: "Add important change", | ||
| 194 | body: "", | ||
| 195 | }, | ||
| 196 | }), | ||
| 197 | argsMustContain: '--settings {"alwaysThinkingEnabled":false}', | ||
| 198 | argsMustNotContain: "--effort", | ||
| 199 | }, | ||
| 200 | (textGeneration) => | ||
| 201 | Effect.gen(function* () { | ||
| 202 | const generated = yield* textGeneration.generateCommitMessage({ | ||
| 203 | cwd: process.cwd(), | ||
| 204 | branch: "feature/claude-effect", | ||
| 205 | stagedSummary: "M README.md", | ||
| 206 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 207 | modelSelection: { | ||
| 208 | ...createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-haiku-4-5", [ | ||
| 209 | { id: "thinking", value: false }, | ||
| 210 | { id: "effort", value: "high" }, | ||
| 211 | ]), | ||
| 212 | }, | ||
| 213 | }); | ||
| 214 | |||
| 215 | expect(generated.subject).toBe("Add important change"); | ||
| 216 | }), | ||
| 217 | ), | ||
| 218 | ); | ||
| 219 | |||
| 220 | it.effect("forwards Claude fast mode and supported effort", () => | ||
| 221 | withFakeClaudeEnv( | ||
| 222 | { | ||
| 223 | output: JSON.stringify({ | ||
| 224 | structured_output: { | ||
| 225 | title: "Improve orchestration flow", | ||
| 226 | body: "Body", | ||
| 227 | }, | ||
| 228 | }), | ||
| 229 | argsMustContain: '--effort max --settings {"fastMode":true}', | ||
| 230 | }, | ||
| 231 | (textGeneration) => | ||
| 232 | Effect.gen(function* () { | ||
| 233 | const generated = yield* textGeneration.generatePrContent({ | ||
| 234 | cwd: process.cwd(), | ||
| 235 | baseBranch: "main", | ||
| 236 | headBranch: "feature/claude-effect", | ||
| 237 | commitSummary: "Improve orchestration", | ||
| 238 | diffSummary: "1 file changed", | ||
| 239 | diffPatch: "diff --git a/README.md b/README.md", | ||
| 240 | modelSelection: { | ||
| 241 | ...createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-opus-4-6", [ | ||
| 242 | { id: "effort", value: "max" }, | ||
| 243 | { id: "fastMode", value: true }, | ||
| 244 | ]), | ||
| 245 | }, | ||
| 246 | }); | ||
| 247 | |||
| 248 | expect(generated.title).toBe("Improve orchestration flow"); | ||
| 249 | }), | ||
| 250 | ), | ||
| 251 | ); | ||
| 252 | |||
| 253 | it.effect("generates thread titles through the Claude provider", () => | ||
| 254 | withFakeClaudeEnv( | ||
| 255 | { | ||
| 256 | output: JSON.stringify({ | ||
| 257 | structured_output: { | ||
| 258 | title: | ||
| 259 | ' "Reconnect failures after restart because the session state does not recover" ', | ||
| 260 | }, | ||
| 261 | }), | ||
| 262 | stdinMustContain: "You write concise thread titles for coding conversations.", | ||
| 263 | }, | ||
| 264 | (textGeneration) => | ||
| 265 | Effect.gen(function* () { | ||
| 266 | const generated = yield* textGeneration.generateThreadTitle({ | ||
| 267 | cwd: process.cwd(), | ||
| 268 | message: "Please investigate reconnect failures after restarting the session.", | ||
| 269 | modelSelection: { | ||
| 270 | instanceId: ProviderInstanceId.make("claudeAgent"), | ||
| 271 | model: "claude-sonnet-4-6", | ||
| 272 | }, | ||
| 273 | }); | ||
| 274 | |||
| 275 | expect(generated.title).toBe( | ||
| 276 | sanitizeThreadTitle( | ||
| 277 | '"Reconnect failures after restart because the session state does not recover"', | ||
| 278 | ), | ||
| 279 | ); | ||
| 280 | }), | ||
| 281 | ), | ||
| 282 | ); | ||
| 283 | |||
| 284 | it.effect("runs Claude text generation with the configured Claude HOME", () => | ||
| 285 | Effect.gen(function* () { | ||
| 286 | const path = yield* Path.Path; | ||
| 287 | const claudeHome = path.join(process.cwd(), ".claude-work-test"); | ||
| 288 | return yield* withFakeClaudeEnv( | ||
| 289 | { | ||
| 290 | output: JSON.stringify({ | ||
| 291 | structured_output: { | ||
| 292 | title: "Use Claude home", | ||
| 293 | }, | ||
| 294 | }), | ||
| 295 | homeMustBe: claudeHome, | ||
| 296 | claudeConfig: { homePath: claudeHome }, | ||
| 297 | }, | ||
| 298 | (textGeneration) => | ||
| 299 | Effect.gen(function* () { | ||
| 300 | const generated = yield* textGeneration.generateThreadTitle({ | ||
| 301 | cwd: process.cwd(), | ||
| 302 | message: "thread title", | ||
| 303 | modelSelection: { | ||
| 304 | instanceId: ProviderInstanceId.make("claudeAgent"), | ||
| 305 | model: "claude-sonnet-4-6", | ||
| 306 | }, | ||
| 307 | }); | ||
| 308 | |||
| 309 | expect(generated.title).toBe(sanitizeThreadTitle("Use Claude home")); | ||
| 310 | }), | ||
| 311 | ); | ||
| 312 | }), | ||
| 313 | ); | ||
| 314 | |||
| 315 | it.effect("falls back when Claude thread title normalization becomes whitespace-only", () => | ||
| 316 | withFakeClaudeEnv( | ||
| 317 | { | ||
| 318 | output: JSON.stringify({ | ||
| 319 | structured_output: { | ||
| 320 | title: ' """ """ ', | ||
| 321 | }, | ||
| 322 | }), | ||
| 323 | }, | ||
| 324 | (textGeneration) => | ||
| 325 | Effect.gen(function* () { | ||
| 326 | const generated = yield* textGeneration.generateThreadTitle({ | ||
| 327 | cwd: process.cwd(), | ||
| 328 | message: "Name this thread.", | ||
| 329 | modelSelection: { | ||
| 330 | instanceId: ProviderInstanceId.make("claudeAgent"), | ||
| 331 | model: "claude-sonnet-4-6", | ||
| 332 | }, | ||
| 333 | }); | ||
| 334 | |||
| 335 | expect(generated.title).toBe("New thread"); | ||
| 336 | }), | ||
| 337 | ), | ||
| 338 | ); | ||
| 339 | }); | ||
apps/server/src/git/Layers/ClaudeTextGeneration.ts deleted-324| ... | @@ -1,324 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * ClaudeTextGeneration – Text generation layer using the Claude CLI. | ||
| 3 | * | ||
| 4 | * Implements the same TextGenerationShape contract as CodexTextGeneration but | ||
| 5 | * delegates to the `claude` CLI (`claude -p`) with structured JSON output | ||
| 6 | * instead of the `codex exec` CLI. | ||
| 7 | * | ||
| 8 | * @module ClaudeTextGeneration | ||
| 9 | */ | ||
| 10 | import { Effect, Option, Schema, Stream } from "effect"; | ||
| 11 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 12 | |||
| 13 | import { type ClaudeSettings, type ModelSelection } from "@t3tools/contracts"; | ||
| 14 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | ||
| 15 | |||
| 16 | import { TextGenerationError } from "@t3tools/contracts"; | ||
| 17 | import { type TextGenerationShape } from "../Services/TextGeneration.ts"; | ||
| 18 | import { | ||
| 19 | buildBranchNamePrompt, | ||
| 20 | buildCommitMessagePrompt, | ||
| 21 | buildPrContentPrompt, | ||
| 22 | buildThreadTitlePrompt, | ||
| 23 | } from "../Prompts.ts"; | ||
| 24 | import { | ||
| 25 | normalizeCliError, | ||
| 26 | sanitizeCommitSubject, | ||
| 27 | sanitizePrTitle, | ||
| 28 | sanitizeThreadTitle, | ||
| 29 | toJsonSchemaObject, | ||
| 30 | } from "../Utils.ts"; | ||
| 31 | import { | ||
| 32 | getModelSelectionStringOptionValue, | ||
| 33 | getProviderOptionDescriptors, | ||
| 34 | } from "@t3tools/shared/model"; | ||
| 35 | import { | ||
| 36 | getClaudeModelCapabilities, | ||
| 37 | normalizeClaudeCliEffort, | ||
| 38 | resolveClaudeApiModelId, | ||
| 39 | resolveClaudeEffort, | ||
| 40 | } from "../../provider/Layers/ClaudeProvider.ts"; | ||
| 41 | import { makeClaudeEnvironment } from "../../provider/Drivers/ClaudeHome.ts"; | ||
| 42 | |||
| 43 | const CLAUDE_TIMEOUT_MS = 180_000; | ||
| 44 | |||
| 45 | /** | ||
| 46 | * Schema for the wrapper JSON returned by `claude -p --output-format json`. | ||
| 47 | * We only care about `structured_output`. | ||
| 48 | */ | ||
| 49 | const ClaudeOutputEnvelope = Schema.Struct({ | ||
| 50 | structured_output: Schema.Unknown, | ||
| 51 | }); | ||
| 52 | |||
| 53 | export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* ( | ||
| 54 | claudeSettings: ClaudeSettings, | ||
| 55 | environment: NodeJS.ProcessEnv = process.env, | ||
| 56 | ) { | ||
| 57 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 58 | const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); | ||
| 59 | |||
| 60 | const readStreamAsString = <E>( | ||
| 61 | operation: string, | ||
| 62 | stream: Stream.Stream<Uint8Array, E>, | ||
| 63 | ): Effect.Effect<string, TextGenerationError> => | ||
| 64 | stream.pipe( | ||
| 65 | Stream.decodeText(), | ||
| 66 | Stream.runFold( | ||
| 67 | () => "", | ||
| 68 | (acc, chunk) => acc + chunk, | ||
| 69 | ), | ||
| 70 | Effect.mapError((cause) => | ||
| 71 | normalizeCliError("claude", operation, cause, "Failed to collect process output"), | ||
| 72 | ), | ||
| 73 | ); | ||
| 74 | |||
| 75 | /** | ||
| 76 | * Spawn the Claude CLI with structured JSON output and return the parsed, | ||
| 77 | * schema-validated result. | ||
| 78 | */ | ||
| 79 | const runClaudeJson = Effect.fn("runClaudeJson")(function* <S extends Schema.Top>({ | ||
| 80 | operation, | ||
| 81 | cwd, | ||
| 82 | prompt, | ||
| 83 | outputSchemaJson, | ||
| 84 | modelSelection, | ||
| 85 | }: { | ||
| 86 | operation: | ||
| 87 | | "generateCommitMessage" | ||
| 88 | | "generatePrContent" | ||
| 89 | | "generateBranchName" | ||
| 90 | | "generateThreadTitle"; | ||
| 91 | cwd: string; | ||
| 92 | prompt: string; | ||
| 93 | outputSchemaJson: S; | ||
| 94 | modelSelection: ModelSelection; | ||
| 95 | }): Effect.fn.Return<S["Type"], TextGenerationError, S["DecodingServices"]> { | ||
| 96 | const jsonSchemaStr = JSON.stringify(toJsonSchemaObject(outputSchemaJson)); | ||
| 97 | const caps = getClaudeModelCapabilities(modelSelection.model); | ||
| 98 | const descriptors = getProviderOptionDescriptors({ | ||
| 99 | caps, | ||
| 100 | selections: modelSelection.options, | ||
| 101 | }); | ||
| 102 | const findDescriptor = (id: string) => descriptors.find((descriptor) => descriptor.id === id); | ||
| 103 | const rawEffortSelection = getModelSelectionStringOptionValue(modelSelection, "effort"); | ||
| 104 | const resolvedEffort = resolveClaudeEffort(caps, rawEffortSelection); | ||
| 105 | const cliEffort = normalizeClaudeCliEffort(resolvedEffort); | ||
| 106 | const thinkingDescriptor = findDescriptor("thinking"); | ||
| 107 | const fastModeDescriptor = findDescriptor("fastMode"); | ||
| 108 | const thinking = | ||
| 109 | thinkingDescriptor?.type === "boolean" ? thinkingDescriptor.currentValue : undefined; | ||
| 110 | const fastMode = | ||
| 111 | fastModeDescriptor?.type === "boolean" ? fastModeDescriptor.currentValue : undefined; | ||
| 112 | const settings = { | ||
| 113 | ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), | ||
| 114 | ...(fastMode ? { fastMode: true } : {}), | ||
| 115 | }; | ||
| 116 | |||
| 117 | const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () { | ||
| 118 | const command = ChildProcess.make( | ||
| 119 | claudeSettings.binaryPath || "claude", | ||
| 120 | [ | ||
| 121 | "-p", | ||
| 122 | "--output-format", | ||
| 123 | "json", | ||
| 124 | "--json-schema", | ||
| 125 | jsonSchemaStr, | ||
| 126 | "--model", | ||
| 127 | resolveClaudeApiModelId(modelSelection), | ||
| 128 | ...(cliEffort ? ["--effort", cliEffort] : []), | ||
| 129 | ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []), | ||
| 130 | "--dangerously-skip-permissions", | ||
| 131 | ], | ||
| 132 | { | ||
| 133 | env: claudeEnvironment, | ||
| 134 | cwd, | ||
| 135 | shell: process.platform === "win32", | ||
| 136 | stdin: { | ||
| 137 | stream: Stream.encodeText(Stream.make(prompt)), | ||
| 138 | }, | ||
| 139 | }, | ||
| 140 | ); | ||
| 141 | |||
| 142 | const child = yield* commandSpawner | ||
| 143 | .spawn(command) | ||
| 144 | .pipe( | ||
| 145 | Effect.mapError((cause) => | ||
| 146 | normalizeCliError("claude", operation, cause, "Failed to spawn Claude CLI process"), | ||
| 147 | ), | ||
| 148 | ); | ||
| 149 | |||
| 150 | const [stdout, stderr, exitCode] = yield* Effect.all( | ||
| 151 | [ | ||
| 152 | readStreamAsString(operation, child.stdout), | ||
| 153 | readStreamAsString(operation, child.stderr), | ||
| 154 | child.exitCode.pipe( | ||
| 155 | Effect.mapError((cause) => | ||
| 156 | normalizeCliError("claude", operation, cause, "Failed to read Claude CLI exit code"), | ||
| 157 | ), | ||
| 158 | ), | ||
| 159 | ], | ||
| 160 | { concurrency: "unbounded" }, | ||
| 161 | ); | ||
| 162 | |||
| 163 | if (exitCode !== 0) { | ||
| 164 | const stderrDetail = stderr.trim(); | ||
| 165 | const stdoutDetail = stdout.trim(); | ||
| 166 | const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail; | ||
| 167 | return yield* new TextGenerationError({ | ||
| 168 | operation, | ||
| 169 | detail: | ||
| 170 | detail.length > 0 | ||
| 171 | ? `Claude CLI command failed: ${detail}` | ||
| 172 | : `Claude CLI command failed with code ${exitCode}.`, | ||
| 173 | }); | ||
| 174 | } | ||
| 175 | |||
| 176 | return stdout; | ||
| 177 | }); | ||
| 178 | |||
| 179 | const rawStdout = yield* runClaudeCommand().pipe( | ||
| 180 | Effect.scoped, | ||
| 181 | Effect.timeoutOption(CLAUDE_TIMEOUT_MS), | ||
| 182 | Effect.flatMap( | ||
| 183 | Option.match({ | ||
| 184 | onNone: () => | ||
| 185 | Effect.fail( | ||
| 186 | new TextGenerationError({ operation, detail: "Claude CLI request timed out." }), | ||
| 187 | ), | ||
| 188 | onSome: (value) => Effect.succeed(value), | ||
| 189 | }), | ||
| 190 | ), | ||
| 191 | ); | ||
| 192 | |||
| 193 | const envelope = yield* Schema.decodeEffect(Schema.fromJsonString(ClaudeOutputEnvelope))( | ||
| 194 | rawStdout, | ||
| 195 | ).pipe( | ||
| 196 | Effect.catchTag("SchemaError", (cause) => | ||
| 197 | Effect.fail( | ||
| 198 | new TextGenerationError({ | ||
| 199 | operation, | ||
| 200 | detail: "Claude CLI returned unexpected output format.", | ||
| 201 | cause, | ||
| 202 | }), | ||
| 203 | ), | ||
| 204 | ), | ||
| 205 | ); | ||
| 206 | |||
| 207 | return yield* Schema.decodeEffect(outputSchemaJson)(envelope.structured_output).pipe( | ||
| 208 | Effect.catchTag("SchemaError", (cause) => | ||
| 209 | Effect.fail( | ||
| 210 | new TextGenerationError({ | ||
| 211 | operation, | ||
| 212 | detail: "Claude returned invalid structured output.", | ||
| 213 | cause, | ||
| 214 | }), | ||
| 215 | ), | ||
| 216 | ), | ||
| 217 | ); | ||
| 218 | }); | ||
| 219 | |||
| 220 | // --------------------------------------------------------------------------- | ||
| 221 | // TextGenerationShape methods | ||
| 222 | // --------------------------------------------------------------------------- | ||
| 223 | |||
| 224 | const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( | ||
| 225 | "ClaudeTextGeneration.generateCommitMessage", | ||
| 226 | )(function* (input) { | ||
| 227 | const { prompt, outputSchema } = buildCommitMessagePrompt({ | ||
| 228 | branch: input.branch, | ||
| 229 | stagedSummary: input.stagedSummary, | ||
| 230 | stagedPatch: input.stagedPatch, | ||
| 231 | includeBranch: input.includeBranch === true, | ||
| 232 | }); | ||
| 233 | |||
| 234 | const generated = yield* runClaudeJson({ | ||
| 235 | operation: "generateCommitMessage", | ||
| 236 | cwd: input.cwd, | ||
| 237 | prompt, | ||
| 238 | outputSchemaJson: outputSchema, | ||
| 239 | modelSelection: input.modelSelection, | ||
| 240 | }); | ||
| 241 | |||
| 242 | return { | ||
| 243 | subject: sanitizeCommitSubject(generated.subject), | ||
| 244 | body: generated.body.trim(), | ||
| 245 | ...("branch" in generated && typeof generated.branch === "string" | ||
| 246 | ? { branch: sanitizeFeatureBranchName(generated.branch) } | ||
| 247 | : {}), | ||
| 248 | }; | ||
| 249 | }); | ||
| 250 | |||
| 251 | const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( | ||
| 252 | "ClaudeTextGeneration.generatePrContent", | ||
| 253 | )(function* (input) { | ||
| 254 | const { prompt, outputSchema } = buildPrContentPrompt({ | ||
| 255 | baseBranch: input.baseBranch, | ||
| 256 | headBranch: input.headBranch, | ||
| 257 | commitSummary: input.commitSummary, | ||
| 258 | diffSummary: input.diffSummary, | ||
| 259 | diffPatch: input.diffPatch, | ||
| 260 | }); | ||
| 261 | |||
| 262 | const generated = yield* runClaudeJson({ | ||
| 263 | operation: "generatePrContent", | ||
| 264 | cwd: input.cwd, | ||
| 265 | prompt, | ||
| 266 | outputSchemaJson: outputSchema, | ||
| 267 | modelSelection: input.modelSelection, | ||
| 268 | }); | ||
| 269 | |||
| 270 | return { | ||
| 271 | title: sanitizePrTitle(generated.title), | ||
| 272 | body: generated.body.trim(), | ||
| 273 | }; | ||
| 274 | }); | ||
| 275 | |||
| 276 | const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( | ||
| 277 | "ClaudeTextGeneration.generateBranchName", | ||
| 278 | )(function* (input) { | ||
| 279 | const { prompt, outputSchema } = buildBranchNamePrompt({ | ||
| 280 | message: input.message, | ||
| 281 | attachments: input.attachments, | ||
| 282 | }); | ||
| 283 | |||
| 284 | const generated = yield* runClaudeJson({ | ||
| 285 | operation: "generateBranchName", | ||
| 286 | cwd: input.cwd, | ||
| 287 | prompt, | ||
| 288 | outputSchemaJson: outputSchema, | ||
| 289 | modelSelection: input.modelSelection, | ||
| 290 | }); | ||
| 291 | |||
| 292 | return { | ||
| 293 | branch: sanitizeBranchFragment(generated.branch), | ||
| 294 | }; | ||
| 295 | }); | ||
| 296 | |||
| 297 | const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( | ||
| 298 | "ClaudeTextGeneration.generateThreadTitle", | ||
| 299 | )(function* (input) { | ||
| 300 | const { prompt, outputSchema } = buildThreadTitlePrompt({ | ||
| 301 | message: input.message, | ||
| 302 | attachments: input.attachments, | ||
| 303 | }); | ||
| 304 | |||
| 305 | const generated = yield* runClaudeJson({ | ||
| 306 | operation: "generateThreadTitle", | ||
| 307 | cwd: input.cwd, | ||
| 308 | prompt, | ||
| 309 | outputSchemaJson: outputSchema, | ||
| 310 | modelSelection: input.modelSelection, | ||
| 311 | }); | ||
| 312 | |||
| 313 | return { | ||
| 314 | title: sanitizeThreadTitle(generated.title), | ||
| 315 | }; | ||
| 316 | }); | ||
| 317 | |||
| 318 | return { | ||
| 319 | generateCommitMessage, | ||
| 320 | generatePrContent, | ||
| 321 | generateBranchName, | ||
| 322 | generateThreadTitle, | ||
| 323 | } satisfies TextGenerationShape; | ||
| 324 | }); | ||
apps/server/src/git/Layers/CodexTextGeneration.test.ts deleted-592| ... | @@ -1,592 +0,0 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { it } from "@effect/vitest"; | ||
| 3 | import { Effect, FileSystem, Layer, Path, Result, Schema } from "effect"; | ||
| 4 | import { createModelSelection } from "@t3tools/shared/model"; | ||
| 5 | import { expect } from "vitest"; | ||
| 6 | |||
| 7 | import { CodexSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; | ||
| 8 | |||
| 9 | import { ServerConfig } from "../../config.ts"; | ||
| 10 | import { type TextGenerationShape } from "../Services/TextGeneration.ts"; | ||
| 11 | import { makeCodexTextGeneration } from "./CodexTextGeneration.ts"; | ||
| 12 | |||
| 13 | const DEFAULT_TEST_MODEL_SELECTION = createModelSelection( | ||
| 14 | ProviderInstanceId.make("codex"), | ||
| 15 | "gpt-5.4-mini", | ||
| 16 | ); | ||
| 17 | |||
| 18 | const CodexTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { | ||
| 19 | prefix: "t3code-codex-text-generation-test-", | ||
| 20 | }).pipe(Layer.provideMerge(NodeServices.layer)); | ||
| 21 | |||
| 22 | function makeFakeCodexBinary( | ||
| 23 | dir: string, | ||
| 24 | input: { | ||
| 25 | output: string; | ||
| 26 | exitCode?: number; | ||
| 27 | stderr?: string; | ||
| 28 | requireImage?: boolean; | ||
| 29 | requireFastServiceTier?: boolean; | ||
| 30 | requireReasoningEffort?: string; | ||
| 31 | forbidReasoningEffort?: boolean; | ||
| 32 | stdinMustContain?: string; | ||
| 33 | stdinMustNotContain?: string; | ||
| 34 | }, | ||
| 35 | ) { | ||
| 36 | return Effect.gen(function* () { | ||
| 37 | const fs = yield* FileSystem.FileSystem; | ||
| 38 | const path = yield* Path.Path; | ||
| 39 | const binDir = path.join(dir, "bin"); | ||
| 40 | const codexPath = path.join(binDir, "codex"); | ||
| 41 | yield* fs.makeDirectory(binDir, { recursive: true }); | ||
| 42 | |||
| 43 | yield* fs.writeFileString( | ||
| 44 | codexPath, | ||
| 45 | [ | ||
| 46 | "#!/bin/sh", | ||
| 47 | 'output_path=""', | ||
| 48 | 'seen_image="0"', | ||
| 49 | 'seen_fast_service_tier="0"', | ||
| 50 | 'seen_reasoning_effort=""', | ||
| 51 | "while [ $# -gt 0 ]; do", | ||
| 52 | ' if [ "$1" = "--image" ]; then', | ||
| 53 | " shift", | ||
| 54 | ' if [ -n "$1" ]; then', | ||
| 55 | ' seen_image="1"', | ||
| 56 | " fi", | ||
| 57 | " shift", | ||
| 58 | " continue", | ||
| 59 | " fi", | ||
| 60 | ' if [ "$1" = "--config" ]; then', | ||
| 61 | " shift", | ||
| 62 | ' if [ "$1" = "service_tier=\\"fast\\"" ]; then', | ||
| 63 | ' seen_fast_service_tier="1"', | ||
| 64 | " fi", | ||
| 65 | ' case "$1" in', | ||
| 66 | " model_reasoning_effort=*)", | ||
| 67 | ' seen_reasoning_effort="$1"', | ||
| 68 | " ;;", | ||
| 69 | " esac", | ||
| 70 | " shift", | ||
| 71 | " continue", | ||
| 72 | " fi", | ||
| 73 | ' if [ "$1" = "--output-last-message" ]; then', | ||
| 74 | " shift", | ||
| 75 | ' output_path="$1"', | ||
| 76 | " shift", | ||
| 77 | " continue", | ||
| 78 | " fi", | ||
| 79 | " shift", | ||
| 80 | "done", | ||
| 81 | 'stdin_content="$(cat)"', | ||
| 82 | ...(input.requireImage | ||
| 83 | ? [ | ||
| 84 | 'if [ "$seen_image" != "1" ]; then', | ||
| 85 | ' printf "%s\\n" "missing --image input" >&2', | ||
| 86 | ` exit 2`, | ||
| 87 | "fi", | ||
| 88 | ] | ||
| 89 | : []), | ||
| 90 | ...(input.requireFastServiceTier | ||
| 91 | ? [ | ||
| 92 | 'if [ "$seen_fast_service_tier" != "1" ]; then', | ||
| 93 | ' printf "%s\\n" "missing fast service tier config" >&2', | ||
| 94 | ` exit 5`, | ||
| 95 | "fi", | ||
| 96 | ] | ||
| 97 | : []), | ||
| 98 | ...(input.requireReasoningEffort !== undefined | ||
| 99 | ? [ | ||
| 100 | `if [ "$seen_reasoning_effort" != "model_reasoning_effort=\\"${input.requireReasoningEffort}\\"" ]; then`, | ||
| 101 | ' printf "%s\\n" "unexpected reasoning effort config: $seen_reasoning_effort" >&2', | ||
| 102 | ` exit 6`, | ||
| 103 | "fi", | ||
| 104 | ] | ||
| 105 | : []), | ||
| 106 | ...(input.forbidReasoningEffort | ||
| 107 | ? [ | ||
| 108 | 'if [ -n "$seen_reasoning_effort" ]; then', | ||
| 109 | ' printf "%s\\n" "reasoning effort config should be omitted: $seen_reasoning_effort" >&2', | ||
| 110 | ` exit 7`, | ||
| 111 | "fi", | ||
| 112 | ] | ||
| 113 | : []), | ||
| 114 | ...(input.stdinMustContain !== undefined | ||
| 115 | ? [ | ||
| 116 | `if ! printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustContain)} >/dev/null; then`, | ||
| 117 | ' printf "%s\\n" "stdin missing expected content" >&2', | ||
| 118 | ` exit 3`, | ||
| 119 | "fi", | ||
| 120 | ] | ||
| 121 | : []), | ||
| 122 | ...(input.stdinMustNotContain !== undefined | ||
| 123 | ? [ | ||
| 124 | `if printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustNotContain)} >/dev/null; then`, | ||
| 125 | ' printf "%s\\n" "stdin contained forbidden content" >&2', | ||
| 126 | ` exit 4`, | ||
| 127 | "fi", | ||
| 128 | ] | ||
| 129 | : []), | ||
| 130 | ...(input.stderr !== undefined | ||
| 131 | ? [`printf "%s\\n" ${JSON.stringify(input.stderr)} >&2`] | ||
| 132 | : []), | ||
| 133 | 'if [ -n "$output_path" ]; then', | ||
| 134 | " cat > \"$output_path\" <<'__T3CODE_FAKE_CODEX_OUTPUT__'", | ||
| 135 | input.output, | ||
| 136 | "__T3CODE_FAKE_CODEX_OUTPUT__", | ||
| 137 | "fi", | ||
| 138 | `exit ${input.exitCode ?? 0}`, | ||
| 139 | "", | ||
| 140 | ].join("\n"), | ||
| 141 | ); | ||
| 142 | yield* fs.chmod(codexPath, 0o755); | ||
| 143 | return codexPath; | ||
| 144 | }); | ||
| 145 | } | ||
| 146 | |||
| 147 | function withFakeCodexEnv<A, E, R>( | ||
| 148 | input: { | ||
| 149 | output: string; | ||
| 150 | exitCode?: number; | ||
| 151 | stderr?: string; | ||
| 152 | requireImage?: boolean; | ||
| 153 | requireFastServiceTier?: boolean; | ||
| 154 | requireReasoningEffort?: string; | ||
| 155 | forbidReasoningEffort?: boolean; | ||
| 156 | stdinMustContain?: string; | ||
| 157 | stdinMustNotContain?: string; | ||
| 158 | }, | ||
| 159 | effectFn: (textGeneration: TextGenerationShape) => Effect.Effect<A, E, R>, | ||
| 160 | ) { | ||
| 161 | return Effect.gen(function* () { | ||
| 162 | const fs = yield* FileSystem.FileSystem; | ||
| 163 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" }); | ||
| 164 | const codexPath = yield* makeFakeCodexBinary(tempDir, input); | ||
| 165 | const config = Schema.decodeSync(CodexSettings)({ binaryPath: codexPath }); | ||
| 166 | const textGeneration = yield* makeCodexTextGeneration(config); | ||
| 167 | return yield* effectFn(textGeneration); | ||
| 168 | }).pipe(Effect.scoped); | ||
| 169 | } | ||
| 170 | |||
| 171 | it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { | ||
| 172 | it.effect("generates and sanitizes commit messages without branch by default", () => | ||
| 173 | withFakeCodexEnv( | ||
| 174 | { | ||
| 175 | output: JSON.stringify({ | ||
| 176 | subject: | ||
| 177 | " Add important change to the system with too much detail and a trailing period.\nsecondary line", | ||
| 178 | body: "\n- added migration\n- updated tests\n", | ||
| 179 | }), | ||
| 180 | stdinMustNotContain: "branch must be a short semantic git branch fragment", | ||
| 181 | }, | ||
| 182 | (textGeneration) => | ||
| 183 | Effect.gen(function* () { | ||
| 184 | const generated = yield* textGeneration.generateCommitMessage({ | ||
| 185 | cwd: process.cwd(), | ||
| 186 | branch: "feature/codex-effect", | ||
| 187 | stagedSummary: "M README.md", | ||
| 188 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 189 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 190 | }); | ||
| 191 | |||
| 192 | expect(generated.subject.length).toBeLessThanOrEqual(72); | ||
| 193 | expect(generated.subject.endsWith(".")).toBe(false); | ||
| 194 | expect(generated.body).toBe("- added migration\n- updated tests"); | ||
| 195 | expect(generated.branch).toBeUndefined(); | ||
| 196 | }), | ||
| 197 | ), | ||
| 198 | ); | ||
| 199 | |||
| 200 | it.effect( | ||
| 201 | "forwards codex fast mode and non-default reasoning effort into codex exec config", | ||
| 202 | () => | ||
| 203 | withFakeCodexEnv( | ||
| 204 | { | ||
| 205 | output: JSON.stringify({ | ||
| 206 | subject: "Add important change", | ||
| 207 | body: "", | ||
| 208 | }), | ||
| 209 | requireFastServiceTier: true, | ||
| 210 | requireReasoningEffort: "xhigh", | ||
| 211 | stdinMustNotContain: "branch must be a short semantic git branch fragment", | ||
| 212 | }, | ||
| 213 | (textGeneration) => | ||
| 214 | textGeneration.generateCommitMessage({ | ||
| 215 | cwd: process.cwd(), | ||
| 216 | branch: "feature/codex-effect", | ||
| 217 | stagedSummary: "M README.md", | ||
| 218 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 219 | modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ | ||
| 220 | { id: "reasoningEffort", value: "xhigh" }, | ||
| 221 | { id: "fastMode", value: true }, | ||
| 222 | ]), | ||
| 223 | }), | ||
| 224 | ), | ||
| 225 | ); | ||
| 226 | |||
| 227 | it.effect("defaults git text generation codex effort to low", () => | ||
| 228 | withFakeCodexEnv( | ||
| 229 | { | ||
| 230 | output: JSON.stringify({ | ||
| 231 | subject: "Add important change", | ||
| 232 | body: "", | ||
| 233 | }), | ||
| 234 | requireReasoningEffort: "low", | ||
| 235 | }, | ||
| 236 | (textGeneration) => | ||
| 237 | textGeneration.generateCommitMessage({ | ||
| 238 | cwd: process.cwd(), | ||
| 239 | branch: "feature/codex-effect", | ||
| 240 | stagedSummary: "M README.md", | ||
| 241 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 242 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 243 | }), | ||
| 244 | ), | ||
| 245 | ); | ||
| 246 | |||
| 247 | it.effect("generates commit message with branch when includeBranch is true", () => | ||
| 248 | withFakeCodexEnv( | ||
| 249 | { | ||
| 250 | output: JSON.stringify({ | ||
| 251 | subject: "Add important change", | ||
| 252 | body: "", | ||
| 253 | branch: "fix/important-system-change", | ||
| 254 | }), | ||
| 255 | stdinMustContain: "branch must be a short semantic git branch fragment", | ||
| 256 | }, | ||
| 257 | (textGeneration) => | ||
| 258 | Effect.gen(function* () { | ||
| 259 | const generated = yield* textGeneration.generateCommitMessage({ | ||
| 260 | cwd: process.cwd(), | ||
| 261 | branch: "feature/codex-effect", | ||
| 262 | stagedSummary: "M README.md", | ||
| 263 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 264 | includeBranch: true, | ||
| 265 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 266 | }); | ||
| 267 | |||
| 268 | expect(generated.subject).toBe("Add important change"); | ||
| 269 | expect(generated.branch).toBe("feature/fix/important-system-change"); | ||
| 270 | }), | ||
| 271 | ), | ||
| 272 | ); | ||
| 273 | |||
| 274 | it.effect("generates PR content and trims markdown body", () => | ||
| 275 | withFakeCodexEnv( | ||
| 276 | { | ||
| 277 | output: JSON.stringify({ | ||
| 278 | title: " Improve orchestration flow\nwith ignored suffix", | ||
| 279 | body: "\n## Summary\n- improve flow\n\n## Testing\n- bun test\n\n", | ||
| 280 | }), | ||
| 281 | }, | ||
| 282 | (textGeneration) => | ||
| 283 | Effect.gen(function* () { | ||
| 284 | const generated = yield* textGeneration.generatePrContent({ | ||
| 285 | cwd: process.cwd(), | ||
| 286 | baseBranch: "main", | ||
| 287 | headBranch: "feature/codex-effect", | ||
| 288 | commitSummary: "feat: improve orchestration flow", | ||
| 289 | diffSummary: "2 files changed", | ||
| 290 | diffPatch: "diff --git a/a.ts b/a.ts", | ||
| 291 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 292 | }); | ||
| 293 | |||
| 294 | expect(generated.title).toBe("Improve orchestration flow"); | ||
| 295 | expect(generated.body.startsWith("## Summary")).toBe(true); | ||
| 296 | expect(generated.body.endsWith("\n\n")).toBe(false); | ||
| 297 | }), | ||
| 298 | ), | ||
| 299 | ); | ||
| 300 | |||
| 301 | it.effect("generates branch names and normalizes branch fragments", () => | ||
| 302 | withFakeCodexEnv( | ||
| 303 | { | ||
| 304 | output: JSON.stringify({ | ||
| 305 | branch: " Feat/Session ", | ||
| 306 | }), | ||
| 307 | stdinMustNotContain: "Image attachments supplied to the model", | ||
| 308 | }, | ||
| 309 | (textGeneration) => | ||
| 310 | Effect.gen(function* () { | ||
| 311 | const generated = yield* textGeneration.generateBranchName({ | ||
| 312 | cwd: process.cwd(), | ||
| 313 | message: "Please update session handling.", | ||
| 314 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 315 | }); | ||
| 316 | |||
| 317 | expect(generated.branch).toBe("feat/session"); | ||
| 318 | }), | ||
| 319 | ), | ||
| 320 | ); | ||
| 321 | |||
| 322 | it.effect("generates thread titles and trims them for sidebar use", () => | ||
| 323 | withFakeCodexEnv( | ||
| 324 | { | ||
| 325 | output: JSON.stringify({ | ||
| 326 | title: | ||
| 327 | ' "Investigate websocket reconnect regressions after worktree restore" \nignored line', | ||
| 328 | }), | ||
| 329 | }, | ||
| 330 | (textGeneration) => | ||
| 331 | Effect.gen(function* () { | ||
| 332 | const generated = yield* textGeneration.generateThreadTitle({ | ||
| 333 | cwd: process.cwd(), | ||
| 334 | message: "Please investigate websocket reconnect regressions after a worktree restore.", | ||
| 335 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 336 | }); | ||
| 337 | |||
| 338 | expect(generated.title).toBe("Investigate websocket reconnect regressions aft..."); | ||
| 339 | }), | ||
| 340 | ), | ||
| 341 | ); | ||
| 342 | |||
| 343 | it.effect("falls back when thread title normalization becomes whitespace-only", () => | ||
| 344 | withFakeCodexEnv( | ||
| 345 | { | ||
| 346 | output: JSON.stringify({ | ||
| 347 | title: ' """ """ ', | ||
| 348 | }), | ||
| 349 | }, | ||
| 350 | (textGeneration) => | ||
| 351 | Effect.gen(function* () { | ||
| 352 | const generated = yield* textGeneration.generateThreadTitle({ | ||
| 353 | cwd: process.cwd(), | ||
| 354 | message: "Name this thread.", | ||
| 355 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 356 | }); | ||
| 357 | |||
| 358 | expect(generated.title).toBe("New thread"); | ||
| 359 | }), | ||
| 360 | ), | ||
| 361 | ); | ||
| 362 | |||
| 363 | it.effect("trims whitespace exposed after quote removal in thread titles", () => | ||
| 364 | withFakeCodexEnv( | ||
| 365 | { | ||
| 366 | output: JSON.stringify({ | ||
| 367 | title: ` "' hello world '" `, | ||
| 368 | }), | ||
| 369 | }, | ||
| 370 | (textGeneration) => | ||
| 371 | Effect.gen(function* () { | ||
| 372 | const generated = yield* textGeneration.generateThreadTitle({ | ||
| 373 | cwd: process.cwd(), | ||
| 374 | message: "Name this thread.", | ||
| 375 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 376 | }); | ||
| 377 | |||
| 378 | expect(generated.title).toBe("hello world"); | ||
| 379 | }), | ||
| 380 | ), | ||
| 381 | ); | ||
| 382 | |||
| 383 | it.effect("omits attachment metadata section when no attachments are provided", () => | ||
| 384 | withFakeCodexEnv( | ||
| 385 | { | ||
| 386 | output: JSON.stringify({ | ||
| 387 | branch: "fix/session-timeout", | ||
| 388 | }), | ||
| 389 | stdinMustNotContain: "Attachment metadata:", | ||
| 390 | }, | ||
| 391 | (textGeneration) => | ||
| 392 | Effect.gen(function* () { | ||
| 393 | const generated = yield* textGeneration.generateBranchName({ | ||
| 394 | cwd: process.cwd(), | ||
| 395 | message: "Fix timeout behavior.", | ||
| 396 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 397 | }); | ||
| 398 | |||
| 399 | expect(generated.branch).toBe("fix/session-timeout"); | ||
| 400 | }), | ||
| 401 | ), | ||
| 402 | ); | ||
| 403 | |||
| 404 | it.effect("passes image attachments through as codex image inputs", () => | ||
| 405 | withFakeCodexEnv( | ||
| 406 | { | ||
| 407 | output: JSON.stringify({ | ||
| 408 | branch: "fix/ui-regression", | ||
| 409 | }), | ||
| 410 | requireImage: true, | ||
| 411 | stdinMustContain: "Attachment metadata:", | ||
| 412 | }, | ||
| 413 | (textGeneration) => | ||
| 414 | Effect.gen(function* () { | ||
| 415 | const fs = yield* FileSystem.FileSystem; | ||
| 416 | const path = yield* Path.Path; | ||
| 417 | const { attachmentsDir } = yield* ServerConfig; | ||
| 418 | const attachmentId = `thread-branch-image-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; | ||
| 419 | const attachmentPath = path.join(attachmentsDir, `${attachmentId}.png`); | ||
| 420 | yield* fs.makeDirectory(attachmentsDir, { recursive: true }); | ||
| 421 | yield* fs.writeFile(attachmentPath, Buffer.from("hello")); | ||
| 422 | |||
| 423 | const generated = yield* textGeneration.generateBranchName({ | ||
| 424 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 425 | cwd: process.cwd(), | ||
| 426 | message: "Fix layout bug from screenshot.", | ||
| 427 | attachments: [ | ||
| 428 | { | ||
| 429 | type: "image", | ||
| 430 | id: attachmentId, | ||
| 431 | name: "bug.png", | ||
| 432 | mimeType: "image/png", | ||
| 433 | sizeBytes: 5, | ||
| 434 | }, | ||
| 435 | ], | ||
| 436 | }); | ||
| 437 | |||
| 438 | expect(generated.branch).toBe("fix/ui-regression"); | ||
| 439 | }), | ||
| 440 | ), | ||
| 441 | ); | ||
| 442 | |||
| 443 | it.effect("resolves persisted attachment ids to files for codex image inputs", () => | ||
| 444 | withFakeCodexEnv( | ||
| 445 | { | ||
| 446 | output: JSON.stringify({ | ||
| 447 | branch: "fix/ui-regression", | ||
| 448 | }), | ||
| 449 | requireImage: true, | ||
| 450 | }, | ||
| 451 | (textGeneration) => | ||
| 452 | Effect.gen(function* () { | ||
| 453 | const fs = yield* FileSystem.FileSystem; | ||
| 454 | const path = yield* Path.Path; | ||
| 455 | const { attachmentsDir } = yield* ServerConfig; | ||
| 456 | const attachmentId = `thread-1-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; | ||
| 457 | const imagePath = path.join(attachmentsDir, `${attachmentId}.png`); | ||
| 458 | yield* fs.makeDirectory(attachmentsDir, { recursive: true }); | ||
| 459 | yield* fs.writeFile(imagePath, Buffer.from("hello")); | ||
| 460 | |||
| 461 | const generated = yield* textGeneration | ||
| 462 | .generateBranchName({ | ||
| 463 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 464 | cwd: process.cwd(), | ||
| 465 | message: "Fix layout bug from screenshot.", | ||
| 466 | attachments: [ | ||
| 467 | { | ||
| 468 | type: "image", | ||
| 469 | id: attachmentId, | ||
| 470 | name: "bug.png", | ||
| 471 | mimeType: "image/png", | ||
| 472 | sizeBytes: 5, | ||
| 473 | }, | ||
| 474 | ], | ||
| 475 | }) | ||
| 476 | .pipe( | ||
| 477 | Effect.tap(() => | ||
| 478 | fs.stat(imagePath).pipe( | ||
| 479 | Effect.map((fileInfo) => { | ||
| 480 | expect(fileInfo.type).toBe("File"); | ||
| 481 | }), | ||
| 482 | ), | ||
| 483 | ), | ||
| 484 | Effect.ensuring(fs.remove(imagePath).pipe(Effect.catch(() => Effect.void))), | ||
| 485 | ); | ||
| 486 | |||
| 487 | expect(generated.branch).toBe("fix/ui-regression"); | ||
| 488 | }), | ||
| 489 | ), | ||
| 490 | ); | ||
| 491 | |||
| 492 | it.effect("ignores missing attachment ids for codex image inputs", () => | ||
| 493 | withFakeCodexEnv( | ||
| 494 | { | ||
| 495 | output: JSON.stringify({ | ||
| 496 | branch: "fix/ui-regression", | ||
| 497 | }), | ||
| 498 | requireImage: true, | ||
| 499 | }, | ||
| 500 | (textGeneration) => | ||
| 501 | Effect.gen(function* () { | ||
| 502 | const fs = yield* FileSystem.FileSystem; | ||
| 503 | const path = yield* Path.Path; | ||
| 504 | const { attachmentsDir } = yield* ServerConfig; | ||
| 505 | const missingAttachmentId = `thread-missing-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; | ||
| 506 | const missingPath = path.join(attachmentsDir, `${missingAttachmentId}.png`); | ||
| 507 | yield* fs.remove(missingPath).pipe(Effect.catch(() => Effect.void)); | ||
| 508 | |||
| 509 | const result = yield* textGeneration | ||
| 510 | .generateBranchName({ | ||
| 511 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 512 | cwd: process.cwd(), | ||
| 513 | message: "Fix layout bug from screenshot.", | ||
| 514 | attachments: [ | ||
| 515 | { | ||
| 516 | type: "image", | ||
| 517 | id: missingAttachmentId, | ||
| 518 | name: "outside.png", | ||
| 519 | mimeType: "image/png", | ||
| 520 | sizeBytes: 5, | ||
| 521 | }, | ||
| 522 | ], | ||
| 523 | }) | ||
| 524 | .pipe(Effect.result); | ||
| 525 | |||
| 526 | expect(Result.isFailure(result)).toBe(true); | ||
| 527 | if (Result.isFailure(result)) { | ||
| 528 | expect(result.failure).toBeInstanceOf(TextGenerationError); | ||
| 529 | expect(result.failure.message).toContain("missing --image input"); | ||
| 530 | } | ||
| 531 | }), | ||
| 532 | ), | ||
| 533 | ); | ||
| 534 | |||
| 535 | it.effect( | ||
| 536 | "fails with typed TextGenerationError when codex returns wrong branch payload shape", | ||
| 537 | () => | ||
| 538 | withFakeCodexEnv( | ||
| 539 | { | ||
| 540 | output: JSON.stringify({ | ||
| 541 | title: "This is not a branch payload", | ||
| 542 | }), | ||
| 543 | }, | ||
| 544 | (textGeneration) => | ||
| 545 | Effect.gen(function* () { | ||
| 546 | const result = yield* textGeneration | ||
| 547 | .generateBranchName({ | ||
| 548 | cwd: process.cwd(), | ||
| 549 | message: "Fix websocket reconnect flake", | ||
| 550 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 551 | }) | ||
| 552 | .pipe(Effect.result); | ||
| 553 | |||
| 554 | expect(Result.isFailure(result)).toBe(true); | ||
| 555 | if (Result.isFailure(result)) { | ||
| 556 | expect(result.failure).toBeInstanceOf(TextGenerationError); | ||
| 557 | expect(result.failure.message).toContain("Codex returned invalid structured output"); | ||
| 558 | } | ||
| 559 | }), | ||
| 560 | ), | ||
| 561 | ); | ||
| 562 | |||
| 563 | it.effect("returns typed TextGenerationError when codex exits non-zero", () => | ||
| 564 | withFakeCodexEnv( | ||
| 565 | { | ||
| 566 | output: JSON.stringify({ subject: "ignored", body: "" }), | ||
| 567 | exitCode: 1, | ||
| 568 | stderr: "codex execution failed", | ||
| 569 | }, | ||
| 570 | (textGeneration) => | ||
| 571 | Effect.gen(function* () { | ||
| 572 | const result = yield* textGeneration | ||
| 573 | .generateCommitMessage({ | ||
| 574 | cwd: process.cwd(), | ||
| 575 | branch: "feature/codex-error", | ||
| 576 | stagedSummary: "M README.md", | ||
| 577 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 578 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 579 | }) | ||
| 580 | .pipe(Effect.result); | ||
| 581 | |||
| 582 | expect(Result.isFailure(result)).toBe(true); | ||
| 583 | if (Result.isFailure(result)) { | ||
| 584 | expect(result.failure).toBeInstanceOf(TextGenerationError); | ||
| 585 | expect(result.failure.message).toContain( | ||
| 586 | "Codex CLI command failed: codex execution failed", | ||
| 587 | ); | ||
| 588 | } | ||
| 589 | }), | ||
| 590 | ), | ||
| 591 | ); | ||
| 592 | }); | ||
apps/server/src/git/Layers/CodexTextGeneration.ts deleted-392| ... | @@ -1,392 +0,0 @@ | ||
| 1 | import { Effect, FileSystem, Option, Path, Random, Schema, Scope, Stream } from "effect"; | ||
| 2 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 3 | |||
| 4 | import { type CodexSettings, type ModelSelection } from "@t3tools/contracts"; | ||
| 5 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | ||
| 6 | |||
| 7 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | ||
| 8 | import { ServerConfig } from "../../config.ts"; | ||
| 9 | import { expandHomePath } from "../../pathExpansion.ts"; | ||
| 10 | import { TextGenerationError } from "@t3tools/contracts"; | ||
| 11 | import { | ||
| 12 | type BranchNameGenerationInput, | ||
| 13 | type ThreadTitleGenerationResult, | ||
| 14 | type TextGenerationShape, | ||
| 15 | } from "../Services/TextGeneration.ts"; | ||
| 16 | import { | ||
| 17 | buildBranchNamePrompt, | ||
| 18 | buildCommitMessagePrompt, | ||
| 19 | buildPrContentPrompt, | ||
| 20 | buildThreadTitlePrompt, | ||
| 21 | } from "../Prompts.ts"; | ||
| 22 | import { | ||
| 23 | normalizeCliError, | ||
| 24 | sanitizeCommitSubject, | ||
| 25 | sanitizePrTitle, | ||
| 26 | sanitizeThreadTitle, | ||
| 27 | toJsonSchemaObject, | ||
| 28 | } from "../Utils.ts"; | ||
| 29 | import { | ||
| 30 | getModelSelectionBooleanOptionValue, | ||
| 31 | getModelSelectionStringOptionValue, | ||
| 32 | } from "@t3tools/shared/model"; | ||
| 33 | |||
| 34 | const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low"; | ||
| 35 | const CODEX_TIMEOUT_MS = 180_000; | ||
| 36 | /** | ||
| 37 | * Build a Codex text-generation closure bound to a specific `CodexSettings` | ||
| 38 | * payload. See `makeCodexAdapter` for the overall per-instance rationale. | ||
| 39 | */ | ||
| 40 | export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(function* ( | ||
| 41 | codexConfig: CodexSettings, | ||
| 42 | environment: NodeJS.ProcessEnv = process.env, | ||
| 43 | ) { | ||
| 44 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 45 | const path = yield* Path.Path; | ||
| 46 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 47 | const serverConfig = yield* Effect.service(ServerConfig); | ||
| 48 | |||
| 49 | type MaterializedImageAttachments = { | ||
| 50 | readonly imagePaths: ReadonlyArray<string>; | ||
| 51 | }; | ||
| 52 | |||
| 53 | const readStreamAsString = <E>( | ||
| 54 | operation: string, | ||
| 55 | stream: Stream.Stream<Uint8Array, E>, | ||
| 56 | ): Effect.Effect<string, TextGenerationError> => | ||
| 57 | stream.pipe( | ||
| 58 | Stream.decodeText(), | ||
| 59 | Stream.runFold( | ||
| 60 | () => "", | ||
| 61 | (acc, chunk) => acc + chunk, | ||
| 62 | ), | ||
| 63 | Effect.mapError((cause) => | ||
| 64 | normalizeCliError("codex", operation, cause, "Failed to collect process output"), | ||
| 65 | ), | ||
| 66 | ); | ||
| 67 | |||
| 68 | const writeTempFile = ( | ||
| 69 | operation: string, | ||
| 70 | prefix: string, | ||
| 71 | content: string, | ||
| 72 | ): Effect.Effect<string, TextGenerationError, Scope.Scope> => { | ||
| 73 | return Effect.gen(function* () { | ||
| 74 | const tempFileId = yield* Random.nextUUIDv4; | ||
| 75 | return yield* fileSystem | ||
| 76 | .makeTempFileScoped({ | ||
| 77 | prefix: `t3code-${prefix}-${process.pid}-${tempFileId}.tmp`, | ||
| 78 | }) | ||
| 79 | .pipe(Effect.tap((filePath) => fileSystem.writeFileString(filePath, content))); | ||
| 80 | }).pipe( | ||
| 81 | Effect.mapError( | ||
| 82 | (cause) => | ||
| 83 | new TextGenerationError({ | ||
| 84 | operation, | ||
| 85 | detail: `Failed to write temp file`, | ||
| 86 | cause, | ||
| 87 | }), | ||
| 88 | ), | ||
| 89 | ); | ||
| 90 | }; | ||
| 91 | |||
| 92 | const safeUnlink = (filePath: string): Effect.Effect<void, never> => | ||
| 93 | fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void)); | ||
| 94 | |||
| 95 | const materializeImageAttachments = Effect.fn("materializeImageAttachments")(function* ( | ||
| 96 | _operation: | ||
| 97 | | "generateCommitMessage" | ||
| 98 | | "generatePrContent" | ||
| 99 | | "generateBranchName" | ||
| 100 | | "generateThreadTitle", | ||
| 101 | attachments: BranchNameGenerationInput["attachments"], | ||
| 102 | ): Effect.fn.Return<MaterializedImageAttachments, TextGenerationError> { | ||
| 103 | if (!attachments || attachments.length === 0) { | ||
| 104 | return { imagePaths: [] }; | ||
| 105 | } | ||
| 106 | |||
| 107 | const imagePaths: string[] = []; | ||
| 108 | for (const attachment of attachments) { | ||
| 109 | if (attachment.type !== "image") { | ||
| 110 | continue; | ||
| 111 | } | ||
| 112 | |||
| 113 | const resolvedPath = resolveAttachmentPath({ | ||
| 114 | attachmentsDir: serverConfig.attachmentsDir, | ||
| 115 | attachment, | ||
| 116 | }); | ||
| 117 | if (!resolvedPath || !path.isAbsolute(resolvedPath)) { | ||
| 118 | continue; | ||
| 119 | } | ||
| 120 | const fileInfo = yield* fileSystem | ||
| 121 | .stat(resolvedPath) | ||
| 122 | .pipe(Effect.catch(() => Effect.succeed(null))); | ||
| 123 | if (!fileInfo || fileInfo.type !== "File") { | ||
| 124 | continue; | ||
| 125 | } | ||
| 126 | imagePaths.push(resolvedPath); | ||
| 127 | } | ||
| 128 | return { imagePaths }; | ||
| 129 | }); | ||
| 130 | |||
| 131 | const runCodexJson = Effect.fn("runCodexJson")(function* <S extends Schema.Top>({ | ||
| 132 | operation, | ||
| 133 | cwd, | ||
| 134 | prompt, | ||
| 135 | outputSchemaJson, | ||
| 136 | imagePaths = [], | ||
| 137 | cleanupPaths = [], | ||
| 138 | modelSelection, | ||
| 139 | }: { | ||
| 140 | operation: | ||
| 141 | | "generateCommitMessage" | ||
| 142 | | "generatePrContent" | ||
| 143 | | "generateBranchName" | ||
| 144 | | "generateThreadTitle"; | ||
| 145 | cwd: string; | ||
| 146 | prompt: string; | ||
| 147 | outputSchemaJson: S; | ||
| 148 | imagePaths?: ReadonlyArray<string>; | ||
| 149 | cleanupPaths?: ReadonlyArray<string>; | ||
| 150 | modelSelection: ModelSelection; | ||
| 151 | }): Effect.fn.Return<S["Type"], TextGenerationError, S["DecodingServices"]> { | ||
| 152 | const schemaPath = yield* writeTempFile( | ||
| 153 | operation, | ||
| 154 | "codex-schema", | ||
| 155 | JSON.stringify(toJsonSchemaObject(outputSchemaJson)), | ||
| 156 | ); | ||
| 157 | const outputPath = yield* writeTempFile(operation, "codex-output", ""); | ||
| 158 | |||
| 159 | const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () { | ||
| 160 | const reasoningEffort = | ||
| 161 | getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? | ||
| 162 | CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; | ||
| 163 | const command = ChildProcess.make( | ||
| 164 | codexConfig.binaryPath || "codex", | ||
| 165 | [ | ||
| 166 | "exec", | ||
| 167 | "--ephemeral", | ||
| 168 | "--skip-git-repo-check", | ||
| 169 | "-s", | ||
| 170 | "read-only", | ||
| 171 | "--model", | ||
| 172 | modelSelection.model, | ||
| 173 | "--config", | ||
| 174 | `model_reasoning_effort="${reasoningEffort}"`, | ||
| 175 | ...(getModelSelectionBooleanOptionValue(modelSelection, "fastMode") === true | ||
| 176 | ? ["--config", `service_tier="fast"`] | ||
| 177 | : []), | ||
| 178 | "--output-schema", | ||
| 179 | schemaPath, | ||
| 180 | "--output-last-message", | ||
| 181 | outputPath, | ||
| 182 | ...imagePaths.flatMap((imagePath) => ["--image", imagePath]), | ||
| 183 | "-", | ||
| 184 | ], | ||
| 185 | { | ||
| 186 | env: { | ||
| 187 | ...environment, | ||
| 188 | ...(codexConfig.homePath ? { CODEX_HOME: expandHomePath(codexConfig.homePath) } : {}), | ||
| 189 | }, | ||
| 190 | cwd, | ||
| 191 | shell: process.platform === "win32", | ||
| 192 | stdin: { | ||
| 193 | stream: Stream.encodeText(Stream.make(prompt)), | ||
| 194 | }, | ||
| 195 | }, | ||
| 196 | ); | ||
| 197 | |||
| 198 | const child = yield* commandSpawner | ||
| 199 | .spawn(command) | ||
| 200 | .pipe( | ||
| 201 | Effect.mapError((cause) => | ||
| 202 | normalizeCliError("codex", operation, cause, "Failed to spawn Codex CLI process"), | ||
| 203 | ), | ||
| 204 | ); | ||
| 205 | |||
| 206 | const [stdout, stderr, exitCode] = yield* Effect.all( | ||
| 207 | [ | ||
| 208 | readStreamAsString(operation, child.stdout), | ||
| 209 | readStreamAsString(operation, child.stderr), | ||
| 210 | child.exitCode.pipe( | ||
| 211 | Effect.mapError((cause) => | ||
| 212 | normalizeCliError("codex", operation, cause, "Failed to read Codex CLI exit code"), | ||
| 213 | ), | ||
| 214 | ), | ||
| 215 | ], | ||
| 216 | { concurrency: "unbounded" }, | ||
| 217 | ); | ||
| 218 | |||
| 219 | if (exitCode !== 0) { | ||
| 220 | const stderrDetail = stderr.trim(); | ||
| 221 | const stdoutDetail = stdout.trim(); | ||
| 222 | const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail; | ||
| 223 | return yield* new TextGenerationError({ | ||
| 224 | operation, | ||
| 225 | detail: | ||
| 226 | detail.length > 0 | ||
| 227 | ? `Codex CLI command failed: ${detail}` | ||
| 228 | : `Codex CLI command failed with code ${exitCode}.`, | ||
| 229 | }); | ||
| 230 | } | ||
| 231 | }); | ||
| 232 | |||
| 233 | const cleanup = Effect.all( | ||
| 234 | [schemaPath, outputPath, ...cleanupPaths].map((filePath) => safeUnlink(filePath)), | ||
| 235 | { | ||
| 236 | concurrency: "unbounded", | ||
| 237 | }, | ||
| 238 | ).pipe(Effect.asVoid); | ||
| 239 | |||
| 240 | return yield* Effect.gen(function* () { | ||
| 241 | yield* runCodexCommand().pipe( | ||
| 242 | Effect.scoped, | ||
| 243 | Effect.timeoutOption(CODEX_TIMEOUT_MS), | ||
| 244 | Effect.flatMap( | ||
| 245 | Option.match({ | ||
| 246 | onNone: () => | ||
| 247 | Effect.fail( | ||
| 248 | new TextGenerationError({ operation, detail: "Codex CLI request timed out." }), | ||
| 249 | ), | ||
| 250 | onSome: () => Effect.void, | ||
| 251 | }), | ||
| 252 | ), | ||
| 253 | ); | ||
| 254 | |||
| 255 | return yield* fileSystem.readFileString(outputPath).pipe( | ||
| 256 | Effect.mapError( | ||
| 257 | (cause) => | ||
| 258 | new TextGenerationError({ | ||
| 259 | operation, | ||
| 260 | detail: "Failed to read Codex output file.", | ||
| 261 | cause, | ||
| 262 | }), | ||
| 263 | ), | ||
| 264 | Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson))), | ||
| 265 | Effect.catchTag("SchemaError", (cause) => | ||
| 266 | Effect.fail( | ||
| 267 | new TextGenerationError({ | ||
| 268 | operation, | ||
| 269 | detail: "Codex returned invalid structured output.", | ||
| 270 | cause, | ||
| 271 | }), | ||
| 272 | ), | ||
| 273 | ), | ||
| 274 | ); | ||
| 275 | }).pipe(Effect.ensuring(cleanup)); | ||
| 276 | }); | ||
| 277 | |||
| 278 | const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( | ||
| 279 | "CodexTextGeneration.generateCommitMessage", | ||
| 280 | )(function* (input) { | ||
| 281 | const { prompt, outputSchema } = buildCommitMessagePrompt({ | ||
| 282 | branch: input.branch, | ||
| 283 | stagedSummary: input.stagedSummary, | ||
| 284 | stagedPatch: input.stagedPatch, | ||
| 285 | includeBranch: input.includeBranch === true, | ||
| 286 | }); | ||
| 287 | |||
| 288 | const generated = yield* runCodexJson({ | ||
| 289 | operation: "generateCommitMessage", | ||
| 290 | cwd: input.cwd, | ||
| 291 | prompt, | ||
| 292 | outputSchemaJson: outputSchema, | ||
| 293 | modelSelection: input.modelSelection, | ||
| 294 | }); | ||
| 295 | |||
| 296 | return { | ||
| 297 | subject: sanitizeCommitSubject(generated.subject), | ||
| 298 | body: generated.body.trim(), | ||
| 299 | ...("branch" in generated && typeof generated.branch === "string" | ||
| 300 | ? { branch: sanitizeFeatureBranchName(generated.branch) } | ||
| 301 | : {}), | ||
| 302 | }; | ||
| 303 | }); | ||
| 304 | |||
| 305 | const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( | ||
| 306 | "CodexTextGeneration.generatePrContent", | ||
| 307 | )(function* (input) { | ||
| 308 | const { prompt, outputSchema } = buildPrContentPrompt({ | ||
| 309 | baseBranch: input.baseBranch, | ||
| 310 | headBranch: input.headBranch, | ||
| 311 | commitSummary: input.commitSummary, | ||
| 312 | diffSummary: input.diffSummary, | ||
| 313 | diffPatch: input.diffPatch, | ||
| 314 | }); | ||
| 315 | |||
| 316 | const generated = yield* runCodexJson({ | ||
| 317 | operation: "generatePrContent", | ||
| 318 | cwd: input.cwd, | ||
| 319 | prompt, | ||
| 320 | outputSchemaJson: outputSchema, | ||
| 321 | modelSelection: input.modelSelection, | ||
| 322 | }); | ||
| 323 | |||
| 324 | return { | ||
| 325 | title: sanitizePrTitle(generated.title), | ||
| 326 | body: generated.body.trim(), | ||
| 327 | }; | ||
| 328 | }); | ||
| 329 | |||
| 330 | const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( | ||
| 331 | "CodexTextGeneration.generateBranchName", | ||
| 332 | )(function* (input) { | ||
| 333 | const { imagePaths } = yield* materializeImageAttachments( | ||
| 334 | "generateBranchName", | ||
| 335 | input.attachments, | ||
| 336 | ); | ||
| 337 | const { prompt, outputSchema } = buildBranchNamePrompt({ | ||
| 338 | message: input.message, | ||
| 339 | attachments: input.attachments, | ||
| 340 | }); | ||
| 341 | |||
| 342 | const generated = yield* runCodexJson({ | ||
| 343 | operation: "generateBranchName", | ||
| 344 | cwd: input.cwd, | ||
| 345 | prompt, | ||
| 346 | outputSchemaJson: outputSchema, | ||
| 347 | imagePaths, | ||
| 348 | modelSelection: input.modelSelection, | ||
| 349 | }); | ||
| 350 | |||
| 351 | return { | ||
| 352 | branch: sanitizeBranchFragment(generated.branch), | ||
| 353 | }; | ||
| 354 | }); | ||
| 355 | |||
| 356 | const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( | ||
| 357 | "CodexTextGeneration.generateThreadTitle", | ||
| 358 | )(function* (input) { | ||
| 359 | const { imagePaths } = yield* materializeImageAttachments( | ||
| 360 | "generateThreadTitle", | ||
| 361 | input.attachments, | ||
| 362 | ); | ||
| 363 | const { prompt, outputSchema } = buildThreadTitlePrompt({ | ||
| 364 | message: input.message, | ||
| 365 | attachments: input.attachments, | ||
| 366 | }); | ||
| 367 | |||
| 368 | const generated = yield* runCodexJson({ | ||
| 369 | operation: "generateThreadTitle", | ||
| 370 | cwd: input.cwd, | ||
| 371 | prompt, | ||
| 372 | outputSchemaJson: outputSchema, | ||
| 373 | imagePaths, | ||
| 374 | modelSelection: input.modelSelection, | ||
| 375 | }); | ||
| 376 | |||
| 377 | return { | ||
| 378 | title: sanitizeThreadTitle(generated.title), | ||
| 379 | } satisfies ThreadTitleGenerationResult; | ||
| 380 | }); | ||
| 381 | |||
| 382 | return { | ||
| 383 | generateCommitMessage, | ||
| 384 | generatePrContent, | ||
| 385 | generateBranchName, | ||
| 386 | generateThreadTitle, | ||
| 387 | } satisfies TextGenerationShape; | ||
| 388 | }); | ||
| 389 | |||
| 390 | // NOTE: `CodexTextGenerationLive` (the singleton Layer) has been removed. | ||
| 391 | // `makeCodexTextGeneration(codexConfig)` is now invoked directly by | ||
| 392 | // `CodexDriver.create()` for each configured instance. | ||
apps/server/src/git/Layers/CursorTextGeneration.test.ts deleted-264| ... | @@ -1,264 +0,0 @@ | ||
| 1 | import * as path from "node:path"; | ||
| 2 | import * as os from "node:os"; | ||
| 3 | import { fileURLToPath } from "node:url"; | ||
| 4 | import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | ||
| 5 | |||
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 7 | import { it } from "@effect/vitest"; | ||
| 8 | import { Effect, Layer, Schema } from "effect"; | ||
| 9 | import { createModelSelection } from "@t3tools/shared/model"; | ||
| 10 | import { expect } from "vitest"; | ||
| 11 | |||
| 12 | import { CursorSettings, ProviderInstanceId } from "@t3tools/contracts"; | ||
| 13 | |||
| 14 | import { ServerConfig } from "../../config.ts"; | ||
| 15 | import { type TextGenerationShape } from "../Services/TextGeneration.ts"; | ||
| 16 | import { makeCursorTextGeneration } from "./CursorTextGeneration.ts"; | ||
| 17 | |||
| 18 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); | ||
| 19 | const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); | ||
| 20 | |||
| 21 | function shellSingleQuote(value: string): string { | ||
| 22 | return `'${value.replaceAll("'", `'"'"'`)}'`; | ||
| 23 | } | ||
| 24 | |||
| 25 | const CursorTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { | ||
| 26 | prefix: "t3code-cursor-text-generation-test-", | ||
| 27 | }).pipe(Layer.provideMerge(NodeServices.layer)); | ||
| 28 | |||
| 29 | function makeAcpAgentWrapper(dir: string, env: Record<string, string>): string { | ||
| 30 | const binDir = path.join(dir, "bin"); | ||
| 31 | const agentPath = path.join(binDir, "agent"); | ||
| 32 | mkdirSync(binDir, { recursive: true }); | ||
| 33 | writeFileSync( | ||
| 34 | agentPath, | ||
| 35 | [ | ||
| 36 | "#!/bin/sh", | ||
| 37 | ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`), | ||
| 38 | 'if [ "$1" != "acp" ]; then', | ||
| 39 | ' printf "%s\\n" "unexpected args: $*" >&2', | ||
| 40 | " exit 11", | ||
| 41 | "fi", | ||
| 42 | `exec bun ${JSON.stringify(mockAgentPath)}`, | ||
| 43 | "", | ||
| 44 | ].join("\n"), | ||
| 45 | "utf8", | ||
| 46 | ); | ||
| 47 | chmodSync(agentPath, 0o755); | ||
| 48 | return agentPath; | ||
| 49 | } | ||
| 50 | |||
| 51 | function withFakeAcpAgent<A, E, R>( | ||
| 52 | env: Record<string, string>, | ||
| 53 | effectFn: (textGeneration: TextGenerationShape) => Effect.Effect<A, E, R>, | ||
| 54 | ) { | ||
| 55 | return Effect.gen(function* () { | ||
| 56 | const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-acp-")); | ||
| 57 | yield* Effect.addFinalizer(() => | ||
| 58 | Effect.sync(() => { | ||
| 59 | rmSync(tempDir, { recursive: true, force: true }); | ||
| 60 | }), | ||
| 61 | ); | ||
| 62 | const agentPath = makeAcpAgentWrapper(tempDir, env); | ||
| 63 | const config = Schema.decodeSync(CursorSettings)({ binaryPath: agentPath }); | ||
| 64 | const textGeneration = yield* makeCursorTextGeneration(config); | ||
| 65 | return yield* effectFn(textGeneration); | ||
| 66 | }).pipe(Effect.scoped); | ||
| 67 | } | ||
| 68 | |||
| 69 | function waitForFileContent(path: string): Effect.Effect<string> { | ||
| 70 | return Effect.promise(async () => { | ||
| 71 | const deadline = Date.now() + 5_000; | ||
| 72 | for (;;) { | ||
| 73 | try { | ||
| 74 | return readFileSync(path, "utf8"); | ||
| 75 | } catch (error) { | ||
| 76 | if (Date.now() >= deadline) { | ||
| 77 | throw error instanceof Error ? error : new Error(String(error)); | ||
| 78 | } | ||
| 79 | } | ||
| 80 | await new Promise((resolve) => setTimeout(resolve, 25)); | ||
| 81 | } | ||
| 82 | }); | ||
| 83 | } | ||
| 84 | |||
| 85 | it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { | ||
| 86 | it.effect("uses ACP model config options instead of raw CLI model ids", () => { | ||
| 87 | const requestLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-log-")); | ||
| 88 | const requestLogPath = path.join(requestLogDir, "requests.ndjson"); | ||
| 89 | |||
| 90 | return withFakeAcpAgent( | ||
| 91 | { | ||
| 92 | T3_ACP_REQUEST_LOG_PATH: requestLogPath, | ||
| 93 | T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ | ||
| 94 | subject: "Add generated commit message", | ||
| 95 | body: "- verify cursor acp model config path", | ||
| 96 | }), | ||
| 97 | }, | ||
| 98 | (textGeneration) => | ||
| 99 | Effect.gen(function* () { | ||
| 100 | const generated = yield* textGeneration.generateCommitMessage({ | ||
| 101 | cwd: process.cwd(), | ||
| 102 | branch: "feature/cursor-text-generation", | ||
| 103 | stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts", | ||
| 104 | stagedPatch: | ||
| 105 | "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts", | ||
| 106 | modelSelection: { | ||
| 107 | ...createModelSelection(ProviderInstanceId.make("cursor"), "gpt-5.4", [ | ||
| 108 | { id: "reasoning", value: "xhigh" }, | ||
| 109 | { id: "fastMode", value: true }, | ||
| 110 | { id: "contextWindow", value: "1m" }, | ||
| 111 | ]), | ||
| 112 | }, | ||
| 113 | }); | ||
| 114 | |||
| 115 | expect(generated.subject).toBe("Add generated commit message"); | ||
| 116 | expect(generated.body).toBe("- verify cursor acp model config path"); | ||
| 117 | |||
| 118 | const requests = readFileSync(requestLogPath, "utf8") | ||
| 119 | .trim() | ||
| 120 | .split("\n") | ||
| 121 | .filter((line) => line.length > 0) | ||
| 122 | .map( | ||
| 123 | (line) => JSON.parse(line) as { method?: string; params?: Record<string, unknown> }, | ||
| 124 | ); | ||
| 125 | |||
| 126 | expect( | ||
| 127 | requests.find((request) => request.method === "initialize")?.params?.clientCapabilities, | ||
| 128 | ).toMatchObject({ | ||
| 129 | _meta: { | ||
| 130 | parameterizedModelPicker: true, | ||
| 131 | }, | ||
| 132 | }); | ||
| 133 | expect( | ||
| 134 | requests.some( | ||
| 135 | (request) => | ||
| 136 | request.method === "session/set_config_option" && | ||
| 137 | request.params?.configId === "model" && | ||
| 138 | request.params?.value === "gpt-5.4", | ||
| 139 | ), | ||
| 140 | ).toBe(true); | ||
| 141 | expect( | ||
| 142 | requests.some( | ||
| 143 | (request) => | ||
| 144 | request.method === "session/set_config_option" && | ||
| 145 | request.params?.configId === "reasoning" && | ||
| 146 | request.params?.value === "extra-high", | ||
| 147 | ), | ||
| 148 | ).toBe(true); | ||
| 149 | expect( | ||
| 150 | requests.some( | ||
| 151 | (request) => | ||
| 152 | request.method === "session/set_config_option" && | ||
| 153 | request.params?.configId === "context" && | ||
| 154 | request.params?.value === "1m", | ||
| 155 | ), | ||
| 156 | ).toBe(true); | ||
| 157 | expect( | ||
| 158 | requests.some( | ||
| 159 | (request) => | ||
| 160 | request.method === "session/set_config_option" && | ||
| 161 | request.params?.configId === "fast" && | ||
| 162 | request.params?.value === "true", | ||
| 163 | ), | ||
| 164 | ).toBe(true); | ||
| 165 | expect( | ||
| 166 | requests.find((request) => request.method === "session/prompt")?.params?.prompt, | ||
| 167 | ).toEqual( | ||
| 168 | expect.arrayContaining([ | ||
| 169 | expect.objectContaining({ | ||
| 170 | type: "text", | ||
| 171 | text: expect.stringContaining("Staged patch:"), | ||
| 172 | }), | ||
| 173 | ]), | ||
| 174 | ); | ||
| 175 | |||
| 176 | rmSync(requestLogDir, { recursive: true, force: true }); | ||
| 177 | }), | ||
| 178 | ); | ||
| 179 | }); | ||
| 180 | |||
| 181 | it.effect("accepts json objects with extra assistant text around them", () => | ||
| 182 | withFakeAcpAgent( | ||
| 183 | { | ||
| 184 | T3_ACP_PROMPT_RESPONSE_TEXT: | ||
| 185 | 'Sure, here is the JSON:\n```json\n{\n "subject": "Update README dummy comment with attribution and date",\n "body": ""\n}\n```\nDone.', | ||
| 186 | }, | ||
| 187 | (textGeneration) => | ||
| 188 | Effect.gen(function* () { | ||
| 189 | const generated = yield* textGeneration.generateCommitMessage({ | ||
| 190 | cwd: process.cwd(), | ||
| 191 | branch: "feature/cursor-noisy-json", | ||
| 192 | stagedSummary: "M README.md", | ||
| 193 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 194 | modelSelection: { | ||
| 195 | instanceId: ProviderInstanceId.make("cursor"), | ||
| 196 | model: "composer-2", | ||
| 197 | }, | ||
| 198 | }); | ||
| 199 | |||
| 200 | expect(generated.subject).toBe("Update README dummy comment with attribution and date"); | ||
| 201 | expect(generated.body).toBe(""); | ||
| 202 | }), | ||
| 203 | ), | ||
| 204 | ); | ||
| 205 | |||
| 206 | it.effect("generates thread titles through Cursor ACP text generation", () => | ||
| 207 | withFakeAcpAgent( | ||
| 208 | { | ||
| 209 | T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ | ||
| 210 | title: '"Trim reconnect spinner status after resume."', | ||
| 211 | }), | ||
| 212 | }, | ||
| 213 | (textGeneration) => | ||
| 214 | Effect.gen(function* () { | ||
| 215 | const generated = yield* textGeneration.generateThreadTitle({ | ||
| 216 | cwd: process.cwd(), | ||
| 217 | message: "Fix the reconnect spinner after a resumed session.", | ||
| 218 | modelSelection: { | ||
| 219 | instanceId: ProviderInstanceId.make("cursor"), | ||
| 220 | model: "composer-2", | ||
| 221 | }, | ||
| 222 | }); | ||
| 223 | |||
| 224 | expect(generated.title).toBe("Trim reconnect spinner status after resume."); | ||
| 225 | }), | ||
| 226 | ), | ||
| 227 | ); | ||
| 228 | |||
| 229 | it.effect("closes the ACP child process after text generation completes", () => { | ||
| 230 | const exitLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-exit-log-")); | ||
| 231 | const exitLogPath = path.join(exitLogDir, "exit.log"); | ||
| 232 | |||
| 233 | return withFakeAcpAgent( | ||
| 234 | { | ||
| 235 | T3_ACP_EXIT_LOG_PATH: exitLogPath, | ||
| 236 | T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ | ||
| 237 | subject: "Close runtime after generation", | ||
| 238 | body: "", | ||
| 239 | }), | ||
| 240 | }, | ||
| 241 | (textGeneration) => | ||
| 242 | Effect.gen(function* () { | ||
| 243 | const generated = yield* textGeneration.generateCommitMessage({ | ||
| 244 | cwd: process.cwd(), | ||
| 245 | branch: "feature/cursor-runtime-close", | ||
| 246 | stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts", | ||
| 247 | stagedPatch: | ||
| 248 | "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts", | ||
| 249 | modelSelection: { | ||
| 250 | instanceId: ProviderInstanceId.make("cursor"), | ||
| 251 | model: "composer-2", | ||
| 252 | }, | ||
| 253 | }); | ||
| 254 | |||
| 255 | expect(generated.subject).toBe("Close runtime after generation"); | ||
| 256 | |||
| 257 | const exitLog = yield* waitForFileContent(exitLogPath); | ||
| 258 | expect(exitLog).toContain("exit:0"); | ||
| 259 | |||
| 260 | rmSync(exitLogDir, { recursive: true, force: true }); | ||
| 261 | }), | ||
| 262 | ); | ||
| 263 | }); | ||
| 264 | }); | ||
apps/server/src/git/Layers/CursorTextGeneration.ts deleted-283| ... | @@ -1,283 +0,0 @@ | ||
| 1 | import { Effect, Option, Ref, Schema } from "effect"; | ||
| 2 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 3 | |||
| 4 | import { type CursorSettings, type ModelSelection } from "@t3tools/contracts"; | ||
| 5 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | ||
| 6 | |||
| 7 | import { TextGenerationError } from "@t3tools/contracts"; | ||
| 8 | import { | ||
| 9 | type ThreadTitleGenerationResult, | ||
| 10 | type TextGenerationShape, | ||
| 11 | } from "../Services/TextGeneration.ts"; | ||
| 12 | import { | ||
| 13 | buildBranchNamePrompt, | ||
| 14 | buildCommitMessagePrompt, | ||
| 15 | buildPrContentPrompt, | ||
| 16 | buildThreadTitlePrompt, | ||
| 17 | } from "../Prompts.ts"; | ||
| 18 | import { | ||
| 19 | extractJsonObject, | ||
| 20 | sanitizeCommitSubject, | ||
| 21 | sanitizePrTitle, | ||
| 22 | sanitizeThreadTitle, | ||
| 23 | } from "../Utils.ts"; | ||
| 24 | import { | ||
| 25 | applyCursorAcpModelSelection, | ||
| 26 | makeCursorAcpRuntime, | ||
| 27 | } from "../../provider/acp/CursorAcpSupport.ts"; | ||
| 28 | |||
| 29 | const CURSOR_TIMEOUT_MS = 180_000; | ||
| 30 | |||
| 31 | function mapCursorAcpError( | ||
| 32 | operation: | ||
| 33 | | "generateCommitMessage" | ||
| 34 | | "generatePrContent" | ||
| 35 | | "generateBranchName" | ||
| 36 | | "generateThreadTitle", | ||
| 37 | detail: string, | ||
| 38 | cause: unknown, | ||
| 39 | ): TextGenerationError { | ||
| 40 | return new TextGenerationError({ | ||
| 41 | operation, | ||
| 42 | detail, | ||
| 43 | ...(cause !== undefined ? { cause } : {}), | ||
| 44 | }); | ||
| 45 | } | ||
| 46 | |||
| 47 | function isTextGenerationError(error: unknown): error is TextGenerationError { | ||
| 48 | return ( | ||
| 49 | typeof error === "object" && | ||
| 50 | error !== null && | ||
| 51 | "_tag" in error && | ||
| 52 | error._tag === "TextGenerationError" | ||
| 53 | ); | ||
| 54 | } | ||
| 55 | |||
| 56 | /** | ||
| 57 | * Build a Cursor text-generation closure bound to a specific `CursorSettings` | ||
| 58 | * payload. See `makeCodexAdapter` for the overall per-instance rationale. | ||
| 59 | */ | ||
| 60 | export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(function* ( | ||
| 61 | cursorSettings: CursorSettings, | ||
| 62 | environment: NodeJS.ProcessEnv = process.env, | ||
| 63 | ) { | ||
| 64 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 65 | |||
| 66 | const runCursorJson = <S extends Schema.Top>({ | ||
| 67 | operation, | ||
| 68 | cwd, | ||
| 69 | prompt, | ||
| 70 | outputSchemaJson, | ||
| 71 | modelSelection, | ||
| 72 | }: { | ||
| 73 | operation: | ||
| 74 | | "generateCommitMessage" | ||
| 75 | | "generatePrContent" | ||
| 76 | | "generateBranchName" | ||
| 77 | | "generateThreadTitle"; | ||
| 78 | cwd: string; | ||
| 79 | prompt: string; | ||
| 80 | outputSchemaJson: S; | ||
| 81 | modelSelection: ModelSelection; | ||
| 82 | }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> => | ||
| 83 | Effect.gen(function* () { | ||
| 84 | const outputRef = yield* Ref.make(""); | ||
| 85 | const runtime = yield* makeCursorAcpRuntime({ | ||
| 86 | cursorSettings, | ||
| 87 | environment, | ||
| 88 | childProcessSpawner: commandSpawner, | ||
| 89 | cwd, | ||
| 90 | clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, | ||
| 91 | }); | ||
| 92 | |||
| 93 | yield* runtime.handleSessionUpdate((notification) => { | ||
| 94 | const update = notification.update; | ||
| 95 | if (update.sessionUpdate !== "agent_message_chunk") { | ||
| 96 | return Effect.void; | ||
| 97 | } | ||
| 98 | const content = update.content; | ||
| 99 | if (content.type !== "text") { | ||
| 100 | return Effect.void; | ||
| 101 | } | ||
| 102 | return Ref.update(outputRef, (current) => current + content.text); | ||
| 103 | }); | ||
| 104 | |||
| 105 | const promptResult = yield* Effect.gen(function* () { | ||
| 106 | yield* runtime.start(); | ||
| 107 | yield* Effect.ignore(runtime.setMode("ask")); | ||
| 108 | yield* applyCursorAcpModelSelection({ | ||
| 109 | runtime, | ||
| 110 | model: modelSelection.model, | ||
| 111 | selections: modelSelection.options, | ||
| 112 | mapError: ({ cause, configId, step }) => | ||
| 113 | mapCursorAcpError( | ||
| 114 | operation, | ||
| 115 | step === "set-config-option" | ||
| 116 | ? `Failed to set Cursor ACP config option "${configId}" for text generation.` | ||
| 117 | : "Failed to set Cursor ACP base model for text generation.", | ||
| 118 | cause, | ||
| 119 | ), | ||
| 120 | }); | ||
| 121 | |||
| 122 | return yield* runtime.prompt({ | ||
| 123 | prompt: [{ type: "text", text: prompt }], | ||
| 124 | }); | ||
| 125 | }).pipe( | ||
| 126 | Effect.timeoutOption(CURSOR_TIMEOUT_MS), | ||
| 127 | Effect.flatMap( | ||
| 128 | Option.match({ | ||
| 129 | onNone: () => | ||
| 130 | Effect.fail( | ||
| 131 | new TextGenerationError({ | ||
| 132 | operation, | ||
| 133 | detail: "Cursor Agent request timed out.", | ||
| 134 | }), | ||
| 135 | ), | ||
| 136 | onSome: (value) => Effect.succeed(value), | ||
| 137 | }), | ||
| 138 | ), | ||
| 139 | Effect.mapError((cause) => | ||
| 140 | isTextGenerationError(cause) | ||
| 141 | ? cause | ||
| 142 | : mapCursorAcpError(operation, "Cursor ACP request failed.", cause), | ||
| 143 | ), | ||
| 144 | ); | ||
| 145 | |||
| 146 | const rawResult = (yield* Ref.get(outputRef)).trim(); | ||
| 147 | if (!rawResult) { | ||
| 148 | return yield* new TextGenerationError({ | ||
| 149 | operation, | ||
| 150 | detail: | ||
| 151 | promptResult.stopReason === "cancelled" | ||
| 152 | ? "Cursor ACP request was cancelled." | ||
| 153 | : "Cursor Agent returned empty output.", | ||
| 154 | }); | ||
| 155 | } | ||
| 156 | |||
| 157 | return yield* Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson))( | ||
| 158 | extractJsonObject(rawResult), | ||
| 159 | ).pipe( | ||
| 160 | Effect.catchTag("SchemaError", (cause) => | ||
| 161 | Effect.fail( | ||
| 162 | new TextGenerationError({ | ||
| 163 | operation, | ||
| 164 | detail: "Cursor Agent returned invalid structured output.", | ||
| 165 | cause, | ||
| 166 | }), | ||
| 167 | ), | ||
| 168 | ), | ||
| 169 | ); | ||
| 170 | }).pipe( | ||
| 171 | Effect.mapError((cause) => | ||
| 172 | isTextGenerationError(cause) | ||
| 173 | ? cause | ||
| 174 | : mapCursorAcpError(operation, "Cursor ACP text generation failed.", cause), | ||
| 175 | ), | ||
| 176 | Effect.scoped, | ||
| 177 | ); | ||
| 178 | |||
| 179 | const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( | ||
| 180 | "CursorTextGeneration.generateCommitMessage", | ||
| 181 | )(function* (input) { | ||
| 182 | const { prompt, outputSchema } = buildCommitMessagePrompt({ | ||
| 183 | branch: input.branch, | ||
| 184 | stagedSummary: input.stagedSummary, | ||
| 185 | stagedPatch: input.stagedPatch, | ||
| 186 | includeBranch: input.includeBranch === true, | ||
| 187 | }); | ||
| 188 | |||
| 189 | const generated = yield* runCursorJson({ | ||
| 190 | operation: "generateCommitMessage", | ||
| 191 | cwd: input.cwd, | ||
| 192 | prompt, | ||
| 193 | outputSchemaJson: outputSchema, | ||
| 194 | modelSelection: input.modelSelection, | ||
| 195 | }); | ||
| 196 | |||
| 197 | return { | ||
| 198 | subject: sanitizeCommitSubject(generated.subject), | ||
| 199 | body: generated.body.trim(), | ||
| 200 | ...("branch" in generated && typeof generated.branch === "string" | ||
| 201 | ? { branch: sanitizeFeatureBranchName(generated.branch) } | ||
| 202 | : {}), | ||
| 203 | }; | ||
| 204 | }); | ||
| 205 | |||
| 206 | const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( | ||
| 207 | "CursorTextGeneration.generatePrContent", | ||
| 208 | )(function* (input) { | ||
| 209 | const { prompt, outputSchema } = buildPrContentPrompt({ | ||
| 210 | baseBranch: input.baseBranch, | ||
| 211 | headBranch: input.headBranch, | ||
| 212 | commitSummary: input.commitSummary, | ||
| 213 | diffSummary: input.diffSummary, | ||
| 214 | diffPatch: input.diffPatch, | ||
| 215 | }); | ||
| 216 | |||
| 217 | const generated = yield* runCursorJson({ | ||
| 218 | operation: "generatePrContent", | ||
| 219 | cwd: input.cwd, | ||
| 220 | prompt, | ||
| 221 | outputSchemaJson: outputSchema, | ||
| 222 | modelSelection: input.modelSelection, | ||
| 223 | }); | ||
| 224 | |||
| 225 | return { | ||
| 226 | title: sanitizePrTitle(generated.title), | ||
| 227 | body: generated.body.trim(), | ||
| 228 | }; | ||
| 229 | }); | ||
| 230 | |||
| 231 | const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( | ||
| 232 | "CursorTextGeneration.generateBranchName", | ||
| 233 | )(function* (input) { | ||
| 234 | const { prompt, outputSchema } = buildBranchNamePrompt({ | ||
| 235 | message: input.message, | ||
| 236 | attachments: input.attachments, | ||
| 237 | }); | ||
| 238 | |||
| 239 | const generated = yield* runCursorJson({ | ||
| 240 | operation: "generateBranchName", | ||
| 241 | cwd: input.cwd, | ||
| 242 | prompt, | ||
| 243 | outputSchemaJson: outputSchema, | ||
| 244 | modelSelection: input.modelSelection, | ||
| 245 | }); | ||
| 246 | |||
| 247 | return { | ||
| 248 | branch: sanitizeBranchFragment(generated.branch), | ||
| 249 | }; | ||
| 250 | }); | ||
| 251 | |||
| 252 | const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( | ||
| 253 | "CursorTextGeneration.generateThreadTitle", | ||
| 254 | )(function* (input) { | ||
| 255 | const { prompt, outputSchema } = buildThreadTitlePrompt({ | ||
| 256 | message: input.message, | ||
| 257 | attachments: input.attachments, | ||
| 258 | }); | ||
| 259 | |||
| 260 | const generated = yield* runCursorJson({ | ||
| 261 | operation: "generateThreadTitle", | ||
| 262 | cwd: input.cwd, | ||
| 263 | prompt, | ||
| 264 | outputSchemaJson: outputSchema, | ||
| 265 | modelSelection: input.modelSelection, | ||
| 266 | }); | ||
| 267 | |||
| 268 | return { | ||
| 269 | title: sanitizeThreadTitle(generated.title), | ||
| 270 | } satisfies ThreadTitleGenerationResult; | ||
| 271 | }); | ||
| 272 | |||
| 273 | return { | ||
| 274 | generateCommitMessage, | ||
| 275 | generatePrContent, | ||
| 276 | generateBranchName, | ||
| 277 | generateThreadTitle, | ||
| 278 | } satisfies TextGenerationShape; | ||
| 279 | }); | ||
| 280 | |||
| 281 | // NOTE: `CursorTextGenerationLive` (the singleton Layer) has been removed. | ||
| 282 | // `makeCursorTextGeneration(cursorConfig)` is now invoked directly by | ||
| 283 | // `CursorDriver.create()` so each provider instance owns its own closure. | ||
apps/server/src/git/Layers/GitCore.test.ts deleted-2333| ... | @@ -1,2333 +0,0 @@ | ||
| 1 | import { existsSync } from "node:fs"; | ||
| 2 | import path from "node:path"; | ||
| 3 | |||
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 5 | import { it } from "@effect/vitest"; | ||
| 6 | import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; | ||
| 7 | import { describe, expect, vi } from "vitest"; | ||
| 8 | |||
| 9 | import { GitCoreLive, makeGitCore } from "./GitCore.ts"; | ||
| 10 | import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; | ||
| 11 | import { GitCommandError } from "@t3tools/contracts"; | ||
| 12 | import { type ProcessRunResult, runProcess } from "../../processRunner.ts"; | ||
| 13 | import { ServerConfig } from "../../config.ts"; | ||
| 14 | |||
| 15 | // ── Helpers ── | ||
| 16 | |||
| 17 | const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-core-test-" }); | ||
| 18 | const GitCoreTestLayer = GitCoreLive.pipe( | ||
| 19 | Layer.provide(ServerConfigLayer), | ||
| 20 | Layer.provide(NodeServices.layer), | ||
| 21 | ); | ||
| 22 | const TestLayer = Layer.mergeAll(NodeServices.layer, GitCoreTestLayer); | ||
| 23 | |||
| 24 | function makeTmpDir( | ||
| 25 | prefix = "git-test-", | ||
| 26 | ): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> { | ||
| 27 | return Effect.gen(function* () { | ||
| 28 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 29 | return yield* fileSystem.makeTempDirectoryScoped({ prefix }); | ||
| 30 | }); | ||
| 31 | } | ||
| 32 | |||
| 33 | function writeTextFile( | ||
| 34 | filePath: string, | ||
| 35 | contents: string, | ||
| 36 | ): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> { | ||
| 37 | return Effect.gen(function* () { | ||
| 38 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 39 | yield* fileSystem.writeFileString(filePath, contents); | ||
| 40 | }); | ||
| 41 | } | ||
| 42 | |||
| 43 | function removePath( | ||
| 44 | targetPath: string, | ||
| 45 | ): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> { | ||
| 46 | return Effect.gen(function* () { | ||
| 47 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 48 | yield* fileSystem.remove(targetPath, { recursive: true, force: true }); | ||
| 49 | }); | ||
| 50 | } | ||
| 51 | |||
| 52 | function makeDirectory( | ||
| 53 | dirPath: string, | ||
| 54 | ): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> { | ||
| 55 | return Effect.gen(function* () { | ||
| 56 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 57 | yield* fileSystem.makeDirectory(dirPath, { recursive: true }); | ||
| 58 | }); | ||
| 59 | } | ||
| 60 | |||
| 61 | /** Run a raw git command for test setup (not under test). */ | ||
| 62 | function git( | ||
| 63 | cwd: string, | ||
| 64 | args: ReadonlyArray<string>, | ||
| 65 | env?: NodeJS.ProcessEnv, | ||
| 66 | ): Effect.Effect<string, GitCommandError, GitCore> { | ||
| 67 | return Effect.gen(function* () { | ||
| 68 | const gitCore = yield* GitCore; | ||
| 69 | const result = yield* gitCore.execute({ | ||
| 70 | operation: "GitCore.test.git", | ||
| 71 | cwd, | ||
| 72 | args, | ||
| 73 | ...(env ? { env } : {}), | ||
| 74 | timeoutMs: 10_000, | ||
| 75 | }); | ||
| 76 | return result.stdout.trim(); | ||
| 77 | }); | ||
| 78 | } | ||
| 79 | |||
| 80 | function configureRemote( | ||
| 81 | cwd: string, | ||
| 82 | remoteName: string, | ||
| 83 | remotePath: string, | ||
| 84 | fetchNamespace: string, | ||
| 85 | ): Effect.Effect<string, GitCommandError, GitCore> { | ||
| 86 | return Effect.gen(function* () { | ||
| 87 | yield* git(cwd, ["config", `remote.${remoteName}.url`, remotePath]); | ||
| 88 | return yield* git(cwd, [ | ||
| 89 | "config", | ||
| 90 | "--replace-all", | ||
| 91 | `remote.${remoteName}.fetch`, | ||
| 92 | `+refs/heads/*:refs/remotes/${fetchNamespace}/*`, | ||
| 93 | ]); | ||
| 94 | }); | ||
| 95 | } | ||
| 96 | |||
| 97 | function runShellCommand(input: { | ||
| 98 | command: string; | ||
| 99 | cwd: string; | ||
| 100 | timeoutMs?: number; | ||
| 101 | maxOutputBytes?: number; | ||
| 102 | }): Effect.Effect<ProcessRunResult, Error> { | ||
| 103 | return Effect.promise(() => { | ||
| 104 | const shellPath = | ||
| 105 | process.platform === "win32" | ||
| 106 | ? (process.env.ComSpec ?? "cmd.exe") | ||
| 107 | : (process.env.SHELL ?? "/bin/sh"); | ||
| 108 | |||
| 109 | const args = | ||
| 110 | process.platform === "win32" ? ["/d", "/s", "/c", input.command] : ["-lc", input.command]; | ||
| 111 | |||
| 112 | return runProcess(shellPath, args, { | ||
| 113 | cwd: input.cwd, | ||
| 114 | timeoutMs: input.timeoutMs ?? 30_000, | ||
| 115 | allowNonZeroExit: true, | ||
| 116 | maxBufferBytes: input.maxOutputBytes ?? 1_000_000, | ||
| 117 | outputMode: "truncate", | ||
| 118 | }); | ||
| 119 | }); | ||
| 120 | } | ||
| 121 | |||
| 122 | const makeIsolatedGitCore = (executeOverride: GitCoreShape["execute"]) => | ||
| 123 | makeGitCore({ executeOverride }).pipe( | ||
| 124 | Effect.provide(Layer.provideMerge(ServerConfigLayer, NodeServices.layer)), | ||
| 125 | ); | ||
| 126 | |||
| 127 | /** Create a repo with an initial commit so branches work. */ | ||
| 128 | function initRepoWithCommit( | ||
| 129 | cwd: string, | ||
| 130 | ): Effect.Effect< | ||
| 131 | { initialBranch: string }, | ||
| 132 | GitCommandError | PlatformError.PlatformError, | ||
| 133 | GitCore | FileSystem.FileSystem | ||
| 134 | > { | ||
| 135 | return Effect.gen(function* () { | ||
| 136 | const core = yield* GitCore; | ||
| 137 | yield* core.initRepo({ cwd }); | ||
| 138 | yield* git(cwd, ["config", "user.email", "test@test.com"]); | ||
| 139 | yield* git(cwd, ["config", "user.name", "Test"]); | ||
| 140 | yield* writeTextFile(path.join(cwd, "README.md"), "# test\n"); | ||
| 141 | yield* git(cwd, ["add", "."]); | ||
| 142 | yield* git(cwd, ["commit", "-m", "initial commit"]); | ||
| 143 | const initialBranch = yield* git(cwd, ["branch", "--show-current"]); | ||
| 144 | return { initialBranch }; | ||
| 145 | }); | ||
| 146 | } | ||
| 147 | |||
| 148 | function commitWithDate( | ||
| 149 | cwd: string, | ||
| 150 | fileName: string, | ||
| 151 | fileContents: string, | ||
| 152 | dateIsoString: string, | ||
| 153 | message: string, | ||
| 154 | ): Effect.Effect< | ||
| 155 | void, | ||
| 156 | GitCommandError | PlatformError.PlatformError, | ||
| 157 | GitCore | FileSystem.FileSystem | ||
| 158 | > { | ||
| 159 | return Effect.gen(function* () { | ||
| 160 | yield* writeTextFile(path.join(cwd, fileName), fileContents); | ||
| 161 | yield* git(cwd, ["add", fileName]); | ||
| 162 | yield* git(cwd, ["commit", "-m", message], { | ||
| 163 | ...process.env, | ||
| 164 | GIT_AUTHOR_DATE: dateIsoString, | ||
| 165 | GIT_COMMITTER_DATE: dateIsoString, | ||
| 166 | }); | ||
| 167 | }); | ||
| 168 | } | ||
| 169 | |||
| 170 | function buildLargeText(lineCount = 20_000): string { | ||
| 171 | return Array.from({ length: lineCount }, (_, index) => `line ${String(index).padStart(5, "0")}`) | ||
| 172 | .join("\n") | ||
| 173 | .concat("\n"); | ||
| 174 | } | ||
| 175 | |||
| 176 | function splitNullSeparatedPaths(input: string): string[] { | ||
| 177 | return input | ||
| 178 | .split("\0") | ||
| 179 | .map((value) => value.trim()) | ||
| 180 | .filter((value) => value.length > 0); | ||
| 181 | } | ||
| 182 | |||
| 183 | // ── Tests ── | ||
| 184 | |||
| 185 | it.layer(TestLayer)("git integration", (it) => { | ||
| 186 | describe("shell process execution", () => { | ||
| 187 | it.effect("caps captured output when maxOutputBytes is exceeded", () => | ||
| 188 | Effect.gen(function* () { | ||
| 189 | const result = yield* runShellCommand({ | ||
| 190 | command: `node -e "process.stdout.write('x'.repeat(2000))"`, | ||
| 191 | cwd: process.cwd(), | ||
| 192 | timeoutMs: 10_000, | ||
| 193 | maxOutputBytes: 128, | ||
| 194 | }); | ||
| 195 | |||
| 196 | expect(result.code).toBe(0); | ||
| 197 | expect(result.stdout.length).toBeLessThanOrEqual(128); | ||
| 198 | expect(result.stdoutTruncated || result.stderrTruncated).toBe(true); | ||
| 199 | }), | ||
| 200 | ); | ||
| 201 | }); | ||
| 202 | |||
| 203 | // ── initGitRepo ── | ||
| 204 | |||
| 205 | describe("initGitRepo", () => { | ||
| 206 | it.effect("creates a valid git repo", () => | ||
| 207 | Effect.gen(function* () { | ||
| 208 | const tmp = yield* makeTmpDir(); | ||
| 209 | yield* (yield* GitCore).initRepo({ cwd: tmp }); | ||
| 210 | expect(existsSync(path.join(tmp, ".git"))).toBe(true); | ||
| 211 | }), | ||
| 212 | ); | ||
| 213 | |||
| 214 | it.effect("listGitBranches reports isRepo: true after init + commit", () => | ||
| 215 | Effect.gen(function* () { | ||
| 216 | const tmp = yield* makeTmpDir(); | ||
| 217 | yield* initRepoWithCommit(tmp); | ||
| 218 | const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); | ||
| 219 | expect(result.isRepo).toBe(true); | ||
| 220 | expect(result.hasOriginRemote).toBe(false); | ||
| 221 | expect(result.branches.length).toBeGreaterThanOrEqual(1); | ||
| 222 | }), | ||
| 223 | ); | ||
| 224 | }); | ||
| 225 | |||
| 226 | describe("workspace helpers", () => { | ||
| 227 | it.effect("filterIgnoredPaths chunks large path lists and preserves kept paths", () => | ||
| 228 | Effect.gen(function* () { | ||
| 229 | const cwd = "/virtual/repo"; | ||
| 230 | const relativePaths = Array.from({ length: 340 }, (_, index) => { | ||
| 231 | const prefix = index % 3 === 0 ? "ignored" : "kept"; | ||
| 232 | return `${prefix}/segment-${String(index).padStart(4, "0")}/${"x".repeat(900)}.ts`; | ||
| 233 | }); | ||
| 234 | const expectedPaths = relativePaths.filter( | ||
| 235 | (relativePath) => !relativePath.startsWith("ignored/"), | ||
| 236 | ); | ||
| 237 | |||
| 238 | const seenChunks: string[][] = []; | ||
| 239 | const core = yield* makeIsolatedGitCore((input) => { | ||
| 240 | if ( | ||
| 241 | input.args.join(" ") !== | ||
| 242 | "-c core.fsmonitor=false -c core.untrackedCache=false check-ignore --no-index -z --stdin" | ||
| 243 | ) { | ||
| 244 | return Effect.fail( | ||
| 245 | new GitCommandError({ | ||
| 246 | operation: input.operation, | ||
| 247 | command: `git ${input.args.join(" ")}`, | ||
| 248 | cwd: input.cwd, | ||
| 249 | detail: "unexpected git command in chunking test", | ||
| 250 | }), | ||
| 251 | ); | ||
| 252 | } | ||
| 253 | |||
| 254 | const chunkPaths = splitNullSeparatedPaths(input.stdin ?? ""); | ||
| 255 | seenChunks.push(chunkPaths); | ||
| 256 | const ignoredPaths = chunkPaths.filter((relativePath) => | ||
| 257 | relativePath.startsWith("ignored/"), | ||
| 258 | ); | ||
| 259 | |||
| 260 | return Effect.succeed({ | ||
| 261 | code: ignoredPaths.length > 0 ? 0 : 1, | ||
| 262 | stdout: ignoredPaths.length > 0 ? `${ignoredPaths.join("\0")}\0` : "", | ||
| 263 | stderr: "", | ||
| 264 | stdoutTruncated: false, | ||
| 265 | stderrTruncated: false, | ||
| 266 | }); | ||
| 267 | }); | ||
| 268 | |||
| 269 | const result = yield* core.filterIgnoredPaths(cwd, relativePaths); | ||
| 270 | |||
| 271 | expect(seenChunks.length).toBeGreaterThan(1); | ||
| 272 | expect(seenChunks.flat()).toEqual(relativePaths); | ||
| 273 | expect(result).toEqual(expectedPaths); | ||
| 274 | }), | ||
| 275 | ); | ||
| 276 | |||
| 277 | it.effect("listWorkspaceFiles disables fsmonitor and untracked cache helpers", () => | ||
| 278 | Effect.gen(function* () { | ||
| 279 | const core = yield* makeIsolatedGitCore((input) => { | ||
| 280 | expect(input.args).toEqual([ | ||
| 281 | "-c", | ||
| 282 | "core.fsmonitor=false", | ||
| 283 | "-c", | ||
| 284 | "core.untrackedCache=false", | ||
| 285 | "ls-files", | ||
| 286 | "--cached", | ||
| 287 | "--others", | ||
| 288 | "--exclude-standard", | ||
| 289 | "-z", | ||
| 290 | ]); | ||
| 291 | return Effect.succeed({ | ||
| 292 | code: 0, | ||
| 293 | stdout: "src/index.ts\0README.md\0", | ||
| 294 | stderr: "", | ||
| 295 | stdoutTruncated: false, | ||
| 296 | stderrTruncated: false, | ||
| 297 | }); | ||
| 298 | }); | ||
| 299 | |||
| 300 | const result = yield* core.listWorkspaceFiles("/virtual/repo"); | ||
| 301 | expect(result.paths).toEqual(["src/index.ts", "README.md"]); | ||
| 302 | expect(result.truncated).toBe(false); | ||
| 303 | }), | ||
| 304 | ); | ||
| 305 | }); | ||
| 306 | |||
| 307 | // ── listGitBranches ── | ||
| 308 | |||
| 309 | describe("listGitBranches", () => { | ||
| 310 | it.effect("returns isRepo: false for non-git directory", () => | ||
| 311 | Effect.gen(function* () { | ||
| 312 | const tmp = yield* makeTmpDir(); | ||
| 313 | const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); | ||
| 314 | expect(result.isRepo).toBe(false); | ||
| 315 | expect(result.hasOriginRemote).toBe(false); | ||
| 316 | expect(result.branches).toEqual([]); | ||
| 317 | }), | ||
| 318 | ); | ||
| 319 | |||
| 320 | it.effect("returns isRepo: false for deleted directories", () => | ||
| 321 | Effect.gen(function* () { | ||
| 322 | const tmp = yield* makeTmpDir(); | ||
| 323 | const deletedDir = path.join(tmp, "deleted-repo"); | ||
| 324 | yield* makeDirectory(deletedDir); | ||
| 325 | yield* removePath(deletedDir); | ||
| 326 | |||
| 327 | const result = yield* (yield* GitCore).listBranches({ cwd: deletedDir }); | ||
| 328 | |||
| 329 | expect(result.isRepo).toBe(false); | ||
| 330 | expect(result.hasOriginRemote).toBe(false); | ||
| 331 | expect(result.branches).toEqual([]); | ||
| 332 | }), | ||
| 333 | ); | ||
| 334 | |||
| 335 | it.effect("returns the current branch with current: true", () => | ||
| 336 | Effect.gen(function* () { | ||
| 337 | const tmp = yield* makeTmpDir(); | ||
| 338 | yield* initRepoWithCommit(tmp); | ||
| 339 | const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); | ||
| 340 | const current = result.branches.find((b) => b.current); | ||
| 341 | expect(current).toBeDefined(); | ||
| 342 | expect(current!.current).toBe(true); | ||
| 343 | }), | ||
| 344 | ); | ||
| 345 | |||
| 346 | it.effect("does not include detached HEAD pseudo-refs as branches", () => | ||
| 347 | Effect.gen(function* () { | ||
| 348 | const tmp = yield* makeTmpDir(); | ||
| 349 | yield* initRepoWithCommit(tmp); | ||
| 350 | yield* git(tmp, ["checkout", "--detach", "HEAD"]); | ||
| 351 | |||
| 352 | const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); | ||
| 353 | expect(result.branches.some((branch) => branch.name.startsWith("("))).toBe(false); | ||
| 354 | expect(result.branches.some((branch) => branch.current)).toBe(false); | ||
| 355 | }), | ||
| 356 | ); | ||
| 357 | |||
| 358 | it.effect("keeps current branch first and sorts the remaining branches by recency", () => | ||
| 359 | Effect.gen(function* () { | ||
| 360 | const tmp = yield* makeTmpDir(); | ||
| 361 | yield* initRepoWithCommit(tmp); | ||
| 362 | const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( | ||
| 363 | (branch) => branch.current, | ||
| 364 | )!.name; | ||
| 365 | |||
| 366 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "older-branch" }); | ||
| 367 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "older-branch" }); | ||
| 368 | yield* commitWithDate( | ||
| 369 | tmp, | ||
| 370 | "older.txt", | ||
| 371 | "older branch change\n", | ||
| 372 | "Thu, 1 Jan 2037 00:00:00 +0000", | ||
| 373 | "older branch change", | ||
| 374 | ); | ||
| 375 | |||
| 376 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: initialBranch }); | ||
| 377 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "newer-branch" }); | ||
| 378 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "newer-branch" }); | ||
| 379 | yield* commitWithDate( | ||
| 380 | tmp, | ||
| 381 | "newer.txt", | ||
| 382 | "newer branch change\n", | ||
| 383 | "Fri, 1 Jan 2038 00:00:00 +0000", | ||
| 384 | "newer branch change", | ||
| 385 | ); | ||
| 386 | |||
| 387 | // Switch away to show current branch is pinned, then remaining branches are recency-sorted. | ||
| 388 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "older-branch" }); | ||
| 389 | |||
| 390 | const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); | ||
| 391 | expect(result.branches[0]!.name).toBe("older-branch"); | ||
| 392 | expect(result.branches[1]!.name).toBe("newer-branch"); | ||
| 393 | }), | ||
| 394 | ); | ||
| 395 | |||
| 396 | it.effect("keeps default branch right after current branch", () => | ||
| 397 | Effect.gen(function* () { | ||
| 398 | const tmp = yield* makeTmpDir(); | ||
| 399 | const remote = yield* makeTmpDir(); | ||
| 400 | yield* initRepoWithCommit(tmp); | ||
| 401 | const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( | ||
| 402 | (branch) => branch.current, | ||
| 403 | )!.name; | ||
| 404 | |||
| 405 | yield* git(remote, ["init", "--bare"]); | ||
| 406 | yield* git(tmp, ["remote", "add", "origin", remote]); | ||
| 407 | yield* git(tmp, ["push", "-u", "origin", defaultBranch]); | ||
| 408 | yield* git(tmp, ["remote", "set-head", "origin", defaultBranch]); | ||
| 409 | |||
| 410 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "current-branch" }); | ||
| 411 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "current-branch" }); | ||
| 412 | yield* commitWithDate( | ||
| 413 | tmp, | ||
| 414 | "current.txt", | ||
| 415 | "current change\n", | ||
| 416 | "Thu, 1 Jan 2037 00:00:00 +0000", | ||
| 417 | "current change", | ||
| 418 | ); | ||
| 419 | |||
| 420 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch }); | ||
| 421 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "newer-branch" }); | ||
| 422 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "newer-branch" }); | ||
| 423 | yield* commitWithDate( | ||
| 424 | tmp, | ||
| 425 | "newer.txt", | ||
| 426 | "newer change\n", | ||
| 427 | "Fri, 1 Jan 2038 00:00:00 +0000", | ||
| 428 | "newer change", | ||
| 429 | ); | ||
| 430 | |||
| 431 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "current-branch" }); | ||
| 432 | |||
| 433 | const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); | ||
| 434 | expect(result.branches[0]!.name).toBe("current-branch"); | ||
| 435 | expect(result.branches[1]!.name).toBe(defaultBranch); | ||
| 436 | expect(result.branches[2]!.name).toBe("newer-branch"); | ||
| 437 | }), | ||
| 438 | ); | ||
| 439 | |||
| 440 | it.effect("lists multiple branches after creating them", () => | ||
| 441 | Effect.gen(function* () { | ||
| 442 | const tmp = yield* makeTmpDir(); | ||
| 443 | yield* initRepoWithCommit(tmp); | ||
| 444 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-a" }); | ||
| 445 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-b" }); | ||
| 446 | |||
| 447 | const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); | ||
| 448 | const names = result.branches.map((b) => b.name); | ||
| 449 | expect(names).toContain("feature-a"); | ||
| 450 | expect(names).toContain("feature-b"); | ||
| 451 | }), | ||
| 452 | ); | ||
| 453 | |||
| 454 | it.effect("paginates branch results and returns paging metadata", () => | ||
| 455 | Effect.gen(function* () { | ||
| 456 | const tmp = yield* makeTmpDir(); | ||
| 457 | const { initialBranch } = yield* initRepoWithCommit(tmp); | ||
| 458 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-a" }); | ||
| 459 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-b" }); | ||
| 460 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-c" }); | ||
| 461 | |||
| 462 | const firstPage = yield* (yield* GitCore).listBranches({ cwd: tmp, limit: 2 }); | ||
| 463 | expect(firstPage.totalCount).toBe(4); | ||
| 464 | expect(firstPage.nextCursor).toBe(2); | ||
| 465 | expect(firstPage.branches.map((branch) => branch.name)).toEqual([ | ||
| 466 | initialBranch, | ||
| 467 | "feature-a", | ||
| 468 | ]); | ||
| 469 | |||
| 470 | const secondPage = yield* (yield* GitCore).listBranches({ | ||
| 471 | cwd: tmp, | ||
| 472 | cursor: firstPage.nextCursor ?? 0, | ||
| 473 | limit: 2, | ||
| 474 | }); | ||
| 475 | expect(secondPage.totalCount).toBe(4); | ||
| 476 | expect(secondPage.nextCursor).toBeNull(); | ||
| 477 | expect(secondPage.branches.map((branch) => branch.name)).toEqual([ | ||
| 478 | "feature-b", | ||
| 479 | "feature-c", | ||
| 480 | ]); | ||
| 481 | }), | ||
| 482 | ); | ||
| 483 | |||
| 484 | it.effect("parses separate branch names when column.ui is always enabled", () => | ||
| 485 | Effect.gen(function* () { | ||
| 486 | const tmp = yield* makeTmpDir(); | ||
| 487 | const { initialBranch } = yield* initRepoWithCommit(tmp); | ||
| 488 | const createdBranchNames = [ | ||
| 489 | "go-bin", | ||
| 490 | "copilot/rewrite-cli-in-go", | ||
| 491 | "copilot/rewrite-cli-in-rust", | ||
| 492 | ] as const; | ||
| 493 | for (const branchName of createdBranchNames) { | ||
| 494 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: branchName }); | ||
| 495 | } | ||
| 496 | yield* git(tmp, ["config", "column.ui", "always"]); | ||
| 497 | |||
| 498 | const rawBranchOutput = yield* git(tmp, ["branch", "--no-color"], { | ||
| 499 | ...process.env, | ||
| 500 | COLUMNS: "120", | ||
| 501 | }); | ||
| 502 | expect( | ||
| 503 | rawBranchOutput | ||
| 504 | .split("\n") | ||
| 505 | .some( | ||
| 506 | (line) => | ||
| 507 | createdBranchNames.filter((branchName) => line.includes(branchName)).length >= 2, | ||
| 508 | ), | ||
| 509 | ).toBe(true); | ||
| 510 | |||
| 511 | const realGitCore = yield* GitCore; | ||
| 512 | const core = yield* makeIsolatedGitCore((input) => | ||
| 513 | realGitCore.execute( | ||
| 514 | input.args[0] === "branch" | ||
| 515 | ? { | ||
| 516 | ...input, | ||
| 517 | env: { ...input.env, COLUMNS: "120" }, | ||
| 518 | } | ||
| 519 | : input, | ||
| 520 | ), | ||
| 521 | ); | ||
| 522 | |||
| 523 | const result = yield* core.listBranches({ cwd: tmp }); | ||
| 524 | const localBranchNames = result.branches | ||
| 525 | .filter((branch) => !branch.isRemote) | ||
| 526 | .map((branch) => branch.name); | ||
| 527 | |||
| 528 | expect(localBranchNames).toHaveLength(4); | ||
| 529 | expect(localBranchNames).toEqual( | ||
| 530 | expect.arrayContaining([initialBranch, ...createdBranchNames]), | ||
| 531 | ); | ||
| 532 | expect( | ||
| 533 | localBranchNames.some( | ||
| 534 | (branchName) => | ||
| 535 | createdBranchNames.filter((createdBranch) => branchName.includes(createdBranch)) | ||
| 536 | .length >= 2, | ||
| 537 | ), | ||
| 538 | ).toBe(false); | ||
| 539 | }), | ||
| 540 | ); | ||
| 541 | |||
| 542 | it.effect("isDefault is false when no remote exists", () => | ||
| 543 | Effect.gen(function* () { | ||
| 544 | const tmp = yield* makeTmpDir(); | ||
| 545 | yield* initRepoWithCommit(tmp); | ||
| 546 | const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); | ||
| 547 | expect(result.branches.every((b) => b.isDefault === false)).toBe(true); | ||
| 548 | }), | ||
| 549 | ); | ||
| 550 | |||
| 551 | it.effect("lists local branches first and remote branches last", () => | ||
| 552 | Effect.gen(function* () { | ||
| 553 | const remote = yield* makeTmpDir(); | ||
| 554 | const tmp = yield* makeTmpDir(); | ||
| 555 | |||
| 556 | yield* git(remote, ["init", "--bare"]); | ||
| 557 | yield* initRepoWithCommit(tmp); | ||
| 558 | const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( | ||
| 559 | (branch) => branch.current, | ||
| 560 | )!.name; | ||
| 561 | |||
| 562 | yield* git(tmp, ["remote", "add", "origin", remote]); | ||
| 563 | yield* git(tmp, ["push", "-u", "origin", defaultBranch]); | ||
| 564 | |||
| 565 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/local-only" }); | ||
| 566 | |||
| 567 | const remoteOnlyBranch = "feature/remote-only"; | ||
| 568 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch }); | ||
| 569 | yield* git(tmp, ["checkout", "-b", remoteOnlyBranch]); | ||
| 570 | yield* git(tmp, ["push", "-u", "origin", remoteOnlyBranch]); | ||
| 571 | yield* git(tmp, ["checkout", defaultBranch]); | ||
| 572 | yield* git(tmp, ["branch", "-D", remoteOnlyBranch]); | ||
| 573 | |||
| 574 | const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); | ||
| 575 | const firstRemoteIndex = result.branches.findIndex((branch) => branch.isRemote); | ||
| 576 | |||
| 577 | expect(result.hasOriginRemote).toBe(true); | ||
| 578 | expect(firstRemoteIndex).toBeGreaterThan(0); | ||
| 579 | expect(result.branches.slice(0, firstRemoteIndex).every((branch) => !branch.isRemote)).toBe( | ||
| 580 | true, | ||
| 581 | ); | ||
| 582 | expect(result.branches.slice(firstRemoteIndex).every((branch) => branch.isRemote)).toBe( | ||
| 583 | true, | ||
| 584 | ); | ||
| 585 | expect( | ||
| 586 | result.branches.some( | ||
| 587 | (branch) => branch.name === "feature/local-only" && !branch.isRemote, | ||
| 588 | ), | ||
| 589 | ).toBe(true); | ||
| 590 | expect( | ||
| 591 | result.branches.some( | ||
| 592 | (branch) => branch.name === "origin/feature/remote-only" && branch.isRemote, | ||
| 593 | ), | ||
| 594 | ).toBe(true); | ||
| 595 | }), | ||
| 596 | ); | ||
| 597 | |||
| 598 | it.effect("includes remoteName metadata for remotes with slash in the name", () => | ||
| 599 | Effect.gen(function* () { | ||
| 600 | const remote = yield* makeTmpDir(); | ||
| 601 | const tmp = yield* makeTmpDir(); | ||
| 602 | const remoteName = "my-org/upstream"; | ||
| 603 | |||
| 604 | yield* git(remote, ["init", "--bare"]); | ||
| 605 | yield* initRepoWithCommit(tmp); | ||
| 606 | const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( | ||
| 607 | (branch) => branch.current, | ||
| 608 | )!.name; | ||
| 609 | |||
| 610 | yield* git(tmp, ["remote", "add", remoteName, remote]); | ||
| 611 | yield* git(tmp, ["push", "-u", remoteName, defaultBranch]); | ||
| 612 | |||
| 613 | const remoteOnlyBranch = "feature/remote-with-remote-name"; | ||
| 614 | yield* git(tmp, ["checkout", "-b", remoteOnlyBranch]); | ||
| 615 | yield* git(tmp, ["push", "-u", remoteName, remoteOnlyBranch]); | ||
| 616 | yield* git(tmp, ["checkout", defaultBranch]); | ||
| 617 | yield* git(tmp, ["branch", "-D", remoteOnlyBranch]); | ||
| 618 | |||
| 619 | const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); | ||
| 620 | const remoteBranch = result.branches.find( | ||
| 621 | (branch) => branch.name === `${remoteName}/${remoteOnlyBranch}`, | ||
| 622 | ); | ||
| 623 | |||
| 624 | expect(remoteBranch).toBeDefined(); | ||
| 625 | expect(remoteBranch?.isRemote).toBe(true); | ||
| 626 | expect(remoteBranch?.remoteName).toBe(remoteName); | ||
| 627 | }), | ||
| 628 | ); | ||
| 629 | |||
| 630 | it.effect( | ||
| 631 | "filters branch queries before pagination and dedupes origin refs with local matches", | ||
| 632 | () => | ||
| 633 | Effect.gen(function* () { | ||
| 634 | const remote = yield* makeTmpDir(); | ||
| 635 | const tmp = yield* makeTmpDir(); | ||
| 636 | |||
| 637 | yield* git(remote, ["init", "--bare"]); | ||
| 638 | const { initialBranch } = yield* initRepoWithCommit(tmp); | ||
| 639 | yield* git(tmp, ["remote", "add", "origin", remote]); | ||
| 640 | yield* git(tmp, ["push", "-u", "origin", initialBranch]); | ||
| 641 | |||
| 642 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/demo" }); | ||
| 643 | yield* git(tmp, ["push", "-u", "origin", "feature/demo"]); | ||
| 644 | |||
| 645 | yield* git(tmp, ["checkout", "-b", "feature/remote-only"]); | ||
| 646 | yield* git(tmp, ["push", "-u", "origin", "feature/remote-only"]); | ||
| 647 | yield* git(tmp, ["checkout", initialBranch]); | ||
| 648 | yield* git(tmp, ["branch", "-D", "feature/remote-only"]); | ||
| 649 | |||
| 650 | const result = yield* (yield* GitCore).listBranches({ | ||
| 651 | cwd: tmp, | ||
| 652 | query: "feature/", | ||
| 653 | limit: 10, | ||
| 654 | }); | ||
| 655 | |||
| 656 | expect(result.totalCount).toBe(2); | ||
| 657 | expect(result.nextCursor).toBeNull(); | ||
| 658 | expect(result.branches.map((branch) => branch.name)).toEqual([ | ||
| 659 | "feature/demo", | ||
| 660 | "origin/feature/remote-only", | ||
| 661 | ]); | ||
| 662 | }), | ||
| 663 | ); | ||
| 664 | }); | ||
| 665 | |||
| 666 | // ── checkoutGitBranch ── | ||
| 667 | |||
| 668 | describe("checkoutGitBranch", () => { | ||
| 669 | it.effect("checks out an existing branch", () => | ||
| 670 | Effect.gen(function* () { | ||
| 671 | const tmp = yield* makeTmpDir(); | ||
| 672 | yield* initRepoWithCommit(tmp); | ||
| 673 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature" }); | ||
| 674 | |||
| 675 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature" }); | ||
| 676 | |||
| 677 | const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); | ||
| 678 | const current = result.branches.find((b) => b.current); | ||
| 679 | expect(current!.name).toBe("feature"); | ||
| 680 | }), | ||
| 681 | ); | ||
| 682 | |||
| 683 | it.effect("refreshes upstream behind count after checkout when remote branch advanced", () => | ||
| 684 | Effect.gen(function* () { | ||
| 685 | const context = yield* Effect.context<never>(); | ||
| 686 | const runPromise = Effect.runPromiseWith(context); | ||
| 687 | |||
| 688 | const remote = yield* makeTmpDir(); | ||
| 689 | const source = yield* makeTmpDir(); | ||
| 690 | const clone = yield* makeTmpDir(); | ||
| 691 | yield* git(remote, ["init", "--bare"]); | ||
| 692 | |||
| 693 | yield* initRepoWithCommit(source); | ||
| 694 | const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( | ||
| 695 | (branch) => branch.current, | ||
| 696 | )!.name; | ||
| 697 | yield* git(source, ["remote", "add", "origin", remote]); | ||
| 698 | yield* git(source, ["push", "-u", "origin", defaultBranch]); | ||
| 699 | |||
| 700 | const featureBranch = "feature-behind"; | ||
| 701 | yield* (yield* GitCore).createBranch({ cwd: source, branch: featureBranch }); | ||
| 702 | yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch }); | ||
| 703 | yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n"); | ||
| 704 | yield* git(source, ["add", "feature.txt"]); | ||
| 705 | yield* git(source, ["commit", "-m", "feature base"]); | ||
| 706 | yield* git(source, ["push", "-u", "origin", featureBranch]); | ||
| 707 | yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: defaultBranch }); | ||
| 708 | |||
| 709 | yield* git(clone, ["clone", remote, "."]); | ||
| 710 | yield* git(clone, ["config", "user.email", "test@test.com"]); | ||
| 711 | yield* git(clone, ["config", "user.name", "Test"]); | ||
| 712 | yield* git(clone, ["checkout", "-b", featureBranch, "--track", `origin/${featureBranch}`]); | ||
| 713 | yield* writeTextFile(path.join(clone, "feature.txt"), "feature from remote\n"); | ||
| 714 | yield* git(clone, ["add", "feature.txt"]); | ||
| 715 | yield* git(clone, ["commit", "-m", "remote feature update"]); | ||
| 716 | yield* git(clone, ["push", "origin", featureBranch]); | ||
| 717 | |||
| 718 | yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch }); | ||
| 719 | const core = yield* GitCore; | ||
| 720 | yield* Effect.promise(() => | ||
| 721 | vi.waitFor( | ||
| 722 | async () => { | ||
| 723 | const details = await runPromise(core.statusDetails(source)); | ||
| 724 | expect(details.branch).toBe(featureBranch); | ||
| 725 | expect(details.aheadCount).toBe(0); | ||
| 726 | expect(details.behindCount).toBe(1); | ||
| 727 | }, | ||
| 728 | { | ||
| 729 | timeout: 10_000, | ||
| 730 | interval: 100, | ||
| 731 | }, | ||
| 732 | ), | ||
| 733 | ); | ||
| 734 | }), | ||
| 735 | ); | ||
| 736 | |||
| 737 | it.effect("statusDetails remains successful when upstream refresh fails after checkout", () => | ||
| 738 | Effect.gen(function* () { | ||
| 739 | const remote = yield* makeTmpDir(); | ||
| 740 | const source = yield* makeTmpDir(); | ||
| 741 | yield* git(remote, ["init", "--bare"]); | ||
| 742 | |||
| 743 | yield* initRepoWithCommit(source); | ||
| 744 | const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( | ||
| 745 | (branch) => branch.current, | ||
| 746 | )!.name; | ||
| 747 | yield* git(source, ["remote", "add", "origin", remote]); | ||
| 748 | yield* git(source, ["push", "-u", "origin", defaultBranch]); | ||
| 749 | |||
| 750 | const featureBranch = "feature-refresh-failure"; | ||
| 751 | yield* git(source, ["branch", featureBranch]); | ||
| 752 | yield* git(source, ["checkout", featureBranch]); | ||
| 753 | yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n"); | ||
| 754 | yield* git(source, ["add", "feature.txt"]); | ||
| 755 | yield* git(source, ["commit", "-m", "feature base"]); | ||
| 756 | yield* git(source, ["push", "-u", "origin", featureBranch]); | ||
| 757 | yield* git(source, ["checkout", defaultBranch]); | ||
| 758 | |||
| 759 | const realGitCore = yield* GitCore; | ||
| 760 | let refreshFetchAttempts = 0; | ||
| 761 | const core = yield* makeIsolatedGitCore((input) => { | ||
| 762 | if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { | ||
| 763 | refreshFetchAttempts += 1; | ||
| 764 | return Effect.fail( | ||
| 765 | new GitCommandError({ | ||
| 766 | operation: "git.test.refreshFailure", | ||
| 767 | command: `git ${input.args.join(" ")}`, | ||
| 768 | cwd: input.cwd, | ||
| 769 | detail: "simulated fetch timeout", | ||
| 770 | }), | ||
| 771 | ); | ||
| 772 | } | ||
| 773 | return realGitCore.execute(input); | ||
| 774 | }); | ||
| 775 | yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); | ||
| 776 | const status = yield* core.statusDetails(source); | ||
| 777 | expect(refreshFetchAttempts).toBe(1); | ||
| 778 | expect(status.branch).toBe(featureBranch); | ||
| 779 | expect(status.upstreamRef).toBe(`origin/${featureBranch}`); | ||
| 780 | expect(yield* git(source, ["branch", "--show-current"])).toBe(featureBranch); | ||
| 781 | }), | ||
| 782 | ); | ||
| 783 | |||
| 784 | it.effect("defers upstream refresh until statusDetails is requested", () => | ||
| 785 | Effect.gen(function* () { | ||
| 786 | const remote = yield* makeTmpDir(); | ||
| 787 | const source = yield* makeTmpDir(); | ||
| 788 | yield* git(remote, ["init", "--bare"]); | ||
| 789 | |||
| 790 | yield* initRepoWithCommit(source); | ||
| 791 | const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( | ||
| 792 | (branch) => branch.current, | ||
| 793 | )!.name; | ||
| 794 | yield* git(source, ["remote", "add", "origin", remote]); | ||
| 795 | yield* git(source, ["push", "-u", "origin", defaultBranch]); | ||
| 796 | |||
| 797 | const featureBranch = "feature/scoped-fetch"; | ||
| 798 | yield* git(source, ["checkout", "-b", featureBranch]); | ||
| 799 | yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n"); | ||
| 800 | yield* git(source, ["add", "feature.txt"]); | ||
| 801 | yield* git(source, ["commit", "-m", "feature base"]); | ||
| 802 | yield* git(source, ["push", "-u", "origin", featureBranch]); | ||
| 803 | yield* git(source, ["checkout", defaultBranch]); | ||
| 804 | |||
| 805 | const realGitCore = yield* GitCore; | ||
| 806 | let refreshFetchAttempts = 0; | ||
| 807 | const core = yield* makeIsolatedGitCore((input) => { | ||
| 808 | if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { | ||
| 809 | refreshFetchAttempts += 1; | ||
| 810 | return Effect.succeed({ | ||
| 811 | code: 0, | ||
| 812 | stdout: "", | ||
| 813 | stderr: "", | ||
| 814 | stdoutTruncated: false, | ||
| 815 | stderrTruncated: false, | ||
| 816 | }); | ||
| 817 | } | ||
| 818 | return realGitCore.execute(input); | ||
| 819 | }); | ||
| 820 | yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); | ||
| 821 | yield* Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, 50))); | ||
| 822 | expect(refreshFetchAttempts).toBe(0); | ||
| 823 | const status = yield* core.statusDetails(source); | ||
| 824 | expect(status.branch).toBe(featureBranch); | ||
| 825 | expect(refreshFetchAttempts).toBe(1); | ||
| 826 | }), | ||
| 827 | ); | ||
| 828 | |||
| 829 | it.effect("coalesces upstream refreshes across sibling worktrees on the same remote", () => | ||
| 830 | Effect.gen(function* () { | ||
| 831 | const ok = (stdout = "") => | ||
| 832 | Effect.succeed({ | ||
| 833 | code: 0, | ||
| 834 | stdout, | ||
| 835 | stderr: "", | ||
| 836 | stdoutTruncated: false, | ||
| 837 | stderrTruncated: false, | ||
| 838 | }); | ||
| 839 | |||
| 840 | let fetchCount = 0; | ||
| 841 | const core = yield* makeIsolatedGitCore((input) => { | ||
| 842 | if ( | ||
| 843 | input.args[0] === "rev-parse" && | ||
| 844 | input.args[1] === "--abbrev-ref" && | ||
| 845 | input.args[2] === "--symbolic-full-name" && | ||
| 846 | input.args[3] === "@{upstream}" | ||
| 847 | ) { | ||
| 848 | return ok( | ||
| 849 | input.cwd === "/repo/worktrees/pr-123" ? "origin/feature/pr-123\n" : "origin/main\n", | ||
| 850 | ); | ||
| 851 | } | ||
| 852 | if (input.args[0] === "remote") { | ||
| 853 | return ok("origin\n"); | ||
| 854 | } | ||
| 855 | if (input.args[0] === "rev-parse" && input.args[1] === "--git-common-dir") { | ||
| 856 | return ok("/repo/.git\n"); | ||
| 857 | } | ||
| 858 | if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { | ||
| 859 | fetchCount += 1; | ||
| 860 | expect(input.cwd).toBe("/repo"); | ||
| 861 | expect(input.args).toEqual([ | ||
| 862 | "--git-dir", | ||
| 863 | "/repo/.git", | ||
| 864 | "fetch", | ||
| 865 | "--quiet", | ||
| 866 | "--no-tags", | ||
| 867 | "origin", | ||
| 868 | ]); | ||
| 869 | return ok(); | ||
| 870 | } | ||
| 871 | if (input.operation === "GitCore.statusDetails.status") { | ||
| 872 | return ok( | ||
| 873 | input.cwd === "/repo/worktrees/pr-123" | ||
| 874 | ? "# branch.head feature/pr-123\n# branch.upstream origin/feature/pr-123\n# branch.ab +0 -0\n" | ||
| 875 | : "# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n", | ||
| 876 | ); | ||
| 877 | } | ||
| 878 | if ( | ||
| 879 | input.operation === "GitCore.statusDetails.unstagedNumstat" || | ||
| 880 | input.operation === "GitCore.statusDetails.stagedNumstat" | ||
| 881 | ) { | ||
| 882 | return ok(); | ||
| 883 | } | ||
| 884 | if (input.operation === "GitCore.statusDetails.defaultRef") { | ||
| 885 | return ok("refs/remotes/origin/main\n"); | ||
| 886 | } | ||
| 887 | return Effect.fail( | ||
| 888 | new GitCommandError({ | ||
| 889 | operation: input.operation, | ||
| 890 | command: `git ${input.args.join(" ")}`, | ||
| 891 | cwd: input.cwd, | ||
| 892 | detail: "Unexpected git command in shared refresh cache test.", | ||
| 893 | }), | ||
| 894 | ); | ||
| 895 | }); | ||
| 896 | |||
| 897 | yield* core.statusDetails("/repo/worktrees/main"); | ||
| 898 | yield* core.statusDetails("/repo/worktrees/pr-123"); | ||
| 899 | expect(fetchCount).toBe(1); | ||
| 900 | }), | ||
| 901 | ); | ||
| 902 | |||
| 903 | it.effect( | ||
| 904 | "briefly backs off failed upstream refreshes across sibling worktrees on one remote", | ||
| 905 | () => | ||
| 906 | Effect.gen(function* () { | ||
| 907 | const ok = (stdout = "") => | ||
| 908 | Effect.succeed({ | ||
| 909 | code: 0, | ||
| 910 | stdout, | ||
| 911 | stderr: "", | ||
| 912 | stdoutTruncated: false, | ||
| 913 | stderrTruncated: false, | ||
| 914 | }); | ||
| 915 | |||
| 916 | let fetchCount = 0; | ||
| 917 | const core = yield* makeIsolatedGitCore((input) => { | ||
| 918 | if ( | ||
| 919 | input.args[0] === "rev-parse" && | ||
| 920 | input.args[1] === "--abbrev-ref" && | ||
| 921 | input.args[2] === "--symbolic-full-name" && | ||
| 922 | input.args[3] === "@{upstream}" | ||
| 923 | ) { | ||
| 924 | return ok( | ||
| 925 | input.cwd === "/repo/worktrees/pr-123" | ||
| 926 | ? "origin/feature/pr-123\n" | ||
| 927 | : "origin/main\n", | ||
| 928 | ); | ||
| 929 | } | ||
| 930 | if (input.args[0] === "remote") { | ||
| 931 | return ok("origin\n"); | ||
| 932 | } | ||
| 933 | if (input.args[0] === "rev-parse" && input.args[1] === "--git-common-dir") { | ||
| 934 | return ok("/repo/.git\n"); | ||
| 935 | } | ||
| 936 | if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { | ||
| 937 | fetchCount += 1; | ||
| 938 | return Effect.fail( | ||
| 939 | new GitCommandError({ | ||
| 940 | operation: input.operation, | ||
| 941 | command: `git ${input.args.join(" ")}`, | ||
| 942 | cwd: input.cwd, | ||
| 943 | detail: "simulated fetch timeout", | ||
| 944 | }), | ||
| 945 | ); | ||
| 946 | } | ||
| 947 | if (input.operation === "GitCore.statusDetails.status") { | ||
| 948 | return ok( | ||
| 949 | input.cwd === "/repo/worktrees/pr-123" | ||
| 950 | ? "# branch.head feature/pr-123\n# branch.upstream origin/feature/pr-123\n# branch.ab +0 -0\n" | ||
| 951 | : "# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n", | ||
| 952 | ); | ||
| 953 | } | ||
| 954 | if ( | ||
| 955 | input.operation === "GitCore.statusDetails.unstagedNumstat" || | ||
| 956 | input.operation === "GitCore.statusDetails.stagedNumstat" | ||
| 957 | ) { | ||
| 958 | return ok(); | ||
| 959 | } | ||
| 960 | if (input.operation === "GitCore.statusDetails.defaultRef") { | ||
| 961 | return ok("refs/remotes/origin/main\n"); | ||
| 962 | } | ||
| 963 | return Effect.fail( | ||
| 964 | new GitCommandError({ | ||
| 965 | operation: input.operation, | ||
| 966 | command: `git ${input.args.join(" ")}`, | ||
| 967 | cwd: input.cwd, | ||
| 968 | detail: "Unexpected git command in refresh failure cooldown test.", | ||
| 969 | }), | ||
| 970 | ); | ||
| 971 | }); | ||
| 972 | |||
| 973 | yield* core.statusDetails("/repo/worktrees/main"); | ||
| 974 | yield* core.statusDetails("/repo/worktrees/pr-123"); | ||
| 975 | expect(fetchCount).toBe(1); | ||
| 976 | }), | ||
| 977 | ); | ||
| 978 | |||
| 979 | it.effect("throws when branch does not exist", () => | ||
| 980 | Effect.gen(function* () { | ||
| 981 | const tmp = yield* makeTmpDir(); | ||
| 982 | yield* initRepoWithCommit(tmp); | ||
| 983 | const result = yield* Effect.result( | ||
| 984 | (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "nonexistent" }), | ||
| 985 | ); | ||
| 986 | expect(result._tag).toBe("Failure"); | ||
| 987 | }), | ||
| 988 | ); | ||
| 989 | |||
| 990 | it.effect("does not silently checkout a local branch when a remote ref no longer exists", () => | ||
| 991 | Effect.gen(function* () { | ||
| 992 | const remote = yield* makeTmpDir(); | ||
| 993 | const source = yield* makeTmpDir(); | ||
| 994 | yield* git(remote, ["init", "--bare"]); | ||
| 995 | |||
| 996 | yield* initRepoWithCommit(source); | ||
| 997 | const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( | ||
| 998 | (branch) => branch.current, | ||
| 999 | )!.name; | ||
| 1000 | yield* git(source, ["remote", "add", "origin", remote]); | ||
| 1001 | yield* git(source, ["push", "-u", "origin", defaultBranch]); | ||
| 1002 | |||
| 1003 | yield* (yield* GitCore).createBranch({ cwd: source, branch: "feature" }); | ||
| 1004 | |||
| 1005 | const checkoutResult = yield* Effect.result( | ||
| 1006 | (yield* GitCore).checkoutBranch({ cwd: source, branch: "origin/feature" }), | ||
| 1007 | ); | ||
| 1008 | expect(checkoutResult._tag).toBe("Failure"); | ||
| 1009 | expect(yield* git(source, ["branch", "--show-current"])).toBe(defaultBranch); | ||
| 1010 | }), | ||
| 1011 | ); | ||
| 1012 | |||
| 1013 | it.effect("checks out a remote tracking branch when remote name contains slashes", () => | ||
| 1014 | Effect.gen(function* () { | ||
| 1015 | const remote = yield* makeTmpDir(); | ||
| 1016 | const prefixRemote = yield* makeTmpDir(); | ||
| 1017 | const source = yield* makeTmpDir(); | ||
| 1018 | const prefixFetchNamespace = "prefix-my-org"; | ||
| 1019 | const prefixRemoteName = "my-org"; | ||
| 1020 | const remoteName = "my-org/upstream"; | ||
| 1021 | const featureBranch = "feature"; | ||
| 1022 | yield* git(remote, ["init", "--bare"]); | ||
| 1023 | yield* git(prefixRemote, ["init", "--bare"]); | ||
| 1024 | |||
| 1025 | yield* initRepoWithCommit(source); | ||
| 1026 | const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( | ||
| 1027 | (branch) => branch.current, | ||
| 1028 | )!.name; | ||
| 1029 | yield* configureRemote(source, prefixRemoteName, prefixRemote, prefixFetchNamespace); | ||
| 1030 | yield* configureRemote(source, remoteName, remote, remoteName); | ||
| 1031 | yield* git(source, ["push", "-u", remoteName, defaultBranch]); | ||
| 1032 | |||
| 1033 | yield* git(source, ["checkout", "-b", featureBranch]); | ||
| 1034 | yield* writeTextFile(path.join(source, "feature.txt"), "feature content\n"); | ||
| 1035 | yield* git(source, ["add", "feature.txt"]); | ||
| 1036 | yield* git(source, ["commit", "-m", "feature commit"]); | ||
| 1037 | yield* git(source, ["push", "-u", remoteName, featureBranch]); | ||
| 1038 | yield* git(source, ["checkout", defaultBranch]); | ||
| 1039 | yield* git(source, ["branch", "-D", featureBranch]); | ||
| 1040 | |||
| 1041 | const checkoutResult = yield* (yield* GitCore).checkoutBranch({ | ||
| 1042 | cwd: source, | ||
| 1043 | branch: `${remoteName}/${featureBranch}`, | ||
| 1044 | }); | ||
| 1045 | |||
| 1046 | expect(checkoutResult.branch).toBe("upstream/feature"); | ||
| 1047 | expect(yield* git(source, ["branch", "--show-current"])).toBe("upstream/feature"); | ||
| 1048 | const realGitCore = yield* GitCore; | ||
| 1049 | let fetchArgs: readonly string[] | null = null; | ||
| 1050 | const core = yield* makeIsolatedGitCore((input) => { | ||
| 1051 | if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { | ||
| 1052 | fetchArgs = [...input.args]; | ||
| 1053 | return Effect.succeed({ | ||
| 1054 | code: 0, | ||
| 1055 | stdout: "", | ||
| 1056 | stderr: "", | ||
| 1057 | stdoutTruncated: false, | ||
| 1058 | stderrTruncated: false, | ||
| 1059 | }); | ||
| 1060 | } | ||
| 1061 | return realGitCore.execute(input); | ||
| 1062 | }); | ||
| 1063 | |||
| 1064 | const status = yield* core.statusDetails(source); | ||
| 1065 | expect(status.branch).toBe("upstream/feature"); | ||
| 1066 | expect(status.upstreamRef).toBe(`${remoteName}/${featureBranch}`); | ||
| 1067 | expect(fetchArgs).toEqual([ | ||
| 1068 | "--git-dir", | ||
| 1069 | path.join(source, ".git"), | ||
| 1070 | "fetch", | ||
| 1071 | "--quiet", | ||
| 1072 | "--no-tags", | ||
| 1073 | remoteName, | ||
| 1074 | ]); | ||
| 1075 | }), | ||
| 1076 | ); | ||
| 1077 | |||
| 1078 | it.effect( | ||
| 1079 | "falls back to detached checkout when --track would conflict with an existing local branch", | ||
| 1080 | () => | ||
| 1081 | Effect.gen(function* () { | ||
| 1082 | const remote = yield* makeTmpDir(); | ||
| 1083 | const source = yield* makeTmpDir(); | ||
| 1084 | yield* git(remote, ["init", "--bare"]); | ||
| 1085 | |||
| 1086 | yield* initRepoWithCommit(source); | ||
| 1087 | const defaultBranch = (yield* (yield* GitCore).listBranches({ | ||
| 1088 | cwd: source, | ||
| 1089 | })).branches.find((branch) => branch.current)!.name; | ||
| 1090 | yield* git(source, ["remote", "add", "origin", remote]); | ||
| 1091 | yield* git(source, ["push", "-u", "origin", defaultBranch]); | ||
| 1092 | |||
| 1093 | // Keep local branch but remove tracking so `--track origin/<branch>` | ||
| 1094 | // would attempt to create an already-existing local branch. | ||
| 1095 | yield* git(source, ["branch", "--unset-upstream"]); | ||
| 1096 | |||
| 1097 | yield* (yield* GitCore).checkoutBranch({ | ||
| 1098 | cwd: source, | ||
| 1099 | branch: `origin/${defaultBranch}`, | ||
| 1100 | }); | ||
| 1101 | |||
| 1102 | const core = yield* GitCore; | ||
| 1103 | const status = yield* core.statusDetails(source); | ||
| 1104 | expect(status.branch).toBeNull(); | ||
| 1105 | }), | ||
| 1106 | ); | ||
| 1107 | |||
| 1108 | it.effect("throws when checkout would overwrite uncommitted changes", () => | ||
| 1109 | Effect.gen(function* () { | ||
| 1110 | const tmp = yield* makeTmpDir(); | ||
| 1111 | yield* initRepoWithCommit(tmp); | ||
| 1112 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "other" }); | ||
| 1113 | |||
| 1114 | // Create a conflicting change: modify README on current branch | ||
| 1115 | yield* writeTextFile(path.join(tmp, "README.md"), "modified\n"); | ||
| 1116 | yield* git(tmp, ["add", "README.md"]); | ||
| 1117 | |||
| 1118 | // First, checkout other branch cleanly | ||
| 1119 | yield* git(tmp, ["stash"]); | ||
| 1120 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "other" }); | ||
| 1121 | yield* writeTextFile(path.join(tmp, "README.md"), "other content\n"); | ||
| 1122 | yield* git(tmp, ["add", "."]); | ||
| 1123 | yield* git(tmp, ["commit", "-m", "other change"]); | ||
| 1124 | |||
| 1125 | // Go back to default branch | ||
| 1126 | const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( | ||
| 1127 | (b) => !b.current, | ||
| 1128 | )!.name; | ||
| 1129 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch }); | ||
| 1130 | |||
| 1131 | // Make uncommitted changes to the same file | ||
| 1132 | yield* writeTextFile(path.join(tmp, "README.md"), "conflicting local\n"); | ||
| 1133 | |||
| 1134 | // Checkout should fail due to uncommitted changes | ||
| 1135 | const result = yield* Effect.result( | ||
| 1136 | (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "other" }), | ||
| 1137 | ); | ||
| 1138 | expect(result._tag).toBe("Failure"); | ||
| 1139 | }), | ||
| 1140 | ); | ||
| 1141 | }); | ||
| 1142 | |||
| 1143 | // ── createGitBranch ── | ||
| 1144 | |||
| 1145 | describe("createGitBranch", () => { | ||
| 1146 | it.effect("creates a new branch visible in listGitBranches", () => | ||
| 1147 | Effect.gen(function* () { | ||
| 1148 | const tmp = yield* makeTmpDir(); | ||
| 1149 | yield* initRepoWithCommit(tmp); | ||
| 1150 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "new-feature" }); | ||
| 1151 | |||
| 1152 | const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); | ||
| 1153 | expect(result.branches.some((b) => b.name === "new-feature")).toBe(true); | ||
| 1154 | }), | ||
| 1155 | ); | ||
| 1156 | |||
| 1157 | it.effect("throws when branch already exists", () => | ||
| 1158 | Effect.gen(function* () { | ||
| 1159 | const tmp = yield* makeTmpDir(); | ||
| 1160 | yield* initRepoWithCommit(tmp); | ||
| 1161 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "dupe" }); | ||
| 1162 | const result = yield* Effect.result( | ||
| 1163 | (yield* GitCore).createBranch({ cwd: tmp, branch: "dupe" }), | ||
| 1164 | ); | ||
| 1165 | expect(result._tag).toBe("Failure"); | ||
| 1166 | }), | ||
| 1167 | ); | ||
| 1168 | }); | ||
| 1169 | |||
| 1170 | // ── renameGitBranch ── | ||
| 1171 | |||
| 1172 | describe("renameGitBranch", () => { | ||
| 1173 | it.effect("renames the current branch", () => | ||
| 1174 | Effect.gen(function* () { | ||
| 1175 | const tmp = yield* makeTmpDir(); | ||
| 1176 | yield* initRepoWithCommit(tmp); | ||
| 1177 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/old-name" }); | ||
| 1178 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature/old-name" }); | ||
| 1179 | |||
| 1180 | const renamed = yield* (yield* GitCore).renameBranch({ | ||
| 1181 | cwd: tmp, | ||
| 1182 | oldBranch: "feature/old-name", | ||
| 1183 | newBranch: "feature/new-name", | ||
| 1184 | }); | ||
| 1185 | |||
| 1186 | expect(renamed.branch).toBe("feature/new-name"); | ||
| 1187 | |||
| 1188 | const branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); | ||
| 1189 | expect(branches.branches.some((branch) => branch.name === "feature/old-name")).toBe(false); | ||
| 1190 | const current = branches.branches.find((branch) => branch.current); | ||
| 1191 | expect(current?.name).toBe("feature/new-name"); | ||
| 1192 | }), | ||
| 1193 | ); | ||
| 1194 | |||
| 1195 | it.effect("returns success without git invocation when old/new names match", () => | ||
| 1196 | Effect.gen(function* () { | ||
| 1197 | const tmp = yield* makeTmpDir(); | ||
| 1198 | yield* initRepoWithCommit(tmp); | ||
| 1199 | const current = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( | ||
| 1200 | (b) => b.current, | ||
| 1201 | )!; | ||
| 1202 | |||
| 1203 | const renamed = yield* (yield* GitCore).renameBranch({ | ||
| 1204 | cwd: tmp, | ||
| 1205 | oldBranch: current.name, | ||
| 1206 | newBranch: current.name, | ||
| 1207 | }); | ||
| 1208 | |||
| 1209 | expect(renamed.branch).toBe(current.name); | ||
| 1210 | }), | ||
| 1211 | ); | ||
| 1212 | |||
| 1213 | it.effect("appends numeric suffix when target branch already exists", () => | ||
| 1214 | Effect.gen(function* () { | ||
| 1215 | const tmp = yield* makeTmpDir(); | ||
| 1216 | yield* initRepoWithCommit(tmp); | ||
| 1217 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/feat/session" }); | ||
| 1218 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/tmp-working" }); | ||
| 1219 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "t3code/tmp-working" }); | ||
| 1220 | |||
| 1221 | const renamed = yield* (yield* GitCore).renameBranch({ | ||
| 1222 | cwd: tmp, | ||
| 1223 | oldBranch: "t3code/tmp-working", | ||
| 1224 | newBranch: "t3code/feat/session", | ||
| 1225 | }); | ||
| 1226 | |||
| 1227 | expect(renamed.branch).toBe("t3code/feat/session-1"); | ||
| 1228 | const branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); | ||
| 1229 | expect(branches.branches.some((branch) => branch.name === "t3code/feat/session")).toBe( | ||
| 1230 | true, | ||
| 1231 | ); | ||
| 1232 | expect(branches.branches.some((branch) => branch.name === "t3code/feat/session-1")).toBe( | ||
| 1233 | true, | ||
| 1234 | ); | ||
| 1235 | const current = branches.branches.find((branch) => branch.current); | ||
| 1236 | expect(current?.name).toBe("t3code/feat/session-1"); | ||
| 1237 | }), | ||
| 1238 | ); | ||
| 1239 | |||
| 1240 | it.effect("increments suffix until it finds an available branch name", () => | ||
| 1241 | Effect.gen(function* () { | ||
| 1242 | const tmp = yield* makeTmpDir(); | ||
| 1243 | yield* initRepoWithCommit(tmp); | ||
| 1244 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/feat/session" }); | ||
| 1245 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/feat/session-1" }); | ||
| 1246 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/tmp-working" }); | ||
| 1247 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "t3code/tmp-working" }); | ||
| 1248 | |||
| 1249 | const renamed = yield* (yield* GitCore).renameBranch({ | ||
| 1250 | cwd: tmp, | ||
| 1251 | oldBranch: "t3code/tmp-working", | ||
| 1252 | newBranch: "t3code/feat/session", | ||
| 1253 | }); | ||
| 1254 | |||
| 1255 | expect(renamed.branch).toBe("t3code/feat/session-2"); | ||
| 1256 | }), | ||
| 1257 | ); | ||
| 1258 | |||
| 1259 | it.effect("uses '--' separator for branch rename arguments", () => | ||
| 1260 | Effect.gen(function* () { | ||
| 1261 | const tmp = yield* makeTmpDir(); | ||
| 1262 | yield* initRepoWithCommit(tmp); | ||
| 1263 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/old-name" }); | ||
| 1264 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature/old-name" }); | ||
| 1265 | |||
| 1266 | const realGitCore = yield* GitCore; | ||
| 1267 | let renameArgs: ReadonlyArray<string> | null = null; | ||
| 1268 | const core = yield* makeIsolatedGitCore((input) => { | ||
| 1269 | if (input.args[0] === "branch" && input.args[1] === "-m") { | ||
| 1270 | renameArgs = [...input.args]; | ||
| 1271 | } | ||
| 1272 | return realGitCore.execute(input); | ||
| 1273 | }); | ||
| 1274 | |||
| 1275 | const renamed = yield* core.renameBranch({ | ||
| 1276 | cwd: tmp, | ||
| 1277 | oldBranch: "feature/old-name", | ||
| 1278 | newBranch: "feature/new-name", | ||
| 1279 | }); | ||
| 1280 | |||
| 1281 | expect(renamed.branch).toBe("feature/new-name"); | ||
| 1282 | expect(renameArgs).toEqual(["branch", "-m", "--", "feature/old-name", "feature/new-name"]); | ||
| 1283 | }), | ||
| 1284 | ); | ||
| 1285 | }); | ||
| 1286 | |||
| 1287 | // ── createGitWorktree + removeGitWorktree ── | ||
| 1288 | |||
| 1289 | describe("createGitWorktree", () => { | ||
| 1290 | it.effect("creates a worktree with a new branch from the base branch", () => | ||
| 1291 | Effect.gen(function* () { | ||
| 1292 | const tmp = yield* makeTmpDir(); | ||
| 1293 | yield* initRepoWithCommit(tmp); | ||
| 1294 | |||
| 1295 | const wtPath = path.join(tmp, "worktree-out"); | ||
| 1296 | const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( | ||
| 1297 | (b) => b.current, | ||
| 1298 | )!.name; | ||
| 1299 | |||
| 1300 | const result = yield* (yield* GitCore).createWorktree({ | ||
| 1301 | cwd: tmp, | ||
| 1302 | branch: currentBranch, | ||
| 1303 | newBranch: "wt-branch", | ||
| 1304 | path: wtPath, | ||
| 1305 | }); | ||
| 1306 | |||
| 1307 | expect(result.worktree.path).toBe(wtPath); | ||
| 1308 | expect(result.worktree.branch).toBe("wt-branch"); | ||
| 1309 | expect(existsSync(wtPath)).toBe(true); | ||
| 1310 | expect(existsSync(path.join(wtPath, "README.md"))).toBe(true); | ||
| 1311 | |||
| 1312 | // Clean up worktree before tmp dir disposal | ||
| 1313 | yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); | ||
| 1314 | }), | ||
| 1315 | ); | ||
| 1316 | |||
| 1317 | it.effect("worktree has the new branch checked out", () => | ||
| 1318 | Effect.gen(function* () { | ||
| 1319 | const tmp = yield* makeTmpDir(); | ||
| 1320 | yield* initRepoWithCommit(tmp); | ||
| 1321 | |||
| 1322 | const wtPath = path.join(tmp, "wt-check-dir"); | ||
| 1323 | const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( | ||
| 1324 | (b) => b.current, | ||
| 1325 | )!.name; | ||
| 1326 | |||
| 1327 | yield* (yield* GitCore).createWorktree({ | ||
| 1328 | cwd: tmp, | ||
| 1329 | branch: currentBranch, | ||
| 1330 | newBranch: "wt-check", | ||
| 1331 | path: wtPath, | ||
| 1332 | }); | ||
| 1333 | |||
| 1334 | // Verify the worktree is on the new branch | ||
| 1335 | const branchOutput = yield* git(wtPath, ["branch", "--show-current"]); | ||
| 1336 | expect(branchOutput).toBe("wt-check"); | ||
| 1337 | |||
| 1338 | yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); | ||
| 1339 | }), | ||
| 1340 | ); | ||
| 1341 | |||
| 1342 | it.effect("creates a worktree for an existing branch when newBranch is omitted", () => | ||
| 1343 | Effect.gen(function* () { | ||
| 1344 | const tmp = yield* makeTmpDir(); | ||
| 1345 | yield* initRepoWithCommit(tmp); | ||
| 1346 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/existing-worktree" }); | ||
| 1347 | |||
| 1348 | const wtPath = path.join(tmp, "wt-existing"); | ||
| 1349 | const result = yield* (yield* GitCore).createWorktree({ | ||
| 1350 | cwd: tmp, | ||
| 1351 | branch: "feature/existing-worktree", | ||
| 1352 | path: wtPath, | ||
| 1353 | }); | ||
| 1354 | |||
| 1355 | expect(result.worktree.path).toBe(wtPath); | ||
| 1356 | expect(result.worktree.branch).toBe("feature/existing-worktree"); | ||
| 1357 | const branchOutput = yield* git(wtPath, ["branch", "--show-current"]); | ||
| 1358 | expect(branchOutput).toBe("feature/existing-worktree"); | ||
| 1359 | |||
| 1360 | yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); | ||
| 1361 | }), | ||
| 1362 | ); | ||
| 1363 | |||
| 1364 | it.effect("throws when new branch name already exists", () => | ||
| 1365 | Effect.gen(function* () { | ||
| 1366 | const tmp = yield* makeTmpDir(); | ||
| 1367 | yield* initRepoWithCommit(tmp); | ||
| 1368 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "existing" }); | ||
| 1369 | |||
| 1370 | const wtPath = path.join(tmp, "wt-conflict"); | ||
| 1371 | const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( | ||
| 1372 | (b) => b.current, | ||
| 1373 | )!.name; | ||
| 1374 | |||
| 1375 | const result = yield* Effect.result( | ||
| 1376 | (yield* GitCore).createWorktree({ | ||
| 1377 | cwd: tmp, | ||
| 1378 | branch: currentBranch, | ||
| 1379 | newBranch: "existing", | ||
| 1380 | path: wtPath, | ||
| 1381 | }), | ||
| 1382 | ); | ||
| 1383 | expect(result._tag).toBe("Failure"); | ||
| 1384 | }), | ||
| 1385 | ); | ||
| 1386 | |||
| 1387 | it.effect("listGitBranches from worktree cwd reports worktree branch as current", () => | ||
| 1388 | Effect.gen(function* () { | ||
| 1389 | const tmp = yield* makeTmpDir(); | ||
| 1390 | yield* initRepoWithCommit(tmp); | ||
| 1391 | |||
| 1392 | const wtPath = path.join(tmp, "wt-list-dir"); | ||
| 1393 | const mainBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( | ||
| 1394 | (b) => b.current, | ||
| 1395 | )!.name; | ||
| 1396 | |||
| 1397 | yield* (yield* GitCore).createWorktree({ | ||
| 1398 | cwd: tmp, | ||
| 1399 | branch: mainBranch, | ||
| 1400 | newBranch: "wt-list", | ||
| 1401 | path: wtPath, | ||
| 1402 | }); | ||
| 1403 | |||
| 1404 | // listGitBranches from the worktree should show wt-list as current | ||
| 1405 | const wtBranches = yield* (yield* GitCore).listBranches({ cwd: wtPath }); | ||
| 1406 | expect(wtBranches.isRepo).toBe(true); | ||
| 1407 | const wtCurrent = wtBranches.branches.find((b) => b.current); | ||
| 1408 | expect(wtCurrent!.name).toBe("wt-list"); | ||
| 1409 | |||
| 1410 | // Main repo should still show the original branch as current | ||
| 1411 | const mainBranches = yield* (yield* GitCore).listBranches({ cwd: tmp }); | ||
| 1412 | const mainCurrent = mainBranches.branches.find((b) => b.current); | ||
| 1413 | expect(mainCurrent!.name).toBe(mainBranch); | ||
| 1414 | |||
| 1415 | yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); | ||
| 1416 | }), | ||
| 1417 | ); | ||
| 1418 | |||
| 1419 | it.effect("removeGitWorktree cleans up the worktree", () => | ||
| 1420 | Effect.gen(function* () { | ||
| 1421 | const tmp = yield* makeTmpDir(); | ||
| 1422 | yield* initRepoWithCommit(tmp); | ||
| 1423 | |||
| 1424 | const wtPath = path.join(tmp, "wt-remove-dir"); | ||
| 1425 | const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( | ||
| 1426 | (b) => b.current, | ||
| 1427 | )!.name; | ||
| 1428 | |||
| 1429 | yield* (yield* GitCore).createWorktree({ | ||
| 1430 | cwd: tmp, | ||
| 1431 | branch: currentBranch, | ||
| 1432 | newBranch: "wt-remove", | ||
| 1433 | path: wtPath, | ||
| 1434 | }); | ||
| 1435 | expect(existsSync(wtPath)).toBe(true); | ||
| 1436 | |||
| 1437 | yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); | ||
| 1438 | expect(existsSync(wtPath)).toBe(false); | ||
| 1439 | }), | ||
| 1440 | ); | ||
| 1441 | |||
| 1442 | it.effect("removeGitWorktree force removes a dirty worktree", () => | ||
| 1443 | Effect.gen(function* () { | ||
| 1444 | const tmp = yield* makeTmpDir(); | ||
| 1445 | yield* initRepoWithCommit(tmp); | ||
| 1446 | |||
| 1447 | const wtPath = path.join(tmp, "wt-dirty-dir"); | ||
| 1448 | const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( | ||
| 1449 | (b) => b.current, | ||
| 1450 | )!.name; | ||
| 1451 | |||
| 1452 | yield* (yield* GitCore).createWorktree({ | ||
| 1453 | cwd: tmp, | ||
| 1454 | branch: currentBranch, | ||
| 1455 | newBranch: "wt-dirty", | ||
| 1456 | path: wtPath, | ||
| 1457 | }); | ||
| 1458 | expect(existsSync(wtPath)).toBe(true); | ||
| 1459 | |||
| 1460 | yield* writeTextFile(path.join(wtPath, "README.md"), "dirty change\n"); | ||
| 1461 | |||
| 1462 | const failedRemove = yield* Effect.result( | ||
| 1463 | (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }), | ||
| 1464 | ); | ||
| 1465 | expect(failedRemove._tag).toBe("Failure"); | ||
| 1466 | expect(existsSync(wtPath)).toBe(true); | ||
| 1467 | |||
| 1468 | yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath, force: true }); | ||
| 1469 | expect(existsSync(wtPath)).toBe(false); | ||
| 1470 | }), | ||
| 1471 | ); | ||
| 1472 | }); | ||
| 1473 | |||
| 1474 | // ── Full flow: local branch checkout ── | ||
| 1475 | |||
| 1476 | describe("full flow: local branch checkout", () => { | ||
| 1477 | it.effect("init → commit → create branch → checkout → verify current", () => | ||
| 1478 | Effect.gen(function* () { | ||
| 1479 | const tmp = yield* makeTmpDir(); | ||
| 1480 | yield* initRepoWithCommit(tmp); | ||
| 1481 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-login" }); | ||
| 1482 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature-login" }); | ||
| 1483 | |||
| 1484 | const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); | ||
| 1485 | const current = result.branches.find((b) => b.current); | ||
| 1486 | expect(current!.name).toBe("feature-login"); | ||
| 1487 | }), | ||
| 1488 | ); | ||
| 1489 | }); | ||
| 1490 | |||
| 1491 | // ── Full flow: worktree creation from base branch ── | ||
| 1492 | |||
| 1493 | describe("full flow: worktree creation", () => { | ||
| 1494 | it.effect("creates worktree with new branch from current branch", () => | ||
| 1495 | Effect.gen(function* () { | ||
| 1496 | const tmp = yield* makeTmpDir(); | ||
| 1497 | yield* initRepoWithCommit(tmp); | ||
| 1498 | |||
| 1499 | const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( | ||
| 1500 | (b) => b.current, | ||
| 1501 | )!.name; | ||
| 1502 | |||
| 1503 | const wtPath = path.join(tmp, "my-worktree"); | ||
| 1504 | const result = yield* (yield* GitCore).createWorktree({ | ||
| 1505 | cwd: tmp, | ||
| 1506 | branch: currentBranch, | ||
| 1507 | newBranch: "feature-wt", | ||
| 1508 | path: wtPath, | ||
| 1509 | }); | ||
| 1510 | |||
| 1511 | // Worktree exists | ||
| 1512 | expect(existsSync(result.worktree.path)).toBe(true); | ||
| 1513 | |||
| 1514 | // Main repo still on original branch | ||
| 1515 | const mainBranches = yield* (yield* GitCore).listBranches({ cwd: tmp }); | ||
| 1516 | const mainCurrent = mainBranches.branches.find((b) => b.current); | ||
| 1517 | expect(mainCurrent!.name).toBe(currentBranch); | ||
| 1518 | |||
| 1519 | // Worktree is on the new branch | ||
| 1520 | const wtBranch = yield* git(wtPath, ["branch", "--show-current"]); | ||
| 1521 | expect(wtBranch).toBe("feature-wt"); | ||
| 1522 | |||
| 1523 | yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); | ||
| 1524 | }), | ||
| 1525 | ); | ||
| 1526 | }); | ||
| 1527 | |||
| 1528 | describe("fetchPullRequestBranch", () => { | ||
| 1529 | it.effect("fetches a GitHub pull request ref into a local branch without checkout", () => | ||
| 1530 | Effect.gen(function* () { | ||
| 1531 | const tmp = yield* makeTmpDir(); | ||
| 1532 | const { initialBranch } = yield* initRepoWithCommit(tmp); | ||
| 1533 | const remoteDir = yield* makeTmpDir("git-remote-"); | ||
| 1534 | yield* git(remoteDir, ["init", "--bare"]); | ||
| 1535 | yield* git(tmp, ["remote", "add", "origin", remoteDir]); | ||
| 1536 | yield* git(tmp, ["push", "-u", "origin", initialBranch]); | ||
| 1537 | yield* git(tmp, ["checkout", "-b", "feature/pr-fetch"]); | ||
| 1538 | yield* writeTextFile(path.join(tmp, "pr-fetch.txt"), "fetch me\n"); | ||
| 1539 | yield* git(tmp, ["add", "pr-fetch.txt"]); | ||
| 1540 | yield* git(tmp, ["commit", "-m", "Add PR fetch branch"]); | ||
| 1541 | yield* git(tmp, ["push", "-u", "origin", "feature/pr-fetch"]); | ||
| 1542 | yield* git(tmp, ["push", "origin", "HEAD:refs/pull/55/head"]); | ||
| 1543 | yield* git(tmp, ["checkout", initialBranch]); | ||
| 1544 | |||
| 1545 | yield* (yield* GitCore).fetchPullRequestBranch({ | ||
| 1546 | cwd: tmp, | ||
| 1547 | prNumber: 55, | ||
| 1548 | branch: "feature/pr-fetch", | ||
| 1549 | }); | ||
| 1550 | |||
| 1551 | const localBranches = yield* git(tmp, ["branch", "--list", "feature/pr-fetch"]); | ||
| 1552 | expect(localBranches).toContain("feature/pr-fetch"); | ||
| 1553 | const currentBranch = yield* git(tmp, ["branch", "--show-current"]); | ||
| 1554 | expect(currentBranch).toBe(initialBranch); | ||
| 1555 | }), | ||
| 1556 | ); | ||
| 1557 | }); | ||
| 1558 | |||
| 1559 | // ── Full flow: thread switching simulation ── | ||
| 1560 | |||
| 1561 | describe("full flow: thread switching (checkout toggling)", () => { | ||
| 1562 | it.effect("checkout a → checkout b → checkout a → current matches", () => | ||
| 1563 | Effect.gen(function* () { | ||
| 1564 | const tmp = yield* makeTmpDir(); | ||
| 1565 | yield* initRepoWithCommit(tmp); | ||
| 1566 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "branch-a" }); | ||
| 1567 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "branch-b" }); | ||
| 1568 | |||
| 1569 | // Simulate switching to thread A's branch | ||
| 1570 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "branch-a" }); | ||
| 1571 | let branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); | ||
| 1572 | expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); | ||
| 1573 | |||
| 1574 | // Simulate switching to thread B's branch | ||
| 1575 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "branch-b" }); | ||
| 1576 | branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); | ||
| 1577 | expect(branches.branches.find((b) => b.current)!.name).toBe("branch-b"); | ||
| 1578 | |||
| 1579 | // Switch back to thread A | ||
| 1580 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "branch-a" }); | ||
| 1581 | branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); | ||
| 1582 | expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); | ||
| 1583 | }), | ||
| 1584 | ); | ||
| 1585 | }); | ||
| 1586 | |||
| 1587 | // ── Full flow: checkout conflict ── | ||
| 1588 | |||
| 1589 | describe("full flow: checkout conflict", () => { | ||
| 1590 | it.effect("uncommitted changes prevent checkout to a diverged branch", () => | ||
| 1591 | Effect.gen(function* () { | ||
| 1592 | const tmp = yield* makeTmpDir(); | ||
| 1593 | yield* initRepoWithCommit(tmp); | ||
| 1594 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "diverged" }); | ||
| 1595 | |||
| 1596 | // Make diverged branch have different file content | ||
| 1597 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "diverged" }); | ||
| 1598 | yield* writeTextFile(path.join(tmp, "README.md"), "diverged content\n"); | ||
| 1599 | yield* git(tmp, ["add", "."]); | ||
| 1600 | yield* git(tmp, ["commit", "-m", "diverge"]); | ||
| 1601 | |||
| 1602 | // Actually, let's just get back to the initial branch explicitly | ||
| 1603 | const allBranches = yield* (yield* GitCore).listBranches({ cwd: tmp }); | ||
| 1604 | const initialBranch = allBranches.branches.find((b) => b.name !== "diverged")!.name; | ||
| 1605 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: initialBranch }); | ||
| 1606 | |||
| 1607 | // Make local uncommitted changes to the same file | ||
| 1608 | yield* writeTextFile(path.join(tmp, "README.md"), "local uncommitted\n"); | ||
| 1609 | |||
| 1610 | // Attempt checkout should fail | ||
| 1611 | const failedCheckout = yield* Effect.result( | ||
| 1612 | (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "diverged" }), | ||
| 1613 | ); | ||
| 1614 | expect(failedCheckout._tag).toBe("Failure"); | ||
| 1615 | |||
| 1616 | // Current branch should still be the initial one | ||
| 1617 | const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); | ||
| 1618 | expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch); | ||
| 1619 | }), | ||
| 1620 | ); | ||
| 1621 | }); | ||
| 1622 | |||
| 1623 | describe("GitCore", () => { | ||
| 1624 | it.effect("supports branch lifecycle operations through the service API", () => | ||
| 1625 | Effect.gen(function* () { | ||
| 1626 | const tmp = yield* makeTmpDir(); | ||
| 1627 | const core = yield* GitCore; | ||
| 1628 | |||
| 1629 | yield* core.initRepo({ cwd: tmp }); | ||
| 1630 | yield* git(tmp, ["config", "user.email", "test@test.com"]); | ||
| 1631 | yield* git(tmp, ["config", "user.name", "Test"]); | ||
| 1632 | yield* writeTextFile(path.join(tmp, "README.md"), "# test\n"); | ||
| 1633 | yield* git(tmp, ["add", "."]); | ||
| 1634 | yield* git(tmp, ["commit", "-m", "initial commit"]); | ||
| 1635 | |||
| 1636 | yield* core.createBranch({ cwd: tmp, branch: "feature/service-api" }); | ||
| 1637 | yield* core.checkoutBranch({ cwd: tmp, branch: "feature/service-api" }); | ||
| 1638 | const branches = yield* core.listBranches({ cwd: tmp }); | ||
| 1639 | |||
| 1640 | expect(branches.isRepo).toBe(true); | ||
| 1641 | expect( | ||
| 1642 | branches.branches.find((branch: { current: boolean; name: string }) => branch.current) | ||
| 1643 | ?.name, | ||
| 1644 | ).toBe("feature/service-api"); | ||
| 1645 | }), | ||
| 1646 | ); | ||
| 1647 | |||
| 1648 | it.effect( | ||
| 1649 | "reuses an existing remote when the target URL only differs by a trailing slash after .git", | ||
| 1650 | () => | ||
| 1651 | Effect.gen(function* () { | ||
| 1652 | const tmp = yield* makeTmpDir(); | ||
| 1653 | yield* initRepoWithCommit(tmp); | ||
| 1654 | const core = yield* GitCore; | ||
| 1655 | |||
| 1656 | yield* git(tmp, ["remote", "add", "origin", "git@github.com:pingdotgg/t3code.git"]); | ||
| 1657 | |||
| 1658 | const remoteName = yield* core.ensureRemote({ | ||
| 1659 | cwd: tmp, | ||
| 1660 | preferredName: "origin", | ||
| 1661 | url: "git@github.com:pingdotgg/t3code.git/", | ||
| 1662 | }); | ||
| 1663 | |||
| 1664 | expect(remoteName).toBe("origin"); | ||
| 1665 | expect((yield* git(tmp, ["remote"])).split("\n").filter(Boolean)).toEqual(["origin"]); | ||
| 1666 | }), | ||
| 1667 | ); | ||
| 1668 | |||
| 1669 | it.effect("reports status details and dirty state", () => | ||
| 1670 | Effect.gen(function* () { | ||
| 1671 | const tmp = yield* makeTmpDir(); | ||
| 1672 | yield* initRepoWithCommit(tmp); | ||
| 1673 | const core = yield* GitCore; | ||
| 1674 | |||
| 1675 | const clean = yield* core.status({ cwd: tmp }); | ||
| 1676 | expect(clean.hasWorkingTreeChanges).toBe(false); | ||
| 1677 | expect(clean.branch).toBeTruthy(); | ||
| 1678 | |||
| 1679 | yield* writeTextFile(path.join(tmp, "README.md"), "updated\n"); | ||
| 1680 | const dirty = yield* core.statusDetails(tmp); | ||
| 1681 | expect(dirty.hasWorkingTreeChanges).toBe(true); | ||
| 1682 | }), | ||
| 1683 | ); | ||
| 1684 | |||
| 1685 | it.effect("returns a non-repo status for deleted directories", () => | ||
| 1686 | Effect.gen(function* () { | ||
| 1687 | const tmp = yield* makeTmpDir(); | ||
| 1688 | const deletedDir = path.join(tmp, "deleted-repo"); | ||
| 1689 | yield* makeDirectory(deletedDir); | ||
| 1690 | yield* removePath(deletedDir); | ||
| 1691 | const core = yield* GitCore; | ||
| 1692 | |||
| 1693 | const status = yield* core.statusDetails(deletedDir); | ||
| 1694 | const localStatus = yield* core.statusDetailsLocal(deletedDir); | ||
| 1695 | |||
| 1696 | expect(status).toEqual({ | ||
| 1697 | isRepo: false, | ||
| 1698 | hasOriginRemote: false, | ||
| 1699 | isDefaultBranch: false, | ||
| 1700 | branch: null, | ||
| 1701 | upstreamRef: null, | ||
| 1702 | hasWorkingTreeChanges: false, | ||
| 1703 | workingTree: { | ||
| 1704 | files: [], | ||
| 1705 | insertions: 0, | ||
| 1706 | deletions: 0, | ||
| 1707 | }, | ||
| 1708 | hasUpstream: false, | ||
| 1709 | aheadCount: 0, | ||
| 1710 | behindCount: 0, | ||
| 1711 | }); | ||
| 1712 | expect(localStatus).toEqual(status); | ||
| 1713 | }), | ||
| 1714 | ); | ||
| 1715 | |||
| 1716 | it.effect("computes ahead count against base branch when no upstream is configured", () => | ||
| 1717 | Effect.gen(function* () { | ||
| 1718 | const tmp = yield* makeTmpDir(); | ||
| 1719 | yield* initRepoWithCommit(tmp); | ||
| 1720 | const core = yield* GitCore; | ||
| 1721 | |||
| 1722 | yield* core.createBranch({ cwd: tmp, branch: "feature/no-upstream-ahead" }); | ||
| 1723 | yield* core.checkoutBranch({ cwd: tmp, branch: "feature/no-upstream-ahead" }); | ||
| 1724 | yield* writeTextFile(path.join(tmp, "feature.txt"), "ahead of base\n"); | ||
| 1725 | yield* git(tmp, ["add", "feature.txt"]); | ||
| 1726 | yield* git(tmp, ["commit", "-m", "feature commit"]); | ||
| 1727 | |||
| 1728 | const details = yield* core.statusDetails(tmp); | ||
| 1729 | expect(details.branch).toBe("feature/no-upstream-ahead"); | ||
| 1730 | expect(details.hasUpstream).toBe(false); | ||
| 1731 | expect(details.aheadCount).toBe(1); | ||
| 1732 | expect(details.behindCount).toBe(0); | ||
| 1733 | }), | ||
| 1734 | ); | ||
| 1735 | |||
| 1736 | it.effect( | ||
| 1737 | "computes ahead count against origin/default when local default branch is missing", | ||
| 1738 | () => | ||
| 1739 | Effect.gen(function* () { | ||
| 1740 | const remote = yield* makeTmpDir(); | ||
| 1741 | const source = yield* makeTmpDir(); | ||
| 1742 | yield* git(remote, ["init", "--bare"]); | ||
| 1743 | |||
| 1744 | yield* initRepoWithCommit(source); | ||
| 1745 | const initialBranch = (yield* (yield* GitCore).listBranches({ | ||
| 1746 | cwd: source, | ||
| 1747 | })).branches.find((branch) => branch.current)!.name; | ||
| 1748 | yield* git(source, ["remote", "add", "origin", remote]); | ||
| 1749 | yield* git(source, ["push", "-u", "origin", initialBranch]); | ||
| 1750 | yield* git(source, ["checkout", "-b", "feature/remote-base-only"]); | ||
| 1751 | yield* writeTextFile( | ||
| 1752 | path.join(source, "feature.txt"), | ||
| 1753 | `ahead of origin/${initialBranch}\n`, | ||
| 1754 | ); | ||
| 1755 | yield* git(source, ["add", "feature.txt"]); | ||
| 1756 | yield* git(source, ["commit", "-m", "feature commit"]); | ||
| 1757 | yield* git(source, ["branch", "-D", initialBranch]); | ||
| 1758 | |||
| 1759 | const core = yield* GitCore; | ||
| 1760 | const details = yield* core.statusDetails(source); | ||
| 1761 | expect(details.branch).toBe("feature/remote-base-only"); | ||
| 1762 | expect(details.hasUpstream).toBe(false); | ||
| 1763 | expect(details.aheadCount).toBe(1); | ||
| 1764 | expect(details.behindCount).toBe(0); | ||
| 1765 | }), | ||
| 1766 | ); | ||
| 1767 | |||
| 1768 | it.effect( | ||
| 1769 | "computes ahead count against a non-origin remote-prefixed gh-merge-base candidate", | ||
| 1770 | () => | ||
| 1771 | Effect.gen(function* () { | ||
| 1772 | const remote = yield* makeTmpDir(); | ||
| 1773 | const source = yield* makeTmpDir(); | ||
| 1774 | const remoteName = "fork-seed"; | ||
| 1775 | yield* git(remote, ["init", "--bare"]); | ||
| 1776 | |||
| 1777 | yield* initRepoWithCommit(source); | ||
| 1778 | const initialBranch = (yield* (yield* GitCore).listBranches({ | ||
| 1779 | cwd: source, | ||
| 1780 | })).branches.find((branch) => branch.current)!.name; | ||
| 1781 | yield* git(source, ["remote", "add", remoteName, remote]); | ||
| 1782 | yield* git(source, ["push", "-u", remoteName, initialBranch]); | ||
| 1783 | yield* git(source, ["checkout", "-b", "feature/non-origin-merge-base"]); | ||
| 1784 | yield* git(source, [ | ||
| 1785 | "config", | ||
| 1786 | "branch.feature/non-origin-merge-base.gh-merge-base", | ||
| 1787 | `${remoteName}/${initialBranch}`, | ||
| 1788 | ]); | ||
| 1789 | yield* writeTextFile( | ||
| 1790 | path.join(source, "feature.txt"), | ||
| 1791 | `ahead of ${remoteName}/${initialBranch}\n`, | ||
| 1792 | ); | ||
| 1793 | yield* git(source, ["add", "feature.txt"]); | ||
| 1794 | yield* git(source, ["commit", "-m", "feature commit"]); | ||
| 1795 | yield* git(source, ["branch", "-D", initialBranch]); | ||
| 1796 | |||
| 1797 | const core = yield* GitCore; | ||
| 1798 | const details = yield* core.statusDetails(source); | ||
| 1799 | expect(details.branch).toBe("feature/non-origin-merge-base"); | ||
| 1800 | expect(details.hasUpstream).toBe(false); | ||
| 1801 | expect(details.aheadCount).toBe(1); | ||
| 1802 | expect(details.behindCount).toBe(0); | ||
| 1803 | }), | ||
| 1804 | ); | ||
| 1805 | |||
| 1806 | it.effect("skips push when no upstream is configured and branch is not ahead of base", () => | ||
| 1807 | Effect.gen(function* () { | ||
| 1808 | const tmp = yield* makeTmpDir(); | ||
| 1809 | yield* initRepoWithCommit(tmp); | ||
| 1810 | const core = yield* GitCore; | ||
| 1811 | |||
| 1812 | yield* core.createBranch({ cwd: tmp, branch: "feature/no-upstream-no-ahead" }); | ||
| 1813 | yield* core.checkoutBranch({ cwd: tmp, branch: "feature/no-upstream-no-ahead" }); | ||
| 1814 | |||
| 1815 | const pushed = yield* core.pushCurrentBranch(tmp, null); | ||
| 1816 | expect(pushed.status).toBe("skipped_up_to_date"); | ||
| 1817 | expect(pushed.branch).toBe("feature/no-upstream-no-ahead"); | ||
| 1818 | expect(pushed.setUpstream).toBeUndefined(); | ||
| 1819 | }), | ||
| 1820 | ); | ||
| 1821 | |||
| 1822 | it.effect("pushes with upstream setup when no comparable base branch exists", () => | ||
| 1823 | Effect.gen(function* () { | ||
| 1824 | const tmp = yield* makeTmpDir(); | ||
| 1825 | const remote = yield* makeTmpDir(); | ||
| 1826 | yield* git(tmp, ["init", "--initial-branch=trunk"]); | ||
| 1827 | yield* git(tmp, ["config", "user.email", "test@test.com"]); | ||
| 1828 | yield* git(tmp, ["config", "user.name", "Test"]); | ||
| 1829 | yield* writeTextFile(path.join(tmp, "README.md"), "hello\n"); | ||
| 1830 | yield* git(tmp, ["add", "README.md"]); | ||
| 1831 | yield* git(tmp, ["commit", "-m", "initial"]); | ||
| 1832 | yield* git(remote, ["init", "--bare"]); | ||
| 1833 | yield* git(tmp, ["remote", "add", "origin", remote]); | ||
| 1834 | yield* git(tmp, ["checkout", "-b", "feature/no-base"]); | ||
| 1835 | |||
| 1836 | const core = yield* GitCore; | ||
| 1837 | const pushed = yield* core.pushCurrentBranch(tmp, null); | ||
| 1838 | expect(pushed.status).toBe("pushed"); | ||
| 1839 | expect(pushed.setUpstream).toBe(true); | ||
| 1840 | expect(pushed.upstreamBranch).toBe("origin/feature/no-base"); | ||
| 1841 | expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( | ||
| 1842 | "origin/feature/no-base", | ||
| 1843 | ); | ||
| 1844 | }), | ||
| 1845 | ); | ||
| 1846 | |||
| 1847 | it.effect("pushes with upstream setup to the only configured non-origin remote", () => | ||
| 1848 | Effect.gen(function* () { | ||
| 1849 | const tmp = yield* makeTmpDir(); | ||
| 1850 | const remote = yield* makeTmpDir(); | ||
| 1851 | yield* git(tmp, ["init", "--initial-branch=main"]); | ||
| 1852 | yield* git(tmp, ["config", "user.email", "test@test.com"]); | ||
| 1853 | yield* git(tmp, ["config", "user.name", "Test"]); | ||
| 1854 | yield* writeTextFile(path.join(tmp, "README.md"), "hello\n"); | ||
| 1855 | yield* git(tmp, ["add", "README.md"]); | ||
| 1856 | yield* git(tmp, ["commit", "-m", "initial"]); | ||
| 1857 | yield* git(remote, ["init", "--bare"]); | ||
| 1858 | yield* git(tmp, ["remote", "add", "fork", remote]); | ||
| 1859 | yield* git(tmp, ["checkout", "-b", "feature/fork-only"]); | ||
| 1860 | |||
| 1861 | const core = yield* GitCore; | ||
| 1862 | const pushed = yield* core.pushCurrentBranch(tmp, null); | ||
| 1863 | expect(pushed.status).toBe("pushed"); | ||
| 1864 | expect(pushed.setUpstream).toBe(true); | ||
| 1865 | expect(pushed.upstreamBranch).toBe("fork/feature/fork-only"); | ||
| 1866 | expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( | ||
| 1867 | "fork/feature/fork-only", | ||
| 1868 | ); | ||
| 1869 | }), | ||
| 1870 | ); | ||
| 1871 | |||
| 1872 | it.effect( | ||
| 1873 | "pushes with upstream setup when comparable base exists but remote branch is missing", | ||
| 1874 | () => | ||
| 1875 | Effect.gen(function* () { | ||
| 1876 | const tmp = yield* makeTmpDir(); | ||
| 1877 | const remote = yield* makeTmpDir(); | ||
| 1878 | yield* git(remote, ["init", "--bare"]); | ||
| 1879 | |||
| 1880 | yield* initRepoWithCommit(tmp); | ||
| 1881 | const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( | ||
| 1882 | (branch) => branch.current, | ||
| 1883 | )!.name; | ||
| 1884 | yield* git(tmp, ["remote", "add", "origin", remote]); | ||
| 1885 | yield* git(tmp, ["push", "-u", "origin", initialBranch]); | ||
| 1886 | |||
| 1887 | yield* writeTextFile(path.join(tmp, "default-ahead.txt"), "ahead on default\n"); | ||
| 1888 | yield* git(tmp, ["add", "default-ahead.txt"]); | ||
| 1889 | yield* git(tmp, ["commit", "-m", "default ahead"]); | ||
| 1890 | |||
| 1891 | const featureBranch = "feature/publish-no-upstream"; | ||
| 1892 | yield* git(tmp, ["checkout", "-b", featureBranch]); | ||
| 1893 | |||
| 1894 | const core = yield* GitCore; | ||
| 1895 | const pushed = yield* core.pushCurrentBranch(tmp, null); | ||
| 1896 | expect(pushed.status).toBe("pushed"); | ||
| 1897 | expect(pushed.setUpstream).toBe(true); | ||
| 1898 | expect(pushed.upstreamBranch).toBe(`origin/${featureBranch}`); | ||
| 1899 | expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( | ||
| 1900 | `origin/${featureBranch}`, | ||
| 1901 | ); | ||
| 1902 | expect(yield* git(tmp, ["ls-remote", "--heads", "origin", featureBranch])).toContain( | ||
| 1903 | featureBranch, | ||
| 1904 | ); | ||
| 1905 | }), | ||
| 1906 | ); | ||
| 1907 | |||
| 1908 | it.effect("prefers branch pushRemote over origin when setting upstream", () => | ||
| 1909 | Effect.gen(function* () { | ||
| 1910 | const tmp = yield* makeTmpDir(); | ||
| 1911 | const origin = yield* makeTmpDir(); | ||
| 1912 | const fork = yield* makeTmpDir(); | ||
| 1913 | yield* git(origin, ["init", "--bare"]); | ||
| 1914 | yield* git(fork, ["init", "--bare"]); | ||
| 1915 | |||
| 1916 | yield* initRepoWithCommit(tmp); | ||
| 1917 | const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( | ||
| 1918 | (branch) => branch.current, | ||
| 1919 | )!.name; | ||
| 1920 | yield* git(tmp, ["remote", "add", "origin", origin]); | ||
| 1921 | yield* git(tmp, ["remote", "add", "fork", fork]); | ||
| 1922 | yield* git(tmp, ["push", "-u", "origin", initialBranch]); | ||
| 1923 | |||
| 1924 | const featureBranch = "feature/push-remote"; | ||
| 1925 | yield* git(tmp, ["checkout", "-b", featureBranch]); | ||
| 1926 | yield* git(tmp, ["config", `branch.${featureBranch}.pushRemote`, "fork"]); | ||
| 1927 | yield* writeTextFile(path.join(tmp, "feature.txt"), "push to fork\n"); | ||
| 1928 | yield* git(tmp, ["add", "feature.txt"]); | ||
| 1929 | yield* git(tmp, ["commit", "-m", "feature commit"]); | ||
| 1930 | |||
| 1931 | const core = yield* GitCore; | ||
| 1932 | const pushed = yield* core.pushCurrentBranch(tmp, null); | ||
| 1933 | expect(pushed.status).toBe("pushed"); | ||
| 1934 | expect(pushed.setUpstream).toBe(true); | ||
| 1935 | expect(pushed.upstreamBranch).toBe(`fork/${featureBranch}`); | ||
| 1936 | expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( | ||
| 1937 | `fork/${featureBranch}`, | ||
| 1938 | ); | ||
| 1939 | expect(yield* git(tmp, ["ls-remote", "--heads", "fork", featureBranch])).toContain( | ||
| 1940 | featureBranch, | ||
| 1941 | ); | ||
| 1942 | }), | ||
| 1943 | ); | ||
| 1944 | |||
| 1945 | it.effect( | ||
| 1946 | "pushes renamed PR worktree branches to their tracked upstream branch even when push.default is current", | ||
| 1947 | () => | ||
| 1948 | Effect.gen(function* () { | ||
| 1949 | const tmp = yield* makeTmpDir(); | ||
| 1950 | const fork = yield* makeTmpDir(); | ||
| 1951 | yield* git(fork, ["init", "--bare"]); | ||
| 1952 | |||
| 1953 | const { initialBranch } = yield* initRepoWithCommit(tmp); | ||
| 1954 | yield* git(tmp, ["remote", "add", "jasonLaster", fork]); | ||
| 1955 | yield* git(tmp, ["checkout", "-b", "statemachine"]); | ||
| 1956 | yield* writeTextFile(path.join(tmp, "fork.txt"), "fork branch\n"); | ||
| 1957 | yield* git(tmp, ["add", "fork.txt"]); | ||
| 1958 | yield* git(tmp, ["commit", "-m", "fork branch"]); | ||
| 1959 | yield* git(tmp, ["push", "-u", "jasonLaster", "statemachine"]); | ||
| 1960 | yield* git(tmp, ["checkout", initialBranch]); | ||
| 1961 | yield* git(tmp, ["branch", "-D", "statemachine"]); | ||
| 1962 | yield* git(tmp, [ | ||
| 1963 | "checkout", | ||
| 1964 | "-b", | ||
| 1965 | "t3code/pr-488/statemachine", | ||
| 1966 | "--track", | ||
| 1967 | "jasonLaster/statemachine", | ||
| 1968 | ]); | ||
| 1969 | yield* git(tmp, ["config", "push.default", "current"]); | ||
| 1970 | yield* writeTextFile(path.join(tmp, "fork.txt"), "updated fork branch\n"); | ||
| 1971 | yield* git(tmp, ["add", "fork.txt"]); | ||
| 1972 | yield* git(tmp, ["commit", "-m", "update reviewed PR branch"]); | ||
| 1973 | |||
| 1974 | const core = yield* GitCore; | ||
| 1975 | const pushed = yield* core.pushCurrentBranch(tmp, null); | ||
| 1976 | |||
| 1977 | expect(pushed.status).toBe("pushed"); | ||
| 1978 | expect(pushed.setUpstream).toBe(false); | ||
| 1979 | expect(pushed.upstreamBranch).toBe("jasonLaster/statemachine"); | ||
| 1980 | expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( | ||
| 1981 | "jasonLaster/statemachine", | ||
| 1982 | ); | ||
| 1983 | expect( | ||
| 1984 | yield* git(tmp, ["ls-remote", "--heads", "jasonLaster", "statemachine"]), | ||
| 1985 | ).toContain("statemachine"); | ||
| 1986 | expect( | ||
| 1987 | yield* git(tmp, ["ls-remote", "--heads", "jasonLaster", "t3code/pr-488/statemachine"]), | ||
| 1988 | ).toBe(""); | ||
| 1989 | }), | ||
| 1990 | ); | ||
| 1991 | |||
| 1992 | it.effect("pushes to the tracked upstream when the remote name contains slashes", () => | ||
| 1993 | Effect.gen(function* () { | ||
| 1994 | const tmp = yield* makeTmpDir(); | ||
| 1995 | const remote = yield* makeTmpDir(); | ||
| 1996 | const prefixRemote = yield* makeTmpDir(); | ||
| 1997 | const prefixFetchNamespace = "prefix-my-org"; | ||
| 1998 | const prefixRemoteName = "my-org"; | ||
| 1999 | const remoteName = "my-org/upstream"; | ||
| 2000 | const featureBranch = "feature/slash-remote-push"; | ||
| 2001 | yield* git(remote, ["init", "--bare"]); | ||
| 2002 | yield* git(prefixRemote, ["init", "--bare"]); | ||
| 2003 | |||
| 2004 | const { initialBranch } = yield* initRepoWithCommit(tmp); | ||
| 2005 | yield* configureRemote(tmp, prefixRemoteName, prefixRemote, prefixFetchNamespace); | ||
| 2006 | yield* configureRemote(tmp, remoteName, remote, remoteName); | ||
| 2007 | yield* git(tmp, ["push", "-u", remoteName, initialBranch]); | ||
| 2008 | |||
| 2009 | yield* git(tmp, ["checkout", "-b", featureBranch]); | ||
| 2010 | yield* writeTextFile(path.join(tmp, "feature.txt"), "first revision\n"); | ||
| 2011 | yield* git(tmp, ["add", "feature.txt"]); | ||
| 2012 | yield* git(tmp, ["commit", "-m", "feature base"]); | ||
| 2013 | yield* git(tmp, ["push", "-u", remoteName, featureBranch]); | ||
| 2014 | |||
| 2015 | yield* writeTextFile(path.join(tmp, "feature.txt"), "second revision\n"); | ||
| 2016 | yield* git(tmp, ["add", "feature.txt"]); | ||
| 2017 | yield* git(tmp, ["commit", "-m", "feature update"]); | ||
| 2018 | |||
| 2019 | const core = yield* GitCore; | ||
| 2020 | const pushed = yield* core.pushCurrentBranch(tmp, null); | ||
| 2021 | expect(pushed.status).toBe("pushed"); | ||
| 2022 | expect(pushed.setUpstream).toBe(false); | ||
| 2023 | expect(pushed.upstreamBranch).toBe(`${remoteName}/${featureBranch}`); | ||
| 2024 | expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( | ||
| 2025 | `${remoteName}/${featureBranch}`, | ||
| 2026 | ); | ||
| 2027 | expect(yield* git(tmp, ["ls-remote", "--heads", remoteName, featureBranch])).toContain( | ||
| 2028 | featureBranch, | ||
| 2029 | ); | ||
| 2030 | }), | ||
| 2031 | ); | ||
| 2032 | |||
| 2033 | it.effect("includes command context when worktree removal fails", () => | ||
| 2034 | Effect.gen(function* () { | ||
| 2035 | const tmp = yield* makeTmpDir(); | ||
| 2036 | yield* initRepoWithCommit(tmp); | ||
| 2037 | const core = yield* GitCore; | ||
| 2038 | const missingWorktreePath = path.join(tmp, "missing-worktree"); | ||
| 2039 | |||
| 2040 | const removeResult = yield* Effect.result( | ||
| 2041 | core.removeWorktree({ cwd: tmp, path: missingWorktreePath }), | ||
| 2042 | ); | ||
| 2043 | expect(removeResult._tag).toBe("Failure"); | ||
| 2044 | if (removeResult._tag !== "Failure") { | ||
| 2045 | return; | ||
| 2046 | } | ||
| 2047 | const message = removeResult.failure.message; | ||
| 2048 | expect(message).toContain("git worktree remove"); | ||
| 2049 | expect(message).toContain(`cwd: ${tmp}`); | ||
| 2050 | expect(message).toContain(missingWorktreePath); | ||
| 2051 | }), | ||
| 2052 | ); | ||
| 2053 | |||
| 2054 | it.effect( | ||
| 2055 | "refreshes upstream before statusDetails so behind count reflects remote updates", | ||
| 2056 | () => | ||
| 2057 | Effect.gen(function* () { | ||
| 2058 | const remote = yield* makeTmpDir(); | ||
| 2059 | const source = yield* makeTmpDir(); | ||
| 2060 | const clone = yield* makeTmpDir(); | ||
| 2061 | yield* git(remote, ["init", "--bare"]); | ||
| 2062 | |||
| 2063 | yield* initRepoWithCommit(source); | ||
| 2064 | const initialBranch = (yield* (yield* GitCore).listBranches({ | ||
| 2065 | cwd: source, | ||
| 2066 | })).branches.find((branch) => branch.current)!.name; | ||
| 2067 | yield* git(source, ["remote", "add", "origin", remote]); | ||
| 2068 | yield* git(source, ["push", "-u", "origin", initialBranch]); | ||
| 2069 | |||
| 2070 | yield* git(clone, ["clone", remote, "."]); | ||
| 2071 | yield* git(clone, ["config", "user.email", "test@test.com"]); | ||
| 2072 | yield* git(clone, ["config", "user.name", "Test"]); | ||
| 2073 | yield* git(clone, [ | ||
| 2074 | "checkout", | ||
| 2075 | "-B", | ||
| 2076 | initialBranch, | ||
| 2077 | "--track", | ||
| 2078 | `origin/${initialBranch}`, | ||
| 2079 | ]); | ||
| 2080 | yield* writeTextFile(path.join(clone, "CHANGELOG.md"), "remote change\n"); | ||
| 2081 | yield* git(clone, ["add", "CHANGELOG.md"]); | ||
| 2082 | yield* git(clone, ["commit", "-m", "remote update"]); | ||
| 2083 | yield* git(clone, ["push", "origin", initialBranch]); | ||
| 2084 | |||
| 2085 | const core = yield* GitCore; | ||
| 2086 | const details = yield* core.statusDetails(source); | ||
| 2087 | expect(details.branch).toBe(initialBranch); | ||
| 2088 | expect(details.aheadCount).toBe(0); | ||
| 2089 | expect(details.behindCount).toBe(1); | ||
| 2090 | }), | ||
| 2091 | ); | ||
| 2092 | |||
| 2093 | it.effect("prepares commit context by auto-staging and creates commit", () => | ||
| 2094 | Effect.gen(function* () { | ||
| 2095 | const tmp = yield* makeTmpDir(); | ||
| 2096 | yield* initRepoWithCommit(tmp); | ||
| 2097 | const core = yield* GitCore; | ||
| 2098 | |||
| 2099 | yield* writeTextFile(path.join(tmp, "README.md"), "new content\n"); | ||
| 2100 | const context = yield* core.prepareCommitContext(tmp); | ||
| 2101 | expect(context).not.toBeNull(); | ||
| 2102 | expect(context!.stagedSummary.length).toBeGreaterThan(0); | ||
| 2103 | expect(context!.stagedPatch.length).toBeGreaterThan(0); | ||
| 2104 | |||
| 2105 | const created = yield* core.commit(tmp, "Add README update", "- include updated content"); | ||
| 2106 | expect(created.commitSha.length).toBeGreaterThan(0); | ||
| 2107 | expect(yield* git(tmp, ["log", "-1", "--pretty=%s"])).toBe("Add README update"); | ||
| 2108 | }), | ||
| 2109 | ); | ||
| 2110 | |||
| 2111 | it.effect("prepareCommitContext stages only selected files when filePaths provided", () => | ||
| 2112 | Effect.gen(function* () { | ||
| 2113 | const tmp = yield* makeTmpDir(); | ||
| 2114 | yield* initRepoWithCommit(tmp); | ||
| 2115 | const core = yield* GitCore; | ||
| 2116 | |||
| 2117 | yield* writeTextFile(path.join(tmp, "a.txt"), "file a\n"); | ||
| 2118 | yield* writeTextFile(path.join(tmp, "b.txt"), "file b\n"); | ||
| 2119 | |||
| 2120 | const context = yield* core.prepareCommitContext(tmp, ["a.txt"]); | ||
| 2121 | expect(context).not.toBeNull(); | ||
| 2122 | expect(context!.stagedSummary).toContain("a.txt"); | ||
| 2123 | expect(context!.stagedSummary).not.toContain("b.txt"); | ||
| 2124 | |||
| 2125 | yield* core.commit(tmp, "Add only a.txt", ""); | ||
| 2126 | |||
| 2127 | // b.txt should still be untracked after commit | ||
| 2128 | const statusAfter = yield* git(tmp, ["status", "--porcelain"]); | ||
| 2129 | expect(statusAfter).toContain("b.txt"); | ||
| 2130 | expect(statusAfter).not.toContain("a.txt"); | ||
| 2131 | }), | ||
| 2132 | ); | ||
| 2133 | |||
| 2134 | it.effect("prepareCommitContext stages everything when filePaths is undefined", () => | ||
| 2135 | Effect.gen(function* () { | ||
| 2136 | const tmp = yield* makeTmpDir(); | ||
| 2137 | yield* initRepoWithCommit(tmp); | ||
| 2138 | const core = yield* GitCore; | ||
| 2139 | |||
| 2140 | yield* writeTextFile(path.join(tmp, "a.txt"), "file a\n"); | ||
| 2141 | yield* writeTextFile(path.join(tmp, "b.txt"), "file b\n"); | ||
| 2142 | |||
| 2143 | const context = yield* core.prepareCommitContext(tmp); | ||
| 2144 | expect(context).not.toBeNull(); | ||
| 2145 | expect(context!.stagedSummary).toContain("a.txt"); | ||
| 2146 | expect(context!.stagedSummary).toContain("b.txt"); | ||
| 2147 | }), | ||
| 2148 | ); | ||
| 2149 | |||
| 2150 | it.effect("prepareCommitContext truncates oversized staged patches instead of failing", () => | ||
| 2151 | Effect.gen(function* () { | ||
| 2152 | const tmp = yield* makeTmpDir(); | ||
| 2153 | yield* initRepoWithCommit(tmp); | ||
| 2154 | const core = yield* GitCore; | ||
| 2155 | |||
| 2156 | yield* writeTextFile(path.join(tmp, "README.md"), buildLargeText()); | ||
| 2157 | |||
| 2158 | const context = yield* core.prepareCommitContext(tmp); | ||
| 2159 | expect(context).not.toBeNull(); | ||
| 2160 | expect(context!.stagedSummary).toContain("README.md"); | ||
| 2161 | expect(context!.stagedPatch).toContain("[truncated]"); | ||
| 2162 | }), | ||
| 2163 | ); | ||
| 2164 | |||
| 2165 | it.effect("readRangeContext truncates oversized diff patches instead of failing", () => | ||
| 2166 | Effect.gen(function* () { | ||
| 2167 | const tmp = yield* makeTmpDir(); | ||
| 2168 | const { initialBranch } = yield* initRepoWithCommit(tmp); | ||
| 2169 | const core = yield* GitCore; | ||
| 2170 | |||
| 2171 | yield* core.createBranch({ cwd: tmp, branch: "feature/large-range-context" }); | ||
| 2172 | yield* core.checkoutBranch({ cwd: tmp, branch: "feature/large-range-context" }); | ||
| 2173 | yield* writeTextFile(path.join(tmp, "large.txt"), buildLargeText()); | ||
| 2174 | yield* git(tmp, ["add", "large.txt"]); | ||
| 2175 | yield* git(tmp, ["commit", "-m", "Add large range context"]); | ||
| 2176 | |||
| 2177 | const rangeContext = yield* core.readRangeContext(tmp, initialBranch); | ||
| 2178 | expect(rangeContext.commitSummary).toContain("Add large range context"); | ||
| 2179 | expect(rangeContext.diffSummary).toContain("large.txt"); | ||
| 2180 | expect(rangeContext.diffPatch).toContain("[truncated]"); | ||
| 2181 | }), | ||
| 2182 | ); | ||
| 2183 | |||
| 2184 | it.effect("pushes with upstream setup and then skips when up to date", () => | ||
| 2185 | Effect.gen(function* () { | ||
| 2186 | const tmp = yield* makeTmpDir(); | ||
| 2187 | const remote = yield* makeTmpDir(); | ||
| 2188 | yield* initRepoWithCommit(tmp); | ||
| 2189 | yield* git(remote, ["init", "--bare"]); | ||
| 2190 | yield* git(tmp, ["remote", "add", "origin", remote]); | ||
| 2191 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/core-push" }); | ||
| 2192 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature/core-push" }); | ||
| 2193 | |||
| 2194 | yield* writeTextFile(path.join(tmp, "feature.txt"), "push me\n"); | ||
| 2195 | const core = yield* GitCore; | ||
| 2196 | const context = yield* core.prepareCommitContext(tmp); | ||
| 2197 | expect(context).not.toBeNull(); | ||
| 2198 | yield* core.commit(tmp, "Add feature file", ""); | ||
| 2199 | |||
| 2200 | const pushed = yield* core.pushCurrentBranch(tmp, null); | ||
| 2201 | expect(pushed.status).toBe("pushed"); | ||
| 2202 | expect(pushed.setUpstream).toBe(true); | ||
| 2203 | expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( | ||
| 2204 | "origin/feature/core-push", | ||
| 2205 | ); | ||
| 2206 | |||
| 2207 | const skipped = yield* core.pushCurrentBranch(tmp, null); | ||
| 2208 | expect(skipped.status).toBe("skipped_up_to_date"); | ||
| 2209 | }), | ||
| 2210 | ); | ||
| 2211 | |||
| 2212 | it.effect("pulls behind branch and then reports up-to-date", () => | ||
| 2213 | Effect.gen(function* () { | ||
| 2214 | const remote = yield* makeTmpDir(); | ||
| 2215 | const source = yield* makeTmpDir(); | ||
| 2216 | const clone = yield* makeTmpDir(); | ||
| 2217 | yield* git(remote, ["init", "--bare"]); | ||
| 2218 | |||
| 2219 | yield* initRepoWithCommit(source); | ||
| 2220 | const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( | ||
| 2221 | (branch) => branch.current, | ||
| 2222 | )!.name; | ||
| 2223 | yield* git(source, ["remote", "add", "origin", remote]); | ||
| 2224 | yield* git(source, ["push", "-u", "origin", initialBranch]); | ||
| 2225 | |||
| 2226 | yield* git(clone, ["clone", remote, "."]); | ||
| 2227 | yield* git(clone, ["config", "user.email", "test@test.com"]); | ||
| 2228 | yield* git(clone, ["config", "user.name", "Test"]); | ||
| 2229 | yield* writeTextFile(path.join(clone, "CHANGELOG.md"), "remote change\n"); | ||
| 2230 | yield* git(clone, ["add", "CHANGELOG.md"]); | ||
| 2231 | yield* git(clone, ["commit", "-m", "remote update"]); | ||
| 2232 | yield* git(clone, ["push", "origin", initialBranch]); | ||
| 2233 | |||
| 2234 | const core = yield* GitCore; | ||
| 2235 | const pulled = yield* core.pullCurrentBranch(source); | ||
| 2236 | expect(pulled.status).toBe("pulled"); | ||
| 2237 | expect((yield* core.statusDetails(source)).behindCount).toBe(0); | ||
| 2238 | |||
| 2239 | const skipped = yield* core.pullCurrentBranch(source); | ||
| 2240 | expect(skipped.status).toBe("skipped_up_to_date"); | ||
| 2241 | }), | ||
| 2242 | ); | ||
| 2243 | |||
| 2244 | it.effect("top-level pullGitBranch rejects when no upstream exists", () => | ||
| 2245 | Effect.gen(function* () { | ||
| 2246 | const tmp = yield* makeTmpDir(); | ||
| 2247 | yield* initRepoWithCommit(tmp); | ||
| 2248 | const result = yield* Effect.result((yield* GitCore).pullCurrentBranch(tmp)); | ||
| 2249 | expect(result._tag).toBe("Failure"); | ||
| 2250 | if (result._tag === "Failure") { | ||
| 2251 | expect(result.failure.message.toLowerCase()).toContain("no upstream"); | ||
| 2252 | } | ||
| 2253 | }), | ||
| 2254 | ); | ||
| 2255 | |||
| 2256 | it.effect("lists branches when recency lookup fails", () => | ||
| 2257 | Effect.gen(function* () { | ||
| 2258 | const tmp = yield* makeTmpDir(); | ||
| 2259 | yield* initRepoWithCommit(tmp); | ||
| 2260 | const realGitCore = yield* GitCore; | ||
| 2261 | let didFailRecency = false; | ||
| 2262 | const core = yield* makeIsolatedGitCore((input) => { | ||
| 2263 | if (!didFailRecency && input.args[0] === "for-each-ref") { | ||
| 2264 | didFailRecency = true; | ||
| 2265 | return Effect.fail( | ||
| 2266 | new GitCommandError({ | ||
| 2267 | operation: "git.test.listBranchesRecency", | ||
| 2268 | command: `git ${input.args.join(" ")}`, | ||
| 2269 | cwd: input.cwd, | ||
| 2270 | detail: "timeout", | ||
| 2271 | }), | ||
| 2272 | ); | ||
| 2273 | } | ||
| 2274 | return realGitCore.execute(input); | ||
| 2275 | }); | ||
| 2276 | |||
| 2277 | const result = yield* core.listBranches({ cwd: tmp }); | ||
| 2278 | |||
| 2279 | expect(result.isRepo).toBe(true); | ||
| 2280 | expect(result.branches.length).toBeGreaterThan(0); | ||
| 2281 | expect(result.branches[0]?.current).toBe(true); | ||
| 2282 | expect(didFailRecency).toBe(true); | ||
| 2283 | }), | ||
| 2284 | ); | ||
| 2285 | |||
| 2286 | it.effect("falls back to empty remote branch data when remote lookups fail", () => | ||
| 2287 | Effect.gen(function* () { | ||
| 2288 | const tmp = yield* makeTmpDir(); | ||
| 2289 | const remote = yield* makeTmpDir(); | ||
| 2290 | yield* initRepoWithCommit(tmp); | ||
| 2291 | yield* git(remote, ["init", "--bare"]); | ||
| 2292 | yield* git(tmp, ["remote", "add", "origin", remote]); | ||
| 2293 | |||
| 2294 | const realGitCore = yield* GitCore; | ||
| 2295 | let didFailRemoteBranches = false; | ||
| 2296 | let didFailRemoteNames = false; | ||
| 2297 | const core = yield* makeIsolatedGitCore((input) => { | ||
| 2298 | if (input.args.join(" ") === "branch --no-color --no-column --remotes") { | ||
| 2299 | didFailRemoteBranches = true; | ||
| 2300 | return Effect.fail( | ||
| 2301 | new GitCommandError({ | ||
| 2302 | operation: "git.test.listBranchesRemoteBranches", | ||
| 2303 | command: `git ${input.args.join(" ")}`, | ||
| 2304 | cwd: input.cwd, | ||
| 2305 | detail: "remote unavailable", | ||
| 2306 | }), | ||
| 2307 | ); | ||
| 2308 | } | ||
| 2309 | if (input.args.join(" ") === "remote") { | ||
| 2310 | didFailRemoteNames = true; | ||
| 2311 | return Effect.fail( | ||
| 2312 | new GitCommandError({ | ||
| 2313 | operation: "git.test.listBranchesRemoteNames", | ||
| 2314 | command: `git ${input.args.join(" ")}`, | ||
| 2315 | cwd: input.cwd, | ||
| 2316 | detail: "remote unavailable", | ||
| 2317 | }), | ||
| 2318 | ); | ||
| 2319 | } | ||
| 2320 | return realGitCore.execute(input); | ||
| 2321 | }); | ||
| 2322 | |||
| 2323 | const result = yield* core.listBranches({ cwd: tmp }); | ||
| 2324 | |||
| 2325 | expect(result.isRepo).toBe(true); | ||
| 2326 | expect(result.branches.length).toBeGreaterThan(0); | ||
| 2327 | expect(result.branches.every((branch) => !branch.isRemote)).toBe(true); | ||
| 2328 | expect(didFailRemoteBranches).toBe(true); | ||
| 2329 | expect(didFailRemoteNames).toBe(true); | ||
| 2330 | }), | ||
| 2331 | ); | ||
| 2332 | }); | ||
| 2333 | }); | ||
apps/server/src/git/Layers/GitCore.ts deleted-2206| ... | @@ -1,2206 +0,0 @@ | ||
| 1 | import { | ||
| 2 | Cache, | ||
| 3 | Data, | ||
| 4 | Duration, | ||
| 5 | Effect, | ||
| 6 | Exit, | ||
| 7 | FileSystem, | ||
| 8 | Layer, | ||
| 9 | Option, | ||
| 10 | Path, | ||
| 11 | PlatformError, | ||
| 12 | Ref, | ||
| 13 | Result, | ||
| 14 | Schema, | ||
| 15 | Scope, | ||
| 16 | Semaphore, | ||
| 17 | Stream, | ||
| 18 | } from "effect"; | ||
| 19 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 20 | |||
| 21 | import { GitCommandError, type GitBranch } from "@t3tools/contracts"; | ||
| 22 | import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; | ||
| 23 | import { compactTraceAttributes } from "../../observability/Attributes.ts"; | ||
| 24 | import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../../observability/Metrics.ts"; | ||
| 25 | import { | ||
| 26 | GitCore, | ||
| 27 | type ExecuteGitProgress, | ||
| 28 | type GitCommitOptions, | ||
| 29 | type GitCoreShape, | ||
| 30 | type GitStatusDetails, | ||
| 31 | type ExecuteGitInput, | ||
| 32 | type ExecuteGitResult, | ||
| 33 | } from "../Services/GitCore.ts"; | ||
| 34 | import { | ||
| 35 | parseRemoteNames, | ||
| 36 | parseRemoteNamesInGitOrder, | ||
| 37 | parseRemoteRefWithRemoteNames, | ||
| 38 | } from "../remoteRefs.ts"; | ||
| 39 | import { ServerConfig } from "../../config.ts"; | ||
| 40 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | ||
| 41 | |||
| 42 | const DEFAULT_TIMEOUT_MS = 30_000; | ||
| 43 | const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; | ||
| 44 | const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; | ||
| 45 | const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000; | ||
| 46 | const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000; | ||
| 47 | const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; | ||
| 48 | const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000; | ||
| 49 | const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; | ||
| 50 | const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; | ||
| 51 | const WORKSPACE_GIT_HARDENED_CONFIG_ARGS = [ | ||
| 52 | "-c", | ||
| 53 | "core.fsmonitor=false", | ||
| 54 | "-c", | ||
| 55 | "core.untrackedCache=false", | ||
| 56 | ] as const; | ||
| 57 | const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); | ||
| 58 | const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); | ||
| 59 | const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); | ||
| 60 | const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; | ||
| 61 | const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; | ||
| 62 | const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; | ||
| 63 | const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitStatusDetails>({ | ||
| 64 | isRepo: false, | ||
| 65 | hasOriginRemote: false, | ||
| 66 | isDefaultBranch: false, | ||
| 67 | branch: null, | ||
| 68 | upstreamRef: null, | ||
| 69 | hasWorkingTreeChanges: false, | ||
| 70 | workingTree: { files: [], insertions: 0, deletions: 0 }, | ||
| 71 | hasUpstream: false, | ||
| 72 | aheadCount: 0, | ||
| 73 | behindCount: 0, | ||
| 74 | }); | ||
| 75 | |||
| 76 | type TraceTailState = { | ||
| 77 | processedChars: number; | ||
| 78 | remainder: string; | ||
| 79 | }; | ||
| 80 | |||
| 81 | class StatusRemoteRefreshCacheKey extends Data.Class<{ | ||
| 82 | gitCommonDir: string; | ||
| 83 | remoteName: string; | ||
| 84 | }> {} | ||
| 85 | |||
| 86 | interface ExecuteGitOptions { | ||
| 87 | stdin?: string | undefined; | ||
| 88 | timeoutMs?: number | undefined; | ||
| 89 | allowNonZeroExit?: boolean | undefined; | ||
| 90 | fallbackErrorMessage?: string | undefined; | ||
| 91 | maxOutputBytes?: number | undefined; | ||
| 92 | truncateOutputAtMaxBytes?: boolean | undefined; | ||
| 93 | progress?: ExecuteGitProgress | undefined; | ||
| 94 | } | ||
| 95 | |||
| 96 | function parseBranchAb(value: string): { ahead: number; behind: number } { | ||
| 97 | const match = value.match(/^\+(\d+)\s+-(\d+)$/); | ||
| 98 | if (!match) return { ahead: 0, behind: 0 }; | ||
| 99 | return { | ||
| 100 | ahead: Number(match[1] ?? "0"), | ||
| 101 | behind: Number(match[2] ?? "0"), | ||
| 102 | }; | ||
| 103 | } | ||
| 104 | |||
| 105 | function parseNumstatEntries( | ||
| 106 | stdout: string, | ||
| 107 | ): Array<{ path: string; insertions: number; deletions: number }> { | ||
| 108 | const entries: Array<{ path: string; insertions: number; deletions: number }> = []; | ||
| 109 | for (const line of stdout.split(/\r?\n/g)) { | ||
| 110 | if (line.trim().length === 0) continue; | ||
| 111 | const [addedRaw, deletedRaw, ...pathParts] = line.split("\t"); | ||
| 112 | const rawPath = | ||
| 113 | pathParts.length > 1 ? (pathParts.at(-1) ?? "").trim() : pathParts.join("\t").trim(); | ||
| 114 | if (rawPath.length === 0) continue; | ||
| 115 | const added = Number.parseInt(addedRaw ?? "0", 10); | ||
| 116 | const deleted = Number.parseInt(deletedRaw ?? "0", 10); | ||
| 117 | const renameArrowIndex = rawPath.indexOf(" => "); | ||
| 118 | const normalizedPath = | ||
| 119 | renameArrowIndex >= 0 ? rawPath.slice(renameArrowIndex + " => ".length).trim() : rawPath; | ||
| 120 | entries.push({ | ||
| 121 | path: normalizedPath.length > 0 ? normalizedPath : rawPath, | ||
| 122 | insertions: Number.isFinite(added) ? added : 0, | ||
| 123 | deletions: Number.isFinite(deleted) ? deleted : 0, | ||
| 124 | }); | ||
| 125 | } | ||
| 126 | return entries; | ||
| 127 | } | ||
| 128 | |||
| 129 | function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { | ||
| 130 | const parts = input.split("\0"); | ||
| 131 | if (parts.length === 0) return []; | ||
| 132 | |||
| 133 | if (truncated && parts[parts.length - 1]?.length) { | ||
| 134 | parts.pop(); | ||
| 135 | } | ||
| 136 | |||
| 137 | return parts.filter((value) => value.length > 0); | ||
| 138 | } | ||
| 139 | |||
| 140 | function chunkPathsForGitCheckIgnore(relativePaths: readonly string[]): string[][] { | ||
| 141 | const chunks: string[][] = []; | ||
| 142 | let chunk: string[] = []; | ||
| 143 | let chunkBytes = 0; | ||
| 144 | |||
| 145 | for (const relativePath of relativePaths) { | ||
| 146 | const relativePathBytes = Buffer.byteLength(relativePath) + 1; | ||
| 147 | if (chunk.length > 0 && chunkBytes + relativePathBytes > GIT_CHECK_IGNORE_MAX_STDIN_BYTES) { | ||
| 148 | chunks.push(chunk); | ||
| 149 | chunk = []; | ||
| 150 | chunkBytes = 0; | ||
| 151 | } | ||
| 152 | |||
| 153 | chunk.push(relativePath); | ||
| 154 | chunkBytes += relativePathBytes; | ||
| 155 | |||
| 156 | if (chunkBytes >= GIT_CHECK_IGNORE_MAX_STDIN_BYTES) { | ||
| 157 | chunks.push(chunk); | ||
| 158 | chunk = []; | ||
| 159 | chunkBytes = 0; | ||
| 160 | } | ||
| 161 | } | ||
| 162 | |||
| 163 | if (chunk.length > 0) { | ||
| 164 | chunks.push(chunk); | ||
| 165 | } | ||
| 166 | |||
| 167 | return chunks; | ||
| 168 | } | ||
| 169 | |||
| 170 | function parsePorcelainPath(line: string): string | null { | ||
| 171 | if (line.startsWith("? ") || line.startsWith("! ")) { | ||
| 172 | const simple = line.slice(2).trim(); | ||
| 173 | return simple.length > 0 ? simple : null; | ||
| 174 | } | ||
| 175 | |||
| 176 | if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) { | ||
| 177 | return null; | ||
| 178 | } | ||
| 179 | |||
| 180 | const tabIndex = line.indexOf("\t"); | ||
| 181 | if (tabIndex >= 0) { | ||
| 182 | const fromTab = line.slice(tabIndex + 1); | ||
| 183 | const [filePath] = fromTab.split("\t"); | ||
| 184 | return filePath?.trim().length ? filePath.trim() : null; | ||
| 185 | } | ||
| 186 | |||
| 187 | const parts = line.trim().split(/\s+/g); | ||
| 188 | const filePath = parts.at(-1) ?? ""; | ||
| 189 | return filePath.length > 0 ? filePath : null; | ||
| 190 | } | ||
| 191 | |||
| 192 | function parseBranchLine(line: string): { name: string; current: boolean } | null { | ||
| 193 | const trimmed = line.trim(); | ||
| 194 | if (trimmed.length === 0) return null; | ||
| 195 | |||
| 196 | const name = trimmed.replace(/^[*+]\s+/, ""); | ||
| 197 | // Exclude symbolic refs like: "origin/HEAD -> origin/main". | ||
| 198 | // Exclude detached HEAD pseudo-refs like: "(HEAD detached at origin/main)". | ||
| 199 | if (name.includes(" -> ") || name.startsWith("(")) return null; | ||
| 200 | |||
| 201 | return { | ||
| 202 | name, | ||
| 203 | current: trimmed.startsWith("* "), | ||
| 204 | }; | ||
| 205 | } | ||
| 206 | |||
| 207 | function filterBranchesForListQuery( | ||
| 208 | branches: ReadonlyArray<GitBranch>, | ||
| 209 | query?: string, | ||
| 210 | ): ReadonlyArray<GitBranch> { | ||
| 211 | if (!query) { | ||
| 212 | return branches; | ||
| 213 | } | ||
| 214 | |||
| 215 | const normalizedQuery = query.toLowerCase(); | ||
| 216 | return branches.filter((branch) => branch.name.toLowerCase().includes(normalizedQuery)); | ||
| 217 | } | ||
| 218 | |||
| 219 | function paginateBranches(input: { | ||
| 220 | branches: ReadonlyArray<GitBranch>; | ||
| 221 | cursor?: number | undefined; | ||
| 222 | limit?: number | undefined; | ||
| 223 | }): { | ||
| 224 | branches: ReadonlyArray<GitBranch>; | ||
| 225 | nextCursor: number | null; | ||
| 226 | totalCount: number; | ||
| 227 | } { | ||
| 228 | const cursor = input.cursor ?? 0; | ||
| 229 | const limit = input.limit ?? GIT_LIST_BRANCHES_DEFAULT_LIMIT; | ||
| 230 | const totalCount = input.branches.length; | ||
| 231 | const branches = input.branches.slice(cursor, cursor + limit); | ||
| 232 | const nextCursor = cursor + branches.length < totalCount ? cursor + branches.length : null; | ||
| 233 | |||
| 234 | return { | ||
| 235 | branches, | ||
| 236 | nextCursor, | ||
| 237 | totalCount, | ||
| 238 | }; | ||
| 239 | } | ||
| 240 | |||
| 241 | function sanitizeRemoteName(value: string): string { | ||
| 242 | const sanitized = value | ||
| 243 | .trim() | ||
| 244 | .replace(/[^A-Za-z0-9._-]+/g, "-") | ||
| 245 | .replace(/^-+|-+$/g, ""); | ||
| 246 | return sanitized.length > 0 ? sanitized : "fork"; | ||
| 247 | } | ||
| 248 | |||
| 249 | function normalizeRemoteUrl(value: string): string { | ||
| 250 | return value | ||
| 251 | .trim() | ||
| 252 | .replace(/\/+$/g, "") | ||
| 253 | .replace(/\.git$/i, "") | ||
| 254 | .toLowerCase(); | ||
| 255 | } | ||
| 256 | |||
| 257 | function parseRemoteFetchUrls(stdout: string): Map<string, string> { | ||
| 258 | const remotes = new Map<string, string>(); | ||
| 259 | for (const line of stdout.split("\n")) { | ||
| 260 | const trimmed = line.trim(); | ||
| 261 | if (trimmed.length === 0) continue; | ||
| 262 | const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); | ||
| 263 | if (!match) continue; | ||
| 264 | const [, remoteName = "", remoteUrl = "", direction = ""] = match; | ||
| 265 | if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) { | ||
| 266 | continue; | ||
| 267 | } | ||
| 268 | remotes.set(remoteName, remoteUrl); | ||
| 269 | } | ||
| 270 | return remotes; | ||
| 271 | } | ||
| 272 | |||
| 273 | function parseUpstreamRefWithRemoteNames( | ||
| 274 | upstreamRef: string, | ||
| 275 | remoteNames: ReadonlyArray<string>, | ||
| 276 | ): { upstreamRef: string; remoteName: string; upstreamBranch: string } | null { | ||
| 277 | const parsed = parseRemoteRefWithRemoteNames(upstreamRef, remoteNames); | ||
| 278 | if (!parsed) { | ||
| 279 | return null; | ||
| 280 | } | ||
| 281 | |||
| 282 | return { | ||
| 283 | upstreamRef, | ||
| 284 | remoteName: parsed.remoteName, | ||
| 285 | upstreamBranch: parsed.branchName, | ||
| 286 | }; | ||
| 287 | } | ||
| 288 | |||
| 289 | function parseUpstreamRefByFirstSeparator( | ||
| 290 | upstreamRef: string, | ||
| 291 | ): { upstreamRef: string; remoteName: string; upstreamBranch: string } | null { | ||
| 292 | const separatorIndex = upstreamRef.indexOf("/"); | ||
| 293 | if (separatorIndex <= 0 || separatorIndex === upstreamRef.length - 1) { | ||
| 294 | return null; | ||
| 295 | } | ||
| 296 | |||
| 297 | const remoteName = upstreamRef.slice(0, separatorIndex).trim(); | ||
| 298 | const upstreamBranch = upstreamRef.slice(separatorIndex + 1).trim(); | ||
| 299 | if (remoteName.length === 0 || upstreamBranch.length === 0) { | ||
| 300 | return null; | ||
| 301 | } | ||
| 302 | |||
| 303 | return { | ||
| 304 | upstreamRef, | ||
| 305 | remoteName, | ||
| 306 | upstreamBranch, | ||
| 307 | }; | ||
| 308 | } | ||
| 309 | |||
| 310 | function parseTrackingBranchByUpstreamRef(stdout: string, upstreamRef: string): string | null { | ||
| 311 | for (const line of stdout.split("\n")) { | ||
| 312 | const trimmedLine = line.trim(); | ||
| 313 | if (trimmedLine.length === 0) { | ||
| 314 | continue; | ||
| 315 | } | ||
| 316 | const [branchNameRaw, upstreamBranchRaw = ""] = trimmedLine.split("\t"); | ||
| 317 | const branchName = branchNameRaw?.trim() ?? ""; | ||
| 318 | const upstreamBranch = upstreamBranchRaw.trim(); | ||
| 319 | if (branchName.length === 0 || upstreamBranch.length === 0) { | ||
| 320 | continue; | ||
| 321 | } | ||
| 322 | if (upstreamBranch === upstreamRef) { | ||
| 323 | return branchName; | ||
| 324 | } | ||
| 325 | } | ||
| 326 | |||
| 327 | return null; | ||
| 328 | } | ||
| 329 | |||
| 330 | function deriveLocalBranchNameFromRemoteRef(branchName: string): string | null { | ||
| 331 | const separatorIndex = branchName.indexOf("/"); | ||
| 332 | if (separatorIndex <= 0 || separatorIndex === branchName.length - 1) { | ||
| 333 | return null; | ||
| 334 | } | ||
| 335 | const localBranch = branchName.slice(separatorIndex + 1).trim(); | ||
| 336 | return localBranch.length > 0 ? localBranch : null; | ||
| 337 | } | ||
| 338 | |||
| 339 | function commandLabel(args: readonly string[]): string { | ||
| 340 | return `git ${args.join(" ")}`; | ||
| 341 | } | ||
| 342 | |||
| 343 | function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): string | null { | ||
| 344 | const trimmed = value.trim(); | ||
| 345 | const prefix = `refs/remotes/${remoteName}/`; | ||
| 346 | if (!trimmed.startsWith(prefix)) { | ||
| 347 | return null; | ||
| 348 | } | ||
| 349 | const branch = trimmed.slice(prefix.length).trim(); | ||
| 350 | return branch.length > 0 ? branch : null; | ||
| 351 | } | ||
| 352 | |||
| 353 | function createGitCommandError( | ||
| 354 | operation: string, | ||
| 355 | cwd: string, | ||
| 356 | args: readonly string[], | ||
| 357 | detail: string, | ||
| 358 | cause?: unknown, | ||
| 359 | ): GitCommandError { | ||
| 360 | return new GitCommandError({ | ||
| 361 | operation, | ||
| 362 | command: commandLabel(args), | ||
| 363 | cwd, | ||
| 364 | detail, | ||
| 365 | ...(cause !== undefined ? { cause } : {}), | ||
| 366 | }); | ||
| 367 | } | ||
| 368 | |||
| 369 | function quoteGitCommand(args: ReadonlyArray<string>): string { | ||
| 370 | return `git ${args.join(" ")}`; | ||
| 371 | } | ||
| 372 | |||
| 373 | function isMissingGitCwdError(error: GitCommandError): boolean { | ||
| 374 | const normalized = `${error.detail}\n${error.message}`.toLowerCase(); | ||
| 375 | return ( | ||
| 376 | normalized.includes("no such file or directory") || | ||
| 377 | normalized.includes("notfound: filesystem.access") || | ||
| 378 | normalized.includes("enoent") || | ||
| 379 | normalized.includes("not a directory") | ||
| 380 | ); | ||
| 381 | } | ||
| 382 | |||
| 383 | function toGitCommandError( | ||
| 384 | input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">, | ||
| 385 | detail: string, | ||
| 386 | ) { | ||
| 387 | return (cause: unknown) => | ||
| 388 | Schema.is(GitCommandError)(cause) | ||
| 389 | ? cause | ||
| 390 | : new GitCommandError({ | ||
| 391 | operation: input.operation, | ||
| 392 | command: quoteGitCommand(input.args), | ||
| 393 | cwd: input.cwd, | ||
| 394 | detail: `${cause instanceof Error && cause.message.length > 0 ? cause.message : "Unknown error"} - ${detail}`, | ||
| 395 | ...(cause !== undefined ? { cause } : {}), | ||
| 396 | }); | ||
| 397 | } | ||
| 398 | |||
| 399 | interface Trace2Monitor { | ||
| 400 | readonly env: NodeJS.ProcessEnv; | ||
| 401 | readonly flush: Effect.Effect<void, never>; | ||
| 402 | } | ||
| 403 | |||
| 404 | const nowUnixNano = (): bigint => BigInt(Date.now()) * 1_000_000n; | ||
| 405 | |||
| 406 | const addCurrentSpanEvent = (name: string, attributes: Record<string, unknown>) => | ||
| 407 | Effect.currentSpan.pipe( | ||
| 408 | Effect.tap((span) => | ||
| 409 | Effect.sync(() => { | ||
| 410 | span.event(name, nowUnixNano(), compactTraceAttributes(attributes)); | ||
| 411 | }), | ||
| 412 | ), | ||
| 413 | Effect.catch(() => Effect.void), | ||
| 414 | ); | ||
| 415 | |||
| 416 | function trace2ChildKey(record: Record<string, unknown>): string | null { | ||
| 417 | const childId = record.child_id; | ||
| 418 | if (typeof childId === "number" || typeof childId === "string") { | ||
| 419 | return String(childId); | ||
| 420 | } | ||
| 421 | const hookName = record.hook_name; | ||
| 422 | return typeof hookName === "string" && hookName.trim().length > 0 ? hookName.trim() : null; | ||
| 423 | } | ||
| 424 | |||
| 425 | const Trace2Record = Schema.Record(Schema.String, Schema.Unknown); | ||
| 426 | |||
| 427 | const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( | ||
| 428 | input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">, | ||
| 429 | progress: ExecuteGitProgress | undefined, | ||
| 430 | ): Effect.fn.Return< | ||
| 431 | Trace2Monitor, | ||
| 432 | PlatformError.PlatformError, | ||
| 433 | Scope.Scope | FileSystem.FileSystem | Path.Path | ||
| 434 | > { | ||
| 435 | if (!progress?.onHookStarted && !progress?.onHookFinished) { | ||
| 436 | return { | ||
| 437 | env: {}, | ||
| 438 | flush: Effect.void, | ||
| 439 | }; | ||
| 440 | } | ||
| 441 | |||
| 442 | const fs = yield* FileSystem.FileSystem; | ||
| 443 | const path = yield* Path.Path; | ||
| 444 | const traceFilePath = yield* fs.makeTempFileScoped({ | ||
| 445 | prefix: `t3code-git-trace2-${process.pid}-`, | ||
| 446 | suffix: ".json", | ||
| 447 | }); | ||
| 448 | const hookStartByChildKey = new Map<string, { hookName: string; startedAtMs: number }>(); | ||
| 449 | const traceTailState = yield* Ref.make<TraceTailState>({ | ||
| 450 | processedChars: 0, | ||
| 451 | remainder: "", | ||
| 452 | }); | ||
| 453 | |||
| 454 | const handleTraceLine = Effect.fn("handleTraceLine")(function* (line: string) { | ||
| 455 | const trimmedLine = line.trim(); | ||
| 456 | if (trimmedLine.length === 0) { | ||
| 457 | return; | ||
| 458 | } | ||
| 459 | |||
| 460 | const traceRecord = decodeJsonResult(Trace2Record)(trimmedLine); | ||
| 461 | if (Result.isFailure(traceRecord)) { | ||
| 462 | yield* Effect.logDebug( | ||
| 463 | `GitCore.trace2: failed to parse trace line for ${quoteGitCommand(input.args)} in ${input.cwd}`, | ||
| 464 | traceRecord.failure, | ||
| 465 | ); | ||
| 466 | return; | ||
| 467 | } | ||
| 468 | |||
| 469 | if (traceRecord.success.child_class !== "hook") { | ||
| 470 | return; | ||
| 471 | } | ||
| 472 | |||
| 473 | const event = traceRecord.success.event; | ||
| 474 | const childKey = trace2ChildKey(traceRecord.success); | ||
| 475 | if (childKey === null) { | ||
| 476 | return; | ||
| 477 | } | ||
| 478 | const started = hookStartByChildKey.get(childKey); | ||
| 479 | const hookNameFromEvent = | ||
| 480 | typeof traceRecord.success.hook_name === "string" ? traceRecord.success.hook_name.trim() : ""; | ||
| 481 | const hookName = hookNameFromEvent.length > 0 ? hookNameFromEvent : (started?.hookName ?? ""); | ||
| 482 | if (hookName.length === 0) { | ||
| 483 | return; | ||
| 484 | } | ||
| 485 | |||
| 486 | if (event === "child_start") { | ||
| 487 | hookStartByChildKey.set(childKey, { hookName, startedAtMs: Date.now() }); | ||
| 488 | yield* addCurrentSpanEvent("git.hook.started", { | ||
| 489 | hookName, | ||
| 490 | }); | ||
| 491 | if (progress.onHookStarted) { | ||
| 492 | yield* progress.onHookStarted(hookName); | ||
| 493 | } | ||
| 494 | return; | ||
| 495 | } | ||
| 496 | |||
| 497 | if (event === "child_exit") { | ||
| 498 | hookStartByChildKey.delete(childKey); | ||
| 499 | const code = traceRecord.success.code; | ||
| 500 | const exitCode = typeof code === "number" && Number.isInteger(code) ? code : null; | ||
| 501 | const durationMs = started ? Math.max(0, Date.now() - started.startedAtMs) : null; | ||
| 502 | yield* addCurrentSpanEvent("git.hook.finished", { | ||
| 503 | hookName: started?.hookName ?? hookName, | ||
| 504 | exitCode, | ||
| 505 | durationMs, | ||
| 506 | }); | ||
| 507 | if (progress.onHookFinished) { | ||
| 508 | yield* progress.onHookFinished({ | ||
| 509 | hookName: started?.hookName ?? hookName, | ||
| 510 | exitCode, | ||
| 511 | durationMs, | ||
| 512 | }); | ||
| 513 | } | ||
| 514 | } | ||
| 515 | }); | ||
| 516 | |||
| 517 | const deltaMutex = yield* Semaphore.make(1); | ||
| 518 | const readTraceDelta = deltaMutex.withPermit( | ||
| 519 | fs.readFileString(traceFilePath).pipe( | ||
| 520 | Effect.flatMap((contents) => | ||
| 521 | Effect.uninterruptible( | ||
| 522 | Ref.modify(traceTailState, ({ processedChars, remainder }) => { | ||
| 523 | if (contents.length <= processedChars) { | ||
| 524 | return [[], { processedChars, remainder }]; | ||
| 525 | } | ||
| 526 | |||
| 527 | const appended = contents.slice(processedChars); | ||
| 528 | const combined = remainder + appended; | ||
| 529 | const lines = combined.split("\n"); | ||
| 530 | const nextRemainder = lines.pop() ?? ""; | ||
| 531 | |||
| 532 | return [ | ||
| 533 | lines.map((line) => line.replace(/\r$/, "")), | ||
| 534 | { | ||
| 535 | processedChars: contents.length, | ||
| 536 | remainder: nextRemainder, | ||
| 537 | }, | ||
| 538 | ]; | ||
| 539 | }).pipe( | ||
| 540 | Effect.flatMap((lines) => Effect.forEach(lines, handleTraceLine, { discard: true })), | ||
| 541 | ), | ||
| 542 | ), | ||
| 543 | ), | ||
| 544 | Effect.ignore({ log: true }), | ||
| 545 | ), | ||
| 546 | ); | ||
| 547 | const traceFileName = path.basename(traceFilePath); | ||
| 548 | yield* Stream.runForEach(fs.watch(traceFilePath), (event) => { | ||
| 549 | const eventPath = event.path; | ||
| 550 | const isTargetTraceEvent = | ||
| 551 | eventPath === traceFilePath || | ||
| 552 | eventPath === traceFileName || | ||
| 553 | path.basename(eventPath) === traceFileName; | ||
| 554 | if (!isTargetTraceEvent) return Effect.void; | ||
| 555 | return readTraceDelta; | ||
| 556 | }).pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped); | ||
| 557 | |||
| 558 | const finalizeTrace2Monitor = Effect.fn("finalizeTrace2Monitor")(function* () { | ||
| 559 | yield* readTraceDelta; | ||
| 560 | const finalLine = yield* Ref.modify(traceTailState, ({ processedChars, remainder }) => [ | ||
| 561 | remainder.trim(), | ||
| 562 | { | ||
| 563 | processedChars, | ||
| 564 | remainder: "", | ||
| 565 | }, | ||
| 566 | ]); | ||
| 567 | if (finalLine.length > 0) { | ||
| 568 | yield* handleTraceLine(finalLine); | ||
| 569 | } | ||
| 570 | }); | ||
| 571 | |||
| 572 | yield* Effect.addFinalizer(finalizeTrace2Monitor); | ||
| 573 | |||
| 574 | return { | ||
| 575 | env: { | ||
| 576 | GIT_TRACE2_EVENT: traceFilePath, | ||
| 577 | }, | ||
| 578 | flush: readTraceDelta, | ||
| 579 | }; | ||
| 580 | }); | ||
| 581 | |||
| 582 | const collectOutput = Effect.fn("collectOutput")(function* <E>( | ||
| 583 | input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">, | ||
| 584 | stream: Stream.Stream<Uint8Array, E>, | ||
| 585 | maxOutputBytes: number, | ||
| 586 | truncateOutputAtMaxBytes: boolean, | ||
| 587 | onLine: ((line: string) => Effect.Effect<void, never>) | undefined, | ||
| 588 | ): Effect.fn.Return<{ readonly text: string; readonly truncated: boolean }, GitCommandError> { | ||
| 589 | const decoder = new TextDecoder(); | ||
| 590 | let bytes = 0; | ||
| 591 | let text = ""; | ||
| 592 | let lineBuffer = ""; | ||
| 593 | let truncated = false; | ||
| 594 | |||
| 595 | const emitCompleteLines = Effect.fn("emitCompleteLines")(function* (flush: boolean) { | ||
| 596 | let newlineIndex = lineBuffer.indexOf("\n"); | ||
| 597 | while (newlineIndex >= 0) { | ||
| 598 | const line = lineBuffer.slice(0, newlineIndex).replace(/\r$/, ""); | ||
| 599 | lineBuffer = lineBuffer.slice(newlineIndex + 1); | ||
| 600 | if (line.length > 0 && onLine) { | ||
| 601 | yield* onLine(line); | ||
| 602 | } | ||
| 603 | newlineIndex = lineBuffer.indexOf("\n"); | ||
| 604 | } | ||
| 605 | |||
| 606 | if (flush) { | ||
| 607 | const trailing = lineBuffer.replace(/\r$/, ""); | ||
| 608 | lineBuffer = ""; | ||
| 609 | if (trailing.length > 0 && onLine) { | ||
| 610 | yield* onLine(trailing); | ||
| 611 | } | ||
| 612 | } | ||
| 613 | }); | ||
| 614 | |||
| 615 | const processChunk = Effect.fn("processChunk")(function* (chunk: Uint8Array) { | ||
| 616 | if (truncateOutputAtMaxBytes && truncated) { | ||
| 617 | return; | ||
| 618 | } | ||
| 619 | const nextBytes = bytes + chunk.byteLength; | ||
| 620 | if (!truncateOutputAtMaxBytes && nextBytes > maxOutputBytes) { | ||
| 621 | return yield* new GitCommandError({ | ||
| 622 | operation: input.operation, | ||
| 623 | command: quoteGitCommand(input.args), | ||
| 624 | cwd: input.cwd, | ||
| 625 | detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`, | ||
| 626 | }); | ||
| 627 | } | ||
| 628 | |||
| 629 | const chunkToDecode = | ||
| 630 | truncateOutputAtMaxBytes && nextBytes > maxOutputBytes | ||
| 631 | ? chunk.subarray(0, Math.max(0, maxOutputBytes - bytes)) | ||
| 632 | : chunk; | ||
| 633 | bytes += chunkToDecode.byteLength; | ||
| 634 | truncated = truncateOutputAtMaxBytes && nextBytes > maxOutputBytes; | ||
| 635 | |||
| 636 | const decoded = decoder.decode(chunkToDecode, { stream: !truncated }); | ||
| 637 | text += decoded; | ||
| 638 | lineBuffer += decoded; | ||
| 639 | yield* emitCompleteLines(false); | ||
| 640 | }); | ||
| 641 | |||
| 642 | yield* Stream.runForEach(stream, processChunk).pipe( | ||
| 643 | Effect.mapError(toGitCommandError(input, "output stream failed.")), | ||
| 644 | ); | ||
| 645 | |||
| 646 | const remainder = truncated ? "" : decoder.decode(); | ||
| 647 | text += remainder; | ||
| 648 | lineBuffer += remainder; | ||
| 649 | yield* emitCompleteLines(true); | ||
| 650 | return { | ||
| 651 | text, | ||
| 652 | truncated, | ||
| 653 | }; | ||
| 654 | }); | ||
| 655 | |||
| 656 | export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { | ||
| 657 | executeOverride?: GitCoreShape["execute"]; | ||
| 658 | }) { | ||
| 659 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 660 | const path = yield* Path.Path; | ||
| 661 | const { worktreesDir } = yield* ServerConfig; | ||
| 662 | |||
| 663 | let executeRaw: GitCoreShape["execute"]; | ||
| 664 | |||
| 665 | if (options?.executeOverride) { | ||
| 666 | executeRaw = options.executeOverride; | ||
| 667 | } else { | ||
| 668 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 669 | executeRaw = Effect.fnUntraced(function* (input) { | ||
| 670 | const commandInput = { | ||
| 671 | ...input, | ||
| 672 | args: [...input.args], | ||
| 673 | } as const; | ||
| 674 | const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; | ||
| 675 | const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; | ||
| 676 | const truncateOutputAtMaxBytes = input.truncateOutputAtMaxBytes ?? false; | ||
| 677 | |||
| 678 | const runGitCommand = Effect.fn("runGitCommand")(function* () { | ||
| 679 | const trace2Monitor = yield* createTrace2Monitor(commandInput, input.progress).pipe( | ||
| 680 | Effect.provideService(Path.Path, path), | ||
| 681 | Effect.provideService(FileSystem.FileSystem, fileSystem), | ||
| 682 | Effect.mapError(toGitCommandError(commandInput, "failed to create trace2 monitor.")), | ||
| 683 | ); | ||
| 684 | const child = yield* commandSpawner | ||
| 685 | .spawn( | ||
| 686 | ChildProcess.make("git", commandInput.args, { | ||
| 687 | cwd: commandInput.cwd, | ||
| 688 | env: { | ||
| 689 | ...process.env, | ||
| 690 | ...input.env, | ||
| 691 | ...trace2Monitor.env, | ||
| 692 | }, | ||
| 693 | }), | ||
| 694 | ) | ||
| 695 | .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn."))); | ||
| 696 | |||
| 697 | const [stdout, stderr, exitCode] = yield* Effect.all( | ||
| 698 | [ | ||
| 699 | collectOutput( | ||
| 700 | commandInput, | ||
| 701 | child.stdout, | ||
| 702 | maxOutputBytes, | ||
| 703 | truncateOutputAtMaxBytes, | ||
| 704 | input.progress?.onStdoutLine, | ||
| 705 | ), | ||
| 706 | collectOutput( | ||
| 707 | commandInput, | ||
| 708 | child.stderr, | ||
| 709 | maxOutputBytes, | ||
| 710 | truncateOutputAtMaxBytes, | ||
| 711 | input.progress?.onStderrLine, | ||
| 712 | ), | ||
| 713 | child.exitCode.pipe( | ||
| 714 | Effect.map((value) => Number(value)), | ||
| 715 | Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")), | ||
| 716 | ), | ||
| 717 | input.stdin === undefined | ||
| 718 | ? Effect.void | ||
| 719 | : Stream.run(Stream.encodeText(Stream.make(input.stdin)), child.stdin).pipe( | ||
| 720 | Effect.mapError(toGitCommandError(commandInput, "failed to write stdin.")), | ||
| 721 | ), | ||
| 722 | ], | ||
| 723 | { concurrency: "unbounded" }, | ||
| 724 | ).pipe(Effect.map(([stdout, stderr, exitCode]) => [stdout, stderr, exitCode] as const)); | ||
| 725 | yield* trace2Monitor.flush; | ||
| 726 | |||
| 727 | if (!input.allowNonZeroExit && exitCode !== 0) { | ||
| 728 | const trimmedStderr = stderr.text.trim(); | ||
| 729 | return yield* new GitCommandError({ | ||
| 730 | operation: commandInput.operation, | ||
| 731 | command: quoteGitCommand(commandInput.args), | ||
| 732 | cwd: commandInput.cwd, | ||
| 733 | detail: | ||
| 734 | trimmedStderr.length > 0 | ||
| 735 | ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}` | ||
| 736 | : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`, | ||
| 737 | }); | ||
| 738 | } | ||
| 739 | |||
| 740 | return { | ||
| 741 | code: exitCode, | ||
| 742 | stdout: stdout.text, | ||
| 743 | stderr: stderr.text, | ||
| 744 | stdoutTruncated: stdout.truncated, | ||
| 745 | stderrTruncated: stderr.truncated, | ||
| 746 | } satisfies ExecuteGitResult; | ||
| 747 | }); | ||
| 748 | |||
| 749 | return yield* runGitCommand().pipe( | ||
| 750 | Effect.scoped, | ||
| 751 | Effect.timeoutOption(timeoutMs), | ||
| 752 | Effect.flatMap((result) => | ||
| 753 | Option.match(result, { | ||
| 754 | onNone: () => | ||
| 755 | Effect.fail( | ||
| 756 | new GitCommandError({ | ||
| 757 | operation: commandInput.operation, | ||
| 758 | command: quoteGitCommand(commandInput.args), | ||
| 759 | cwd: commandInput.cwd, | ||
| 760 | detail: `${quoteGitCommand(commandInput.args)} timed out.`, | ||
| 761 | }), | ||
| 762 | ), | ||
| 763 | onSome: Effect.succeed, | ||
| 764 | }), | ||
| 765 | ), | ||
| 766 | ); | ||
| 767 | }); | ||
| 768 | } | ||
| 769 | |||
| 770 | const execute: GitCoreShape["execute"] = (input) => | ||
| 771 | executeRaw(input).pipe( | ||
| 772 | withMetrics({ | ||
| 773 | counter: gitCommandsTotal, | ||
| 774 | timer: gitCommandDuration, | ||
| 775 | attributes: { | ||
| 776 | operation: input.operation, | ||
| 777 | }, | ||
| 778 | }), | ||
| 779 | Effect.withSpan(input.operation, { | ||
| 780 | kind: "client", | ||
| 781 | attributes: { | ||
| 782 | "git.operation": input.operation, | ||
| 783 | "git.cwd": input.cwd, | ||
| 784 | "git.args_count": input.args.length, | ||
| 785 | }, | ||
| 786 | }), | ||
| 787 | ); | ||
| 788 | |||
| 789 | const executeGit = ( | ||
| 790 | operation: string, | ||
| 791 | cwd: string, | ||
| 792 | args: readonly string[], | ||
| 793 | options: ExecuteGitOptions = {}, | ||
| 794 | ): Effect.Effect<ExecuteGitResult, GitCommandError> => | ||
| 795 | execute({ | ||
| 796 | operation, | ||
| 797 | cwd, | ||
| 798 | args, | ||
| 799 | ...(options.stdin !== undefined ? { stdin: options.stdin } : {}), | ||
| 800 | allowNonZeroExit: true, | ||
| 801 | ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), | ||
| 802 | ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), | ||
| 803 | ...(options.truncateOutputAtMaxBytes !== undefined | ||
| 804 | ? { truncateOutputAtMaxBytes: options.truncateOutputAtMaxBytes } | ||
| 805 | : {}), | ||
| 806 | ...(options.progress ? { progress: options.progress } : {}), | ||
| 807 | }).pipe( | ||
| 808 | Effect.flatMap((result) => { | ||
| 809 | if (options.allowNonZeroExit || result.code === 0) { | ||
| 810 | return Effect.succeed(result); | ||
| 811 | } | ||
| 812 | const stderr = result.stderr.trim(); | ||
| 813 | if (stderr.length > 0) { | ||
| 814 | return Effect.fail(createGitCommandError(operation, cwd, args, stderr)); | ||
| 815 | } | ||
| 816 | if (options.fallbackErrorMessage) { | ||
| 817 | return Effect.fail( | ||
| 818 | createGitCommandError(operation, cwd, args, options.fallbackErrorMessage), | ||
| 819 | ); | ||
| 820 | } | ||
| 821 | return Effect.fail( | ||
| 822 | createGitCommandError( | ||
| 823 | operation, | ||
| 824 | cwd, | ||
| 825 | args, | ||
| 826 | `${commandLabel(args)} failed: code=${result.code ?? "null"}`, | ||
| 827 | ), | ||
| 828 | ); | ||
| 829 | }), | ||
| 830 | ); | ||
| 831 | |||
| 832 | const runGit = ( | ||
| 833 | operation: string, | ||
| 834 | cwd: string, | ||
| 835 | args: readonly string[], | ||
| 836 | allowNonZeroExit = false, | ||
| 837 | ): Effect.Effect<void, GitCommandError> => | ||
| 838 | executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(Effect.asVoid); | ||
| 839 | |||
| 840 | const runGitStdout = ( | ||
| 841 | operation: string, | ||
| 842 | cwd: string, | ||
| 843 | args: readonly string[], | ||
| 844 | allowNonZeroExit = false, | ||
| 845 | ): Effect.Effect<string, GitCommandError> => | ||
| 846 | executeGit(operation, cwd, args, { allowNonZeroExit }).pipe( | ||
| 847 | Effect.map((result) => result.stdout), | ||
| 848 | ); | ||
| 849 | |||
| 850 | const runGitStdoutWithOptions = ( | ||
| 851 | operation: string, | ||
| 852 | cwd: string, | ||
| 853 | args: readonly string[], | ||
| 854 | options: ExecuteGitOptions = {}, | ||
| 855 | ): Effect.Effect<string, GitCommandError> => | ||
| 856 | executeGit(operation, cwd, args, options).pipe( | ||
| 857 | Effect.map((result) => | ||
| 858 | result.stdoutTruncated ? `${result.stdout}${OUTPUT_TRUNCATED_MARKER}` : result.stdout, | ||
| 859 | ), | ||
| 860 | ); | ||
| 861 | |||
| 862 | const branchExists = (cwd: string, branch: string): Effect.Effect<boolean, GitCommandError> => | ||
| 863 | executeGit( | ||
| 864 | "GitCore.branchExists", | ||
| 865 | cwd, | ||
| 866 | ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], | ||
| 867 | { | ||
| 868 | allowNonZeroExit: true, | ||
| 869 | timeoutMs: 5_000, | ||
| 870 | }, | ||
| 871 | ).pipe(Effect.map((result) => result.code === 0)); | ||
| 872 | |||
| 873 | const resolveAvailableBranchName = Effect.fn("resolveAvailableBranchName")(function* ( | ||
| 874 | cwd: string, | ||
| 875 | desiredBranch: string, | ||
| 876 | ) { | ||
| 877 | const isDesiredTaken = yield* branchExists(cwd, desiredBranch); | ||
| 878 | if (!isDesiredTaken) { | ||
| 879 | return desiredBranch; | ||
| 880 | } | ||
| 881 | |||
| 882 | for (let suffix = 1; suffix <= 100; suffix += 1) { | ||
| 883 | const candidate = `${desiredBranch}-${suffix}`; | ||
| 884 | const isCandidateTaken = yield* branchExists(cwd, candidate); | ||
| 885 | if (!isCandidateTaken) { | ||
| 886 | return candidate; | ||
| 887 | } | ||
| 888 | } | ||
| 889 | |||
| 890 | return yield* createGitCommandError( | ||
| 891 | "GitCore.renameBranch", | ||
| 892 | cwd, | ||
| 893 | ["branch", "-m", "--", desiredBranch], | ||
| 894 | `Could not find an available branch name for '${desiredBranch}'.`, | ||
| 895 | ); | ||
| 896 | }); | ||
| 897 | |||
| 898 | const resolveCurrentUpstream = Effect.fn("resolveCurrentUpstream")(function* (cwd: string) { | ||
| 899 | const upstreamRef = yield* runGitStdout( | ||
| 900 | "GitCore.resolveCurrentUpstream", | ||
| 901 | cwd, | ||
| 902 | ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], | ||
| 903 | true, | ||
| 904 | ).pipe(Effect.map((stdout) => stdout.trim())); | ||
| 905 | |||
| 906 | if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") { | ||
| 907 | return null; | ||
| 908 | } | ||
| 909 | |||
| 910 | const remoteNames = yield* runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe( | ||
| 911 | Effect.map(parseRemoteNames), | ||
| 912 | Effect.catch(() => Effect.succeed<ReadonlyArray<string>>([])), | ||
| 913 | ); | ||
| 914 | return ( | ||
| 915 | parseUpstreamRefWithRemoteNames(upstreamRef, remoteNames) ?? | ||
| 916 | parseUpstreamRefByFirstSeparator(upstreamRef) | ||
| 917 | ); | ||
| 918 | }); | ||
| 919 | |||
| 920 | const fetchRemoteForStatus = ( | ||
| 921 | gitCommonDir: string, | ||
| 922 | remoteName: string, | ||
| 923 | ): Effect.Effect<void, GitCommandError> => { | ||
| 924 | const fetchCwd = | ||
| 925 | path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; | ||
| 926 | return executeGit( | ||
| 927 | "GitCore.fetchRemoteForStatus", | ||
| 928 | fetchCwd, | ||
| 929 | ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName], | ||
| 930 | { | ||
| 931 | allowNonZeroExit: true, | ||
| 932 | timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), | ||
| 933 | }, | ||
| 934 | ).pipe(Effect.asVoid); | ||
| 935 | }; | ||
| 936 | |||
| 937 | const resolveGitCommonDir = Effect.fn("resolveGitCommonDir")(function* (cwd: string) { | ||
| 938 | const gitCommonDir = yield* runGitStdout("GitCore.resolveGitCommonDir", cwd, [ | ||
| 939 | "rev-parse", | ||
| 940 | "--git-common-dir", | ||
| 941 | ]).pipe(Effect.map((stdout) => stdout.trim())); | ||
| 942 | return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir); | ||
| 943 | }); | ||
| 944 | |||
| 945 | const refreshStatusRemoteCacheEntry = Effect.fn("refreshStatusRemoteCacheEntry")(function* ( | ||
| 946 | cacheKey: StatusRemoteRefreshCacheKey, | ||
| 947 | ) { | ||
| 948 | yield* fetchRemoteForStatus(cacheKey.gitCommonDir, cacheKey.remoteName); | ||
| 949 | return true as const; | ||
| 950 | }); | ||
| 951 | |||
| 952 | const statusRemoteRefreshCache = yield* Cache.makeWith(refreshStatusRemoteCacheEntry, { | ||
| 953 | capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY, | ||
| 954 | // Keep successful refreshes warm and briefly back off failed refreshes to avoid retry storms. | ||
| 955 | timeToLive: (exit) => | ||
| 956 | Exit.isSuccess(exit) | ||
| 957 | ? STATUS_UPSTREAM_REFRESH_INTERVAL | ||
| 958 | : STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN, | ||
| 959 | }); | ||
| 960 | |||
| 961 | const refreshStatusUpstreamIfStale = Effect.fn("refreshStatusUpstreamIfStale")(function* ( | ||
| 962 | cwd: string, | ||
| 963 | ) { | ||
| 964 | const upstream = yield* resolveCurrentUpstream(cwd); | ||
| 965 | if (!upstream) return; | ||
| 966 | const gitCommonDir = yield* resolveGitCommonDir(cwd); | ||
| 967 | yield* Cache.get( | ||
| 968 | statusRemoteRefreshCache, | ||
| 969 | new StatusRemoteRefreshCacheKey({ | ||
| 970 | gitCommonDir, | ||
| 971 | remoteName: upstream.remoteName, | ||
| 972 | }), | ||
| 973 | ); | ||
| 974 | }); | ||
| 975 | |||
| 976 | const resolveDefaultBranchName = ( | ||
| 977 | cwd: string, | ||
| 978 | remoteName: string, | ||
| 979 | ): Effect.Effect<string | null, GitCommandError> => | ||
| 980 | executeGit( | ||
| 981 | "GitCore.resolveDefaultBranchName", | ||
| 982 | cwd, | ||
| 983 | ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`], | ||
| 984 | { allowNonZeroExit: true }, | ||
| 985 | ).pipe( | ||
| 986 | Effect.map((result) => { | ||
| 987 | if (result.code !== 0) { | ||
| 988 | return null; | ||
| 989 | } | ||
| 990 | return parseDefaultBranchFromRemoteHeadRef(result.stdout, remoteName); | ||
| 991 | }), | ||
| 992 | ); | ||
| 993 | |||
| 994 | const remoteBranchExists = ( | ||
| 995 | cwd: string, | ||
| 996 | remoteName: string, | ||
| 997 | branch: string, | ||
| 998 | ): Effect.Effect<boolean, GitCommandError> => | ||
| 999 | executeGit( | ||
| 1000 | "GitCore.remoteBranchExists", | ||
| 1001 | cwd, | ||
| 1002 | ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${branch}`], | ||
| 1003 | { | ||
| 1004 | allowNonZeroExit: true, | ||
| 1005 | }, | ||
| 1006 | ).pipe(Effect.map((result) => result.code === 0)); | ||
| 1007 | |||
| 1008 | const originRemoteExists = (cwd: string): Effect.Effect<boolean, GitCommandError> => | ||
| 1009 | executeGit("GitCore.originRemoteExists", cwd, ["remote", "get-url", "origin"], { | ||
| 1010 | allowNonZeroExit: true, | ||
| 1011 | }).pipe(Effect.map((result) => result.code === 0)); | ||
| 1012 | |||
| 1013 | const listRemoteNames = (cwd: string): Effect.Effect<ReadonlyArray<string>, GitCommandError> => | ||
| 1014 | runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe( | ||
| 1015 | Effect.map(parseRemoteNamesInGitOrder), | ||
| 1016 | ); | ||
| 1017 | |||
| 1018 | const resolvePrimaryRemoteName = Effect.fn("resolvePrimaryRemoteName")(function* (cwd: string) { | ||
| 1019 | if (yield* originRemoteExists(cwd)) { | ||
| 1020 | return "origin"; | ||
| 1021 | } | ||
| 1022 | const remotes = yield* listRemoteNames(cwd); | ||
| 1023 | const [firstRemote] = remotes; | ||
| 1024 | if (firstRemote) { | ||
| 1025 | return firstRemote; | ||
| 1026 | } | ||
| 1027 | return yield* createGitCommandError( | ||
| 1028 | "GitCore.resolvePrimaryRemoteName", | ||
| 1029 | cwd, | ||
| 1030 | ["remote"], | ||
| 1031 | "No git remote is configured for this repository.", | ||
| 1032 | ); | ||
| 1033 | }); | ||
| 1034 | |||
| 1035 | const resolvePushRemoteName = Effect.fn("resolvePushRemoteName")(function* ( | ||
| 1036 | cwd: string, | ||
| 1037 | branch: string, | ||
| 1038 | ) { | ||
| 1039 | const branchPushRemote = yield* runGitStdout( | ||
| 1040 | "GitCore.resolvePushRemoteName.branchPushRemote", | ||
| 1041 | cwd, | ||
| 1042 | ["config", "--get", `branch.${branch}.pushRemote`], | ||
| 1043 | true, | ||
| 1044 | ).pipe(Effect.map((stdout) => stdout.trim())); | ||
| 1045 | if (branchPushRemote.length > 0) { | ||
| 1046 | return branchPushRemote; | ||
| 1047 | } | ||
| 1048 | |||
| 1049 | const pushDefaultRemote = yield* runGitStdout( | ||
| 1050 | "GitCore.resolvePushRemoteName.remotePushDefault", | ||
| 1051 | cwd, | ||
| 1052 | ["config", "--get", "remote.pushDefault"], | ||
| 1053 | true, | ||
| 1054 | ).pipe(Effect.map((stdout) => stdout.trim())); | ||
| 1055 | if (pushDefaultRemote.length > 0) { | ||
| 1056 | return pushDefaultRemote; | ||
| 1057 | } | ||
| 1058 | |||
| 1059 | return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.catch(() => Effect.succeed(null))); | ||
| 1060 | }); | ||
| 1061 | |||
| 1062 | const ensureRemote: GitCoreShape["ensureRemote"] = Effect.fn("ensureRemote")(function* (input) { | ||
| 1063 | const preferredName = sanitizeRemoteName(input.preferredName); | ||
| 1064 | const normalizedTargetUrl = normalizeRemoteUrl(input.url); | ||
| 1065 | const remoteFetchUrls = yield* runGitStdout("GitCore.ensureRemote.listRemoteUrls", input.cwd, [ | ||
| 1066 | "remote", | ||
| 1067 | "-v", | ||
| 1068 | ]).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout))); | ||
| 1069 | |||
| 1070 | for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { | ||
| 1071 | if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) { | ||
| 1072 | return remoteName; | ||
| 1073 | } | ||
| 1074 | } | ||
| 1075 | |||
| 1076 | let remoteName = preferredName; | ||
| 1077 | let suffix = 1; | ||
| 1078 | while (remoteFetchUrls.has(remoteName)) { | ||
| 1079 | remoteName = `${preferredName}-${suffix}`; | ||
| 1080 | suffix += 1; | ||
| 1081 | } | ||
| 1082 | |||
| 1083 | yield* runGit("GitCore.ensureRemote.add", input.cwd, ["remote", "add", remoteName, input.url]); | ||
| 1084 | return remoteName; | ||
| 1085 | }); | ||
| 1086 | |||
| 1087 | const resolveBaseBranchForNoUpstream = Effect.fn("resolveBaseBranchForNoUpstream")(function* ( | ||
| 1088 | cwd: string, | ||
| 1089 | branch: string, | ||
| 1090 | ) { | ||
| 1091 | const configuredBaseBranch = yield* runGitStdout( | ||
| 1092 | "GitCore.resolveBaseBranchForNoUpstream.config", | ||
| 1093 | cwd, | ||
| 1094 | ["config", "--get", `branch.${branch}.gh-merge-base`], | ||
| 1095 | true, | ||
| 1096 | ).pipe(Effect.map((stdout) => stdout.trim())); | ||
| 1097 | |||
| 1098 | const primaryRemoteName = yield* resolvePrimaryRemoteName(cwd).pipe( | ||
| 1099 | Effect.catch(() => Effect.succeed(null)), | ||
| 1100 | ); | ||
| 1101 | const defaultBranch = | ||
| 1102 | primaryRemoteName === null ? null : yield* resolveDefaultBranchName(cwd, primaryRemoteName); | ||
| 1103 | const candidates = [ | ||
| 1104 | configuredBaseBranch.length > 0 ? configuredBaseBranch : null, | ||
| 1105 | defaultBranch, | ||
| 1106 | ...DEFAULT_BASE_BRANCH_CANDIDATES, | ||
| 1107 | ]; | ||
| 1108 | |||
| 1109 | for (const candidate of candidates) { | ||
| 1110 | if (!candidate) { | ||
| 1111 | continue; | ||
| 1112 | } | ||
| 1113 | |||
| 1114 | const remotePrefix = | ||
| 1115 | primaryRemoteName && primaryRemoteName !== "origin" ? `${primaryRemoteName}/` : null; | ||
| 1116 | const normalizedCandidate = candidate.startsWith("origin/") | ||
| 1117 | ? candidate.slice("origin/".length) | ||
| 1118 | : remotePrefix && candidate.startsWith(remotePrefix) | ||
| 1119 | ? candidate.slice(remotePrefix.length) | ||
| 1120 | : candidate; | ||
| 1121 | if (normalizedCandidate.length === 0 || normalizedCandidate === branch) { | ||
| 1122 | continue; | ||
| 1123 | } | ||
| 1124 | |||
| 1125 | if (yield* branchExists(cwd, normalizedCandidate)) { | ||
| 1126 | return normalizedCandidate; | ||
| 1127 | } | ||
| 1128 | |||
| 1129 | if ( | ||
| 1130 | primaryRemoteName && | ||
| 1131 | (yield* remoteBranchExists(cwd, primaryRemoteName, normalizedCandidate)) | ||
| 1132 | ) { | ||
| 1133 | return `${primaryRemoteName}/${normalizedCandidate}`; | ||
| 1134 | } | ||
| 1135 | } | ||
| 1136 | |||
| 1137 | return null; | ||
| 1138 | }); | ||
| 1139 | |||
| 1140 | const computeAheadCountAgainstBase = Effect.fn("computeAheadCountAgainstBase")(function* ( | ||
| 1141 | cwd: string, | ||
| 1142 | branch: string, | ||
| 1143 | ) { | ||
| 1144 | const baseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch); | ||
| 1145 | if (!baseBranch) { | ||
| 1146 | return 0; | ||
| 1147 | } | ||
| 1148 | |||
| 1149 | const result = yield* executeGit( | ||
| 1150 | "GitCore.computeAheadCountAgainstBase", | ||
| 1151 | cwd, | ||
| 1152 | ["rev-list", "--count", `${baseBranch}..HEAD`], | ||
| 1153 | { allowNonZeroExit: true }, | ||
| 1154 | ); | ||
| 1155 | if (result.code !== 0) { | ||
| 1156 | return 0; | ||
| 1157 | } | ||
| 1158 | |||
| 1159 | const parsed = Number.parseInt(result.stdout.trim(), 10); | ||
| 1160 | return Number.isFinite(parsed) ? Math.max(0, parsed) : 0; | ||
| 1161 | }); | ||
| 1162 | |||
| 1163 | const readBranchRecency = Effect.fn("readBranchRecency")(function* (cwd: string) { | ||
| 1164 | const branchRecency = yield* executeGit( | ||
| 1165 | "GitCore.readBranchRecency", | ||
| 1166 | cwd, | ||
| 1167 | [ | ||
| 1168 | "for-each-ref", | ||
| 1169 | "--format=%(refname:short)%09%(committerdate:unix)", | ||
| 1170 | "refs/heads", | ||
| 1171 | "refs/remotes", | ||
| 1172 | ], | ||
| 1173 | { | ||
| 1174 | timeoutMs: 15_000, | ||
| 1175 | allowNonZeroExit: true, | ||
| 1176 | }, | ||
| 1177 | ); | ||
| 1178 | |||
| 1179 | const branchLastCommit = new Map<string, number>(); | ||
| 1180 | if (branchRecency.code !== 0) { | ||
| 1181 | return branchLastCommit; | ||
| 1182 | } | ||
| 1183 | |||
| 1184 | for (const line of branchRecency.stdout.split("\n")) { | ||
| 1185 | if (line.length === 0) { | ||
| 1186 | continue; | ||
| 1187 | } | ||
| 1188 | const [name, lastCommitRaw] = line.split("\t"); | ||
| 1189 | if (!name) { | ||
| 1190 | continue; | ||
| 1191 | } | ||
| 1192 | const lastCommit = Number.parseInt(lastCommitRaw ?? "0", 10); | ||
| 1193 | branchLastCommit.set(name, Number.isFinite(lastCommit) ? lastCommit : 0); | ||
| 1194 | } | ||
| 1195 | |||
| 1196 | return branchLastCommit; | ||
| 1197 | }); | ||
| 1198 | |||
| 1199 | const readStatusDetailsLocal = Effect.fn("readStatusDetailsLocal")(function* (cwd: string) { | ||
| 1200 | const statusResult = yield* executeGit( | ||
| 1201 | "GitCore.statusDetails.status", | ||
| 1202 | cwd, | ||
| 1203 | ["status", "--porcelain=2", "--branch"], | ||
| 1204 | { | ||
| 1205 | allowNonZeroExit: true, | ||
| 1206 | }, | ||
| 1207 | ).pipe(Effect.catchIf(isMissingGitCwdError, () => Effect.succeed(null))); | ||
| 1208 | |||
| 1209 | if (statusResult === null) { | ||
| 1210 | return NON_REPOSITORY_STATUS_DETAILS; | ||
| 1211 | } | ||
| 1212 | |||
| 1213 | if (statusResult.code !== 0) { | ||
| 1214 | const stderr = statusResult.stderr.trim(); | ||
| 1215 | return yield* createGitCommandError( | ||
| 1216 | "GitCore.statusDetails.status", | ||
| 1217 | cwd, | ||
| 1218 | ["status", "--porcelain=2", "--branch"], | ||
| 1219 | stderr || "git status failed", | ||
| 1220 | ); | ||
| 1221 | } | ||
| 1222 | |||
| 1223 | const [unstagedNumstatStdout, stagedNumstatStdout, defaultRefResult, hasOriginRemote] = | ||
| 1224 | yield* Effect.all( | ||
| 1225 | [ | ||
| 1226 | runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]), | ||
| 1227 | runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, [ | ||
| 1228 | "diff", | ||
| 1229 | "--cached", | ||
| 1230 | "--numstat", | ||
| 1231 | ]), | ||
| 1232 | executeGit( | ||
| 1233 | "GitCore.statusDetails.defaultRef", | ||
| 1234 | cwd, | ||
| 1235 | ["symbolic-ref", "refs/remotes/origin/HEAD"], | ||
| 1236 | { | ||
| 1237 | allowNonZeroExit: true, | ||
| 1238 | }, | ||
| 1239 | ), | ||
| 1240 | originRemoteExists(cwd).pipe(Effect.catch(() => Effect.succeed(false))), | ||
| 1241 | ], | ||
| 1242 | { concurrency: "unbounded" }, | ||
| 1243 | ); | ||
| 1244 | const statusStdout = statusResult.stdout; | ||
| 1245 | const defaultBranch = | ||
| 1246 | defaultRefResult.code === 0 | ||
| 1247 | ? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "") | ||
| 1248 | : null; | ||
| 1249 | |||
| 1250 | let branch: string | null = null; | ||
| 1251 | let upstreamRef: string | null = null; | ||
| 1252 | let aheadCount = 0; | ||
| 1253 | let behindCount = 0; | ||
| 1254 | let hasWorkingTreeChanges = false; | ||
| 1255 | const changedFilesWithoutNumstat = new Set<string>(); | ||
| 1256 | |||
| 1257 | for (const line of statusStdout.split(/\r?\n/g)) { | ||
| 1258 | if (line.startsWith("# branch.head ")) { | ||
| 1259 | const value = line.slice("# branch.head ".length).trim(); | ||
| 1260 | branch = value.startsWith("(") ? null : value; | ||
| 1261 | continue; | ||
| 1262 | } | ||
| 1263 | if (line.startsWith("# branch.upstream ")) { | ||
| 1264 | const value = line.slice("# branch.upstream ".length).trim(); | ||
| 1265 | upstreamRef = value.length > 0 ? value : null; | ||
| 1266 | continue; | ||
| 1267 | } | ||
| 1268 | if (line.startsWith("# branch.ab ")) { | ||
| 1269 | const value = line.slice("# branch.ab ".length).trim(); | ||
| 1270 | const parsed = parseBranchAb(value); | ||
| 1271 | aheadCount = parsed.ahead; | ||
| 1272 | behindCount = parsed.behind; | ||
| 1273 | continue; | ||
| 1274 | } | ||
| 1275 | if (line.trim().length > 0 && !line.startsWith("#")) { | ||
| 1276 | hasWorkingTreeChanges = true; | ||
| 1277 | const pathValue = parsePorcelainPath(line); | ||
| 1278 | if (pathValue) changedFilesWithoutNumstat.add(pathValue); | ||
| 1279 | } | ||
| 1280 | } | ||
| 1281 | |||
| 1282 | if (!upstreamRef && branch) { | ||
| 1283 | aheadCount = yield* computeAheadCountAgainstBase(cwd, branch).pipe( | ||
| 1284 | Effect.catch(() => Effect.succeed(0)), | ||
| 1285 | ); | ||
| 1286 | behindCount = 0; | ||
| 1287 | } | ||
| 1288 | |||
| 1289 | const stagedEntries = parseNumstatEntries(stagedNumstatStdout); | ||
| 1290 | const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout); | ||
| 1291 | const fileStatMap = new Map<string, { insertions: number; deletions: number }>(); | ||
| 1292 | for (const entry of [...stagedEntries, ...unstagedEntries]) { | ||
| 1293 | const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 }; | ||
| 1294 | existing.insertions += entry.insertions; | ||
| 1295 | existing.deletions += entry.deletions; | ||
| 1296 | fileStatMap.set(entry.path, existing); | ||
| 1297 | } | ||
| 1298 | |||
| 1299 | let insertions = 0; | ||
| 1300 | let deletions = 0; | ||
| 1301 | const files = Array.from(fileStatMap.entries()) | ||
| 1302 | .map(([filePath, stat]) => { | ||
| 1303 | insertions += stat.insertions; | ||
| 1304 | deletions += stat.deletions; | ||
| 1305 | return { path: filePath, insertions: stat.insertions, deletions: stat.deletions }; | ||
| 1306 | }) | ||
| 1307 | .toSorted((a, b) => a.path.localeCompare(b.path)); | ||
| 1308 | |||
| 1309 | for (const filePath of changedFilesWithoutNumstat) { | ||
| 1310 | if (fileStatMap.has(filePath)) continue; | ||
| 1311 | files.push({ path: filePath, insertions: 0, deletions: 0 }); | ||
| 1312 | } | ||
| 1313 | files.sort((a, b) => a.path.localeCompare(b.path)); | ||
| 1314 | |||
| 1315 | return { | ||
| 1316 | isRepo: true, | ||
| 1317 | hasOriginRemote, | ||
| 1318 | isDefaultBranch: | ||
| 1319 | branch !== null && | ||
| 1320 | (branch === defaultBranch || | ||
| 1321 | (defaultBranch === null && (branch === "main" || branch === "master"))), | ||
| 1322 | branch, | ||
| 1323 | upstreamRef, | ||
| 1324 | hasWorkingTreeChanges, | ||
| 1325 | workingTree: { | ||
| 1326 | files, | ||
| 1327 | insertions, | ||
| 1328 | deletions, | ||
| 1329 | }, | ||
| 1330 | hasUpstream: upstreamRef !== null, | ||
| 1331 | aheadCount, | ||
| 1332 | behindCount, | ||
| 1333 | }; | ||
| 1334 | }); | ||
| 1335 | |||
| 1336 | const statusDetailsLocal: GitCoreShape["statusDetailsLocal"] = Effect.fn("statusDetailsLocal")( | ||
| 1337 | function* (cwd) { | ||
| 1338 | return yield* readStatusDetailsLocal(cwd); | ||
| 1339 | }, | ||
| 1340 | ); | ||
| 1341 | |||
| 1342 | const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) { | ||
| 1343 | yield* refreshStatusUpstreamIfStale(cwd).pipe( | ||
| 1344 | Effect.catchIf(isMissingGitCwdError, () => Effect.void), | ||
| 1345 | Effect.ignoreCause({ log: true }), | ||
| 1346 | ); | ||
| 1347 | return yield* readStatusDetailsLocal(cwd); | ||
| 1348 | }); | ||
| 1349 | |||
| 1350 | const status: GitCoreShape["status"] = (input) => | ||
| 1351 | statusDetails(input.cwd).pipe( | ||
| 1352 | Effect.map((details) => ({ | ||
| 1353 | isRepo: details.isRepo, | ||
| 1354 | hasOriginRemote: details.hasOriginRemote, | ||
| 1355 | isDefaultBranch: details.isDefaultBranch, | ||
| 1356 | branch: details.branch, | ||
| 1357 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, | ||
| 1358 | workingTree: details.workingTree, | ||
| 1359 | hasUpstream: details.hasUpstream, | ||
| 1360 | aheadCount: details.aheadCount, | ||
| 1361 | behindCount: details.behindCount, | ||
| 1362 | pr: null, | ||
| 1363 | })), | ||
| 1364 | ); | ||
| 1365 | |||
| 1366 | const prepareCommitContext: GitCoreShape["prepareCommitContext"] = Effect.fn( | ||
| 1367 | "prepareCommitContext", | ||
| 1368 | )(function* (cwd, filePaths) { | ||
| 1369 | if (filePaths && filePaths.length > 0) { | ||
| 1370 | yield* runGit("GitCore.prepareCommitContext.reset", cwd, ["reset"]).pipe( | ||
| 1371 | Effect.catch(() => Effect.void), | ||
| 1372 | ); | ||
| 1373 | yield* runGit("GitCore.prepareCommitContext.addSelected", cwd, [ | ||
| 1374 | "add", | ||
| 1375 | "-A", | ||
| 1376 | "--", | ||
| 1377 | ...filePaths, | ||
| 1378 | ]); | ||
| 1379 | } else { | ||
| 1380 | yield* runGit("GitCore.prepareCommitContext.addAll", cwd, ["add", "-A"]); | ||
| 1381 | } | ||
| 1382 | |||
| 1383 | const stagedSummary = yield* runGitStdout("GitCore.prepareCommitContext.stagedSummary", cwd, [ | ||
| 1384 | "diff", | ||
| 1385 | "--cached", | ||
| 1386 | "--name-status", | ||
| 1387 | ]).pipe(Effect.map((stdout) => stdout.trim())); | ||
| 1388 | if (stagedSummary.length === 0) { | ||
| 1389 | return null; | ||
| 1390 | } | ||
| 1391 | |||
| 1392 | const stagedPatch = yield* runGitStdoutWithOptions( | ||
| 1393 | "GitCore.prepareCommitContext.stagedPatch", | ||
| 1394 | cwd, | ||
| 1395 | ["diff", "--cached", "--patch", "--minimal"], | ||
| 1396 | { | ||
| 1397 | maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES, | ||
| 1398 | truncateOutputAtMaxBytes: true, | ||
| 1399 | }, | ||
| 1400 | ); | ||
| 1401 | |||
| 1402 | return { | ||
| 1403 | stagedSummary, | ||
| 1404 | stagedPatch, | ||
| 1405 | }; | ||
| 1406 | }); | ||
| 1407 | |||
| 1408 | const commit: GitCoreShape["commit"] = Effect.fn("commit")(function* ( | ||
| 1409 | cwd, | ||
| 1410 | subject, | ||
| 1411 | body, | ||
| 1412 | options?: GitCommitOptions, | ||
| 1413 | ) { | ||
| 1414 | const args = ["commit", "-m", subject]; | ||
| 1415 | const trimmedBody = body.trim(); | ||
| 1416 | if (trimmedBody.length > 0) { | ||
| 1417 | args.push("-m", trimmedBody); | ||
| 1418 | } | ||
| 1419 | const progress = | ||
| 1420 | options?.progress?.onOutputLine === undefined | ||
| 1421 | ? options?.progress | ||
| 1422 | : { | ||
| 1423 | ...options.progress, | ||
| 1424 | onStdoutLine: (line: string) => | ||
| 1425 | options.progress?.onOutputLine?.({ stream: "stdout", text: line }) ?? Effect.void, | ||
| 1426 | onStderrLine: (line: string) => | ||
| 1427 | options.progress?.onOutputLine?.({ stream: "stderr", text: line }) ?? Effect.void, | ||
| 1428 | }; | ||
| 1429 | yield* executeGit("GitCore.commit.commit", cwd, args, { | ||
| 1430 | ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), | ||
| 1431 | ...(progress ? { progress } : {}), | ||
| 1432 | }).pipe(Effect.asVoid); | ||
| 1433 | const commitSha = yield* runGitStdout("GitCore.commit.revParseHead", cwd, [ | ||
| 1434 | "rev-parse", | ||
| 1435 | "HEAD", | ||
| 1436 | ]).pipe(Effect.map((stdout) => stdout.trim())); | ||
| 1437 | |||
| 1438 | return { commitSha }; | ||
| 1439 | }); | ||
| 1440 | |||
| 1441 | const pushCurrentBranch: GitCoreShape["pushCurrentBranch"] = Effect.fn("pushCurrentBranch")( | ||
| 1442 | function* (cwd, fallbackBranch) { | ||
| 1443 | const details = yield* statusDetails(cwd); | ||
| 1444 | const branch = details.branch ?? fallbackBranch; | ||
| 1445 | if (!branch) { | ||
| 1446 | return yield* createGitCommandError( | ||
| 1447 | "GitCore.pushCurrentBranch", | ||
| 1448 | cwd, | ||
| 1449 | ["push"], | ||
| 1450 | "Cannot push from detached HEAD.", | ||
| 1451 | ); | ||
| 1452 | } | ||
| 1453 | |||
| 1454 | const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0; | ||
| 1455 | if (hasNoLocalDelta) { | ||
| 1456 | if (details.hasUpstream) { | ||
| 1457 | return { | ||
| 1458 | status: "skipped_up_to_date" as const, | ||
| 1459 | branch, | ||
| 1460 | ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), | ||
| 1461 | }; | ||
| 1462 | } | ||
| 1463 | |||
| 1464 | const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe( | ||
| 1465 | Effect.catch(() => Effect.succeed(null)), | ||
| 1466 | ); | ||
| 1467 | if (comparableBaseBranch) { | ||
| 1468 | const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( | ||
| 1469 | Effect.catch(() => Effect.succeed(null)), | ||
| 1470 | ); | ||
| 1471 | if (!publishRemoteName) { | ||
| 1472 | return { | ||
| 1473 | status: "skipped_up_to_date" as const, | ||
| 1474 | branch, | ||
| 1475 | }; | ||
| 1476 | } | ||
| 1477 | |||
| 1478 | const hasRemoteBranch = yield* remoteBranchExists(cwd, publishRemoteName, branch).pipe( | ||
| 1479 | Effect.catch(() => Effect.succeed(false)), | ||
| 1480 | ); | ||
| 1481 | if (hasRemoteBranch) { | ||
| 1482 | return { | ||
| 1483 | status: "skipped_up_to_date" as const, | ||
| 1484 | branch, | ||
| 1485 | }; | ||
| 1486 | } | ||
| 1487 | } | ||
| 1488 | } | ||
| 1489 | |||
| 1490 | if (!details.hasUpstream) { | ||
| 1491 | const publishRemoteName = yield* resolvePushRemoteName(cwd, branch); | ||
| 1492 | if (!publishRemoteName) { | ||
| 1493 | return yield* createGitCommandError( | ||
| 1494 | "GitCore.pushCurrentBranch", | ||
| 1495 | cwd, | ||
| 1496 | ["push"], | ||
| 1497 | "Cannot push because no git remote is configured for this repository.", | ||
| 1498 | ); | ||
| 1499 | } | ||
| 1500 | yield* runGit("GitCore.pushCurrentBranch.pushWithUpstream", cwd, [ | ||
| 1501 | "push", | ||
| 1502 | "-u", | ||
| 1503 | publishRemoteName, | ||
| 1504 | `HEAD:refs/heads/${branch}`, | ||
| 1505 | ]); | ||
| 1506 | return { | ||
| 1507 | status: "pushed" as const, | ||
| 1508 | branch, | ||
| 1509 | upstreamBranch: `${publishRemoteName}/${branch}`, | ||
| 1510 | setUpstream: true, | ||
| 1511 | }; | ||
| 1512 | } | ||
| 1513 | |||
| 1514 | const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe( | ||
| 1515 | Effect.catch(() => Effect.succeed(null)), | ||
| 1516 | ); | ||
| 1517 | if (currentUpstream) { | ||
| 1518 | yield* runGit("GitCore.pushCurrentBranch.pushUpstream", cwd, [ | ||
| 1519 | "push", | ||
| 1520 | currentUpstream.remoteName, | ||
| 1521 | `HEAD:${currentUpstream.upstreamBranch}`, | ||
| 1522 | ]); | ||
| 1523 | return { | ||
| 1524 | status: "pushed" as const, | ||
| 1525 | branch, | ||
| 1526 | upstreamBranch: currentUpstream.upstreamRef, | ||
| 1527 | setUpstream: false, | ||
| 1528 | }; | ||
| 1529 | } | ||
| 1530 | |||
| 1531 | yield* runGit("GitCore.pushCurrentBranch.push", cwd, ["push"]); | ||
| 1532 | return { | ||
| 1533 | status: "pushed" as const, | ||
| 1534 | branch, | ||
| 1535 | ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), | ||
| 1536 | setUpstream: false, | ||
| 1537 | }; | ||
| 1538 | }, | ||
| 1539 | ); | ||
| 1540 | |||
| 1541 | const pullCurrentBranch: GitCoreShape["pullCurrentBranch"] = Effect.fn("pullCurrentBranch")( | ||
| 1542 | function* (cwd) { | ||
| 1543 | const details = yield* statusDetails(cwd); | ||
| 1544 | const branch = details.branch; | ||
| 1545 | if (!branch) { | ||
| 1546 | return yield* createGitCommandError( | ||
| 1547 | "GitCore.pullCurrentBranch", | ||
| 1548 | cwd, | ||
| 1549 | ["pull", "--ff-only"], | ||
| 1550 | "Cannot pull from detached HEAD.", | ||
| 1551 | ); | ||
| 1552 | } | ||
| 1553 | if (!details.hasUpstream) { | ||
| 1554 | return yield* createGitCommandError( | ||
| 1555 | "GitCore.pullCurrentBranch", | ||
| 1556 | cwd, | ||
| 1557 | ["pull", "--ff-only"], | ||
| 1558 | "Current branch has no upstream configured. Push with upstream first.", | ||
| 1559 | ); | ||
| 1560 | } | ||
| 1561 | const beforeSha = yield* runGitStdout( | ||
| 1562 | "GitCore.pullCurrentBranch.beforeSha", | ||
| 1563 | cwd, | ||
| 1564 | ["rev-parse", "HEAD"], | ||
| 1565 | true, | ||
| 1566 | ).pipe(Effect.map((stdout) => stdout.trim())); | ||
| 1567 | yield* executeGit("GitCore.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { | ||
| 1568 | timeoutMs: 30_000, | ||
| 1569 | fallbackErrorMessage: "git pull failed", | ||
| 1570 | }); | ||
| 1571 | const afterSha = yield* runGitStdout( | ||
| 1572 | "GitCore.pullCurrentBranch.afterSha", | ||
| 1573 | cwd, | ||
| 1574 | ["rev-parse", "HEAD"], | ||
| 1575 | true, | ||
| 1576 | ).pipe(Effect.map((stdout) => stdout.trim())); | ||
| 1577 | |||
| 1578 | const refreshed = yield* statusDetails(cwd); | ||
| 1579 | return { | ||
| 1580 | status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", | ||
| 1581 | branch, | ||
| 1582 | upstreamBranch: refreshed.upstreamRef, | ||
| 1583 | }; | ||
| 1584 | }, | ||
| 1585 | ); | ||
| 1586 | |||
| 1587 | const readRangeContext: GitCoreShape["readRangeContext"] = Effect.fn("readRangeContext")( | ||
| 1588 | function* (cwd, baseBranch) { | ||
| 1589 | const range = `${baseBranch}..HEAD`; | ||
| 1590 | const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( | ||
| 1591 | [ | ||
| 1592 | runGitStdoutWithOptions( | ||
| 1593 | "GitCore.readRangeContext.log", | ||
| 1594 | cwd, | ||
| 1595 | ["log", "--oneline", range], | ||
| 1596 | { | ||
| 1597 | maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, | ||
| 1598 | truncateOutputAtMaxBytes: true, | ||
| 1599 | }, | ||
| 1600 | ), | ||
| 1601 | runGitStdoutWithOptions( | ||
| 1602 | "GitCore.readRangeContext.diffStat", | ||
| 1603 | cwd, | ||
| 1604 | ["diff", "--stat", range], | ||
| 1605 | { | ||
| 1606 | maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES, | ||
| 1607 | truncateOutputAtMaxBytes: true, | ||
| 1608 | }, | ||
| 1609 | ), | ||
| 1610 | runGitStdoutWithOptions( | ||
| 1611 | "GitCore.readRangeContext.diffPatch", | ||
| 1612 | cwd, | ||
| 1613 | ["diff", "--patch", "--minimal", range], | ||
| 1614 | { | ||
| 1615 | maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, | ||
| 1616 | truncateOutputAtMaxBytes: true, | ||
| 1617 | }, | ||
| 1618 | ), | ||
| 1619 | ], | ||
| 1620 | { concurrency: "unbounded" }, | ||
| 1621 | ); | ||
| 1622 | |||
| 1623 | return { | ||
| 1624 | commitSummary, | ||
| 1625 | diffSummary, | ||
| 1626 | diffPatch, | ||
| 1627 | }; | ||
| 1628 | }, | ||
| 1629 | ); | ||
| 1630 | |||
| 1631 | const readConfigValue: GitCoreShape["readConfigValue"] = (cwd, key) => | ||
| 1632 | runGitStdout("GitCore.readConfigValue", cwd, ["config", "--get", key], true).pipe( | ||
| 1633 | Effect.map((stdout) => stdout.trim()), | ||
| 1634 | Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), | ||
| 1635 | ); | ||
| 1636 | |||
| 1637 | const isInsideWorkTree: GitCoreShape["isInsideWorkTree"] = (cwd) => | ||
| 1638 | executeGit("GitCore.isInsideWorkTree", cwd, ["rev-parse", "--is-inside-work-tree"], { | ||
| 1639 | allowNonZeroExit: true, | ||
| 1640 | timeoutMs: 5_000, | ||
| 1641 | maxOutputBytes: 4_096, | ||
| 1642 | }).pipe(Effect.map((result) => result.code === 0 && result.stdout.trim() === "true")); | ||
| 1643 | |||
| 1644 | const listWorkspaceFiles: GitCoreShape["listWorkspaceFiles"] = (cwd) => | ||
| 1645 | executeGit( | ||
| 1646 | "GitCore.listWorkspaceFiles", | ||
| 1647 | cwd, | ||
| 1648 | [ | ||
| 1649 | ...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, | ||
| 1650 | "ls-files", | ||
| 1651 | "--cached", | ||
| 1652 | "--others", | ||
| 1653 | "--exclude-standard", | ||
| 1654 | "-z", | ||
| 1655 | ], | ||
| 1656 | { | ||
| 1657 | allowNonZeroExit: true, | ||
| 1658 | timeoutMs: 20_000, | ||
| 1659 | maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, | ||
| 1660 | truncateOutputAtMaxBytes: true, | ||
| 1661 | }, | ||
| 1662 | ).pipe( | ||
| 1663 | Effect.flatMap((result) => | ||
| 1664 | result.code === 0 | ||
| 1665 | ? Effect.succeed({ | ||
| 1666 | paths: splitNullSeparatedPaths(result.stdout, result.stdoutTruncated), | ||
| 1667 | truncated: result.stdoutTruncated, | ||
| 1668 | }) | ||
| 1669 | : Effect.fail( | ||
| 1670 | createGitCommandError( | ||
| 1671 | "GitCore.listWorkspaceFiles", | ||
| 1672 | cwd, | ||
| 1673 | [ | ||
| 1674 | ...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, | ||
| 1675 | "ls-files", | ||
| 1676 | "--cached", | ||
| 1677 | "--others", | ||
| 1678 | "--exclude-standard", | ||
| 1679 | "-z", | ||
| 1680 | ], | ||
| 1681 | result.stderr.trim().length > 0 ? result.stderr.trim() : "git ls-files failed", | ||
| 1682 | ), | ||
| 1683 | ), | ||
| 1684 | ), | ||
| 1685 | ); | ||
| 1686 | |||
| 1687 | const filterIgnoredPaths: GitCoreShape["filterIgnoredPaths"] = (cwd, relativePaths) => | ||
| 1688 | Effect.gen(function* () { | ||
| 1689 | if (relativePaths.length === 0) { | ||
| 1690 | return relativePaths; | ||
| 1691 | } | ||
| 1692 | |||
| 1693 | const ignoredPaths = new Set<string>(); | ||
| 1694 | const chunks = chunkPathsForGitCheckIgnore(relativePaths); | ||
| 1695 | |||
| 1696 | for (const chunk of chunks) { | ||
| 1697 | const result = yield* executeGit( | ||
| 1698 | "GitCore.filterIgnoredPaths", | ||
| 1699 | cwd, | ||
| 1700 | [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"], | ||
| 1701 | { | ||
| 1702 | stdin: `${chunk.join("\0")}\0`, | ||
| 1703 | allowNonZeroExit: true, | ||
| 1704 | timeoutMs: 20_000, | ||
| 1705 | maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, | ||
| 1706 | truncateOutputAtMaxBytes: true, | ||
| 1707 | }, | ||
| 1708 | ); | ||
| 1709 | |||
| 1710 | if (result.code !== 0 && result.code !== 1) { | ||
| 1711 | return yield* createGitCommandError( | ||
| 1712 | "GitCore.filterIgnoredPaths", | ||
| 1713 | cwd, | ||
| 1714 | [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"], | ||
| 1715 | result.stderr.trim().length > 0 ? result.stderr.trim() : "git check-ignore failed", | ||
| 1716 | ); | ||
| 1717 | } | ||
| 1718 | |||
| 1719 | for (const ignoredPath of splitNullSeparatedPaths(result.stdout, result.stdoutTruncated)) { | ||
| 1720 | ignoredPaths.add(ignoredPath); | ||
| 1721 | } | ||
| 1722 | } | ||
| 1723 | |||
| 1724 | if (ignoredPaths.size === 0) { | ||
| 1725 | return relativePaths; | ||
| 1726 | } | ||
| 1727 | |||
| 1728 | return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath)); | ||
| 1729 | }); | ||
| 1730 | |||
| 1731 | const listBranches: GitCoreShape["listBranches"] = Effect.fn("listBranches")(function* (input) { | ||
| 1732 | const branchRecencyPromise = readBranchRecency(input.cwd).pipe( | ||
| 1733 | Effect.catch(() => Effect.succeed(new Map<string, number>())), | ||
| 1734 | ); | ||
| 1735 | const localBranchResult = yield* executeGit( | ||
| 1736 | "GitCore.listBranches.branchNoColor", | ||
| 1737 | input.cwd, | ||
| 1738 | ["branch", "--no-color", "--no-column"], | ||
| 1739 | { | ||
| 1740 | timeoutMs: 10_000, | ||
| 1741 | allowNonZeroExit: true, | ||
| 1742 | }, | ||
| 1743 | ).pipe( | ||
| 1744 | Effect.catchIf(isMissingGitCwdError, () => | ||
| 1745 | Effect.succeed({ | ||
| 1746 | code: 128, | ||
| 1747 | stdout: "", | ||
| 1748 | stderr: "fatal: not a git repository", | ||
| 1749 | stdoutTruncated: false, | ||
| 1750 | stderrTruncated: false, | ||
| 1751 | }), | ||
| 1752 | ), | ||
| 1753 | ); | ||
| 1754 | |||
| 1755 | if (localBranchResult.code !== 0) { | ||
| 1756 | const stderr = localBranchResult.stderr.trim(); | ||
| 1757 | if (stderr.toLowerCase().includes("not a git repository")) { | ||
| 1758 | return { | ||
| 1759 | branches: [], | ||
| 1760 | isRepo: false, | ||
| 1761 | hasOriginRemote: false, | ||
| 1762 | nextCursor: null, | ||
| 1763 | totalCount: 0, | ||
| 1764 | }; | ||
| 1765 | } | ||
| 1766 | return yield* createGitCommandError( | ||
| 1767 | "GitCore.listBranches", | ||
| 1768 | input.cwd, | ||
| 1769 | ["branch", "--no-color", "--no-column"], | ||
| 1770 | stderr || "git branch failed", | ||
| 1771 | ); | ||
| 1772 | } | ||
| 1773 | |||
| 1774 | const remoteBranchResultEffect = executeGit( | ||
| 1775 | "GitCore.listBranches.remoteBranches", | ||
| 1776 | input.cwd, | ||
| 1777 | ["branch", "--no-color", "--no-column", "--remotes"], | ||
| 1778 | { | ||
| 1779 | timeoutMs: 10_000, | ||
| 1780 | allowNonZeroExit: true, | ||
| 1781 | }, | ||
| 1782 | ).pipe( | ||
| 1783 | Effect.catch((error) => | ||
| 1784 | Effect.logWarning( | ||
| 1785 | `GitCore.listBranches: remote branch lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote branch list.`, | ||
| 1786 | ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })), | ||
| 1787 | ), | ||
| 1788 | ); | ||
| 1789 | |||
| 1790 | const remoteNamesResultEffect = executeGit( | ||
| 1791 | "GitCore.listBranches.remoteNames", | ||
| 1792 | input.cwd, | ||
| 1793 | ["remote"], | ||
| 1794 | { | ||
| 1795 | timeoutMs: 5_000, | ||
| 1796 | allowNonZeroExit: true, | ||
| 1797 | }, | ||
| 1798 | ).pipe( | ||
| 1799 | Effect.catch((error) => | ||
| 1800 | Effect.logWarning( | ||
| 1801 | `GitCore.listBranches: remote name lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote name list.`, | ||
| 1802 | ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })), | ||
| 1803 | ), | ||
| 1804 | ); | ||
| 1805 | |||
| 1806 | const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] = | ||
| 1807 | yield* Effect.all( | ||
| 1808 | [ | ||
| 1809 | executeGit( | ||
| 1810 | "GitCore.listBranches.defaultRef", | ||
| 1811 | input.cwd, | ||
| 1812 | ["symbolic-ref", "refs/remotes/origin/HEAD"], | ||
| 1813 | { | ||
| 1814 | timeoutMs: 5_000, | ||
| 1815 | allowNonZeroExit: true, | ||
| 1816 | }, | ||
| 1817 | ), | ||
| 1818 | executeGit( | ||
| 1819 | "GitCore.listBranches.worktreeList", | ||
| 1820 | input.cwd, | ||
| 1821 | ["worktree", "list", "--porcelain"], | ||
| 1822 | { | ||
| 1823 | timeoutMs: 5_000, | ||
| 1824 | allowNonZeroExit: true, | ||
| 1825 | }, | ||
| 1826 | ), | ||
| 1827 | remoteBranchResultEffect, | ||
| 1828 | remoteNamesResultEffect, | ||
| 1829 | branchRecencyPromise, | ||
| 1830 | ], | ||
| 1831 | { concurrency: "unbounded" }, | ||
| 1832 | ); | ||
| 1833 | |||
| 1834 | const remoteNames = | ||
| 1835 | remoteNamesResult.code === 0 ? parseRemoteNames(remoteNamesResult.stdout) : []; | ||
| 1836 | if (remoteBranchResult.code !== 0 && remoteBranchResult.stderr.trim().length > 0) { | ||
| 1837 | yield* Effect.logWarning( | ||
| 1838 | `GitCore.listBranches: remote branch lookup returned code ${remoteBranchResult.code} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote branch list.`, | ||
| 1839 | ); | ||
| 1840 | } | ||
| 1841 | if (remoteNamesResult.code !== 0 && remoteNamesResult.stderr.trim().length > 0) { | ||
| 1842 | yield* Effect.logWarning( | ||
| 1843 | `GitCore.listBranches: remote name lookup returned code ${remoteNamesResult.code} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`, | ||
| 1844 | ); | ||
| 1845 | } | ||
| 1846 | |||
| 1847 | const defaultBranch = | ||
| 1848 | defaultRef.code === 0 | ||
| 1849 | ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") | ||
| 1850 | : null; | ||
| 1851 | |||
| 1852 | const worktreeMap = new Map<string, string>(); | ||
| 1853 | if (worktreeList.code === 0) { | ||
| 1854 | let currentPath: string | null = null; | ||
| 1855 | for (const line of worktreeList.stdout.split("\n")) { | ||
| 1856 | if (line.startsWith("worktree ")) { | ||
| 1857 | const candidatePath = line.slice("worktree ".length); | ||
| 1858 | const exists = yield* fileSystem.stat(candidatePath).pipe( | ||
| 1859 | Effect.map(() => true), | ||
| 1860 | Effect.catch(() => Effect.succeed(false)), | ||
| 1861 | ); | ||
| 1862 | currentPath = exists ? candidatePath : null; | ||
| 1863 | } else if (line.startsWith("branch refs/heads/") && currentPath) { | ||
| 1864 | worktreeMap.set(line.slice("branch refs/heads/".length), currentPath); | ||
| 1865 | } else if (line === "") { | ||
| 1866 | currentPath = null; | ||
| 1867 | } | ||
| 1868 | } | ||
| 1869 | } | ||
| 1870 | |||
| 1871 | const localBranches = localBranchResult.stdout | ||
| 1872 | .split("\n") | ||
| 1873 | .map(parseBranchLine) | ||
| 1874 | .filter((branch): branch is { name: string; current: boolean } => branch !== null) | ||
| 1875 | .map((branch) => ({ | ||
| 1876 | name: branch.name, | ||
| 1877 | current: branch.current, | ||
| 1878 | isRemote: false, | ||
| 1879 | isDefault: branch.name === defaultBranch, | ||
| 1880 | worktreePath: worktreeMap.get(branch.name) ?? null, | ||
| 1881 | })) | ||
| 1882 | .toSorted((a, b) => { | ||
| 1883 | const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; | ||
| 1884 | const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; | ||
| 1885 | if (aPriority !== bPriority) return aPriority - bPriority; | ||
| 1886 | |||
| 1887 | const aLastCommit = branchLastCommit.get(a.name) ?? 0; | ||
| 1888 | const bLastCommit = branchLastCommit.get(b.name) ?? 0; | ||
| 1889 | if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; | ||
| 1890 | return a.name.localeCompare(b.name); | ||
| 1891 | }); | ||
| 1892 | |||
| 1893 | const remoteBranches = | ||
| 1894 | remoteBranchResult.code === 0 | ||
| 1895 | ? remoteBranchResult.stdout | ||
| 1896 | .split("\n") | ||
| 1897 | .map(parseBranchLine) | ||
| 1898 | .filter((branch): branch is { name: string; current: boolean } => branch !== null) | ||
| 1899 | .map((branch) => { | ||
| 1900 | const parsedRemoteRef = parseRemoteRefWithRemoteNames(branch.name, remoteNames); | ||
| 1901 | const remoteBranch: { | ||
| 1902 | name: string; | ||
| 1903 | current: boolean; | ||
| 1904 | isRemote: boolean; | ||
| 1905 | remoteName?: string; | ||
| 1906 | isDefault: boolean; | ||
| 1907 | worktreePath: string | null; | ||
| 1908 | } = { | ||
| 1909 | name: branch.name, | ||
| 1910 | current: false, | ||
| 1911 | isRemote: true, | ||
| 1912 | isDefault: false, | ||
| 1913 | worktreePath: null, | ||
| 1914 | }; | ||
| 1915 | if (parsedRemoteRef) { | ||
| 1916 | remoteBranch.remoteName = parsedRemoteRef.remoteName; | ||
| 1917 | } | ||
| 1918 | return remoteBranch; | ||
| 1919 | }) | ||
| 1920 | .toSorted((a, b) => { | ||
| 1921 | const aLastCommit = branchLastCommit.get(a.name) ?? 0; | ||
| 1922 | const bLastCommit = branchLastCommit.get(b.name) ?? 0; | ||
| 1923 | if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; | ||
| 1924 | return a.name.localeCompare(b.name); | ||
| 1925 | }) | ||
| 1926 | : []; | ||
| 1927 | |||
| 1928 | const branches = paginateBranches({ | ||
| 1929 | branches: filterBranchesForListQuery( | ||
| 1930 | dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]), | ||
| 1931 | input.query, | ||
| 1932 | ), | ||
| 1933 | cursor: input.cursor, | ||
| 1934 | limit: input.limit, | ||
| 1935 | }); | ||
| 1936 | |||
| 1937 | return { | ||
| 1938 | branches: [...branches.branches], | ||
| 1939 | isRepo: true, | ||
| 1940 | hasOriginRemote: remoteNames.includes("origin"), | ||
| 1941 | nextCursor: branches.nextCursor, | ||
| 1942 | totalCount: branches.totalCount, | ||
| 1943 | }; | ||
| 1944 | }); | ||
| 1945 | |||
| 1946 | const createWorktree: GitCoreShape["createWorktree"] = Effect.fn("createWorktree")( | ||
| 1947 | function* (input) { | ||
| 1948 | const targetBranch = input.newBranch ?? input.branch; | ||
| 1949 | const sanitizedBranch = targetBranch.replace(/\//g, "-"); | ||
| 1950 | const repoName = path.basename(input.cwd); | ||
| 1951 | const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); | ||
| 1952 | const args = input.newBranch | ||
| 1953 | ? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch] | ||
| 1954 | : ["worktree", "add", worktreePath, input.branch]; | ||
| 1955 | |||
| 1956 | yield* executeGit("GitCore.createWorktree", input.cwd, args, { | ||
| 1957 | fallbackErrorMessage: "git worktree add failed", | ||
| 1958 | }); | ||
| 1959 | |||
| 1960 | return { | ||
| 1961 | worktree: { | ||
| 1962 | path: worktreePath, | ||
| 1963 | branch: targetBranch, | ||
| 1964 | }, | ||
| 1965 | }; | ||
| 1966 | }, | ||
| 1967 | ); | ||
| 1968 | |||
| 1969 | const fetchPullRequestBranch: GitCoreShape["fetchPullRequestBranch"] = Effect.fn( | ||
| 1970 | "fetchPullRequestBranch", | ||
| 1971 | )(function* (input) { | ||
| 1972 | const remoteName = yield* resolvePrimaryRemoteName(input.cwd); | ||
| 1973 | yield* executeGit( | ||
| 1974 | "GitCore.fetchPullRequestBranch", | ||
| 1975 | input.cwd, | ||
| 1976 | [ | ||
| 1977 | "fetch", | ||
| 1978 | "--quiet", | ||
| 1979 | "--no-tags", | ||
| 1980 | remoteName, | ||
| 1981 | `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, | ||
| 1982 | ], | ||
| 1983 | { | ||
| 1984 | fallbackErrorMessage: "git fetch pull request branch failed", | ||
| 1985 | }, | ||
| 1986 | ); | ||
| 1987 | }); | ||
| 1988 | |||
| 1989 | const fetchRemoteBranch: GitCoreShape["fetchRemoteBranch"] = Effect.fn("fetchRemoteBranch")( | ||
| 1990 | function* (input) { | ||
| 1991 | yield* runGit("GitCore.fetchRemoteBranch.fetch", input.cwd, [ | ||
| 1992 | "fetch", | ||
| 1993 | "--quiet", | ||
| 1994 | "--no-tags", | ||
| 1995 | input.remoteName, | ||
| 1996 | `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, | ||
| 1997 | ]); | ||
| 1998 | |||
| 1999 | const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch); | ||
| 2000 | const targetRef = `${input.remoteName}/${input.remoteBranch}`; | ||
| 2001 | yield* runGit( | ||
| 2002 | "GitCore.fetchRemoteBranch.materialize", | ||
| 2003 | input.cwd, | ||
| 2004 | localBranchAlreadyExists | ||
| 2005 | ? ["branch", "--force", input.localBranch, targetRef] | ||
| 2006 | : ["branch", input.localBranch, targetRef], | ||
| 2007 | ); | ||
| 2008 | }, | ||
| 2009 | ); | ||
| 2010 | |||
| 2011 | const setBranchUpstream: GitCoreShape["setBranchUpstream"] = (input) => | ||
| 2012 | runGit("GitCore.setBranchUpstream", input.cwd, [ | ||
| 2013 | "branch", | ||
| 2014 | "--set-upstream-to", | ||
| 2015 | `${input.remoteName}/${input.remoteBranch}`, | ||
| 2016 | input.branch, | ||
| 2017 | ]); | ||
| 2018 | |||
| 2019 | const removeWorktree: GitCoreShape["removeWorktree"] = Effect.fn("removeWorktree")( | ||
| 2020 | function* (input) { | ||
| 2021 | const args = ["worktree", "remove"]; | ||
| 2022 | if (input.force) { | ||
| 2023 | args.push("--force"); | ||
| 2024 | } | ||
| 2025 | args.push(input.path); | ||
| 2026 | yield* executeGit("GitCore.removeWorktree", input.cwd, args, { | ||
| 2027 | timeoutMs: 15_000, | ||
| 2028 | fallbackErrorMessage: "git worktree remove failed", | ||
| 2029 | }).pipe( | ||
| 2030 | Effect.mapError((error) => | ||
| 2031 | createGitCommandError( | ||
| 2032 | "GitCore.removeWorktree", | ||
| 2033 | input.cwd, | ||
| 2034 | args, | ||
| 2035 | `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error.message}`, | ||
| 2036 | error, | ||
| 2037 | ), | ||
| 2038 | ), | ||
| 2039 | ); | ||
| 2040 | }, | ||
| 2041 | ); | ||
| 2042 | |||
| 2043 | const renameBranch: GitCoreShape["renameBranch"] = Effect.fn("renameBranch")(function* (input) { | ||
| 2044 | if (input.oldBranch === input.newBranch) { | ||
| 2045 | return { branch: input.newBranch }; | ||
| 2046 | } | ||
| 2047 | const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch); | ||
| 2048 | |||
| 2049 | yield* executeGit( | ||
| 2050 | "GitCore.renameBranch", | ||
| 2051 | input.cwd, | ||
| 2052 | ["branch", "-m", "--", input.oldBranch, targetBranch], | ||
| 2053 | { | ||
| 2054 | timeoutMs: 10_000, | ||
| 2055 | fallbackErrorMessage: "git branch rename failed", | ||
| 2056 | }, | ||
| 2057 | ); | ||
| 2058 | |||
| 2059 | return { branch: targetBranch }; | ||
| 2060 | }); | ||
| 2061 | |||
| 2062 | const checkoutBranch: GitCoreShape["checkoutBranch"] = Effect.fn("checkoutBranch")( | ||
| 2063 | function* (input) { | ||
| 2064 | const [localInputExists, remoteExists] = yield* Effect.all( | ||
| 2065 | [ | ||
| 2066 | executeGit( | ||
| 2067 | "GitCore.checkoutBranch.localInputExists", | ||
| 2068 | input.cwd, | ||
| 2069 | ["show-ref", "--verify", "--quiet", `refs/heads/${input.branch}`], | ||
| 2070 | { | ||
| 2071 | timeoutMs: 5_000, | ||
| 2072 | allowNonZeroExit: true, | ||
| 2073 | }, | ||
| 2074 | ).pipe(Effect.map((result) => result.code === 0)), | ||
| 2075 | executeGit( | ||
| 2076 | "GitCore.checkoutBranch.remoteExists", | ||
| 2077 | input.cwd, | ||
| 2078 | ["show-ref", "--verify", "--quiet", `refs/remotes/${input.branch}`], | ||
| 2079 | { | ||
| 2080 | timeoutMs: 5_000, | ||
| 2081 | allowNonZeroExit: true, | ||
| 2082 | }, | ||
| 2083 | ).pipe(Effect.map((result) => result.code === 0)), | ||
| 2084 | ], | ||
| 2085 | { concurrency: "unbounded" }, | ||
| 2086 | ); | ||
| 2087 | |||
| 2088 | const localTrackingBranch = remoteExists | ||
| 2089 | ? yield* executeGit( | ||
| 2090 | "GitCore.checkoutBranch.localTrackingBranch", | ||
| 2091 | input.cwd, | ||
| 2092 | ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"], | ||
| 2093 | { | ||
| 2094 | timeoutMs: 5_000, | ||
| 2095 | allowNonZeroExit: true, | ||
| 2096 | }, | ||
| 2097 | ).pipe( | ||
| 2098 | Effect.map((result) => | ||
| 2099 | result.code === 0 | ||
| 2100 | ? parseTrackingBranchByUpstreamRef(result.stdout, input.branch) | ||
| 2101 | : null, | ||
| 2102 | ), | ||
| 2103 | ) | ||
| 2104 | : null; | ||
| 2105 | |||
| 2106 | const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.branch); | ||
| 2107 | const localTrackedBranchTargetExists = | ||
| 2108 | remoteExists && localTrackedBranchCandidate | ||
| 2109 | ? yield* executeGit( | ||
| 2110 | "GitCore.checkoutBranch.localTrackedBranchTargetExists", | ||
| 2111 | input.cwd, | ||
| 2112 | ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`], | ||
| 2113 | { | ||
| 2114 | timeoutMs: 5_000, | ||
| 2115 | allowNonZeroExit: true, | ||
| 2116 | }, | ||
| 2117 | ).pipe(Effect.map((result) => result.code === 0)) | ||
| 2118 | : false; | ||
| 2119 | |||
| 2120 | const checkoutArgs = localInputExists | ||
| 2121 | ? ["checkout", input.branch] | ||
| 2122 | : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists | ||
| 2123 | ? ["checkout", input.branch] | ||
| 2124 | : remoteExists && !localTrackingBranch | ||
| 2125 | ? ["checkout", "--track", input.branch] | ||
| 2126 | : remoteExists && localTrackingBranch | ||
| 2127 | ? ["checkout", localTrackingBranch] | ||
| 2128 | : ["checkout", input.branch]; | ||
| 2129 | |||
| 2130 | yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, checkoutArgs, { | ||
| 2131 | timeoutMs: 10_000, | ||
| 2132 | fallbackErrorMessage: "git checkout failed", | ||
| 2133 | }); | ||
| 2134 | |||
| 2135 | const branch = yield* runGitStdout("GitCore.checkoutBranch.currentBranch", input.cwd, [ | ||
| 2136 | "branch", | ||
| 2137 | "--show-current", | ||
| 2138 | ]).pipe(Effect.map((stdout) => stdout.trim() || null)); | ||
| 2139 | |||
| 2140 | return { branch }; | ||
| 2141 | }, | ||
| 2142 | ); | ||
| 2143 | |||
| 2144 | const createBranch: GitCoreShape["createBranch"] = Effect.fn("createBranch")(function* (input) { | ||
| 2145 | yield* executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], { | ||
| 2146 | timeoutMs: 10_000, | ||
| 2147 | fallbackErrorMessage: "git branch create failed", | ||
| 2148 | }); | ||
| 2149 | if (input.checkout) { | ||
| 2150 | yield* checkoutBranch({ cwd: input.cwd, branch: input.branch }); | ||
| 2151 | } | ||
| 2152 | |||
| 2153 | return { branch: input.branch }; | ||
| 2154 | }); | ||
| 2155 | |||
| 2156 | const initRepo: GitCoreShape["initRepo"] = (input) => | ||
| 2157 | executeGit("GitCore.initRepo", input.cwd, ["init"], { | ||
| 2158 | timeoutMs: 10_000, | ||
| 2159 | fallbackErrorMessage: "git init failed", | ||
| 2160 | }).pipe(Effect.asVoid); | ||
| 2161 | |||
| 2162 | const listLocalBranchNames: GitCoreShape["listLocalBranchNames"] = (cwd) => | ||
| 2163 | runGitStdout("GitCore.listLocalBranchNames", cwd, [ | ||
| 2164 | "branch", | ||
| 2165 | "--list", | ||
| 2166 | "--no-column", | ||
| 2167 | "--format=%(refname:short)", | ||
| 2168 | ]).pipe( | ||
| 2169 | Effect.map((stdout) => | ||
| 2170 | stdout | ||
| 2171 | .split("\n") | ||
| 2172 | .map((line) => line.trim()) | ||
| 2173 | .filter((line) => line.length > 0), | ||
| 2174 | ), | ||
| 2175 | ); | ||
| 2176 | |||
| 2177 | return { | ||
| 2178 | execute, | ||
| 2179 | status, | ||
| 2180 | statusDetails, | ||
| 2181 | statusDetailsLocal, | ||
| 2182 | prepareCommitContext, | ||
| 2183 | commit, | ||
| 2184 | pushCurrentBranch, | ||
| 2185 | pullCurrentBranch, | ||
| 2186 | readRangeContext, | ||
| 2187 | readConfigValue, | ||
| 2188 | isInsideWorkTree, | ||
| 2189 | listWorkspaceFiles, | ||
| 2190 | filterIgnoredPaths, | ||
| 2191 | listBranches, | ||
| 2192 | createWorktree, | ||
| 2193 | fetchPullRequestBranch, | ||
| 2194 | ensureRemote, | ||
| 2195 | fetchRemoteBranch, | ||
| 2196 | setBranchUpstream, | ||
| 2197 | removeWorktree, | ||
| 2198 | renameBranch, | ||
| 2199 | createBranch, | ||
| 2200 | checkoutBranch, | ||
| 2201 | initRepo, | ||
| 2202 | listLocalBranchNames, | ||
| 2203 | } satisfies GitCoreShape; | ||
| 2204 | }); | ||
| 2205 | |||
| 2206 | export const GitCoreLive = Layer.effect(GitCore, makeGitCore()); | ||
apps/server/src/git/Layers/GitHubCli.test.ts deleted-227| ... | @@ -1,227 +0,0 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import { Effect } from "effect"; | ||
| 3 | import { afterEach, expect, vi } from "vitest"; | ||
| 4 | |||
| 5 | vi.mock("../../processRunner", () => ({ | ||
| 6 | runProcess: vi.fn(), | ||
| 7 | })); | ||
| 8 | |||
| 9 | import { runProcess } from "../../processRunner.ts"; | ||
| 10 | import { GitHubCli } from "../Services/GitHubCli.ts"; | ||
| 11 | import { GitHubCliLive } from "./GitHubCli.ts"; | ||
| 12 | |||
| 13 | const mockedRunProcess = vi.mocked(runProcess); | ||
| 14 | const layer = it.layer(GitHubCliLive); | ||
| 15 | |||
| 16 | afterEach(() => { | ||
| 17 | mockedRunProcess.mockReset(); | ||
| 18 | }); | ||
| 19 | |||
| 20 | layer("GitHubCliLive", (it) => { | ||
| 21 | it.effect("parses pull request view output", () => | ||
| 22 | Effect.gen(function* () { | ||
| 23 | mockedRunProcess.mockResolvedValueOnce({ | ||
| 24 | stdout: JSON.stringify({ | ||
| 25 | number: 42, | ||
| 26 | title: "Add PR thread creation", | ||
| 27 | url: "https://github.com/pingdotgg/codething-mvp/pull/42", | ||
| 28 | baseRefName: "main", | ||
| 29 | headRefName: "feature/pr-threads", | ||
| 30 | state: "OPEN", | ||
| 31 | mergedAt: null, | ||
| 32 | isCrossRepository: true, | ||
| 33 | headRepository: { | ||
| 34 | nameWithOwner: "octocat/codething-mvp", | ||
| 35 | }, | ||
| 36 | headRepositoryOwner: { | ||
| 37 | login: "octocat", | ||
| 38 | }, | ||
| 39 | }), | ||
| 40 | stderr: "", | ||
| 41 | code: 0, | ||
| 42 | signal: null, | ||
| 43 | timedOut: false, | ||
| 44 | }); | ||
| 45 | |||
| 46 | const result = yield* Effect.gen(function* () { | ||
| 47 | const gh = yield* GitHubCli; | ||
| 48 | return yield* gh.getPullRequest({ | ||
| 49 | cwd: "/repo", | ||
| 50 | reference: "#42", | ||
| 51 | }); | ||
| 52 | }); | ||
| 53 | |||
| 54 | assert.deepStrictEqual(result, { | ||
| 55 | number: 42, | ||
| 56 | title: "Add PR thread creation", | ||
| 57 | url: "https://github.com/pingdotgg/codething-mvp/pull/42", | ||
| 58 | baseRefName: "main", | ||
| 59 | headRefName: "feature/pr-threads", | ||
| 60 | state: "open", | ||
| 61 | isCrossRepository: true, | ||
| 62 | headRepositoryNameWithOwner: "octocat/codething-mvp", | ||
| 63 | headRepositoryOwnerLogin: "octocat", | ||
| 64 | }); | ||
| 65 | expect(mockedRunProcess).toHaveBeenCalledWith( | ||
| 66 | "gh", | ||
| 67 | [ | ||
| 68 | "pr", | ||
| 69 | "view", | ||
| 70 | "#42", | ||
| 71 | "--json", | ||
| 72 | "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", | ||
| 73 | ], | ||
| 74 | expect.objectContaining({ cwd: "/repo" }), | ||
| 75 | ); | ||
| 76 | }), | ||
| 77 | ); | ||
| 78 | |||
| 79 | it.effect("trims pull request fields decoded from gh json", () => | ||
| 80 | Effect.gen(function* () { | ||
| 81 | mockedRunProcess.mockResolvedValueOnce({ | ||
| 82 | stdout: JSON.stringify({ | ||
| 83 | number: 42, | ||
| 84 | title: " Add PR thread creation \n", | ||
| 85 | url: " https://github.com/pingdotgg/codething-mvp/pull/42 ", | ||
| 86 | baseRefName: " main ", | ||
| 87 | headRefName: "\tfeature/pr-threads\t", | ||
| 88 | state: "OPEN", | ||
| 89 | mergedAt: null, | ||
| 90 | isCrossRepository: true, | ||
| 91 | headRepository: { | ||
| 92 | nameWithOwner: " octocat/codething-mvp ", | ||
| 93 | }, | ||
| 94 | headRepositoryOwner: { | ||
| 95 | login: " octocat ", | ||
| 96 | }, | ||
| 97 | }), | ||
| 98 | stderr: "", | ||
| 99 | code: 0, | ||
| 100 | signal: null, | ||
| 101 | timedOut: false, | ||
| 102 | }); | ||
| 103 | |||
| 104 | const result = yield* Effect.gen(function* () { | ||
| 105 | const gh = yield* GitHubCli; | ||
| 106 | return yield* gh.getPullRequest({ | ||
| 107 | cwd: "/repo", | ||
| 108 | reference: "#42", | ||
| 109 | }); | ||
| 110 | }); | ||
| 111 | |||
| 112 | assert.deepStrictEqual(result, { | ||
| 113 | number: 42, | ||
| 114 | title: "Add PR thread creation", | ||
| 115 | url: "https://github.com/pingdotgg/codething-mvp/pull/42", | ||
| 116 | baseRefName: "main", | ||
| 117 | headRefName: "feature/pr-threads", | ||
| 118 | state: "open", | ||
| 119 | isCrossRepository: true, | ||
| 120 | headRepositoryNameWithOwner: "octocat/codething-mvp", | ||
| 121 | headRepositoryOwnerLogin: "octocat", | ||
| 122 | }); | ||
| 123 | }), | ||
| 124 | ); | ||
| 125 | |||
| 126 | it.effect("skips invalid entries when parsing pr lists", () => | ||
| 127 | Effect.gen(function* () { | ||
| 128 | mockedRunProcess.mockResolvedValueOnce({ | ||
| 129 | stdout: JSON.stringify([ | ||
| 130 | { | ||
| 131 | number: 0, | ||
| 132 | title: "invalid", | ||
| 133 | url: "https://github.com/pingdotgg/codething-mvp/pull/0", | ||
| 134 | baseRefName: "main", | ||
| 135 | headRefName: "feature/invalid", | ||
| 136 | }, | ||
| 137 | { | ||
| 138 | number: 43, | ||
| 139 | title: " Valid PR ", | ||
| 140 | url: " https://github.com/pingdotgg/codething-mvp/pull/43 ", | ||
| 141 | baseRefName: " main ", | ||
| 142 | headRefName: " feature/pr-list ", | ||
| 143 | headRepository: { | ||
| 144 | nameWithOwner: " ", | ||
| 145 | }, | ||
| 146 | headRepositoryOwner: { | ||
| 147 | login: " ", | ||
| 148 | }, | ||
| 149 | }, | ||
| 150 | ]), | ||
| 151 | stderr: "", | ||
| 152 | code: 0, | ||
| 153 | signal: null, | ||
| 154 | timedOut: false, | ||
| 155 | }); | ||
| 156 | |||
| 157 | const result = yield* Effect.gen(function* () { | ||
| 158 | const gh = yield* GitHubCli; | ||
| 159 | return yield* gh.listOpenPullRequests({ | ||
| 160 | cwd: "/repo", | ||
| 161 | headSelector: "feature/pr-list", | ||
| 162 | }); | ||
| 163 | }); | ||
| 164 | |||
| 165 | assert.deepStrictEqual(result, [ | ||
| 166 | { | ||
| 167 | number: 43, | ||
| 168 | title: "Valid PR", | ||
| 169 | url: "https://github.com/pingdotgg/codething-mvp/pull/43", | ||
| 170 | baseRefName: "main", | ||
| 171 | headRefName: "feature/pr-list", | ||
| 172 | state: "open", | ||
| 173 | }, | ||
| 174 | ]); | ||
| 175 | }), | ||
| 176 | ); | ||
| 177 | |||
| 178 | it.effect("reads repository clone URLs", () => | ||
| 179 | Effect.gen(function* () { | ||
| 180 | mockedRunProcess.mockResolvedValueOnce({ | ||
| 181 | stdout: JSON.stringify({ | ||
| 182 | nameWithOwner: "octocat/codething-mvp", | ||
| 183 | url: "https://github.com/octocat/codething-mvp", | ||
| 184 | sshUrl: "git@github.com:octocat/codething-mvp.git", | ||
| 185 | }), | ||
| 186 | stderr: "", | ||
| 187 | code: 0, | ||
| 188 | signal: null, | ||
| 189 | timedOut: false, | ||
| 190 | }); | ||
| 191 | |||
| 192 | const result = yield* Effect.gen(function* () { | ||
| 193 | const gh = yield* GitHubCli; | ||
| 194 | return yield* gh.getRepositoryCloneUrls({ | ||
| 195 | cwd: "/repo", | ||
| 196 | repository: "octocat/codething-mvp", | ||
| 197 | }); | ||
| 198 | }); | ||
| 199 | |||
| 200 | assert.deepStrictEqual(result, { | ||
| 201 | nameWithOwner: "octocat/codething-mvp", | ||
| 202 | url: "https://github.com/octocat/codething-mvp", | ||
| 203 | sshUrl: "git@github.com:octocat/codething-mvp.git", | ||
| 204 | }); | ||
| 205 | }), | ||
| 206 | ); | ||
| 207 | |||
| 208 | it.effect("surfaces a friendly error when the pull request is not found", () => | ||
| 209 | Effect.gen(function* () { | ||
| 210 | mockedRunProcess.mockRejectedValueOnce( | ||
| 211 | new Error( | ||
| 212 | "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)", | ||
| 213 | ), | ||
| 214 | ); | ||
| 215 | |||
| 216 | const error = yield* Effect.gen(function* () { | ||
| 217 | const gh = yield* GitHubCli; | ||
| 218 | return yield* gh.getPullRequest({ | ||
| 219 | cwd: "/repo", | ||
| 220 | reference: "4888", | ||
| 221 | }); | ||
| 222 | }).pipe(Effect.flip); | ||
| 223 | |||
| 224 | assert.equal(error.message.includes("Pull request not found"), true); | ||
| 225 | }), | ||
| 226 | ); | ||
| 227 | }); | ||
apps/server/src/git/Layers/GitHubCli.ts deleted-240| ... | @@ -1,240 +0,0 @@ | ||
| 1 | import { Effect, Layer, Result, Schema, SchemaIssue } from "effect"; | ||
| 2 | import { TrimmedNonEmptyString } from "@t3tools/contracts"; | ||
| 3 | |||
| 4 | import { runProcess } from "../../processRunner.ts"; | ||
| 5 | import { GitHubCliError } from "@t3tools/contracts"; | ||
| 6 | import { | ||
| 7 | GitHubCli, | ||
| 8 | type GitHubRepositoryCloneUrls, | ||
| 9 | type GitHubCliShape, | ||
| 10 | } from "../Services/GitHubCli.ts"; | ||
| 11 | import { | ||
| 12 | decodeGitHubPullRequestJson, | ||
| 13 | decodeGitHubPullRequestListJson, | ||
| 14 | formatGitHubJsonDecodeError, | ||
| 15 | } from "../githubPullRequests.ts"; | ||
| 16 | |||
| 17 | const DEFAULT_TIMEOUT_MS = 30_000; | ||
| 18 | |||
| 19 | function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown): GitHubCliError { | ||
| 20 | if (error instanceof Error) { | ||
| 21 | if (error.message.includes("Command not found: gh")) { | ||
| 22 | return new GitHubCliError({ | ||
| 23 | operation, | ||
| 24 | detail: "GitHub CLI (`gh`) is required but not available on PATH.", | ||
| 25 | cause: error, | ||
| 26 | }); | ||
| 27 | } | ||
| 28 | |||
| 29 | const lower = error.message.toLowerCase(); | ||
| 30 | if ( | ||
| 31 | lower.includes("authentication failed") || | ||
| 32 | lower.includes("not logged in") || | ||
| 33 | lower.includes("gh auth login") || | ||
| 34 | lower.includes("no oauth token") | ||
| 35 | ) { | ||
| 36 | return new GitHubCliError({ | ||
| 37 | operation, | ||
| 38 | detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", | ||
| 39 | cause: error, | ||
| 40 | }); | ||
| 41 | } | ||
| 42 | |||
| 43 | if ( | ||
| 44 | lower.includes("could not resolve to a pullrequest") || | ||
| 45 | lower.includes("repository.pullrequest") || | ||
| 46 | lower.includes("no pull requests found for branch") || | ||
| 47 | lower.includes("pull request not found") | ||
| 48 | ) { | ||
| 49 | return new GitHubCliError({ | ||
| 50 | operation, | ||
| 51 | detail: "Pull request not found. Check the PR number or URL and try again.", | ||
| 52 | cause: error, | ||
| 53 | }); | ||
| 54 | } | ||
| 55 | |||
| 56 | return new GitHubCliError({ | ||
| 57 | operation, | ||
| 58 | detail: `GitHub CLI command failed: ${error.message}`, | ||
| 59 | cause: error, | ||
| 60 | }); | ||
| 61 | } | ||
| 62 | |||
| 63 | return new GitHubCliError({ | ||
| 64 | operation, | ||
| 65 | detail: "GitHub CLI command failed.", | ||
| 66 | cause: error, | ||
| 67 | }); | ||
| 68 | } | ||
| 69 | |||
| 70 | const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ | ||
| 71 | nameWithOwner: TrimmedNonEmptyString, | ||
| 72 | url: TrimmedNonEmptyString, | ||
| 73 | sshUrl: TrimmedNonEmptyString, | ||
| 74 | }); | ||
| 75 | |||
| 76 | function normalizeRepositoryCloneUrls( | ||
| 77 | raw: Schema.Schema.Type<typeof RawGitHubRepositoryCloneUrlsSchema>, | ||
| 78 | ): GitHubRepositoryCloneUrls { | ||
| 79 | return { | ||
| 80 | nameWithOwner: raw.nameWithOwner, | ||
| 81 | url: raw.url, | ||
| 82 | sshUrl: raw.sshUrl, | ||
| 83 | }; | ||
| 84 | } | ||
| 85 | |||
| 86 | function decodeGitHubJson<S extends Schema.Top>( | ||
| 87 | raw: string, | ||
| 88 | schema: S, | ||
| 89 | operation: "listOpenPullRequests" | "getPullRequest" | "getRepositoryCloneUrls", | ||
| 90 | invalidDetail: string, | ||
| 91 | ): Effect.Effect<S["Type"], GitHubCliError, S["DecodingServices"]> { | ||
| 92 | return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( | ||
| 93 | Effect.mapError( | ||
| 94 | (error) => | ||
| 95 | new GitHubCliError({ | ||
| 96 | operation, | ||
| 97 | detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, | ||
| 98 | cause: error, | ||
| 99 | }), | ||
| 100 | ), | ||
| 101 | ); | ||
| 102 | } | ||
| 103 | |||
| 104 | const makeGitHubCli = Effect.sync(() => { | ||
| 105 | const execute: GitHubCliShape["execute"] = (input) => | ||
| 106 | Effect.tryPromise({ | ||
| 107 | try: () => | ||
| 108 | runProcess("gh", input.args, { | ||
| 109 | cwd: input.cwd, | ||
| 110 | timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, | ||
| 111 | }), | ||
| 112 | catch: (error) => normalizeGitHubCliError("execute", error), | ||
| 113 | }); | ||
| 114 | |||
| 115 | const service = { | ||
| 116 | execute, | ||
| 117 | listOpenPullRequests: (input) => | ||
| 118 | execute({ | ||
| 119 | cwd: input.cwd, | ||
| 120 | args: [ | ||
| 121 | "pr", | ||
| 122 | "list", | ||
| 123 | "--head", | ||
| 124 | input.headSelector, | ||
| 125 | "--state", | ||
| 126 | "open", | ||
| 127 | "--limit", | ||
| 128 | String(input.limit ?? 1), | ||
| 129 | "--json", | ||
| 130 | "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", | ||
| 131 | ], | ||
| 132 | }).pipe( | ||
| 133 | Effect.map((result) => result.stdout.trim()), | ||
| 134 | Effect.flatMap((raw) => | ||
| 135 | raw.length === 0 | ||
| 136 | ? Effect.succeed([]) | ||
| 137 | : Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( | ||
| 138 | Effect.flatMap((decoded) => { | ||
| 139 | if (!Result.isSuccess(decoded)) { | ||
| 140 | return Effect.fail( | ||
| 141 | new GitHubCliError({ | ||
| 142 | operation: "listOpenPullRequests", | ||
| 143 | detail: `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, | ||
| 144 | cause: decoded.failure, | ||
| 145 | }), | ||
| 146 | ); | ||
| 147 | } | ||
| 148 | |||
| 149 | return Effect.succeed( | ||
| 150 | decoded.success.map(({ updatedAt: _updatedAt, ...summary }) => summary), | ||
| 151 | ); | ||
| 152 | }), | ||
| 153 | ), | ||
| 154 | ), | ||
| 155 | ), | ||
| 156 | getPullRequest: (input) => | ||
| 157 | execute({ | ||
| 158 | cwd: input.cwd, | ||
| 159 | args: [ | ||
| 160 | "pr", | ||
| 161 | "view", | ||
| 162 | input.reference, | ||
| 163 | "--json", | ||
| 164 | "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", | ||
| 165 | ], | ||
| 166 | }).pipe( | ||
| 167 | Effect.map((result) => result.stdout.trim()), | ||
| 168 | Effect.flatMap((raw) => | ||
| 169 | Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe( | ||
| 170 | Effect.flatMap((decoded) => { | ||
| 171 | if (!Result.isSuccess(decoded)) { | ||
| 172 | return Effect.fail( | ||
| 173 | new GitHubCliError({ | ||
| 174 | operation: "getPullRequest", | ||
| 175 | detail: `GitHub CLI returned invalid pull request JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, | ||
| 176 | cause: decoded.failure, | ||
| 177 | }), | ||
| 178 | ); | ||
| 179 | } | ||
| 180 | |||
| 181 | return Effect.succeed( | ||
| 182 | (({ updatedAt: _updatedAt, ...summary }) => summary)(decoded.success), | ||
| 183 | ); | ||
| 184 | }), | ||
| 185 | ), | ||
| 186 | ), | ||
| 187 | ), | ||
| 188 | getRepositoryCloneUrls: (input) => | ||
| 189 | execute({ | ||
| 190 | cwd: input.cwd, | ||
| 191 | args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], | ||
| 192 | }).pipe( | ||
| 193 | Effect.map((result) => result.stdout.trim()), | ||
| 194 | Effect.flatMap((raw) => | ||
| 195 | decodeGitHubJson( | ||
| 196 | raw, | ||
| 197 | RawGitHubRepositoryCloneUrlsSchema, | ||
| 198 | "getRepositoryCloneUrls", | ||
| 199 | "GitHub CLI returned invalid repository JSON.", | ||
| 200 | ), | ||
| 201 | ), | ||
| 202 | Effect.map(normalizeRepositoryCloneUrls), | ||
| 203 | ), | ||
| 204 | createPullRequest: (input) => | ||
| 205 | execute({ | ||
| 206 | cwd: input.cwd, | ||
| 207 | args: [ | ||
| 208 | "pr", | ||
| 209 | "create", | ||
| 210 | "--base", | ||
| 211 | input.baseBranch, | ||
| 212 | "--head", | ||
| 213 | input.headSelector, | ||
| 214 | "--title", | ||
| 215 | input.title, | ||
| 216 | "--body-file", | ||
| 217 | input.bodyFile, | ||
| 218 | ], | ||
| 219 | }).pipe(Effect.asVoid), | ||
| 220 | getDefaultBranch: (input) => | ||
| 221 | execute({ | ||
| 222 | cwd: input.cwd, | ||
| 223 | args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], | ||
| 224 | }).pipe( | ||
| 225 | Effect.map((value) => { | ||
| 226 | const trimmed = value.stdout.trim(); | ||
| 227 | return trimmed.length > 0 ? trimmed : null; | ||
| 228 | }), | ||
| 229 | ), | ||
| 230 | checkoutPullRequest: (input) => | ||
| 231 | execute({ | ||
| 232 | cwd: input.cwd, | ||
| 233 | args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], | ||
| 234 | }).pipe(Effect.asVoid), | ||
| 235 | } satisfies GitHubCliShape; | ||
| 236 | |||
| 237 | return service; | ||
| 238 | }); | ||
| 239 | |||
| 240 | export const GitHubCliLive = Layer.effect(GitHubCli, makeGitHubCli); | ||
apps/server/src/git/Layers/GitManager.test.ts deleted-3127| ... | @@ -1,3127 +0,0 @@ | ||
| 1 | import fs from "node:fs"; | ||
| 2 | import path from "node:path"; | ||
| 3 | import { spawnSync } from "node:child_process"; | ||
| 4 | |||
| 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 6 | import { it } from "@effect/vitest"; | ||
| 7 | import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; | ||
| 8 | import { expect } from "vitest"; | ||
| 9 | import type { | ||
| 10 | GitActionProgressEvent, | ||
| 11 | GitPreparePullRequestThreadInput, | ||
| 12 | ModelSelection, | ||
| 13 | ThreadId, | ||
| 14 | } from "@t3tools/contracts"; | ||
| 15 | |||
| 16 | import { GitCommandError, GitHubCliError, TextGenerationError } from "@t3tools/contracts"; | ||
| 17 | import { type GitManagerShape } from "../Services/GitManager.ts"; | ||
| 18 | import { | ||
| 19 | type GitHubCliShape, | ||
| 20 | type GitHubPullRequestSummary, | ||
| 21 | GitHubCli, | ||
| 22 | } from "../Services/GitHubCli.ts"; | ||
| 23 | import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; | ||
| 24 | import { GitCoreLive } from "./GitCore.ts"; | ||
| 25 | import { GitCore } from "../Services/GitCore.ts"; | ||
| 26 | import { makeGitManager } from "./GitManager.ts"; | ||
| 27 | import { ServerConfig } from "../../config.ts"; | ||
| 28 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 29 | import { | ||
| 30 | ProjectSetupScriptRunner, | ||
| 31 | type ProjectSetupScriptRunnerInput, | ||
| 32 | type ProjectSetupScriptRunnerShape, | ||
| 33 | } from "../../project/Services/ProjectSetupScriptRunner.ts"; | ||
| 34 | |||
| 35 | interface FakeGhScenario { | ||
| 36 | prListSequence?: string[]; | ||
| 37 | prListByHeadSelector?: Record<string, string>; | ||
| 38 | prListSequenceByHeadSelector?: Record<string, string[]>; | ||
| 39 | createdPrUrl?: string; | ||
| 40 | defaultBranch?: string; | ||
| 41 | pullRequest?: { | ||
| 42 | number: number; | ||
| 43 | title: string; | ||
| 44 | url: string; | ||
| 45 | baseRefName: string; | ||
| 46 | headRefName: string; | ||
| 47 | state?: "open" | "closed" | "merged"; | ||
| 48 | isCrossRepository?: boolean; | ||
| 49 | headRepositoryNameWithOwner?: string | null; | ||
| 50 | headRepositoryOwnerLogin?: string | null; | ||
| 51 | }; | ||
| 52 | repositoryCloneUrls?: Record<string, { url: string; sshUrl: string }>; | ||
| 53 | failWith?: GitHubCliError; | ||
| 54 | } | ||
| 55 | |||
| 56 | interface FakeGitTextGeneration { | ||
| 57 | generateCommitMessage: (input: { | ||
| 58 | cwd: string; | ||
| 59 | branch: string | null; | ||
| 60 | stagedSummary: string; | ||
| 61 | stagedPatch: string; | ||
| 62 | includeBranch?: boolean; | ||
| 63 | modelSelection: ModelSelection; | ||
| 64 | }) => Effect.Effect< | ||
| 65 | { subject: string; body: string; branch?: string | undefined }, | ||
| 66 | TextGenerationError | ||
| 67 | >; | ||
| 68 | generatePrContent: (input: { | ||
| 69 | cwd: string; | ||
| 70 | baseBranch: string; | ||
| 71 | headBranch: string; | ||
| 72 | commitSummary: string; | ||
| 73 | diffSummary: string; | ||
| 74 | diffPatch: string; | ||
| 75 | modelSelection: ModelSelection; | ||
| 76 | }) => Effect.Effect<{ title: string; body: string }, TextGenerationError>; | ||
| 77 | generateBranchName: (input: { | ||
| 78 | cwd: string; | ||
| 79 | message: string; | ||
| 80 | modelSelection: ModelSelection; | ||
| 81 | }) => Effect.Effect<{ branch: string }, TextGenerationError>; | ||
| 82 | generateThreadTitle: (input: { | ||
| 83 | cwd: string; | ||
| 84 | message: string; | ||
| 85 | modelSelection: ModelSelection; | ||
| 86 | }) => Effect.Effect<{ title: string }, TextGenerationError>; | ||
| 87 | } | ||
| 88 | |||
| 89 | type FakePullRequest = NonNullable<FakeGhScenario["pullRequest"]>; | ||
| 90 | |||
| 91 | function normalizeFakePullRequestSummary(raw: unknown): GitHubPullRequestSummary | null { | ||
| 92 | if (!raw || typeof raw !== "object") { | ||
| 93 | return null; | ||
| 94 | } | ||
| 95 | |||
| 96 | const record = raw as Record<string, unknown>; | ||
| 97 | const number = record.number; | ||
| 98 | const title = record.title; | ||
| 99 | const url = record.url; | ||
| 100 | const baseRefName = record.baseRefName; | ||
| 101 | const headRefName = record.headRefName; | ||
| 102 | const headRepository = | ||
| 103 | typeof record.headRepository === "object" && record.headRepository !== null | ||
| 104 | ? (record.headRepository as Record<string, unknown>) | ||
| 105 | : null; | ||
| 106 | const headRepositoryOwner = | ||
| 107 | typeof record.headRepositoryOwner === "object" && record.headRepositoryOwner !== null | ||
| 108 | ? (record.headRepositoryOwner as Record<string, unknown>) | ||
| 109 | : null; | ||
| 110 | |||
| 111 | if ( | ||
| 112 | typeof number !== "number" || | ||
| 113 | typeof title !== "string" || | ||
| 114 | typeof url !== "string" || | ||
| 115 | typeof baseRefName !== "string" || | ||
| 116 | typeof headRefName !== "string" | ||
| 117 | ) { | ||
| 118 | return null; | ||
| 119 | } | ||
| 120 | |||
| 121 | const state = | ||
| 122 | typeof record.state === "string" | ||
| 123 | ? record.state === "OPEN" || record.state === "open" | ||
| 124 | ? "open" | ||
| 125 | : record.state === "CLOSED" || record.state === "closed" | ||
| 126 | ? "closed" | ||
| 127 | : "merged" | ||
| 128 | : undefined; | ||
| 129 | const isCrossRepository = | ||
| 130 | typeof record.isCrossRepository === "boolean" ? record.isCrossRepository : undefined; | ||
| 131 | const headRepositoryNameWithOwner = | ||
| 132 | typeof record.headRepositoryNameWithOwner === "string" | ||
| 133 | ? record.headRepositoryNameWithOwner | ||
| 134 | : typeof headRepository?.nameWithOwner === "string" | ||
| 135 | ? headRepository.nameWithOwner | ||
| 136 | : undefined; | ||
| 137 | const headRepositoryOwnerLogin = | ||
| 138 | typeof record.headRepositoryOwnerLogin === "string" | ||
| 139 | ? record.headRepositoryOwnerLogin | ||
| 140 | : typeof headRepositoryOwner?.login === "string" | ||
| 141 | ? headRepositoryOwner.login | ||
| 142 | : undefined; | ||
| 143 | |||
| 144 | return { | ||
| 145 | number, | ||
| 146 | title, | ||
| 147 | url, | ||
| 148 | baseRefName, | ||
| 149 | headRefName, | ||
| 150 | ...(state ? { state } : {}), | ||
| 151 | ...(isCrossRepository !== undefined ? { isCrossRepository } : {}), | ||
| 152 | ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), | ||
| 153 | ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), | ||
| 154 | }; | ||
| 155 | } | ||
| 156 | |||
| 157 | function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void { | ||
| 158 | const result = spawnSync("git", args, { | ||
| 159 | cwd, | ||
| 160 | encoding: "utf8", | ||
| 161 | }); | ||
| 162 | if (result.status === 0) { | ||
| 163 | return; | ||
| 164 | } | ||
| 165 | throw new GitHubCliError({ | ||
| 166 | operation: "execute", | ||
| 167 | detail: `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`, | ||
| 168 | }); | ||
| 169 | } | ||
| 170 | |||
| 171 | function isGitHubCliError(error: unknown): error is GitHubCliError { | ||
| 172 | return ( | ||
| 173 | typeof error === "object" && | ||
| 174 | error !== null && | ||
| 175 | "_tag" in error && | ||
| 176 | (error as { _tag?: unknown })._tag === "GitHubCliError" | ||
| 177 | ); | ||
| 178 | } | ||
| 179 | |||
| 180 | function makeTempDir( | ||
| 181 | prefix: string, | ||
| 182 | ): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> { | ||
| 183 | return Effect.gen(function* () { | ||
| 184 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 185 | return yield* fileSystem.makeTempDirectoryScoped({ prefix }); | ||
| 186 | }); | ||
| 187 | } | ||
| 188 | |||
| 189 | function removePath( | ||
| 190 | targetPath: string, | ||
| 191 | ): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> { | ||
| 192 | return Effect.gen(function* () { | ||
| 193 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 194 | yield* fileSystem.remove(targetPath, { recursive: true, force: true }); | ||
| 195 | }); | ||
| 196 | } | ||
| 197 | |||
| 198 | function makeDirectory( | ||
| 199 | dirPath: string, | ||
| 200 | ): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> { | ||
| 201 | return Effect.gen(function* () { | ||
| 202 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 203 | yield* fileSystem.makeDirectory(dirPath, { recursive: true }); | ||
| 204 | }); | ||
| 205 | } | ||
| 206 | |||
| 207 | function runGit( | ||
| 208 | cwd: string, | ||
| 209 | args: readonly string[], | ||
| 210 | allowNonZeroExit = false, | ||
| 211 | ): Effect.Effect< | ||
| 212 | { readonly code: number; readonly stdout: string; readonly stderr: string }, | ||
| 213 | GitCommandError, | ||
| 214 | GitCore | ||
| 215 | > { | ||
| 216 | return Effect.gen(function* () { | ||
| 217 | const gitCore = yield* GitCore; | ||
| 218 | return yield* gitCore.execute({ | ||
| 219 | operation: "GitManager.test.runGit", | ||
| 220 | cwd, | ||
| 221 | args, | ||
| 222 | allowNonZeroExit, | ||
| 223 | }); | ||
| 224 | }); | ||
| 225 | } | ||
| 226 | |||
| 227 | function initRepo( | ||
| 228 | cwd: string, | ||
| 229 | ): Effect.Effect< | ||
| 230 | void, | ||
| 231 | PlatformError.PlatformError | GitCommandError, | ||
| 232 | FileSystem.FileSystem | Scope.Scope | GitCore | ||
| 233 | > { | ||
| 234 | return Effect.gen(function* () { | ||
| 235 | const fs = yield* FileSystem.FileSystem; | ||
| 236 | yield* runGit(cwd, ["init", "--initial-branch=main"]); | ||
| 237 | yield* runGit(cwd, ["config", "user.email", "test@example.com"]); | ||
| 238 | yield* runGit(cwd, ["config", "user.name", "Test User"]); | ||
| 239 | yield* fs.writeFileString(path.join(cwd, "README.md"), "hello\n"); | ||
| 240 | yield* runGit(cwd, ["add", "README.md"]); | ||
| 241 | yield* runGit(cwd, ["commit", "-m", "Initial commit"]); | ||
| 242 | }); | ||
| 243 | } | ||
| 244 | |||
| 245 | function createBareRemote(): Effect.Effect< | ||
| 246 | string, | ||
| 247 | PlatformError.PlatformError | GitCommandError, | ||
| 248 | FileSystem.FileSystem | Scope.Scope | GitCore | ||
| 249 | > { | ||
| 250 | return Effect.gen(function* () { | ||
| 251 | const remoteDir = yield* makeTempDir("t3code-git-remote-"); | ||
| 252 | yield* runGit(remoteDir, ["init", "--bare"]); | ||
| 253 | return remoteDir; | ||
| 254 | }); | ||
| 255 | } | ||
| 256 | |||
| 257 | function configureRemote( | ||
| 258 | cwd: string, | ||
| 259 | remoteName: string, | ||
| 260 | remotePath: string, | ||
| 261 | fetchNamespace: string, | ||
| 262 | ): Effect.Effect<void, GitCommandError, GitCore> { | ||
| 263 | return Effect.gen(function* () { | ||
| 264 | yield* runGit(cwd, ["config", `remote.${remoteName}.url`, remotePath]); | ||
| 265 | yield* runGit(cwd, [ | ||
| 266 | "config", | ||
| 267 | "--replace-all", | ||
| 268 | `remote.${remoteName}.fetch`, | ||
| 269 | `+refs/heads/*:refs/remotes/${fetchNamespace}/*`, | ||
| 270 | ]); | ||
| 271 | }); | ||
| 272 | } | ||
| 273 | |||
| 274 | function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): TextGenerationShape { | ||
| 275 | const implementation: FakeGitTextGeneration = { | ||
| 276 | generateCommitMessage: (input) => | ||
| 277 | Effect.succeed({ | ||
| 278 | subject: "Implement stacked git actions", | ||
| 279 | body: "", | ||
| 280 | ...(input.includeBranch ? { branch: "feature/implement-stacked-git-actions" } : {}), | ||
| 281 | }), | ||
| 282 | generatePrContent: () => | ||
| 283 | Effect.succeed({ | ||
| 284 | title: "Add stacked git actions", | ||
| 285 | body: "## Summary\n- Add stacked git workflow\n\n## Testing\n- Not run", | ||
| 286 | }), | ||
| 287 | generateBranchName: () => | ||
| 288 | Effect.succeed({ | ||
| 289 | branch: "update-workflow", | ||
| 290 | }), | ||
| 291 | generateThreadTitle: () => | ||
| 292 | Effect.succeed({ | ||
| 293 | title: "Update workflow", | ||
| 294 | }), | ||
| 295 | ...overrides, | ||
| 296 | }; | ||
| 297 | |||
| 298 | return { | ||
| 299 | generateCommitMessage: (input) => | ||
| 300 | implementation.generateCommitMessage(input).pipe( | ||
| 301 | Effect.mapError( | ||
| 302 | (cause) => | ||
| 303 | new TextGenerationError({ | ||
| 304 | operation: "generateCommitMessage", | ||
| 305 | detail: "fake text generation failed", | ||
| 306 | ...(cause !== undefined ? { cause } : {}), | ||
| 307 | }), | ||
| 308 | ), | ||
| 309 | ), | ||
| 310 | generatePrContent: (input) => | ||
| 311 | implementation.generatePrContent(input).pipe( | ||
| 312 | Effect.mapError( | ||
| 313 | (cause) => | ||
| 314 | new TextGenerationError({ | ||
| 315 | operation: "generatePrContent", | ||
| 316 | detail: "fake text generation failed", | ||
| 317 | ...(cause !== undefined ? { cause } : {}), | ||
| 318 | }), | ||
| 319 | ), | ||
| 320 | ), | ||
| 321 | generateBranchName: (input) => | ||
| 322 | implementation.generateBranchName(input).pipe( | ||
| 323 | Effect.mapError( | ||
| 324 | (cause) => | ||
| 325 | new TextGenerationError({ | ||
| 326 | operation: "generateBranchName", | ||
| 327 | detail: "fake text generation failed", | ||
| 328 | ...(cause !== undefined ? { cause } : {}), | ||
| 329 | }), | ||
| 330 | ), | ||
| 331 | ), | ||
| 332 | generateThreadTitle: (input) => | ||
| 333 | implementation.generateThreadTitle(input).pipe( | ||
| 334 | Effect.mapError( | ||
| 335 | (cause) => | ||
| 336 | new TextGenerationError({ | ||
| 337 | operation: "generateThreadTitle", | ||
| 338 | detail: "fake text generation failed", | ||
| 339 | ...(cause !== undefined ? { cause } : {}), | ||
| 340 | }), | ||
| 341 | ), | ||
| 342 | ), | ||
| 343 | }; | ||
| 344 | } | ||
| 345 | |||
| 346 | function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { | ||
| 347 | service: GitHubCliShape; | ||
| 348 | ghCalls: string[]; | ||
| 349 | } { | ||
| 350 | const prListQueue = [...(scenario.prListSequence ?? [])]; | ||
| 351 | const prListQueueByHeadSelector = new Map( | ||
| 352 | Object.entries(scenario.prListSequenceByHeadSelector ?? {}).map(([headSelector, values]) => [ | ||
| 353 | headSelector, | ||
| 354 | [...values], | ||
| 355 | ]), | ||
| 356 | ); | ||
| 357 | const ghCalls: string[] = []; | ||
| 358 | |||
| 359 | const execute: GitHubCliShape["execute"] = (input) => { | ||
| 360 | const args = [...input.args]; | ||
| 361 | ghCalls.push(args.join(" ")); | ||
| 362 | |||
| 363 | if (scenario.failWith) { | ||
| 364 | return Effect.fail(scenario.failWith); | ||
| 365 | } | ||
| 366 | |||
| 367 | if (args[0] === "pr" && args[1] === "list") { | ||
| 368 | const headSelectorIndex = args.findIndex((value) => value === "--head"); | ||
| 369 | const headSelector = | ||
| 370 | headSelectorIndex >= 0 && headSelectorIndex < args.length - 1 | ||
| 371 | ? args[headSelectorIndex + 1] | ||
| 372 | : undefined; | ||
| 373 | const mappedQueue = | ||
| 374 | typeof headSelector === "string" | ||
| 375 | ? prListQueueByHeadSelector.get(headSelector)?.shift() | ||
| 376 | : undefined; | ||
| 377 | const mappedStdout = | ||
| 378 | typeof headSelector === "string" | ||
| 379 | ? scenario.prListByHeadSelector?.[headSelector] | ||
| 380 | : undefined; | ||
| 381 | const stdout = (mappedQueue ?? mappedStdout ?? prListQueue.shift() ?? "[]") + "\n"; | ||
| 382 | return Effect.succeed({ | ||
| 383 | stdout, | ||
| 384 | stderr: "", | ||
| 385 | code: 0, | ||
| 386 | signal: null, | ||
| 387 | timedOut: false, | ||
| 388 | }); | ||
| 389 | } | ||
| 390 | |||
| 391 | if (args[0] === "pr" && args[1] === "create") { | ||
| 392 | return Effect.succeed({ | ||
| 393 | stdout: | ||
| 394 | (scenario.createdPrUrl ?? "https://github.com/pingdotgg/codething-mvp/pull/101") + "\n", | ||
| 395 | stderr: "", | ||
| 396 | code: 0, | ||
| 397 | signal: null, | ||
| 398 | timedOut: false, | ||
| 399 | }); | ||
| 400 | } | ||
| 401 | |||
| 402 | if (args[0] === "pr" && args[1] === "view") { | ||
| 403 | const pullRequest: FakePullRequest = scenario.pullRequest ?? { | ||
| 404 | number: 101, | ||
| 405 | title: "Pull request", | ||
| 406 | url: "https://github.com/pingdotgg/codething-mvp/pull/101", | ||
| 407 | baseRefName: "main", | ||
| 408 | headRefName: "feature/pull-request", | ||
| 409 | state: "open", | ||
| 410 | }; | ||
| 411 | return Effect.succeed({ | ||
| 412 | stdout: | ||
| 413 | JSON.stringify({ | ||
| 414 | ...pullRequest, | ||
| 415 | ...(pullRequest.headRepositoryNameWithOwner | ||
| 416 | ? { | ||
| 417 | headRepository: { | ||
| 418 | nameWithOwner: pullRequest.headRepositoryNameWithOwner, | ||
| 419 | }, | ||
| 420 | } | ||
| 421 | : {}), | ||
| 422 | ...(pullRequest.headRepositoryOwnerLogin | ||
| 423 | ? { | ||
| 424 | headRepositoryOwner: { | ||
| 425 | login: pullRequest.headRepositoryOwnerLogin, | ||
| 426 | }, | ||
| 427 | } | ||
| 428 | : {}), | ||
| 429 | }) + "\n", | ||
| 430 | stderr: "", | ||
| 431 | code: 0, | ||
| 432 | signal: null, | ||
| 433 | timedOut: false, | ||
| 434 | }); | ||
| 435 | } | ||
| 436 | |||
| 437 | if (args[0] === "pr" && args[1] === "checkout") { | ||
| 438 | return Effect.try({ | ||
| 439 | try: () => { | ||
| 440 | const headBranch = scenario.pullRequest?.headRefName; | ||
| 441 | if (headBranch) { | ||
| 442 | const existingBranch = spawnSync( | ||
| 443 | "git", | ||
| 444 | ["show-ref", "--verify", "--quiet", `refs/heads/${headBranch}`], | ||
| 445 | { | ||
| 446 | cwd: input.cwd, | ||
| 447 | encoding: "utf8", | ||
| 448 | }, | ||
| 449 | ); | ||
| 450 | if (existingBranch.status === 0) { | ||
| 451 | runGitSyncForFakeGh(input.cwd, ["checkout", headBranch]); | ||
| 452 | } else { | ||
| 453 | runGitSyncForFakeGh(input.cwd, ["checkout", "-b", headBranch]); | ||
| 454 | } | ||
| 455 | } | ||
| 456 | return { | ||
| 457 | stdout: "", | ||
| 458 | stderr: "", | ||
| 459 | code: 0, | ||
| 460 | signal: null, | ||
| 461 | timedOut: false, | ||
| 462 | }; | ||
| 463 | }, | ||
| 464 | catch: (error) => | ||
| 465 | isGitHubCliError(error) | ||
| 466 | ? error | ||
| 467 | : new GitHubCliError({ | ||
| 468 | operation: "execute", | ||
| 469 | detail: | ||
| 470 | error instanceof Error | ||
| 471 | ? `Failed to simulate gh checkout: ${error.message}` | ||
| 472 | : "Failed to simulate gh checkout.", | ||
| 473 | }), | ||
| 474 | }); | ||
| 475 | } | ||
| 476 | |||
| 477 | if (args[0] === "repo" && args[1] === "view") { | ||
| 478 | const repository = args[2]; | ||
| 479 | if (typeof repository === "string" && args.includes("nameWithOwner,url,sshUrl")) { | ||
| 480 | const cloneUrls = scenario.repositoryCloneUrls?.[repository]; | ||
| 481 | if (!cloneUrls) { | ||
| 482 | return Effect.fail( | ||
| 483 | new GitHubCliError({ | ||
| 484 | operation: "execute", | ||
| 485 | detail: `Unexpected repository lookup: ${repository}`, | ||
| 486 | }), | ||
| 487 | ); | ||
| 488 | } | ||
| 489 | return Effect.succeed({ | ||
| 490 | stdout: | ||
| 491 | JSON.stringify({ | ||
| 492 | nameWithOwner: repository, | ||
| 493 | url: cloneUrls.url, | ||
| 494 | sshUrl: cloneUrls.sshUrl, | ||
| 495 | }) + "\n", | ||
| 496 | stderr: "", | ||
| 497 | code: 0, | ||
| 498 | signal: null, | ||
| 499 | timedOut: false, | ||
| 500 | }); | ||
| 501 | } | ||
| 502 | return Effect.succeed({ | ||
| 503 | stdout: `${scenario.defaultBranch ?? "main"}\n`, | ||
| 504 | stderr: "", | ||
| 505 | code: 0, | ||
| 506 | signal: null, | ||
| 507 | timedOut: false, | ||
| 508 | }); | ||
| 509 | } | ||
| 510 | |||
| 511 | return Effect.fail( | ||
| 512 | new GitHubCliError({ | ||
| 513 | operation: "execute", | ||
| 514 | detail: `Unexpected gh command: ${args.join(" ")}`, | ||
| 515 | }), | ||
| 516 | ); | ||
| 517 | }; | ||
| 518 | |||
| 519 | return { | ||
| 520 | service: { | ||
| 521 | execute, | ||
| 522 | listOpenPullRequests: (input) => | ||
| 523 | execute({ | ||
| 524 | cwd: input.cwd, | ||
| 525 | args: [ | ||
| 526 | "pr", | ||
| 527 | "list", | ||
| 528 | "--head", | ||
| 529 | input.headSelector, | ||
| 530 | "--state", | ||
| 531 | "open", | ||
| 532 | "--limit", | ||
| 533 | String(input.limit ?? 1), | ||
| 534 | "--json", | ||
| 535 | "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", | ||
| 536 | ], | ||
| 537 | }).pipe( | ||
| 538 | Effect.map((result) => JSON.parse(result.stdout) as unknown[]), | ||
| 539 | Effect.map((raw) => | ||
| 540 | raw | ||
| 541 | .map((entry) => normalizeFakePullRequestSummary(entry)) | ||
| 542 | .filter((entry): entry is GitHubPullRequestSummary => entry !== null), | ||
| 543 | ), | ||
| 544 | ), | ||
| 545 | createPullRequest: (input) => | ||
| 546 | execute({ | ||
| 547 | cwd: input.cwd, | ||
| 548 | args: [ | ||
| 549 | "pr", | ||
| 550 | "create", | ||
| 551 | "--base", | ||
| 552 | input.baseBranch, | ||
| 553 | "--head", | ||
| 554 | input.headSelector, | ||
| 555 | "--title", | ||
| 556 | input.title, | ||
| 557 | "--body-file", | ||
| 558 | input.bodyFile, | ||
| 559 | ], | ||
| 560 | }).pipe(Effect.asVoid), | ||
| 561 | getDefaultBranch: (input) => | ||
| 562 | execute({ | ||
| 563 | cwd: input.cwd, | ||
| 564 | args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], | ||
| 565 | }).pipe( | ||
| 566 | Effect.map((result) => { | ||
| 567 | const value = result.stdout.trim(); | ||
| 568 | return value.length > 0 ? value : null; | ||
| 569 | }), | ||
| 570 | ), | ||
| 571 | getPullRequest: (input) => | ||
| 572 | execute({ | ||
| 573 | cwd: input.cwd, | ||
| 574 | args: [ | ||
| 575 | "pr", | ||
| 576 | "view", | ||
| 577 | input.reference, | ||
| 578 | "--json", | ||
| 579 | "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", | ||
| 580 | ], | ||
| 581 | }).pipe(Effect.map((result) => JSON.parse(result.stdout) as GitHubPullRequestSummary)), | ||
| 582 | getRepositoryCloneUrls: (input) => | ||
| 583 | execute({ | ||
| 584 | cwd: input.cwd, | ||
| 585 | args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], | ||
| 586 | }).pipe(Effect.map((result) => JSON.parse(result.stdout))), | ||
| 587 | checkoutPullRequest: (input) => | ||
| 588 | execute({ | ||
| 589 | cwd: input.cwd, | ||
| 590 | args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], | ||
| 591 | }).pipe(Effect.asVoid), | ||
| 592 | }, | ||
| 593 | ghCalls, | ||
| 594 | }; | ||
| 595 | } | ||
| 596 | |||
| 597 | function runStackedAction( | ||
| 598 | manager: GitManagerShape, | ||
| 599 | input: { | ||
| 600 | cwd: string; | ||
| 601 | action: "commit" | "push" | "create_pr" | "commit_push" | "commit_push_pr"; | ||
| 602 | actionId?: string; | ||
| 603 | commitMessage?: string; | ||
| 604 | featureBranch?: boolean; | ||
| 605 | filePaths?: readonly string[]; | ||
| 606 | }, | ||
| 607 | options?: Parameters<GitManagerShape["runStackedAction"]>[1], | ||
| 608 | ) { | ||
| 609 | return manager.runStackedAction( | ||
| 610 | { | ||
| 611 | ...input, | ||
| 612 | actionId: input.actionId ?? "test-action-id", | ||
| 613 | }, | ||
| 614 | options, | ||
| 615 | ); | ||
| 616 | } | ||
| 617 | |||
| 618 | function resolvePullRequest(manager: GitManagerShape, input: { cwd: string; reference: string }) { | ||
| 619 | return manager.resolvePullRequest(input); | ||
| 620 | } | ||
| 621 | |||
| 622 | function preparePullRequestThread( | ||
| 623 | manager: GitManagerShape, | ||
| 624 | input: GitPreparePullRequestThreadInput, | ||
| 625 | ) { | ||
| 626 | return manager.preparePullRequestThread(input); | ||
| 627 | } | ||
| 628 | |||
| 629 | function makeManager(input?: { | ||
| 630 | ghScenario?: FakeGhScenario; | ||
| 631 | textGeneration?: Partial<FakeGitTextGeneration>; | ||
| 632 | setupScriptRunner?: ProjectSetupScriptRunnerShape; | ||
| 633 | }) { | ||
| 634 | const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); | ||
| 635 | const textGeneration = createTextGeneration(input?.textGeneration); | ||
| 636 | const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { | ||
| 637 | prefix: "t3-git-manager-test-", | ||
| 638 | }); | ||
| 639 | |||
| 640 | const serverSettingsLayer = ServerSettingsService.layerTest(); | ||
| 641 | |||
| 642 | const gitCoreLayer = GitCoreLive.pipe( | ||
| 643 | Layer.provideMerge(NodeServices.layer), | ||
| 644 | Layer.provideMerge(ServerConfigLayer), | ||
| 645 | ); | ||
| 646 | |||
| 647 | const managerLayer = Layer.mergeAll( | ||
| 648 | Layer.succeed(GitHubCli, gitHubCli), | ||
| 649 | Layer.succeed(TextGeneration, textGeneration), | ||
| 650 | Layer.succeed( | ||
| 651 | ProjectSetupScriptRunner, | ||
| 652 | input?.setupScriptRunner ?? { | ||
| 653 | runForThread: () => Effect.succeed({ status: "no-script" as const }), | ||
| 654 | }, | ||
| 655 | ), | ||
| 656 | gitCoreLayer, | ||
| 657 | serverSettingsLayer, | ||
| 658 | ).pipe(Layer.provideMerge(NodeServices.layer)); | ||
| 659 | |||
| 660 | return makeGitManager().pipe( | ||
| 661 | Effect.provide(managerLayer), | ||
| 662 | Effect.map((manager) => ({ manager, ghCalls })), | ||
| 663 | ); | ||
| 664 | } | ||
| 665 | |||
| 666 | const asThreadId = (threadId: string) => threadId as ThreadId; | ||
| 667 | |||
| 668 | const GitManagerTestLayer = GitCoreLive.pipe( | ||
| 669 | Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })), | ||
| 670 | Layer.provideMerge(NodeServices.layer), | ||
| 671 | ); | ||
| 672 | |||
| 673 | it.layer(GitManagerTestLayer)("GitManager", (it) => { | ||
| 674 | it.effect("status includes PR metadata when branch already has an open PR", () => | ||
| 675 | Effect.gen(function* () { | ||
| 676 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 677 | yield* initRepo(repoDir); | ||
| 678 | yield* runGit(repoDir, ["checkout", "-b", "feature/status-open-pr"]); | ||
| 679 | const remoteDir = yield* createBareRemote(); | ||
| 680 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 681 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-open-pr"]); | ||
| 682 | |||
| 683 | const { manager } = yield* makeManager({ | ||
| 684 | ghScenario: { | ||
| 685 | prListSequence: [ | ||
| 686 | JSON.stringify([ | ||
| 687 | { | ||
| 688 | number: 13, | ||
| 689 | title: "Existing PR", | ||
| 690 | url: "https://github.com/pingdotgg/codething-mvp/pull/13", | ||
| 691 | baseRefName: "main", | ||
| 692 | headRefName: "feature/status-open-pr", | ||
| 693 | }, | ||
| 694 | ]), | ||
| 695 | ], | ||
| 696 | }, | ||
| 697 | }); | ||
| 698 | |||
| 699 | const status = yield* manager.status({ cwd: repoDir }); | ||
| 700 | expect(status.isRepo).toBe(true); | ||
| 701 | expect(status.hasOriginRemote).toBe(true); | ||
| 702 | expect(status.isDefaultBranch).toBe(false); | ||
| 703 | expect(status.branch).toBe("feature/status-open-pr"); | ||
| 704 | expect(status.pr).toEqual({ | ||
| 705 | number: 13, | ||
| 706 | title: "Existing PR", | ||
| 707 | url: "https://github.com/pingdotgg/codething-mvp/pull/13", | ||
| 708 | baseBranch: "main", | ||
| 709 | headBranch: "feature/status-open-pr", | ||
| 710 | state: "open", | ||
| 711 | }); | ||
| 712 | }), | ||
| 713 | ); | ||
| 714 | |||
| 715 | it.effect("status trims PR metadata returned by gh before publishing it", () => | ||
| 716 | Effect.gen(function* () { | ||
| 717 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 718 | yield* initRepo(repoDir); | ||
| 719 | yield* runGit(repoDir, ["checkout", "-b", "feature/status-trimmed-pr"]); | ||
| 720 | const remoteDir = yield* createBareRemote(); | ||
| 721 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 722 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-trimmed-pr"]); | ||
| 723 | |||
| 724 | const { manager } = yield* makeManager({ | ||
| 725 | ghScenario: { | ||
| 726 | prListSequence: [ | ||
| 727 | JSON.stringify([ | ||
| 728 | { | ||
| 729 | number: 14, | ||
| 730 | title: " Existing PR title \n", | ||
| 731 | url: " https://github.com/pingdotgg/codething-mvp/pull/14 ", | ||
| 732 | baseRefName: " main ", | ||
| 733 | headRefName: "\tfeature/status-trimmed-pr\t", | ||
| 734 | }, | ||
| 735 | ]), | ||
| 736 | ], | ||
| 737 | }, | ||
| 738 | }); | ||
| 739 | |||
| 740 | const status = yield* manager.status({ cwd: repoDir }); | ||
| 741 | |||
| 742 | expect(status.pr).toEqual({ | ||
| 743 | number: 14, | ||
| 744 | title: "Existing PR title", | ||
| 745 | url: "https://github.com/pingdotgg/codething-mvp/pull/14", | ||
| 746 | baseBranch: "main", | ||
| 747 | headBranch: "feature/status-trimmed-pr", | ||
| 748 | state: "open", | ||
| 749 | }); | ||
| 750 | }), | ||
| 751 | ); | ||
| 752 | |||
| 753 | it.effect("status ignores invalid gh pr list entries and keeps valid ones", () => | ||
| 754 | Effect.gen(function* () { | ||
| 755 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 756 | yield* initRepo(repoDir); | ||
| 757 | yield* runGit(repoDir, ["checkout", "-b", "feature/status-valid-pr-entry"]); | ||
| 758 | const remoteDir = yield* createBareRemote(); | ||
| 759 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 760 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-valid-pr-entry"]); | ||
| 761 | |||
| 762 | const { manager } = yield* makeManager({ | ||
| 763 | ghScenario: { | ||
| 764 | prListSequence: [ | ||
| 765 | JSON.stringify([ | ||
| 766 | { | ||
| 767 | number: 0, | ||
| 768 | title: "invalid", | ||
| 769 | url: "https://github.com/pingdotgg/codething-mvp/pull/0", | ||
| 770 | baseRefName: "main", | ||
| 771 | headRefName: "feature/invalid", | ||
| 772 | }, | ||
| 773 | { | ||
| 774 | number: 15, | ||
| 775 | title: " Valid PR title ", | ||
| 776 | url: " https://github.com/pingdotgg/codething-mvp/pull/15 ", | ||
| 777 | baseRefName: " main ", | ||
| 778 | headRefName: "\tfeature/status-valid-pr-entry\t", | ||
| 779 | headRepository: { | ||
| 780 | nameWithOwner: " ", | ||
| 781 | }, | ||
| 782 | headRepositoryOwner: { | ||
| 783 | login: " ", | ||
| 784 | }, | ||
| 785 | }, | ||
| 786 | ]), | ||
| 787 | ], | ||
| 788 | }, | ||
| 789 | }); | ||
| 790 | |||
| 791 | const status = yield* manager.status({ cwd: repoDir }); | ||
| 792 | |||
| 793 | expect(status.pr).toEqual({ | ||
| 794 | number: 15, | ||
| 795 | title: "Valid PR title", | ||
| 796 | url: "https://github.com/pingdotgg/codething-mvp/pull/15", | ||
| 797 | baseBranch: "main", | ||
| 798 | headBranch: "feature/status-valid-pr-entry", | ||
| 799 | state: "open", | ||
| 800 | }); | ||
| 801 | }), | ||
| 802 | ); | ||
| 803 | |||
| 804 | it.effect("status preserves lowercase merged and closed PR states from gh json", () => | ||
| 805 | Effect.gen(function* () { | ||
| 806 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 807 | yield* initRepo(repoDir); | ||
| 808 | yield* runGit(repoDir, ["checkout", "-b", "feature/status-lowercase-state"]); | ||
| 809 | const remoteDir = yield* createBareRemote(); | ||
| 810 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 811 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-lowercase-state"]); | ||
| 812 | |||
| 813 | const { manager } = yield* makeManager({ | ||
| 814 | ghScenario: { | ||
| 815 | prListSequence: [ | ||
| 816 | JSON.stringify([ | ||
| 817 | { | ||
| 818 | number: 16, | ||
| 819 | title: "Closed PR", | ||
| 820 | url: "https://github.com/pingdotgg/codething-mvp/pull/16", | ||
| 821 | baseRefName: "main", | ||
| 822 | headRefName: "feature/status-lowercase-state", | ||
| 823 | state: "closed", | ||
| 824 | updatedAt: "2026-01-01T00:00:00.000Z", | ||
| 825 | }, | ||
| 826 | { | ||
| 827 | number: 17, | ||
| 828 | title: "Merged PR", | ||
| 829 | url: "https://github.com/pingdotgg/codething-mvp/pull/17", | ||
| 830 | baseRefName: "main", | ||
| 831 | headRefName: "feature/status-lowercase-state", | ||
| 832 | state: "merged", | ||
| 833 | updatedAt: "2026-01-02T00:00:00.000Z", | ||
| 834 | }, | ||
| 835 | ]), | ||
| 836 | ], | ||
| 837 | }, | ||
| 838 | }); | ||
| 839 | |||
| 840 | const status = yield* manager.status({ cwd: repoDir }); | ||
| 841 | |||
| 842 | expect(status.pr).toEqual({ | ||
| 843 | number: 17, | ||
| 844 | title: "Merged PR", | ||
| 845 | url: "https://github.com/pingdotgg/codething-mvp/pull/17", | ||
| 846 | baseBranch: "main", | ||
| 847 | headBranch: "feature/status-lowercase-state", | ||
| 848 | state: "merged", | ||
| 849 | }); | ||
| 850 | }), | ||
| 851 | ); | ||
| 852 | |||
| 853 | it.effect("status returns an explicit non-repo result for non-git directories", () => | ||
| 854 | Effect.gen(function* () { | ||
| 855 | const cwd = yield* makeTempDir("t3code-git-manager-non-repo-"); | ||
| 856 | const { manager } = yield* makeManager(); | ||
| 857 | |||
| 858 | const status = yield* manager.status({ cwd }); | ||
| 859 | |||
| 860 | expect(status).toEqual({ | ||
| 861 | isRepo: false, | ||
| 862 | hasOriginRemote: false, | ||
| 863 | isDefaultBranch: false, | ||
| 864 | branch: null, | ||
| 865 | hasWorkingTreeChanges: false, | ||
| 866 | workingTree: { | ||
| 867 | files: [], | ||
| 868 | insertions: 0, | ||
| 869 | deletions: 0, | ||
| 870 | }, | ||
| 871 | hasUpstream: false, | ||
| 872 | aheadCount: 0, | ||
| 873 | behindCount: 0, | ||
| 874 | pr: null, | ||
| 875 | }); | ||
| 876 | }), | ||
| 877 | ); | ||
| 878 | |||
| 879 | it.effect("status returns an explicit non-repo result for deleted directories", () => | ||
| 880 | Effect.gen(function* () { | ||
| 881 | const rootDir = yield* makeTempDir("t3code-git-manager-missing-dir-"); | ||
| 882 | const cwd = path.join(rootDir, "deleted-repo"); | ||
| 883 | yield* makeDirectory(cwd); | ||
| 884 | yield* removePath(cwd); | ||
| 885 | const { manager } = yield* makeManager(); | ||
| 886 | |||
| 887 | const status = yield* manager.status({ cwd }); | ||
| 888 | |||
| 889 | expect(status).toEqual({ | ||
| 890 | isRepo: false, | ||
| 891 | hasOriginRemote: false, | ||
| 892 | isDefaultBranch: false, | ||
| 893 | branch: null, | ||
| 894 | hasWorkingTreeChanges: false, | ||
| 895 | workingTree: { | ||
| 896 | files: [], | ||
| 897 | insertions: 0, | ||
| 898 | deletions: 0, | ||
| 899 | }, | ||
| 900 | hasUpstream: false, | ||
| 901 | aheadCount: 0, | ||
| 902 | behindCount: 0, | ||
| 903 | pr: null, | ||
| 904 | }); | ||
| 905 | }), | ||
| 906 | ); | ||
| 907 | |||
| 908 | it.effect("status briefly caches repeated lookups for the same cwd", () => | ||
| 909 | Effect.gen(function* () { | ||
| 910 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 911 | yield* initRepo(repoDir); | ||
| 912 | yield* runGit(repoDir, ["checkout", "-b", "feature/status-cache"]); | ||
| 913 | const remoteDir = yield* createBareRemote(); | ||
| 914 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 915 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-cache"]); | ||
| 916 | |||
| 917 | const existingPr = { | ||
| 918 | number: 113, | ||
| 919 | title: "Cached PR", | ||
| 920 | url: "https://github.com/pingdotgg/codething-mvp/pull/113", | ||
| 921 | baseRefName: "main", | ||
| 922 | headRefName: "feature/status-cache", | ||
| 923 | }; | ||
| 924 | const { manager, ghCalls } = yield* makeManager({ | ||
| 925 | ghScenario: { | ||
| 926 | prListSequence: [JSON.stringify([existingPr]), JSON.stringify([existingPr])], | ||
| 927 | }, | ||
| 928 | }); | ||
| 929 | |||
| 930 | const first = yield* manager.status({ cwd: repoDir }); | ||
| 931 | const second = yield* manager.status({ cwd: repoDir }); | ||
| 932 | |||
| 933 | expect(first.pr?.number).toBe(113); | ||
| 934 | expect(second.pr?.number).toBe(113); | ||
| 935 | expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1); | ||
| 936 | }), | ||
| 937 | ); | ||
| 938 | |||
| 939 | it.effect( | ||
| 940 | "status ignores unrelated fork PRs when the current branch tracks the same repository", | ||
| 941 | () => | ||
| 942 | Effect.gen(function* () { | ||
| 943 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 944 | yield* initRepo(repoDir); | ||
| 945 | const remoteDir = yield* createBareRemote(); | ||
| 946 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 947 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); | ||
| 948 | |||
| 949 | const { manager } = yield* makeManager({ | ||
| 950 | ghScenario: { | ||
| 951 | prListSequence: [ | ||
| 952 | JSON.stringify([ | ||
| 953 | { | ||
| 954 | number: 1661, | ||
| 955 | title: "Fork PR from main", | ||
| 956 | url: "https://github.com/pingdotgg/t3code/pull/1661", | ||
| 957 | baseRefName: "main", | ||
| 958 | headRefName: "main", | ||
| 959 | state: "OPEN", | ||
| 960 | updatedAt: "2026-04-01T15:00:00Z", | ||
| 961 | isCrossRepository: true, | ||
| 962 | headRepository: { | ||
| 963 | nameWithOwner: "lnieuwenhuis/t3code", | ||
| 964 | }, | ||
| 965 | headRepositoryOwner: { | ||
| 966 | login: "lnieuwenhuis", | ||
| 967 | }, | ||
| 968 | }, | ||
| 969 | ]), | ||
| 970 | ], | ||
| 971 | }, | ||
| 972 | }); | ||
| 973 | |||
| 974 | const status = yield* manager.status({ cwd: repoDir }); | ||
| 975 | expect(status.branch).toBe("main"); | ||
| 976 | expect(status.pr).toBeNull(); | ||
| 977 | }), | ||
| 978 | ); | ||
| 979 | |||
| 980 | it.effect( | ||
| 981 | "status detects cross-repo PRs from the upstream remote URL owner", | ||
| 982 | () => | ||
| 983 | Effect.gen(function* () { | ||
| 984 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 985 | yield* initRepo(repoDir); | ||
| 986 | const forkDir = yield* createBareRemote(); | ||
| 987 | yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); | ||
| 988 | yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); | ||
| 989 | fs.writeFileSync(path.join(repoDir, "fork-pr.txt"), "fork pr\n"); | ||
| 990 | yield* runGit(repoDir, ["add", "fork-pr.txt"]); | ||
| 991 | yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]); | ||
| 992 | yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); | ||
| 993 | yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-488/statemachine"]); | ||
| 994 | yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); | ||
| 995 | yield* runGit(repoDir, [ | ||
| 996 | "config", | ||
| 997 | "remote.fork-seed.url", | ||
| 998 | "git@github.com:jasonLaster/codething-mvp.git", | ||
| 999 | ]); | ||
| 1000 | |||
| 1001 | const { manager, ghCalls } = yield* makeManager({ | ||
| 1002 | ghScenario: { | ||
| 1003 | prListSequence: [ | ||
| 1004 | JSON.stringify([]), | ||
| 1005 | JSON.stringify([]), | ||
| 1006 | JSON.stringify([ | ||
| 1007 | { | ||
| 1008 | number: 488, | ||
| 1009 | title: "Rebase this PR on latest main", | ||
| 1010 | url: "https://github.com/pingdotgg/codething-mvp/pull/488", | ||
| 1011 | baseRefName: "main", | ||
| 1012 | headRefName: "statemachine", | ||
| 1013 | state: "OPEN", | ||
| 1014 | updatedAt: "2026-03-10T07:00:00Z", | ||
| 1015 | isCrossRepository: true, | ||
| 1016 | headRepository: { | ||
| 1017 | nameWithOwner: "jasonLaster/codething-mvp", | ||
| 1018 | }, | ||
| 1019 | headRepositoryOwner: { | ||
| 1020 | login: "jasonLaster", | ||
| 1021 | }, | ||
| 1022 | }, | ||
| 1023 | ]), | ||
| 1024 | ], | ||
| 1025 | }, | ||
| 1026 | }); | ||
| 1027 | |||
| 1028 | const status = yield* manager.status({ cwd: repoDir }); | ||
| 1029 | expect(status.branch).toBe("t3code/pr-488/statemachine"); | ||
| 1030 | expect(status.pr).toEqual({ | ||
| 1031 | number: 488, | ||
| 1032 | title: "Rebase this PR on latest main", | ||
| 1033 | url: "https://github.com/pingdotgg/codething-mvp/pull/488", | ||
| 1034 | baseBranch: "main", | ||
| 1035 | headBranch: "statemachine", | ||
| 1036 | state: "open", | ||
| 1037 | }); | ||
| 1038 | expect(ghCalls).toContain( | ||
| 1039 | "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | ||
| 1040 | ); | ||
| 1041 | }), | ||
| 1042 | 20_000, | ||
| 1043 | ); | ||
| 1044 | |||
| 1045 | it.effect( | ||
| 1046 | "status ignores synthetic local branch aliases when the upstream remote name contains slashes", | ||
| 1047 | () => | ||
| 1048 | Effect.gen(function* () { | ||
| 1049 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1050 | yield* initRepo(repoDir); | ||
| 1051 | const originDir = yield* createBareRemote(); | ||
| 1052 | const upstreamDir = yield* createBareRemote(); | ||
| 1053 | yield* configureRemote(repoDir, "origin", originDir, "origin"); | ||
| 1054 | yield* configureRemote(repoDir, "my-org/upstream", upstreamDir, "my-org/upstream"); | ||
| 1055 | |||
| 1056 | yield* runGit(repoDir, ["checkout", "-b", "effect-atom"]); | ||
| 1057 | yield* runGit(repoDir, ["push", "-u", "origin", "effect-atom"]); | ||
| 1058 | yield* runGit(repoDir, ["push", "-u", "my-org/upstream", "effect-atom"]); | ||
| 1059 | yield* runGit(repoDir, [ | ||
| 1060 | "config", | ||
| 1061 | "remote.origin.url", | ||
| 1062 | "git@github.com:pingdotgg/codething-mvp.git", | ||
| 1063 | ]); | ||
| 1064 | yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]); | ||
| 1065 | yield* runGit(repoDir, [ | ||
| 1066 | "config", | ||
| 1067 | "remote.my-org/upstream.url", | ||
| 1068 | "git@github.com:pingdotgg/codething-mvp.git", | ||
| 1069 | ]); | ||
| 1070 | yield* runGit(repoDir, ["config", "remote.my-org/upstream.pushurl", upstreamDir]); | ||
| 1071 | yield* runGit(repoDir, ["checkout", "main"]); | ||
| 1072 | yield* runGit(repoDir, ["branch", "-D", "effect-atom"]); | ||
| 1073 | yield* runGit(repoDir, ["checkout", "--track", "my-org/upstream/effect-atom"]); | ||
| 1074 | |||
| 1075 | const { manager, ghCalls } = yield* makeManager({ | ||
| 1076 | ghScenario: { | ||
| 1077 | prListByHeadSelector: { | ||
| 1078 | "effect-atom": JSON.stringify([ | ||
| 1079 | { | ||
| 1080 | number: 1618, | ||
| 1081 | title: "Correct PR", | ||
| 1082 | url: "https://github.com/pingdotgg/t3code/pull/1618", | ||
| 1083 | baseRefName: "main", | ||
| 1084 | headRefName: "effect-atom", | ||
| 1085 | state: "OPEN", | ||
| 1086 | updatedAt: "2026-03-01T10:00:00Z", | ||
| 1087 | }, | ||
| 1088 | ]), | ||
| 1089 | "upstream/effect-atom": JSON.stringify([ | ||
| 1090 | { | ||
| 1091 | number: 1518, | ||
| 1092 | title: "Wrong PR", | ||
| 1093 | url: "https://github.com/pingdotgg/t3code/pull/1518", | ||
| 1094 | baseRefName: "main", | ||
| 1095 | headRefName: "upstream/effect-atom", | ||
| 1096 | state: "OPEN", | ||
| 1097 | updatedAt: "2026-04-01T10:00:00Z", | ||
| 1098 | }, | ||
| 1099 | ]), | ||
| 1100 | "pingdotgg:effect-atom": JSON.stringify([]), | ||
| 1101 | "my-org/upstream:effect-atom": JSON.stringify([]), | ||
| 1102 | "pingdotgg:upstream/effect-atom": JSON.stringify([ | ||
| 1103 | { | ||
| 1104 | number: 1518, | ||
| 1105 | title: "Wrong PR", | ||
| 1106 | url: "https://github.com/pingdotgg/t3code/pull/1518", | ||
| 1107 | baseRefName: "main", | ||
| 1108 | headRefName: "upstream/effect-atom", | ||
| 1109 | state: "OPEN", | ||
| 1110 | updatedAt: "2026-04-01T10:00:00Z", | ||
| 1111 | }, | ||
| 1112 | ]), | ||
| 1113 | "my-org/upstream:upstream/effect-atom": JSON.stringify([ | ||
| 1114 | { | ||
| 1115 | number: 1518, | ||
| 1116 | title: "Wrong PR", | ||
| 1117 | url: "https://github.com/pingdotgg/t3code/pull/1518", | ||
| 1118 | baseRefName: "main", | ||
| 1119 | headRefName: "upstream/effect-atom", | ||
| 1120 | state: "OPEN", | ||
| 1121 | updatedAt: "2026-04-01T10:00:00Z", | ||
| 1122 | }, | ||
| 1123 | ]), | ||
| 1124 | }, | ||
| 1125 | }, | ||
| 1126 | }); | ||
| 1127 | |||
| 1128 | const status = yield* manager.status({ cwd: repoDir }); | ||
| 1129 | expect(status.branch).toBe("upstream/effect-atom"); | ||
| 1130 | expect(status.pr).toEqual({ | ||
| 1131 | number: 1618, | ||
| 1132 | title: "Correct PR", | ||
| 1133 | url: "https://github.com/pingdotgg/t3code/pull/1618", | ||
| 1134 | baseBranch: "main", | ||
| 1135 | headBranch: "effect-atom", | ||
| 1136 | state: "open", | ||
| 1137 | }); | ||
| 1138 | expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe( | ||
| 1139 | false, | ||
| 1140 | ); | ||
| 1141 | expect( | ||
| 1142 | ghCalls.some((call) => call.includes("pr list --head pingdotgg:upstream/effect-atom ")), | ||
| 1143 | ).toBe(false); | ||
| 1144 | expect( | ||
| 1145 | ghCalls.some((call) => | ||
| 1146 | call.includes("pr list --head my-org/upstream:upstream/effect-atom "), | ||
| 1147 | ), | ||
| 1148 | ).toBe(false); | ||
| 1149 | }), | ||
| 1150 | 20_000, | ||
| 1151 | ); | ||
| 1152 | |||
| 1153 | it.effect("status returns merged PR state when latest PR was merged", () => | ||
| 1154 | Effect.gen(function* () { | ||
| 1155 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1156 | yield* initRepo(repoDir); | ||
| 1157 | yield* runGit(repoDir, ["checkout", "-b", "feature/status-merged-pr"]); | ||
| 1158 | |||
| 1159 | const { manager } = yield* makeManager({ | ||
| 1160 | ghScenario: { | ||
| 1161 | prListSequence: [ | ||
| 1162 | JSON.stringify([ | ||
| 1163 | { | ||
| 1164 | number: 22, | ||
| 1165 | title: "Merged PR", | ||
| 1166 | url: "https://github.com/pingdotgg/codething-mvp/pull/22", | ||
| 1167 | baseRefName: "main", | ||
| 1168 | headRefName: "feature/status-merged-pr", | ||
| 1169 | state: "MERGED", | ||
| 1170 | mergedAt: "2026-01-30T10:00:00Z", | ||
| 1171 | updatedAt: "2026-01-30T10:00:00Z", | ||
| 1172 | }, | ||
| 1173 | ]), | ||
| 1174 | ], | ||
| 1175 | }, | ||
| 1176 | }); | ||
| 1177 | |||
| 1178 | const status = yield* manager.status({ cwd: repoDir }); | ||
| 1179 | expect(status.branch).toBe("feature/status-merged-pr"); | ||
| 1180 | expect(status.pr).toEqual({ | ||
| 1181 | number: 22, | ||
| 1182 | title: "Merged PR", | ||
| 1183 | url: "https://github.com/pingdotgg/codething-mvp/pull/22", | ||
| 1184 | baseBranch: "main", | ||
| 1185 | headBranch: "feature/status-merged-pr", | ||
| 1186 | state: "merged", | ||
| 1187 | }); | ||
| 1188 | }), | ||
| 1189 | ); | ||
| 1190 | |||
| 1191 | it.effect("status hides merged PRs on the default branch", () => | ||
| 1192 | Effect.gen(function* () { | ||
| 1193 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1194 | yield* initRepo(repoDir); | ||
| 1195 | |||
| 1196 | const { manager } = yield* makeManager({ | ||
| 1197 | ghScenario: { | ||
| 1198 | prListSequence: [ | ||
| 1199 | JSON.stringify([ | ||
| 1200 | { | ||
| 1201 | number: 23, | ||
| 1202 | title: "Merged PR", | ||
| 1203 | url: "https://github.com/pingdotgg/codething-mvp/pull/23", | ||
| 1204 | baseRefName: "feature/status-default-branch-target", | ||
| 1205 | headRefName: "main", | ||
| 1206 | state: "MERGED", | ||
| 1207 | mergedAt: "2026-01-30T10:00:00Z", | ||
| 1208 | updatedAt: "2026-01-30T10:00:00Z", | ||
| 1209 | }, | ||
| 1210 | ]), | ||
| 1211 | ], | ||
| 1212 | }, | ||
| 1213 | }); | ||
| 1214 | |||
| 1215 | const status = yield* manager.status({ cwd: repoDir }); | ||
| 1216 | expect(status.branch).toBe("main"); | ||
| 1217 | expect(status.pr).toBeNull(); | ||
| 1218 | }), | ||
| 1219 | ); | ||
| 1220 | |||
| 1221 | it.effect("status prefers open PR when merged PR has newer updatedAt", () => | ||
| 1222 | Effect.gen(function* () { | ||
| 1223 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1224 | yield* initRepo(repoDir); | ||
| 1225 | yield* runGit(repoDir, ["checkout", "-b", "feature/status-open-over-merged"]); | ||
| 1226 | |||
| 1227 | const { manager } = yield* makeManager({ | ||
| 1228 | ghScenario: { | ||
| 1229 | prListSequence: [ | ||
| 1230 | JSON.stringify([ | ||
| 1231 | { | ||
| 1232 | number: 45, | ||
| 1233 | title: "Merged PR", | ||
| 1234 | url: "https://github.com/pingdotgg/codething-mvp/pull/45", | ||
| 1235 | baseRefName: "main", | ||
| 1236 | headRefName: "feature/status-open-over-merged", | ||
| 1237 | state: "MERGED", | ||
| 1238 | mergedAt: "2026-01-31T10:00:00Z", | ||
| 1239 | updatedAt: "2026-02-01T10:00:00Z", | ||
| 1240 | }, | ||
| 1241 | { | ||
| 1242 | number: 46, | ||
| 1243 | title: "Open PR", | ||
| 1244 | url: "https://github.com/pingdotgg/codething-mvp/pull/46", | ||
| 1245 | baseRefName: "main", | ||
| 1246 | headRefName: "feature/status-open-over-merged", | ||
| 1247 | state: "OPEN", | ||
| 1248 | updatedAt: "2026-01-30T10:00:00Z", | ||
| 1249 | }, | ||
| 1250 | ]), | ||
| 1251 | ], | ||
| 1252 | }, | ||
| 1253 | }); | ||
| 1254 | |||
| 1255 | const status = yield* manager.status({ cwd: repoDir }); | ||
| 1256 | expect(status.branch).toBe("feature/status-open-over-merged"); | ||
| 1257 | expect(status.pr).toEqual({ | ||
| 1258 | number: 46, | ||
| 1259 | title: "Open PR", | ||
| 1260 | url: "https://github.com/pingdotgg/codething-mvp/pull/46", | ||
| 1261 | baseBranch: "main", | ||
| 1262 | headBranch: "feature/status-open-over-merged", | ||
| 1263 | state: "open", | ||
| 1264 | }); | ||
| 1265 | }), | ||
| 1266 | ); | ||
| 1267 | |||
| 1268 | it.effect("status is resilient to gh lookup failures and returns pr null", () => | ||
| 1269 | Effect.gen(function* () { | ||
| 1270 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1271 | yield* initRepo(repoDir); | ||
| 1272 | yield* runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]); | ||
| 1273 | const remoteDir = yield* createBareRemote(); | ||
| 1274 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 1275 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-no-gh"]); | ||
| 1276 | |||
| 1277 | const { manager } = yield* makeManager({ | ||
| 1278 | ghScenario: { | ||
| 1279 | failWith: new GitHubCliError({ | ||
| 1280 | operation: "execute", | ||
| 1281 | detail: "GitHub CLI (`gh`) is required but not available on PATH.", | ||
| 1282 | }), | ||
| 1283 | }, | ||
| 1284 | }); | ||
| 1285 | |||
| 1286 | const status = yield* manager.status({ cwd: repoDir }); | ||
| 1287 | expect(status.branch).toBe("feature/status-no-gh"); | ||
| 1288 | expect(status.pr).toBeNull(); | ||
| 1289 | }), | ||
| 1290 | ); | ||
| 1291 | |||
| 1292 | it.effect("creates a commit when working tree is dirty", () => | ||
| 1293 | Effect.gen(function* () { | ||
| 1294 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1295 | yield* initRepo(repoDir); | ||
| 1296 | fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nworld\n"); | ||
| 1297 | |||
| 1298 | const { manager } = yield* makeManager(); | ||
| 1299 | const result = yield* runStackedAction(manager, { | ||
| 1300 | cwd: repoDir, | ||
| 1301 | action: "commit", | ||
| 1302 | }); | ||
| 1303 | |||
| 1304 | expect(result.branch.status).toBe("skipped_not_requested"); | ||
| 1305 | expect(result.commit.status).toBe("created"); | ||
| 1306 | expect(result.push.status).toBe("skipped_not_requested"); | ||
| 1307 | expect(result.pr.status).toBe("skipped_not_requested"); | ||
| 1308 | expect(result.toast).toMatchObject({ | ||
| 1309 | description: "Implement stacked git actions", | ||
| 1310 | cta: { | ||
| 1311 | kind: "run_action", | ||
| 1312 | label: "Push", | ||
| 1313 | action: { | ||
| 1314 | kind: "push", | ||
| 1315 | }, | ||
| 1316 | }, | ||
| 1317 | }); | ||
| 1318 | expect(result.toast.title).toMatch(/^Committed [0-9a-f]{7}$/); | ||
| 1319 | expect( | ||
| 1320 | yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe( | ||
| 1321 | Effect.map((result) => result.stdout.trim()), | ||
| 1322 | ), | ||
| 1323 | ).toBe("Implement stacked git actions"); | ||
| 1324 | }), | ||
| 1325 | ); | ||
| 1326 | |||
| 1327 | it.effect("uses custom commit message when provided", () => | ||
| 1328 | Effect.gen(function* () { | ||
| 1329 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1330 | yield* initRepo(repoDir); | ||
| 1331 | fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom\n"); | ||
| 1332 | let generatedCount = 0; | ||
| 1333 | |||
| 1334 | const { manager } = yield* makeManager({ | ||
| 1335 | textGeneration: { | ||
| 1336 | generateCommitMessage: (input) => | ||
| 1337 | Effect.sync(() => { | ||
| 1338 | generatedCount += 1; | ||
| 1339 | return { | ||
| 1340 | subject: "this should not be used", | ||
| 1341 | body: "", | ||
| 1342 | ...(input.includeBranch ? { branch: "feature/unused" } : {}), | ||
| 1343 | }; | ||
| 1344 | }), | ||
| 1345 | }, | ||
| 1346 | }); | ||
| 1347 | const result = yield* runStackedAction(manager, { | ||
| 1348 | cwd: repoDir, | ||
| 1349 | action: "commit", | ||
| 1350 | commitMessage: "feat: custom summary line\n\n- details from user", | ||
| 1351 | }); | ||
| 1352 | |||
| 1353 | expect(result.branch.status).toBe("skipped_not_requested"); | ||
| 1354 | expect(result.commit.status).toBe("created"); | ||
| 1355 | expect(result.commit.subject).toBe("feat: custom summary line"); | ||
| 1356 | expect(generatedCount).toBe(0); | ||
| 1357 | expect( | ||
| 1358 | yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe( | ||
| 1359 | Effect.map((result) => result.stdout.trim()), | ||
| 1360 | ), | ||
| 1361 | ).toBe("feat: custom summary line"); | ||
| 1362 | expect( | ||
| 1363 | yield* runGit(repoDir, ["log", "-1", "--pretty=%b"]).pipe( | ||
| 1364 | Effect.map((result) => result.stdout.trim()), | ||
| 1365 | ), | ||
| 1366 | ).toContain("- details from user"); | ||
| 1367 | }), | ||
| 1368 | ); | ||
| 1369 | |||
| 1370 | it.effect("commits only selected files when filePaths is provided", () => | ||
| 1371 | Effect.gen(function* () { | ||
| 1372 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1373 | yield* initRepo(repoDir); | ||
| 1374 | fs.writeFileSync(path.join(repoDir, "a.txt"), "file a\n"); | ||
| 1375 | fs.writeFileSync(path.join(repoDir, "b.txt"), "file b\n"); | ||
| 1376 | |||
| 1377 | const { manager } = yield* makeManager(); | ||
| 1378 | const result = yield* runStackedAction(manager, { | ||
| 1379 | cwd: repoDir, | ||
| 1380 | action: "commit", | ||
| 1381 | filePaths: ["a.txt"], | ||
| 1382 | }); | ||
| 1383 | |||
| 1384 | expect(result.commit.status).toBe("created"); | ||
| 1385 | |||
| 1386 | // b.txt should remain in the working tree | ||
| 1387 | const statusStdout = yield* runGit(repoDir, ["status", "--porcelain"]).pipe( | ||
| 1388 | Effect.map((r) => r.stdout), | ||
| 1389 | ); | ||
| 1390 | expect(statusStdout).toContain("b.txt"); | ||
| 1391 | expect(statusStdout).not.toContain("a.txt"); | ||
| 1392 | }), | ||
| 1393 | ); | ||
| 1394 | |||
| 1395 | it.effect("creates feature branch, commits, and pushes with featureBranch option", () => | ||
| 1396 | Effect.gen(function* () { | ||
| 1397 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1398 | yield* initRepo(repoDir); | ||
| 1399 | const remoteDir = yield* createBareRemote(); | ||
| 1400 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 1401 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); | ||
| 1402 | fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nfeature-branch\n"); | ||
| 1403 | let generatedCount = 0; | ||
| 1404 | |||
| 1405 | const { manager } = yield* makeManager({ | ||
| 1406 | textGeneration: { | ||
| 1407 | generateCommitMessage: (input) => | ||
| 1408 | Effect.sync(() => { | ||
| 1409 | generatedCount += 1; | ||
| 1410 | return { | ||
| 1411 | subject: "Implement stacked git actions", | ||
| 1412 | body: "", | ||
| 1413 | ...(input.includeBranch ? { branch: "feature/implement-stacked-git-actions" } : {}), | ||
| 1414 | }; | ||
| 1415 | }), | ||
| 1416 | }, | ||
| 1417 | }); | ||
| 1418 | const result = yield* runStackedAction(manager, { | ||
| 1419 | cwd: repoDir, | ||
| 1420 | action: "commit_push", | ||
| 1421 | featureBranch: true, | ||
| 1422 | }); | ||
| 1423 | |||
| 1424 | expect(result.branch.status).toBe("created"); | ||
| 1425 | expect(result.branch.name).toBe("feature/implement-stacked-git-actions"); | ||
| 1426 | expect(result.commit.status).toBe("created"); | ||
| 1427 | expect(result.push.status).toBe("pushed"); | ||
| 1428 | expect(result.toast).toMatchObject({ | ||
| 1429 | description: "Implement stacked git actions", | ||
| 1430 | cta: { | ||
| 1431 | kind: "run_action", | ||
| 1432 | label: "Create PR", | ||
| 1433 | action: { | ||
| 1434 | kind: "create_pr", | ||
| 1435 | }, | ||
| 1436 | }, | ||
| 1437 | }); | ||
| 1438 | expect(result.toast.title).toMatch( | ||
| 1439 | /^Pushed [0-9a-f]{7} to origin\/feature\/implement-stacked-git-actions$/, | ||
| 1440 | ); | ||
| 1441 | expect( | ||
| 1442 | yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "HEAD"]).pipe( | ||
| 1443 | Effect.map((result) => result.stdout.trim()), | ||
| 1444 | ), | ||
| 1445 | ).toBe("feature/implement-stacked-git-actions"); | ||
| 1446 | |||
| 1447 | const mainSha = yield* runGit(repoDir, ["rev-parse", "main"]).pipe( | ||
| 1448 | Effect.map((r) => r.stdout.trim()), | ||
| 1449 | ); | ||
| 1450 | const mergeBase = yield* runGit(repoDir, ["merge-base", "main", "HEAD"]).pipe( | ||
| 1451 | Effect.map((r) => r.stdout.trim()), | ||
| 1452 | ); | ||
| 1453 | expect(mergeBase).toBe(mainSha); | ||
| 1454 | expect(generatedCount).toBe(1); | ||
| 1455 | }), | ||
| 1456 | ); | ||
| 1457 | |||
| 1458 | it.effect("featureBranch uses custom commit message and derives branch name", () => | ||
| 1459 | Effect.gen(function* () { | ||
| 1460 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1461 | yield* initRepo(repoDir); | ||
| 1462 | fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom-feature\n"); | ||
| 1463 | let generatedCount = 0; | ||
| 1464 | |||
| 1465 | const { manager } = yield* makeManager({ | ||
| 1466 | textGeneration: { | ||
| 1467 | generateCommitMessage: (input) => | ||
| 1468 | Effect.sync(() => { | ||
| 1469 | generatedCount += 1; | ||
| 1470 | return { | ||
| 1471 | subject: "unused", | ||
| 1472 | body: "", | ||
| 1473 | ...(input.includeBranch ? { branch: "feature/unused" } : {}), | ||
| 1474 | }; | ||
| 1475 | }), | ||
| 1476 | }, | ||
| 1477 | }); | ||
| 1478 | const result = yield* runStackedAction(manager, { | ||
| 1479 | cwd: repoDir, | ||
| 1480 | action: "commit", | ||
| 1481 | featureBranch: true, | ||
| 1482 | commitMessage: "feat: custom summary line\n\n- details from user", | ||
| 1483 | }); | ||
| 1484 | |||
| 1485 | expect(result.branch.status).toBe("created"); | ||
| 1486 | expect(result.branch.name).toBe("feature/feat-custom-summary-line"); | ||
| 1487 | expect(result.commit.status).toBe("created"); | ||
| 1488 | expect(result.commit.subject).toBe("feat: custom summary line"); | ||
| 1489 | expect(generatedCount).toBe(0); | ||
| 1490 | |||
| 1491 | const mainSha = yield* runGit(repoDir, ["rev-parse", "main"]).pipe( | ||
| 1492 | Effect.map((r) => r.stdout.trim()), | ||
| 1493 | ); | ||
| 1494 | const mergeBase = yield* runGit(repoDir, ["merge-base", "main", result.branch.name!]).pipe( | ||
| 1495 | Effect.map((r) => r.stdout.trim()), | ||
| 1496 | ); | ||
| 1497 | expect(mergeBase).toBe(mainSha); | ||
| 1498 | }), | ||
| 1499 | ); | ||
| 1500 | |||
| 1501 | it.effect("skips commit when there are no uncommitted changes", () => | ||
| 1502 | Effect.gen(function* () { | ||
| 1503 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1504 | yield* initRepo(repoDir); | ||
| 1505 | |||
| 1506 | const { manager } = yield* makeManager(); | ||
| 1507 | const result = yield* runStackedAction(manager, { | ||
| 1508 | cwd: repoDir, | ||
| 1509 | action: "commit", | ||
| 1510 | }); | ||
| 1511 | |||
| 1512 | expect(result.branch.status).toBe("skipped_not_requested"); | ||
| 1513 | expect(result.commit.status).toBe("skipped_no_changes"); | ||
| 1514 | expect(result.push.status).toBe("skipped_not_requested"); | ||
| 1515 | expect(result.pr.status).toBe("skipped_not_requested"); | ||
| 1516 | }), | ||
| 1517 | ); | ||
| 1518 | |||
| 1519 | it.effect("featureBranch returns error when worktree is clean", () => | ||
| 1520 | Effect.gen(function* () { | ||
| 1521 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1522 | yield* initRepo(repoDir); | ||
| 1523 | |||
| 1524 | const { manager } = yield* makeManager(); | ||
| 1525 | const errorMessage = yield* runStackedAction(manager, { | ||
| 1526 | cwd: repoDir, | ||
| 1527 | action: "commit", | ||
| 1528 | featureBranch: true, | ||
| 1529 | }).pipe( | ||
| 1530 | Effect.flip, | ||
| 1531 | Effect.map((error) => error.message), | ||
| 1532 | ); | ||
| 1533 | |||
| 1534 | expect(errorMessage).toContain("no changes to commit"); | ||
| 1535 | }), | ||
| 1536 | ); | ||
| 1537 | |||
| 1538 | it.effect("commits and pushes with upstream auto-setup when needed", () => | ||
| 1539 | Effect.gen(function* () { | ||
| 1540 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1541 | yield* initRepo(repoDir); | ||
| 1542 | yield* runGit(repoDir, ["checkout", "-b", "feature/stacked-flow"]); | ||
| 1543 | const remoteDir = yield* createBareRemote(); | ||
| 1544 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 1545 | fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n"); | ||
| 1546 | |||
| 1547 | const { manager } = yield* makeManager(); | ||
| 1548 | const result = yield* runStackedAction(manager, { | ||
| 1549 | cwd: repoDir, | ||
| 1550 | action: "commit_push", | ||
| 1551 | }); | ||
| 1552 | |||
| 1553 | expect(result.branch.status).toBe("skipped_not_requested"); | ||
| 1554 | expect(result.commit.status).toBe("created"); | ||
| 1555 | expect(result.push.status).toBe("pushed"); | ||
| 1556 | expect(result.push.setUpstream).toBe(true); | ||
| 1557 | expect( | ||
| 1558 | yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe( | ||
| 1559 | Effect.map((result) => result.stdout.trim()), | ||
| 1560 | ), | ||
| 1561 | ).toBe("origin/feature/stacked-flow"); | ||
| 1562 | }), | ||
| 1563 | ); | ||
| 1564 | |||
| 1565 | it.effect( | ||
| 1566 | "pushes and creates PR from a no-upstream branch when local commits are ahead of base", | ||
| 1567 | () => | ||
| 1568 | Effect.gen(function* () { | ||
| 1569 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1570 | yield* initRepo(repoDir); | ||
| 1571 | yield* runGit(repoDir, ["checkout", "-b", "feature/no-upstream-pr"]); | ||
| 1572 | const remoteDir = yield* createBareRemote(); | ||
| 1573 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 1574 | fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n"); | ||
| 1575 | |||
| 1576 | const { manager, ghCalls } = yield* makeManager({ | ||
| 1577 | ghScenario: { | ||
| 1578 | prListSequence: [ | ||
| 1579 | "[]", | ||
| 1580 | JSON.stringify([ | ||
| 1581 | { | ||
| 1582 | number: 77, | ||
| 1583 | title: "Add no-upstream PR flow", | ||
| 1584 | url: "https://github.com/pingdotgg/codething-mvp/pull/77", | ||
| 1585 | baseRefName: "main", | ||
| 1586 | headRefName: "feature/no-upstream-pr", | ||
| 1587 | }, | ||
| 1588 | ]), | ||
| 1589 | ], | ||
| 1590 | }, | ||
| 1591 | }); | ||
| 1592 | |||
| 1593 | const result = yield* runStackedAction(manager, { | ||
| 1594 | cwd: repoDir, | ||
| 1595 | action: "commit_push_pr", | ||
| 1596 | }); | ||
| 1597 | |||
| 1598 | expect(result.branch.status).toBe("skipped_not_requested"); | ||
| 1599 | expect(result.commit.status).toBe("created"); | ||
| 1600 | expect(result.push.status).toBe("pushed"); | ||
| 1601 | expect(result.push.setUpstream).toBe(true); | ||
| 1602 | expect(result.pr.status).toBe("created"); | ||
| 1603 | expect( | ||
| 1604 | yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe( | ||
| 1605 | Effect.map((result) => result.stdout.trim()), | ||
| 1606 | ), | ||
| 1607 | ).toBe("origin/feature/no-upstream-pr"); | ||
| 1608 | expect( | ||
| 1609 | ghCalls.some((call) => | ||
| 1610 | call.includes("pr create --base main --head feature/no-upstream-pr"), | ||
| 1611 | ), | ||
| 1612 | ).toBe(true); | ||
| 1613 | }), | ||
| 1614 | ); | ||
| 1615 | |||
| 1616 | it.effect("skips push when branch is already up to date", () => | ||
| 1617 | Effect.gen(function* () { | ||
| 1618 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1619 | yield* initRepo(repoDir); | ||
| 1620 | yield* runGit(repoDir, ["checkout", "-b", "feature/up-to-date"]); | ||
| 1621 | const remoteDir = yield* createBareRemote(); | ||
| 1622 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 1623 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/up-to-date"]); | ||
| 1624 | |||
| 1625 | const { manager } = yield* makeManager(); | ||
| 1626 | const result = yield* runStackedAction(manager, { | ||
| 1627 | cwd: repoDir, | ||
| 1628 | action: "commit_push", | ||
| 1629 | }); | ||
| 1630 | |||
| 1631 | expect(result.branch.status).toBe("skipped_not_requested"); | ||
| 1632 | expect(result.commit.status).toBe("skipped_no_changes"); | ||
| 1633 | expect(result.push.status).toBe("skipped_up_to_date"); | ||
| 1634 | }), | ||
| 1635 | ); | ||
| 1636 | |||
| 1637 | it.effect("pushes existing clean commits without rerunning commit logic", () => | ||
| 1638 | Effect.gen(function* () { | ||
| 1639 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1640 | yield* initRepo(repoDir); | ||
| 1641 | yield* runGit(repoDir, ["checkout", "-b", "feature/push-only"]); | ||
| 1642 | const remoteDir = yield* createBareRemote(); | ||
| 1643 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 1644 | fs.writeFileSync(path.join(repoDir, "push-only.txt"), "push only\n"); | ||
| 1645 | yield* runGit(repoDir, ["add", "push-only.txt"]); | ||
| 1646 | yield* runGit(repoDir, ["commit", "-m", "Push only branch"]); | ||
| 1647 | |||
| 1648 | const { manager } = yield* makeManager(); | ||
| 1649 | const result = yield* runStackedAction(manager, { | ||
| 1650 | cwd: repoDir, | ||
| 1651 | action: "push", | ||
| 1652 | }); | ||
| 1653 | |||
| 1654 | expect(result.commit.status).toBe("skipped_not_requested"); | ||
| 1655 | expect(result.push.status).toBe("pushed"); | ||
| 1656 | expect(result.pr.status).toBe("skipped_not_requested"); | ||
| 1657 | expect( | ||
| 1658 | yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe( | ||
| 1659 | Effect.map((output) => output.stdout.trim()), | ||
| 1660 | ), | ||
| 1661 | ).toBe("origin/feature/push-only"); | ||
| 1662 | }), | ||
| 1663 | ); | ||
| 1664 | |||
| 1665 | it.effect("create_pr pushes a clean branch before creating the PR when needed", () => | ||
| 1666 | Effect.gen(function* () { | ||
| 1667 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1668 | yield* initRepo(repoDir); | ||
| 1669 | yield* runGit(repoDir, ["checkout", "-b", "feature/create-pr-only"]); | ||
| 1670 | const remoteDir = yield* createBareRemote(); | ||
| 1671 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 1672 | fs.writeFileSync(path.join(repoDir, "create-pr-only.txt"), "create pr\n"); | ||
| 1673 | yield* runGit(repoDir, ["add", "create-pr-only.txt"]); | ||
| 1674 | yield* runGit(repoDir, ["commit", "-m", "Create PR only branch"]); | ||
| 1675 | |||
| 1676 | const { manager, ghCalls } = yield* makeManager({ | ||
| 1677 | ghScenario: { | ||
| 1678 | prListSequence: [ | ||
| 1679 | "[]", | ||
| 1680 | JSON.stringify([ | ||
| 1681 | { | ||
| 1682 | number: 303, | ||
| 1683 | title: "Create PR only branch", | ||
| 1684 | url: "https://github.com/pingdotgg/codething-mvp/pull/303", | ||
| 1685 | baseRefName: "main", | ||
| 1686 | headRefName: "feature/create-pr-only", | ||
| 1687 | }, | ||
| 1688 | ]), | ||
| 1689 | ], | ||
| 1690 | }, | ||
| 1691 | }); | ||
| 1692 | |||
| 1693 | const result = yield* runStackedAction(manager, { | ||
| 1694 | cwd: repoDir, | ||
| 1695 | action: "create_pr", | ||
| 1696 | }); | ||
| 1697 | |||
| 1698 | expect(result.commit.status).toBe("skipped_not_requested"); | ||
| 1699 | expect(result.push.status).toBe("pushed"); | ||
| 1700 | expect(result.push.setUpstream).toBe(true); | ||
| 1701 | expect(result.pr.status).toBe("created"); | ||
| 1702 | expect(result.pr.number).toBe(303); | ||
| 1703 | expect( | ||
| 1704 | ghCalls.some((call) => | ||
| 1705 | call.includes("pr create --base main --head feature/create-pr-only"), | ||
| 1706 | ), | ||
| 1707 | ).toBe(true); | ||
| 1708 | }), | ||
| 1709 | ); | ||
| 1710 | |||
| 1711 | it.effect("returns existing PR metadata for commit/push/pr action", () => | ||
| 1712 | Effect.gen(function* () { | ||
| 1713 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1714 | yield* initRepo(repoDir); | ||
| 1715 | yield* runGit(repoDir, ["checkout", "-b", "feature/existing-pr"]); | ||
| 1716 | const remoteDir = yield* createBareRemote(); | ||
| 1717 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 1718 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/existing-pr"]); | ||
| 1719 | |||
| 1720 | const { manager, ghCalls } = yield* makeManager({ | ||
| 1721 | ghScenario: { | ||
| 1722 | prListSequence: [ | ||
| 1723 | JSON.stringify([ | ||
| 1724 | { | ||
| 1725 | number: 42, | ||
| 1726 | title: "Existing PR", | ||
| 1727 | url: "https://github.com/pingdotgg/codething-mvp/pull/42", | ||
| 1728 | baseRefName: "main", | ||
| 1729 | headRefName: "feature/existing-pr", | ||
| 1730 | }, | ||
| 1731 | ]), | ||
| 1732 | ], | ||
| 1733 | }, | ||
| 1734 | }); | ||
| 1735 | const result = yield* runStackedAction(manager, { | ||
| 1736 | cwd: repoDir, | ||
| 1737 | action: "commit_push_pr", | ||
| 1738 | }); | ||
| 1739 | |||
| 1740 | expect(result.branch.status).toBe("skipped_not_requested"); | ||
| 1741 | expect(result.pr.status).toBe("opened_existing"); | ||
| 1742 | expect(result.pr.number).toBe(42); | ||
| 1743 | expect(result.toast).toEqual({ | ||
| 1744 | title: "Opened PR #42", | ||
| 1745 | description: "Existing PR", | ||
| 1746 | cta: { | ||
| 1747 | kind: "open_pr", | ||
| 1748 | label: "View PR", | ||
| 1749 | url: "https://github.com/pingdotgg/codething-mvp/pull/42", | ||
| 1750 | }, | ||
| 1751 | }); | ||
| 1752 | expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); | ||
| 1753 | }), | ||
| 1754 | ); | ||
| 1755 | |||
| 1756 | it.effect( | ||
| 1757 | "returns existing cross-repo PR metadata using the fork owner selector", | ||
| 1758 | () => | ||
| 1759 | Effect.gen(function* () { | ||
| 1760 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1761 | yield* initRepo(repoDir); | ||
| 1762 | yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); | ||
| 1763 | const forkDir = yield* createBareRemote(); | ||
| 1764 | yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); | ||
| 1765 | yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); | ||
| 1766 | yield* runGit(repoDir, [ | ||
| 1767 | "config", | ||
| 1768 | "remote.fork-seed.url", | ||
| 1769 | "git@github.com:octocat/codething-mvp.git", | ||
| 1770 | ]); | ||
| 1771 | |||
| 1772 | const { manager, ghCalls } = yield* makeManager({ | ||
| 1773 | ghScenario: { | ||
| 1774 | prListSequence: [ | ||
| 1775 | JSON.stringify([]), | ||
| 1776 | JSON.stringify([ | ||
| 1777 | { | ||
| 1778 | number: 142, | ||
| 1779 | title: "Existing fork PR", | ||
| 1780 | url: "https://github.com/pingdotgg/codething-mvp/pull/142", | ||
| 1781 | baseRefName: "main", | ||
| 1782 | headRefName: "statemachine", | ||
| 1783 | state: "OPEN", | ||
| 1784 | isCrossRepository: true, | ||
| 1785 | headRepository: { | ||
| 1786 | nameWithOwner: "octocat/codething-mvp", | ||
| 1787 | }, | ||
| 1788 | headRepositoryOwner: { | ||
| 1789 | login: "octocat", | ||
| 1790 | }, | ||
| 1791 | }, | ||
| 1792 | ]), | ||
| 1793 | ], | ||
| 1794 | }, | ||
| 1795 | }); | ||
| 1796 | |||
| 1797 | const result = yield* runStackedAction(manager, { | ||
| 1798 | cwd: repoDir, | ||
| 1799 | action: "commit_push_pr", | ||
| 1800 | }); | ||
| 1801 | |||
| 1802 | expect(result.pr.status).toBe("opened_existing"); | ||
| 1803 | expect(result.pr.number).toBe(142); | ||
| 1804 | expect( | ||
| 1805 | ghCalls.some((call) => | ||
| 1806 | call.includes("pr list --head octocat:statemachine --state open --limit 1"), | ||
| 1807 | ), | ||
| 1808 | ).toBe(true); | ||
| 1809 | expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); | ||
| 1810 | }), | ||
| 1811 | 12_000, | ||
| 1812 | ); | ||
| 1813 | |||
| 1814 | it.effect( | ||
| 1815 | "returns the correct existing PR when a slash remote checks out to a synthetic local alias", | ||
| 1816 | () => | ||
| 1817 | Effect.gen(function* () { | ||
| 1818 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1819 | yield* initRepo(repoDir); | ||
| 1820 | const originDir = yield* createBareRemote(); | ||
| 1821 | const upstreamDir = yield* createBareRemote(); | ||
| 1822 | yield* configureRemote(repoDir, "origin", originDir, "origin"); | ||
| 1823 | yield* configureRemote(repoDir, "my-org/upstream", upstreamDir, "my-org/upstream"); | ||
| 1824 | |||
| 1825 | yield* runGit(repoDir, ["checkout", "-b", "effect-atom"]); | ||
| 1826 | yield* runGit(repoDir, ["push", "-u", "origin", "effect-atom"]); | ||
| 1827 | yield* runGit(repoDir, ["push", "-u", "my-org/upstream", "effect-atom"]); | ||
| 1828 | yield* runGit(repoDir, [ | ||
| 1829 | "config", | ||
| 1830 | "remote.origin.url", | ||
| 1831 | "git@github.com:pingdotgg/codething-mvp.git", | ||
| 1832 | ]); | ||
| 1833 | yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]); | ||
| 1834 | yield* runGit(repoDir, [ | ||
| 1835 | "config", | ||
| 1836 | "remote.my-org/upstream.url", | ||
| 1837 | "git@github.com:pingdotgg/codething-mvp.git", | ||
| 1838 | ]); | ||
| 1839 | yield* runGit(repoDir, ["config", "remote.my-org/upstream.pushurl", upstreamDir]); | ||
| 1840 | yield* runGit(repoDir, ["checkout", "main"]); | ||
| 1841 | yield* runGit(repoDir, ["branch", "-D", "effect-atom"]); | ||
| 1842 | yield* runGit(repoDir, ["checkout", "--track", "my-org/upstream/effect-atom"]); | ||
| 1843 | fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); | ||
| 1844 | yield* runGit(repoDir, ["add", "changes.txt"]); | ||
| 1845 | yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); | ||
| 1846 | |||
| 1847 | const { manager, ghCalls } = yield* makeManager({ | ||
| 1848 | ghScenario: { | ||
| 1849 | prListByHeadSelector: { | ||
| 1850 | "effect-atom": JSON.stringify([ | ||
| 1851 | { | ||
| 1852 | number: 1618, | ||
| 1853 | title: "Correct PR", | ||
| 1854 | url: "https://github.com/pingdotgg/t3code/pull/1618", | ||
| 1855 | baseRefName: "main", | ||
| 1856 | headRefName: "effect-atom", | ||
| 1857 | }, | ||
| 1858 | ]), | ||
| 1859 | "upstream/effect-atom": JSON.stringify([ | ||
| 1860 | { | ||
| 1861 | number: 1518, | ||
| 1862 | title: "Wrong PR", | ||
| 1863 | url: "https://github.com/pingdotgg/t3code/pull/1518", | ||
| 1864 | baseRefName: "main", | ||
| 1865 | headRefName: "upstream/effect-atom", | ||
| 1866 | }, | ||
| 1867 | ]), | ||
| 1868 | "pingdotgg:effect-atom": JSON.stringify([]), | ||
| 1869 | "my-org/upstream:effect-atom": JSON.stringify([]), | ||
| 1870 | "pingdotgg:upstream/effect-atom": JSON.stringify([ | ||
| 1871 | { | ||
| 1872 | number: 1518, | ||
| 1873 | title: "Wrong PR", | ||
| 1874 | url: "https://github.com/pingdotgg/t3code/pull/1518", | ||
| 1875 | baseRefName: "main", | ||
| 1876 | headRefName: "upstream/effect-atom", | ||
| 1877 | }, | ||
| 1878 | ]), | ||
| 1879 | "my-org/upstream:upstream/effect-atom": JSON.stringify([ | ||
| 1880 | { | ||
| 1881 | number: 1518, | ||
| 1882 | title: "Wrong PR", | ||
| 1883 | url: "https://github.com/pingdotgg/t3code/pull/1518", | ||
| 1884 | baseRefName: "main", | ||
| 1885 | headRefName: "upstream/effect-atom", | ||
| 1886 | }, | ||
| 1887 | ]), | ||
| 1888 | }, | ||
| 1889 | }, | ||
| 1890 | }); | ||
| 1891 | |||
| 1892 | const result = yield* runStackedAction(manager, { | ||
| 1893 | cwd: repoDir, | ||
| 1894 | action: "commit_push_pr", | ||
| 1895 | }); | ||
| 1896 | |||
| 1897 | expect(result.pr.status).toBe("opened_existing"); | ||
| 1898 | expect(result.pr.number).toBe(1618); | ||
| 1899 | expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe( | ||
| 1900 | false, | ||
| 1901 | ); | ||
| 1902 | }), | ||
| 1903 | 20_000, | ||
| 1904 | ); | ||
| 1905 | |||
| 1906 | it.effect( | ||
| 1907 | "prefers owner-qualified selectors before bare branch names for cross-repo PRs", | ||
| 1908 | () => | ||
| 1909 | Effect.gen(function* () { | ||
| 1910 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1911 | yield* initRepo(repoDir); | ||
| 1912 | yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); | ||
| 1913 | const forkDir = yield* createBareRemote(); | ||
| 1914 | yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); | ||
| 1915 | yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); | ||
| 1916 | yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-142/statemachine"]); | ||
| 1917 | yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); | ||
| 1918 | yield* runGit(repoDir, [ | ||
| 1919 | "config", | ||
| 1920 | "remote.fork-seed.url", | ||
| 1921 | "git@github.com:octocat/codething-mvp.git", | ||
| 1922 | ]); | ||
| 1923 | |||
| 1924 | const { manager, ghCalls } = yield* makeManager({ | ||
| 1925 | ghScenario: { | ||
| 1926 | prListByHeadSelector: { | ||
| 1927 | "t3code/pr-142/statemachine": JSON.stringify([]), | ||
| 1928 | statemachine: JSON.stringify([ | ||
| 1929 | { | ||
| 1930 | number: 41, | ||
| 1931 | title: "Unrelated same-repo PR", | ||
| 1932 | url: "https://github.com/pingdotgg/codething-mvp/pull/41", | ||
| 1933 | baseRefName: "main", | ||
| 1934 | headRefName: "statemachine", | ||
| 1935 | }, | ||
| 1936 | ]), | ||
| 1937 | "octocat:statemachine": JSON.stringify([ | ||
| 1938 | { | ||
| 1939 | number: 142, | ||
| 1940 | title: "Existing fork PR", | ||
| 1941 | url: "https://github.com/pingdotgg/codething-mvp/pull/142", | ||
| 1942 | baseRefName: "main", | ||
| 1943 | headRefName: "statemachine", | ||
| 1944 | state: "OPEN", | ||
| 1945 | isCrossRepository: true, | ||
| 1946 | headRepository: { | ||
| 1947 | nameWithOwner: "octocat/codething-mvp", | ||
| 1948 | }, | ||
| 1949 | headRepositoryOwner: { | ||
| 1950 | login: "octocat", | ||
| 1951 | }, | ||
| 1952 | }, | ||
| 1953 | ]), | ||
| 1954 | "fork-seed:statemachine": JSON.stringify([]), | ||
| 1955 | }, | ||
| 1956 | }, | ||
| 1957 | }); | ||
| 1958 | |||
| 1959 | const result = yield* runStackedAction(manager, { | ||
| 1960 | cwd: repoDir, | ||
| 1961 | action: "commit_push_pr", | ||
| 1962 | }); | ||
| 1963 | |||
| 1964 | expect(result.pr.status).toBe("opened_existing"); | ||
| 1965 | expect(result.pr.number).toBe(142); | ||
| 1966 | |||
| 1967 | const ownerSelectorCallIndex = ghCalls.findIndex((call) => | ||
| 1968 | call.includes("pr list --head octocat:statemachine --state open --limit 1"), | ||
| 1969 | ); | ||
| 1970 | expect(ownerSelectorCallIndex).toBeGreaterThanOrEqual(0); | ||
| 1971 | expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); | ||
| 1972 | }), | ||
| 1973 | 12_000, | ||
| 1974 | ); | ||
| 1975 | |||
| 1976 | it.effect( | ||
| 1977 | "stops probing head selectors after finding an existing PR", | ||
| 1978 | () => | ||
| 1979 | Effect.gen(function* () { | ||
| 1980 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 1981 | yield* initRepo(repoDir); | ||
| 1982 | yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); | ||
| 1983 | const forkDir = yield* createBareRemote(); | ||
| 1984 | yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); | ||
| 1985 | yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); | ||
| 1986 | yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-142/statemachine"]); | ||
| 1987 | yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); | ||
| 1988 | yield* runGit(repoDir, [ | ||
| 1989 | "config", | ||
| 1990 | "remote.fork-seed.url", | ||
| 1991 | "git@github.com:octocat/codething-mvp.git", | ||
| 1992 | ]); | ||
| 1993 | |||
| 1994 | const { manager, ghCalls } = yield* makeManager({ | ||
| 1995 | ghScenario: { | ||
| 1996 | prListByHeadSelector: { | ||
| 1997 | "octocat:statemachine": JSON.stringify([ | ||
| 1998 | { | ||
| 1999 | number: 142, | ||
| 2000 | title: "Existing fork PR", | ||
| 2001 | url: "https://github.com/pingdotgg/codething-mvp/pull/142", | ||
| 2002 | baseRefName: "main", | ||
| 2003 | headRefName: "statemachine", | ||
| 2004 | state: "OPEN", | ||
| 2005 | isCrossRepository: true, | ||
| 2006 | headRepository: { | ||
| 2007 | nameWithOwner: "octocat/codething-mvp", | ||
| 2008 | }, | ||
| 2009 | headRepositoryOwner: { | ||
| 2010 | login: "octocat", | ||
| 2011 | }, | ||
| 2012 | }, | ||
| 2013 | ]), | ||
| 2014 | "fork-seed:statemachine": JSON.stringify([]), | ||
| 2015 | "t3code/pr-142/statemachine": JSON.stringify([]), | ||
| 2016 | statemachine: JSON.stringify([]), | ||
| 2017 | }, | ||
| 2018 | }, | ||
| 2019 | }); | ||
| 2020 | |||
| 2021 | const result = yield* runStackedAction(manager, { | ||
| 2022 | cwd: repoDir, | ||
| 2023 | action: "commit_push_pr", | ||
| 2024 | }); | ||
| 2025 | |||
| 2026 | expect(result.pr.status).toBe("opened_existing"); | ||
| 2027 | expect(result.pr.number).toBe(142); | ||
| 2028 | |||
| 2029 | const openLookupCalls = ghCalls.filter((call) => call.includes("--state open --limit 1")); | ||
| 2030 | expect(openLookupCalls).toHaveLength(1); | ||
| 2031 | expect(openLookupCalls[0]).toContain( | ||
| 2032 | "pr list --head octocat:statemachine --state open --limit 1", | ||
| 2033 | ); | ||
| 2034 | }), | ||
| 2035 | 12_000, | ||
| 2036 | ); | ||
| 2037 | |||
| 2038 | it.effect("creates PR when one does not already exist", () => | ||
| 2039 | Effect.gen(function* () { | ||
| 2040 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2041 | yield* initRepo(repoDir); | ||
| 2042 | yield* runGit(repoDir, ["checkout", "-b", "feature-create-pr"]); | ||
| 2043 | const remoteDir = yield* createBareRemote(); | ||
| 2044 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 2045 | fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); | ||
| 2046 | yield* runGit(repoDir, ["add", "changes.txt"]); | ||
| 2047 | yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); | ||
| 2048 | yield* runGit(repoDir, ["push", "-u", "origin", "feature-create-pr"]); | ||
| 2049 | yield* runGit(repoDir, ["config", "branch.feature-create-pr.gh-merge-base", "main"]); | ||
| 2050 | |||
| 2051 | const { manager, ghCalls } = yield* makeManager({ | ||
| 2052 | ghScenario: { | ||
| 2053 | prListSequence: [ | ||
| 2054 | "[]", | ||
| 2055 | JSON.stringify([ | ||
| 2056 | { | ||
| 2057 | number: 88, | ||
| 2058 | title: "Add stacked git actions", | ||
| 2059 | url: "https://github.com/pingdotgg/codething-mvp/pull/88", | ||
| 2060 | baseRefName: "main", | ||
| 2061 | headRefName: "feature-create-pr", | ||
| 2062 | }, | ||
| 2063 | ]), | ||
| 2064 | ], | ||
| 2065 | }, | ||
| 2066 | }); | ||
| 2067 | const result = yield* runStackedAction(manager, { | ||
| 2068 | cwd: repoDir, | ||
| 2069 | action: "commit_push_pr", | ||
| 2070 | }); | ||
| 2071 | |||
| 2072 | expect(result.branch.status).toBe("skipped_not_requested"); | ||
| 2073 | expect(result.pr.status).toBe("created"); | ||
| 2074 | expect(result.pr.number).toBe(88); | ||
| 2075 | expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); | ||
| 2076 | expect( | ||
| 2077 | ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")), | ||
| 2078 | ).toBe(true); | ||
| 2079 | expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); | ||
| 2080 | }), | ||
| 2081 | ); | ||
| 2082 | |||
| 2083 | it.effect( | ||
| 2084 | "creates a new PR instead of reusing an unrelated fork PR with the same head branch", | ||
| 2085 | () => | ||
| 2086 | Effect.gen(function* () { | ||
| 2087 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2088 | yield* initRepo(repoDir); | ||
| 2089 | yield* runGit(repoDir, ["checkout", "-b", "feature/no-fork-match"]); | ||
| 2090 | const remoteDir = yield* createBareRemote(); | ||
| 2091 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 2092 | fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); | ||
| 2093 | yield* runGit(repoDir, ["add", "changes.txt"]); | ||
| 2094 | yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); | ||
| 2095 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/no-fork-match"]); | ||
| 2096 | |||
| 2097 | const { manager, ghCalls } = yield* makeManager({ | ||
| 2098 | ghScenario: { | ||
| 2099 | prListSequence: [ | ||
| 2100 | JSON.stringify([ | ||
| 2101 | { | ||
| 2102 | number: 1661, | ||
| 2103 | title: "Fork PR with same branch name", | ||
| 2104 | url: "https://github.com/pingdotgg/t3code/pull/1661", | ||
| 2105 | baseRefName: "main", | ||
| 2106 | headRefName: "feature/no-fork-match", | ||
| 2107 | state: "OPEN", | ||
| 2108 | isCrossRepository: true, | ||
| 2109 | headRepository: { | ||
| 2110 | nameWithOwner: "lnieuwenhuis/t3code", | ||
| 2111 | }, | ||
| 2112 | headRepositoryOwner: { | ||
| 2113 | login: "lnieuwenhuis", | ||
| 2114 | }, | ||
| 2115 | }, | ||
| 2116 | ]), | ||
| 2117 | JSON.stringify([ | ||
| 2118 | { | ||
| 2119 | number: 188, | ||
| 2120 | title: "Add stacked git actions", | ||
| 2121 | url: "https://github.com/pingdotgg/codething-mvp/pull/188", | ||
| 2122 | baseRefName: "main", | ||
| 2123 | headRefName: "feature/no-fork-match", | ||
| 2124 | state: "OPEN", | ||
| 2125 | isCrossRepository: false, | ||
| 2126 | }, | ||
| 2127 | ]), | ||
| 2128 | ], | ||
| 2129 | }, | ||
| 2130 | }); | ||
| 2131 | const result = yield* runStackedAction(manager, { | ||
| 2132 | cwd: repoDir, | ||
| 2133 | action: "commit_push_pr", | ||
| 2134 | }); | ||
| 2135 | |||
| 2136 | expect(result.pr.status).toBe("created"); | ||
| 2137 | expect(result.pr.number).toBe(188); | ||
| 2138 | expect(result.toast).toEqual({ | ||
| 2139 | title: "Created PR #188", | ||
| 2140 | description: "Add stacked git actions", | ||
| 2141 | cta: { | ||
| 2142 | kind: "open_pr", | ||
| 2143 | label: "View PR", | ||
| 2144 | url: "https://github.com/pingdotgg/codething-mvp/pull/188", | ||
| 2145 | }, | ||
| 2146 | }); | ||
| 2147 | expect( | ||
| 2148 | ghCalls.some((call) => | ||
| 2149 | call.includes("pr create --base main --head feature/no-fork-match"), | ||
| 2150 | ), | ||
| 2151 | ).toBe(true); | ||
| 2152 | }), | ||
| 2153 | ); | ||
| 2154 | |||
| 2155 | it.effect("creates cross-repo PRs with the fork owner selector and default base branch", () => | ||
| 2156 | Effect.gen(function* () { | ||
| 2157 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2158 | yield* initRepo(repoDir); | ||
| 2159 | const forkDir = yield* createBareRemote(); | ||
| 2160 | yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); | ||
| 2161 | yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); | ||
| 2162 | fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); | ||
| 2163 | yield* runGit(repoDir, ["add", "changes.txt"]); | ||
| 2164 | yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); | ||
| 2165 | yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); | ||
| 2166 | yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-91/statemachine"]); | ||
| 2167 | yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); | ||
| 2168 | yield* runGit(repoDir, [ | ||
| 2169 | "config", | ||
| 2170 | "remote.fork-seed.url", | ||
| 2171 | "git@github.com:octocat/codething-mvp.git", | ||
| 2172 | ]); | ||
| 2173 | |||
| 2174 | const { manager, ghCalls } = yield* makeManager({ | ||
| 2175 | ghScenario: { | ||
| 2176 | prListSequenceByHeadSelector: { | ||
| 2177 | "octocat:statemachine": [ | ||
| 2178 | JSON.stringify([]), | ||
| 2179 | JSON.stringify([ | ||
| 2180 | { | ||
| 2181 | number: 188, | ||
| 2182 | title: "Add stacked git actions", | ||
| 2183 | url: "https://github.com/pingdotgg/codething-mvp/pull/188", | ||
| 2184 | baseRefName: "main", | ||
| 2185 | headRefName: "statemachine", | ||
| 2186 | state: "OPEN", | ||
| 2187 | isCrossRepository: true, | ||
| 2188 | headRepository: { | ||
| 2189 | nameWithOwner: "octocat/codething-mvp", | ||
| 2190 | }, | ||
| 2191 | headRepositoryOwner: { | ||
| 2192 | login: "octocat", | ||
| 2193 | }, | ||
| 2194 | }, | ||
| 2195 | ]), | ||
| 2196 | ], | ||
| 2197 | "fork-seed:statemachine": [JSON.stringify([])], | ||
| 2198 | statemachine: [JSON.stringify([])], | ||
| 2199 | }, | ||
| 2200 | }, | ||
| 2201 | }); | ||
| 2202 | |||
| 2203 | const result = yield* runStackedAction(manager, { | ||
| 2204 | cwd: repoDir, | ||
| 2205 | action: "commit_push_pr", | ||
| 2206 | }); | ||
| 2207 | |||
| 2208 | expect(result.pr.status).toBe("created"); | ||
| 2209 | expect(result.pr.number).toBe(188); | ||
| 2210 | expect( | ||
| 2211 | ghCalls.some((call) => call.includes("pr create --base main --head octocat:statemachine")), | ||
| 2212 | ).toBe(true); | ||
| 2213 | expect( | ||
| 2214 | ghCalls.some((call) => | ||
| 2215 | call.includes("pr create --base statemachine --head octocat:statemachine"), | ||
| 2216 | ), | ||
| 2217 | ).toBe(false); | ||
| 2218 | }), | ||
| 2219 | ); | ||
| 2220 | |||
| 2221 | it.effect("rejects push/pr actions from detached HEAD", () => | ||
| 2222 | Effect.gen(function* () { | ||
| 2223 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2224 | yield* initRepo(repoDir); | ||
| 2225 | yield* runGit(repoDir, ["checkout", "--detach", "HEAD"]); | ||
| 2226 | |||
| 2227 | const { manager } = yield* makeManager(); | ||
| 2228 | const errorMessage = yield* runStackedAction(manager, { | ||
| 2229 | cwd: repoDir, | ||
| 2230 | action: "commit_push", | ||
| 2231 | }).pipe( | ||
| 2232 | Effect.flip, | ||
| 2233 | Effect.map((error) => error.message), | ||
| 2234 | ); | ||
| 2235 | expect(errorMessage).toContain("detached HEAD"); | ||
| 2236 | }), | ||
| 2237 | ); | ||
| 2238 | |||
| 2239 | it.effect("surfaces missing gh binary errors", () => | ||
| 2240 | Effect.gen(function* () { | ||
| 2241 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2242 | yield* initRepo(repoDir); | ||
| 2243 | yield* runGit(repoDir, ["checkout", "-b", "feature/gh-missing"]); | ||
| 2244 | const remoteDir = yield* createBareRemote(); | ||
| 2245 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 2246 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/gh-missing"]); | ||
| 2247 | |||
| 2248 | const { manager } = yield* makeManager({ | ||
| 2249 | ghScenario: { | ||
| 2250 | failWith: new GitHubCliError({ | ||
| 2251 | operation: "execute", | ||
| 2252 | detail: "GitHub CLI (`gh`) is required but not available on PATH.", | ||
| 2253 | }), | ||
| 2254 | }, | ||
| 2255 | }); | ||
| 2256 | |||
| 2257 | const errorMessage = yield* runStackedAction(manager, { | ||
| 2258 | cwd: repoDir, | ||
| 2259 | action: "commit_push_pr", | ||
| 2260 | }).pipe( | ||
| 2261 | Effect.flip, | ||
| 2262 | Effect.map((error) => error.message), | ||
| 2263 | ); | ||
| 2264 | expect(errorMessage).toContain("GitHub CLI (`gh`) is required"); | ||
| 2265 | }), | ||
| 2266 | ); | ||
| 2267 | |||
| 2268 | it.effect("surfaces gh auth errors with guidance", () => | ||
| 2269 | Effect.gen(function* () { | ||
| 2270 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2271 | yield* initRepo(repoDir); | ||
| 2272 | yield* runGit(repoDir, ["checkout", "-b", "feature/gh-auth"]); | ||
| 2273 | const remoteDir = yield* createBareRemote(); | ||
| 2274 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 2275 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/gh-auth"]); | ||
| 2276 | |||
| 2277 | const { manager } = yield* makeManager({ | ||
| 2278 | ghScenario: { | ||
| 2279 | failWith: new GitHubCliError({ | ||
| 2280 | operation: "execute", | ||
| 2281 | detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", | ||
| 2282 | }), | ||
| 2283 | }, | ||
| 2284 | }); | ||
| 2285 | |||
| 2286 | const errorMessage = yield* runStackedAction(manager, { | ||
| 2287 | cwd: repoDir, | ||
| 2288 | action: "commit_push_pr", | ||
| 2289 | }).pipe( | ||
| 2290 | Effect.flip, | ||
| 2291 | Effect.map((error) => error.message), | ||
| 2292 | ); | ||
| 2293 | expect(errorMessage).toContain("gh auth login"); | ||
| 2294 | }), | ||
| 2295 | ); | ||
| 2296 | |||
| 2297 | it.effect("resolves pull requests from #number references", () => | ||
| 2298 | Effect.gen(function* () { | ||
| 2299 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2300 | yield* initRepo(repoDir); | ||
| 2301 | |||
| 2302 | const { manager, ghCalls } = yield* makeManager({ | ||
| 2303 | ghScenario: { | ||
| 2304 | pullRequest: { | ||
| 2305 | number: 42, | ||
| 2306 | title: "Resolve PR", | ||
| 2307 | url: "https://github.com/pingdotgg/codething-mvp/pull/42", | ||
| 2308 | baseRefName: "main", | ||
| 2309 | headRefName: "feature/resolve-pr", | ||
| 2310 | state: "open", | ||
| 2311 | }, | ||
| 2312 | }, | ||
| 2313 | }); | ||
| 2314 | |||
| 2315 | const result = yield* resolvePullRequest(manager, { | ||
| 2316 | cwd: repoDir, | ||
| 2317 | reference: "#42", | ||
| 2318 | }); | ||
| 2319 | |||
| 2320 | expect(result.pullRequest).toEqual({ | ||
| 2321 | number: 42, | ||
| 2322 | title: "Resolve PR", | ||
| 2323 | url: "https://github.com/pingdotgg/codething-mvp/pull/42", | ||
| 2324 | baseBranch: "main", | ||
| 2325 | headBranch: "feature/resolve-pr", | ||
| 2326 | state: "open", | ||
| 2327 | }); | ||
| 2328 | expect(ghCalls.some((call) => call.startsWith("pr view 42 "))).toBe(true); | ||
| 2329 | }), | ||
| 2330 | ); | ||
| 2331 | |||
| 2332 | it.effect("prepares pull request threads in local mode by checking out the PR branch", () => | ||
| 2333 | Effect.gen(function* () { | ||
| 2334 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2335 | yield* initRepo(repoDir); | ||
| 2336 | yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local"]); | ||
| 2337 | fs.writeFileSync(path.join(repoDir, "local.txt"), "local\n"); | ||
| 2338 | yield* runGit(repoDir, ["add", "local.txt"]); | ||
| 2339 | yield* runGit(repoDir, ["commit", "-m", "Local PR branch"]); | ||
| 2340 | |||
| 2341 | const { manager, ghCalls } = yield* makeManager({ | ||
| 2342 | ghScenario: { | ||
| 2343 | pullRequest: { | ||
| 2344 | number: 64, | ||
| 2345 | title: "Local PR", | ||
| 2346 | url: "https://github.com/pingdotgg/codething-mvp/pull/64", | ||
| 2347 | baseRefName: "main", | ||
| 2348 | headRefName: "feature/pr-local", | ||
| 2349 | state: "open", | ||
| 2350 | }, | ||
| 2351 | }, | ||
| 2352 | }); | ||
| 2353 | |||
| 2354 | const result = yield* preparePullRequestThread(manager, { | ||
| 2355 | cwd: repoDir, | ||
| 2356 | reference: "#64", | ||
| 2357 | mode: "local", | ||
| 2358 | }); | ||
| 2359 | |||
| 2360 | expect(result.branch).toBe("feature/pr-local"); | ||
| 2361 | expect(result.worktreePath).toBeNull(); | ||
| 2362 | const branch = (yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim(); | ||
| 2363 | expect(branch).toBe("feature/pr-local"); | ||
| 2364 | expect(ghCalls).toContain("pr checkout 64 --force"); | ||
| 2365 | }), | ||
| 2366 | ); | ||
| 2367 | |||
| 2368 | it.effect("prepares pull request threads in worktree mode on the PR head branch", () => | ||
| 2369 | Effect.gen(function* () { | ||
| 2370 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2371 | yield* initRepo(repoDir); | ||
| 2372 | const remoteDir = yield* createBareRemote(); | ||
| 2373 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 2374 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); | ||
| 2375 | yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree"]); | ||
| 2376 | fs.writeFileSync(path.join(repoDir, "worktree.txt"), "worktree\n"); | ||
| 2377 | yield* runGit(repoDir, ["add", "worktree.txt"]); | ||
| 2378 | yield* runGit(repoDir, ["commit", "-m", "PR worktree branch"]); | ||
| 2379 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree"]); | ||
| 2380 | yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/77/head"]); | ||
| 2381 | yield* runGit(repoDir, ["checkout", "main"]); | ||
| 2382 | |||
| 2383 | const { manager } = yield* makeManager({ | ||
| 2384 | ghScenario: { | ||
| 2385 | pullRequest: { | ||
| 2386 | number: 77, | ||
| 2387 | title: "Worktree PR", | ||
| 2388 | url: "https://github.com/pingdotgg/codething-mvp/pull/77", | ||
| 2389 | baseRefName: "main", | ||
| 2390 | headRefName: "feature/pr-worktree", | ||
| 2391 | state: "open", | ||
| 2392 | }, | ||
| 2393 | }, | ||
| 2394 | }); | ||
| 2395 | |||
| 2396 | const result = yield* preparePullRequestThread(manager, { | ||
| 2397 | cwd: repoDir, | ||
| 2398 | reference: "77", | ||
| 2399 | mode: "worktree", | ||
| 2400 | }); | ||
| 2401 | |||
| 2402 | expect(result.branch).toBe("feature/pr-worktree"); | ||
| 2403 | expect(result.worktreePath).not.toBeNull(); | ||
| 2404 | expect(fs.existsSync(result.worktreePath as string)).toBe(true); | ||
| 2405 | const worktreeBranch = (yield* runGit(result.worktreePath as string, [ | ||
| 2406 | "branch", | ||
| 2407 | "--show-current", | ||
| 2408 | ])).stdout.trim(); | ||
| 2409 | expect(worktreeBranch).toBe("feature/pr-worktree"); | ||
| 2410 | }), | ||
| 2411 | ); | ||
| 2412 | |||
| 2413 | it.effect("launches setup only when creating a new PR worktree", () => | ||
| 2414 | Effect.gen(function* () { | ||
| 2415 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2416 | yield* initRepo(repoDir); | ||
| 2417 | const remoteDir = yield* createBareRemote(); | ||
| 2418 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 2419 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); | ||
| 2420 | yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree-setup"]); | ||
| 2421 | fs.writeFileSync(path.join(repoDir, "setup.txt"), "setup\n"); | ||
| 2422 | yield* runGit(repoDir, ["add", "setup.txt"]); | ||
| 2423 | yield* runGit(repoDir, ["commit", "-m", "PR worktree setup branch"]); | ||
| 2424 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree-setup"]); | ||
| 2425 | yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/177/head"]); | ||
| 2426 | yield* runGit(repoDir, ["checkout", "main"]); | ||
| 2427 | |||
| 2428 | const setupCalls: ProjectSetupScriptRunnerInput[] = []; | ||
| 2429 | const { manager } = yield* makeManager({ | ||
| 2430 | ghScenario: { | ||
| 2431 | pullRequest: { | ||
| 2432 | number: 177, | ||
| 2433 | title: "Worktree setup PR", | ||
| 2434 | url: "https://github.com/pingdotgg/codething-mvp/pull/177", | ||
| 2435 | baseRefName: "main", | ||
| 2436 | headRefName: "feature/pr-worktree-setup", | ||
| 2437 | state: "open", | ||
| 2438 | }, | ||
| 2439 | }, | ||
| 2440 | setupScriptRunner: { | ||
| 2441 | runForThread: (setupInput) => | ||
| 2442 | Effect.sync(() => { | ||
| 2443 | setupCalls.push(setupInput); | ||
| 2444 | return { status: "no-script" as const }; | ||
| 2445 | }), | ||
| 2446 | }, | ||
| 2447 | }); | ||
| 2448 | |||
| 2449 | const result = yield* preparePullRequestThread(manager, { | ||
| 2450 | cwd: repoDir, | ||
| 2451 | reference: "177", | ||
| 2452 | mode: "worktree", | ||
| 2453 | threadId: asThreadId("thread-pr-setup"), | ||
| 2454 | }); | ||
| 2455 | |||
| 2456 | expect(result.worktreePath).not.toBeNull(); | ||
| 2457 | expect(setupCalls).toHaveLength(1); | ||
| 2458 | expect(setupCalls[0]).toEqual({ | ||
| 2459 | threadId: "thread-pr-setup", | ||
| 2460 | projectCwd: repoDir, | ||
| 2461 | worktreePath: result.worktreePath as string, | ||
| 2462 | }); | ||
| 2463 | }), | ||
| 2464 | ); | ||
| 2465 | |||
| 2466 | it.effect("preserves fork upstream tracking when preparing a worktree PR thread", () => | ||
| 2467 | Effect.gen(function* () { | ||
| 2468 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2469 | yield* initRepo(repoDir); | ||
| 2470 | const originDir = yield* createBareRemote(); | ||
| 2471 | const forkDir = yield* createBareRemote(); | ||
| 2472 | yield* runGit(repoDir, ["remote", "add", "origin", originDir]); | ||
| 2473 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); | ||
| 2474 | yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); | ||
| 2475 | yield* runGit(repoDir, ["checkout", "-b", "feature/pr-fork"]); | ||
| 2476 | fs.writeFileSync(path.join(repoDir, "fork.txt"), "fork\n"); | ||
| 2477 | yield* runGit(repoDir, ["add", "fork.txt"]); | ||
| 2478 | yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]); | ||
| 2479 | yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-fork"]); | ||
| 2480 | yield* runGit(repoDir, ["checkout", "main"]); | ||
| 2481 | |||
| 2482 | const { manager } = yield* makeManager({ | ||
| 2483 | ghScenario: { | ||
| 2484 | pullRequest: { | ||
| 2485 | number: 81, | ||
| 2486 | title: "Fork PR", | ||
| 2487 | url: "https://github.com/pingdotgg/codething-mvp/pull/81", | ||
| 2488 | baseRefName: "main", | ||
| 2489 | headRefName: "feature/pr-fork", | ||
| 2490 | state: "open", | ||
| 2491 | isCrossRepository: true, | ||
| 2492 | headRepositoryNameWithOwner: "octocat/codething-mvp", | ||
| 2493 | headRepositoryOwnerLogin: "octocat", | ||
| 2494 | }, | ||
| 2495 | repositoryCloneUrls: { | ||
| 2496 | "octocat/codething-mvp": { | ||
| 2497 | url: forkDir, | ||
| 2498 | sshUrl: forkDir, | ||
| 2499 | }, | ||
| 2500 | }, | ||
| 2501 | }, | ||
| 2502 | }); | ||
| 2503 | |||
| 2504 | const result = yield* preparePullRequestThread(manager, { | ||
| 2505 | cwd: repoDir, | ||
| 2506 | reference: "81", | ||
| 2507 | mode: "worktree", | ||
| 2508 | }); | ||
| 2509 | |||
| 2510 | expect(result.worktreePath).not.toBeNull(); | ||
| 2511 | const upstreamRef = (yield* runGit(result.worktreePath as string, [ | ||
| 2512 | "rev-parse", | ||
| 2513 | "--abbrev-ref", | ||
| 2514 | "@{upstream}", | ||
| 2515 | ])).stdout.trim(); | ||
| 2516 | expect(upstreamRef).toBe("fork-seed/feature/pr-fork"); | ||
| 2517 | expect(upstreamRef.startsWith("origin/")).toBe(false); | ||
| 2518 | expect( | ||
| 2519 | (yield* runGit(result.worktreePath as string, [ | ||
| 2520 | "config", | ||
| 2521 | "--get", | ||
| 2522 | "remote.fork-seed.url", | ||
| 2523 | ])).stdout.trim(), | ||
| 2524 | ).toBe(forkDir); | ||
| 2525 | }), | ||
| 2526 | ); | ||
| 2527 | |||
| 2528 | it.effect("preserves fork upstream tracking when preparing a local PR thread", () => | ||
| 2529 | Effect.gen(function* () { | ||
| 2530 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2531 | yield* initRepo(repoDir); | ||
| 2532 | const originDir = yield* createBareRemote(); | ||
| 2533 | const forkDir = yield* createBareRemote(); | ||
| 2534 | yield* runGit(repoDir, ["remote", "add", "origin", originDir]); | ||
| 2535 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); | ||
| 2536 | yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); | ||
| 2537 | yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-fork"]); | ||
| 2538 | fs.writeFileSync(path.join(repoDir, "local-fork.txt"), "local fork\n"); | ||
| 2539 | yield* runGit(repoDir, ["add", "local-fork.txt"]); | ||
| 2540 | yield* runGit(repoDir, ["commit", "-m", "Local fork PR branch"]); | ||
| 2541 | yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-local-fork"]); | ||
| 2542 | yield* runGit(repoDir, ["checkout", "main"]); | ||
| 2543 | yield* runGit(repoDir, ["branch", "-D", "feature/pr-local-fork"]); | ||
| 2544 | |||
| 2545 | const { manager } = yield* makeManager({ | ||
| 2546 | ghScenario: { | ||
| 2547 | pullRequest: { | ||
| 2548 | number: 82, | ||
| 2549 | title: "Local Fork PR", | ||
| 2550 | url: "https://github.com/pingdotgg/codething-mvp/pull/82", | ||
| 2551 | baseRefName: "main", | ||
| 2552 | headRefName: "feature/pr-local-fork", | ||
| 2553 | state: "open", | ||
| 2554 | isCrossRepository: true, | ||
| 2555 | headRepositoryNameWithOwner: "octocat/codething-mvp", | ||
| 2556 | headRepositoryOwnerLogin: "octocat", | ||
| 2557 | }, | ||
| 2558 | repositoryCloneUrls: { | ||
| 2559 | "octocat/codething-mvp": { | ||
| 2560 | url: forkDir, | ||
| 2561 | sshUrl: forkDir, | ||
| 2562 | }, | ||
| 2563 | }, | ||
| 2564 | }, | ||
| 2565 | }); | ||
| 2566 | |||
| 2567 | const result = yield* preparePullRequestThread(manager, { | ||
| 2568 | cwd: repoDir, | ||
| 2569 | reference: "82", | ||
| 2570 | mode: "local", | ||
| 2571 | }); | ||
| 2572 | |||
| 2573 | expect(result.worktreePath).toBeNull(); | ||
| 2574 | expect(result.branch).toBe("feature/pr-local-fork"); | ||
| 2575 | expect( | ||
| 2576 | (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), | ||
| 2577 | ).toBe("fork-seed/feature/pr-local-fork"); | ||
| 2578 | }), | ||
| 2579 | ); | ||
| 2580 | |||
| 2581 | it.effect("derives fork repository identity from PR URL when GitHub omits nameWithOwner", () => | ||
| 2582 | Effect.gen(function* () { | ||
| 2583 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2584 | yield* initRepo(repoDir); | ||
| 2585 | const originDir = yield* createBareRemote(); | ||
| 2586 | const forkDir = yield* createBareRemote(); | ||
| 2587 | yield* runGit(repoDir, ["remote", "add", "origin", originDir]); | ||
| 2588 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); | ||
| 2589 | yield* runGit(repoDir, ["remote", "add", "binbandit-seed", forkDir]); | ||
| 2590 | yield* runGit(repoDir, ["checkout", "-b", "fix/git-action-default-without-origin"]); | ||
| 2591 | fs.writeFileSync(path.join(repoDir, "derived-fork.txt"), "derived fork\n"); | ||
| 2592 | yield* runGit(repoDir, ["add", "derived-fork.txt"]); | ||
| 2593 | yield* runGit(repoDir, ["commit", "-m", "Derived fork PR branch"]); | ||
| 2594 | yield* runGit(repoDir, [ | ||
| 2595 | "push", | ||
| 2596 | "-u", | ||
| 2597 | "binbandit-seed", | ||
| 2598 | "fix/git-action-default-without-origin", | ||
| 2599 | ]); | ||
| 2600 | yield* runGit(repoDir, ["checkout", "main"]); | ||
| 2601 | yield* runGit(repoDir, ["branch", "-D", "fix/git-action-default-without-origin"]); | ||
| 2602 | |||
| 2603 | const { manager } = yield* makeManager({ | ||
| 2604 | ghScenario: { | ||
| 2605 | pullRequest: { | ||
| 2606 | number: 642, | ||
| 2607 | title: "fix: use commit as the default git action without origin", | ||
| 2608 | url: "https://github.com/pingdotgg/t3code/pull/642", | ||
| 2609 | baseRefName: "main", | ||
| 2610 | headRefName: "fix/git-action-default-without-origin", | ||
| 2611 | state: "open", | ||
| 2612 | isCrossRepository: true, | ||
| 2613 | headRepositoryOwnerLogin: "binbandit", | ||
| 2614 | }, | ||
| 2615 | repositoryCloneUrls: { | ||
| 2616 | "binbandit/t3code": { | ||
| 2617 | url: forkDir, | ||
| 2618 | sshUrl: forkDir, | ||
| 2619 | }, | ||
| 2620 | }, | ||
| 2621 | }, | ||
| 2622 | }); | ||
| 2623 | |||
| 2624 | const result = yield* preparePullRequestThread(manager, { | ||
| 2625 | cwd: repoDir, | ||
| 2626 | reference: "642", | ||
| 2627 | mode: "local", | ||
| 2628 | }); | ||
| 2629 | |||
| 2630 | expect(result.branch).toBe("fix/git-action-default-without-origin"); | ||
| 2631 | expect(result.worktreePath).toBeNull(); | ||
| 2632 | expect( | ||
| 2633 | (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), | ||
| 2634 | ).toBe("binbandit-seed/fix/git-action-default-without-origin"); | ||
| 2635 | }), | ||
| 2636 | ); | ||
| 2637 | |||
| 2638 | it.effect("reuses an existing dedicated worktree for the PR head branch", () => | ||
| 2639 | Effect.gen(function* () { | ||
| 2640 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2641 | yield* initRepo(repoDir); | ||
| 2642 | yield* runGit(repoDir, ["checkout", "-b", "feature/pr-existing-worktree"]); | ||
| 2643 | fs.writeFileSync(path.join(repoDir, "existing.txt"), "existing\n"); | ||
| 2644 | yield* runGit(repoDir, ["add", "existing.txt"]); | ||
| 2645 | yield* runGit(repoDir, ["commit", "-m", "Existing worktree branch"]); | ||
| 2646 | yield* runGit(repoDir, ["checkout", "main"]); | ||
| 2647 | const worktreePath = path.join(repoDir, "..", `pr-existing-${Date.now()}`); | ||
| 2648 | yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-existing-worktree"]); | ||
| 2649 | |||
| 2650 | const setupCalls: ProjectSetupScriptRunnerInput[] = []; | ||
| 2651 | const { manager } = yield* makeManager({ | ||
| 2652 | ghScenario: { | ||
| 2653 | pullRequest: { | ||
| 2654 | number: 78, | ||
| 2655 | title: "Existing worktree PR", | ||
| 2656 | url: "https://github.com/pingdotgg/codething-mvp/pull/78", | ||
| 2657 | baseRefName: "main", | ||
| 2658 | headRefName: "feature/pr-existing-worktree", | ||
| 2659 | state: "open", | ||
| 2660 | }, | ||
| 2661 | }, | ||
| 2662 | setupScriptRunner: { | ||
| 2663 | runForThread: (setupInput) => | ||
| 2664 | Effect.sync(() => { | ||
| 2665 | setupCalls.push(setupInput); | ||
| 2666 | return { status: "no-script" as const }; | ||
| 2667 | }), | ||
| 2668 | }, | ||
| 2669 | }); | ||
| 2670 | |||
| 2671 | const result = yield* preparePullRequestThread(manager, { | ||
| 2672 | cwd: repoDir, | ||
| 2673 | reference: "78", | ||
| 2674 | mode: "worktree", | ||
| 2675 | threadId: asThreadId("thread-pr-existing-worktree"), | ||
| 2676 | }); | ||
| 2677 | |||
| 2678 | expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe( | ||
| 2679 | fs.realpathSync.native(worktreePath), | ||
| 2680 | ); | ||
| 2681 | expect(result.branch).toBe("feature/pr-existing-worktree"); | ||
| 2682 | expect(setupCalls).toHaveLength(0); | ||
| 2683 | }), | ||
| 2684 | ); | ||
| 2685 | |||
| 2686 | it.effect( | ||
| 2687 | "does not block fork PR worktree prep when the fork head branch collides with root main", | ||
| 2688 | () => | ||
| 2689 | Effect.gen(function* () { | ||
| 2690 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2691 | yield* initRepo(repoDir); | ||
| 2692 | const originDir = yield* createBareRemote(); | ||
| 2693 | const forkDir = yield* createBareRemote(); | ||
| 2694 | yield* runGit(repoDir, ["remote", "add", "origin", originDir]); | ||
| 2695 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); | ||
| 2696 | yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); | ||
| 2697 | yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]); | ||
| 2698 | fs.writeFileSync(path.join(repoDir, "fork-main.txt"), "fork main\n"); | ||
| 2699 | yield* runGit(repoDir, ["add", "fork-main.txt"]); | ||
| 2700 | yield* runGit(repoDir, ["commit", "-m", "Fork main branch"]); | ||
| 2701 | yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]); | ||
| 2702 | yield* runGit(repoDir, ["checkout", "main"]); | ||
| 2703 | const mainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim(); | ||
| 2704 | |||
| 2705 | const { manager } = yield* makeManager({ | ||
| 2706 | ghScenario: { | ||
| 2707 | pullRequest: { | ||
| 2708 | number: 91, | ||
| 2709 | title: "Fork main PR", | ||
| 2710 | url: "https://github.com/pingdotgg/codething-mvp/pull/91", | ||
| 2711 | baseRefName: "main", | ||
| 2712 | headRefName: "main", | ||
| 2713 | state: "open", | ||
| 2714 | isCrossRepository: true, | ||
| 2715 | headRepositoryNameWithOwner: "octocat/codething-mvp", | ||
| 2716 | headRepositoryOwnerLogin: "octocat", | ||
| 2717 | }, | ||
| 2718 | repositoryCloneUrls: { | ||
| 2719 | "octocat/codething-mvp": { | ||
| 2720 | url: forkDir, | ||
| 2721 | sshUrl: forkDir, | ||
| 2722 | }, | ||
| 2723 | }, | ||
| 2724 | }, | ||
| 2725 | }); | ||
| 2726 | |||
| 2727 | const result = yield* preparePullRequestThread(manager, { | ||
| 2728 | cwd: repoDir, | ||
| 2729 | reference: "91", | ||
| 2730 | mode: "worktree", | ||
| 2731 | }); | ||
| 2732 | |||
| 2733 | expect(result.branch).toBe("t3code/pr-91/main"); | ||
| 2734 | expect(result.worktreePath).not.toBeNull(); | ||
| 2735 | expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main"); | ||
| 2736 | expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(mainBefore); | ||
| 2737 | expect( | ||
| 2738 | (yield* runGit(result.worktreePath as string, [ | ||
| 2739 | "branch", | ||
| 2740 | "--show-current", | ||
| 2741 | ])).stdout.trim(), | ||
| 2742 | ).toBe("t3code/pr-91/main"); | ||
| 2743 | }), | ||
| 2744 | ); | ||
| 2745 | |||
| 2746 | it.effect( | ||
| 2747 | "does not overwrite an existing local main branch when preparing a fork PR worktree", | ||
| 2748 | () => | ||
| 2749 | Effect.gen(function* () { | ||
| 2750 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2751 | yield* initRepo(repoDir); | ||
| 2752 | const originDir = yield* createBareRemote(); | ||
| 2753 | const forkDir = yield* createBareRemote(); | ||
| 2754 | yield* runGit(repoDir, ["remote", "add", "origin", originDir]); | ||
| 2755 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); | ||
| 2756 | yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); | ||
| 2757 | yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]); | ||
| 2758 | fs.writeFileSync(path.join(repoDir, "fork-main-second.txt"), "fork main second\n"); | ||
| 2759 | yield* runGit(repoDir, ["add", "fork-main-second.txt"]); | ||
| 2760 | yield* runGit(repoDir, ["commit", "-m", "Fork main second branch"]); | ||
| 2761 | yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]); | ||
| 2762 | yield* runGit(repoDir, ["checkout", "main"]); | ||
| 2763 | const localMainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim(); | ||
| 2764 | yield* runGit(repoDir, ["checkout", "-b", "feature/root-branch"]); | ||
| 2765 | |||
| 2766 | const { manager } = yield* makeManager({ | ||
| 2767 | ghScenario: { | ||
| 2768 | pullRequest: { | ||
| 2769 | number: 92, | ||
| 2770 | title: "Fork main overwrite PR", | ||
| 2771 | url: "https://github.com/pingdotgg/codething-mvp/pull/92", | ||
| 2772 | baseRefName: "main", | ||
| 2773 | headRefName: "main", | ||
| 2774 | state: "open", | ||
| 2775 | isCrossRepository: true, | ||
| 2776 | headRepositoryNameWithOwner: "octocat/codething-mvp", | ||
| 2777 | headRepositoryOwnerLogin: "octocat", | ||
| 2778 | }, | ||
| 2779 | repositoryCloneUrls: { | ||
| 2780 | "octocat/codething-mvp": { | ||
| 2781 | url: forkDir, | ||
| 2782 | sshUrl: forkDir, | ||
| 2783 | }, | ||
| 2784 | }, | ||
| 2785 | }, | ||
| 2786 | }); | ||
| 2787 | |||
| 2788 | const result = yield* preparePullRequestThread(manager, { | ||
| 2789 | cwd: repoDir, | ||
| 2790 | reference: "92", | ||
| 2791 | mode: "worktree", | ||
| 2792 | }); | ||
| 2793 | |||
| 2794 | expect(result.branch).toBe("t3code/pr-92/main"); | ||
| 2795 | expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(localMainBefore); | ||
| 2796 | expect( | ||
| 2797 | (yield* runGit(result.worktreePath as string, [ | ||
| 2798 | "rev-parse", | ||
| 2799 | "--abbrev-ref", | ||
| 2800 | "@{upstream}", | ||
| 2801 | ])).stdout.trim(), | ||
| 2802 | ).toBe("fork-seed/main"); | ||
| 2803 | }), | ||
| 2804 | ); | ||
| 2805 | |||
| 2806 | it.effect("reuses an existing PR worktree and restores fork upstream tracking", () => | ||
| 2807 | Effect.gen(function* () { | ||
| 2808 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2809 | yield* initRepo(repoDir); | ||
| 2810 | const originDir = yield* createBareRemote(); | ||
| 2811 | const forkDir = yield* createBareRemote(); | ||
| 2812 | yield* runGit(repoDir, ["remote", "add", "origin", originDir]); | ||
| 2813 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); | ||
| 2814 | yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); | ||
| 2815 | yield* runGit(repoDir, ["checkout", "-b", "feature/pr-reused-fork"]); | ||
| 2816 | fs.writeFileSync(path.join(repoDir, "reused-fork.txt"), "reused fork\n"); | ||
| 2817 | yield* runGit(repoDir, ["add", "reused-fork.txt"]); | ||
| 2818 | yield* runGit(repoDir, ["commit", "-m", "Reused fork PR branch"]); | ||
| 2819 | yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-reused-fork"]); | ||
| 2820 | yield* runGit(repoDir, ["checkout", "main"]); | ||
| 2821 | const worktreePath = path.join(repoDir, "..", `pr-reused-fork-${Date.now()}`); | ||
| 2822 | yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-fork"]); | ||
| 2823 | yield* runGit(worktreePath, ["branch", "--unset-upstream"], true); | ||
| 2824 | |||
| 2825 | const { manager } = yield* makeManager({ | ||
| 2826 | ghScenario: { | ||
| 2827 | pullRequest: { | ||
| 2828 | number: 83, | ||
| 2829 | title: "Reused Fork PR", | ||
| 2830 | url: "https://github.com/pingdotgg/codething-mvp/pull/83", | ||
| 2831 | baseRefName: "main", | ||
| 2832 | headRefName: "feature/pr-reused-fork", | ||
| 2833 | state: "open", | ||
| 2834 | isCrossRepository: true, | ||
| 2835 | headRepositoryNameWithOwner: "octocat/codething-mvp", | ||
| 2836 | headRepositoryOwnerLogin: "octocat", | ||
| 2837 | }, | ||
| 2838 | repositoryCloneUrls: { | ||
| 2839 | "octocat/codething-mvp": { | ||
| 2840 | url: forkDir, | ||
| 2841 | sshUrl: forkDir, | ||
| 2842 | }, | ||
| 2843 | }, | ||
| 2844 | }, | ||
| 2845 | }); | ||
| 2846 | |||
| 2847 | const result = yield* preparePullRequestThread(manager, { | ||
| 2848 | cwd: repoDir, | ||
| 2849 | reference: "83", | ||
| 2850 | mode: "worktree", | ||
| 2851 | }); | ||
| 2852 | |||
| 2853 | expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe( | ||
| 2854 | fs.realpathSync.native(worktreePath), | ||
| 2855 | ); | ||
| 2856 | expect( | ||
| 2857 | (yield* runGit(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), | ||
| 2858 | ).toBe("fork-seed/feature/pr-reused-fork"); | ||
| 2859 | }), | ||
| 2860 | ); | ||
| 2861 | |||
| 2862 | it.effect("does not fail PR worktree prep when setup terminal startup fails", () => | ||
| 2863 | Effect.gen(function* () { | ||
| 2864 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2865 | yield* initRepo(repoDir); | ||
| 2866 | const remoteDir = yield* createBareRemote(); | ||
| 2867 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 2868 | yield* runGit(repoDir, ["push", "-u", "origin", "main"]); | ||
| 2869 | yield* runGit(repoDir, ["checkout", "-b", "feature/pr-setup-failure"]); | ||
| 2870 | fs.writeFileSync(path.join(repoDir, "setup-failure.txt"), "setup failure\n"); | ||
| 2871 | yield* runGit(repoDir, ["add", "setup-failure.txt"]); | ||
| 2872 | yield* runGit(repoDir, ["commit", "-m", "PR setup failure branch"]); | ||
| 2873 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-setup-failure"]); | ||
| 2874 | yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/184/head"]); | ||
| 2875 | yield* runGit(repoDir, ["checkout", "main"]); | ||
| 2876 | |||
| 2877 | const { manager } = yield* makeManager({ | ||
| 2878 | ghScenario: { | ||
| 2879 | pullRequest: { | ||
| 2880 | number: 184, | ||
| 2881 | title: "Setup failure PR", | ||
| 2882 | url: "https://github.com/pingdotgg/codething-mvp/pull/184", | ||
| 2883 | baseRefName: "main", | ||
| 2884 | headRefName: "feature/pr-setup-failure", | ||
| 2885 | state: "open", | ||
| 2886 | }, | ||
| 2887 | }, | ||
| 2888 | setupScriptRunner: { | ||
| 2889 | runForThread: () => Effect.fail(new Error("terminal start failed")), | ||
| 2890 | }, | ||
| 2891 | }); | ||
| 2892 | |||
| 2893 | const result = yield* preparePullRequestThread(manager, { | ||
| 2894 | cwd: repoDir, | ||
| 2895 | reference: "184", | ||
| 2896 | mode: "worktree", | ||
| 2897 | threadId: asThreadId("thread-pr-setup-failure"), | ||
| 2898 | }); | ||
| 2899 | |||
| 2900 | expect(result.branch).toBe("feature/pr-setup-failure"); | ||
| 2901 | expect(result.worktreePath).not.toBeNull(); | ||
| 2902 | expect(fs.existsSync(result.worktreePath as string)).toBe(true); | ||
| 2903 | }), | ||
| 2904 | ); | ||
| 2905 | |||
| 2906 | it.effect("rejects worktree prep when the PR head branch is checked out in the main repo", () => | ||
| 2907 | Effect.gen(function* () { | ||
| 2908 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2909 | yield* initRepo(repoDir); | ||
| 2910 | yield* runGit(repoDir, ["checkout", "-b", "feature/pr-root-only"]); | ||
| 2911 | |||
| 2912 | const { manager } = yield* makeManager({ | ||
| 2913 | ghScenario: { | ||
| 2914 | pullRequest: { | ||
| 2915 | number: 79, | ||
| 2916 | title: "Root-only PR", | ||
| 2917 | url: "https://github.com/pingdotgg/codething-mvp/pull/79", | ||
| 2918 | baseRefName: "main", | ||
| 2919 | headRefName: "feature/pr-root-only", | ||
| 2920 | state: "open", | ||
| 2921 | }, | ||
| 2922 | }, | ||
| 2923 | }); | ||
| 2924 | |||
| 2925 | const errorMessage = yield* preparePullRequestThread(manager, { | ||
| 2926 | cwd: repoDir, | ||
| 2927 | reference: "79", | ||
| 2928 | mode: "worktree", | ||
| 2929 | }).pipe( | ||
| 2930 | Effect.flip, | ||
| 2931 | Effect.map((error) => error.message), | ||
| 2932 | ); | ||
| 2933 | |||
| 2934 | expect(errorMessage).toContain("already checked out in the main repo"); | ||
| 2935 | }), | ||
| 2936 | ); | ||
| 2937 | |||
| 2938 | it.effect("emits ordered progress events for commit hooks", () => | ||
| 2939 | Effect.gen(function* () { | ||
| 2940 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 2941 | yield* initRepo(repoDir); | ||
| 2942 | fs.writeFileSync(path.join(repoDir, "hooked.txt"), "hooked\n"); | ||
| 2943 | fs.writeFileSync( | ||
| 2944 | path.join(repoDir, ".git", "hooks", "pre-commit"), | ||
| 2945 | '#!/bin/sh\necho "hook: start" >&2\nsleep 1\necho "hook: end" >&2\n', | ||
| 2946 | { mode: 0o755 }, | ||
| 2947 | ); | ||
| 2948 | |||
| 2949 | const { manager } = yield* makeManager(); | ||
| 2950 | const events: GitActionProgressEvent[] = []; | ||
| 2951 | |||
| 2952 | const result = yield* runStackedAction( | ||
| 2953 | manager, | ||
| 2954 | { | ||
| 2955 | cwd: repoDir, | ||
| 2956 | action: "commit", | ||
| 2957 | }, | ||
| 2958 | { | ||
| 2959 | actionId: "action-1", | ||
| 2960 | progressReporter: { | ||
| 2961 | publish: (event) => | ||
| 2962 | Effect.sync(() => { | ||
| 2963 | events.push(event); | ||
| 2964 | }), | ||
| 2965 | }, | ||
| 2966 | }, | ||
| 2967 | ); | ||
| 2968 | |||
| 2969 | expect(result.commit.status).toBe("created"); | ||
| 2970 | expect(events.map((event) => event.kind)).toContain("action_started"); | ||
| 2971 | expect(events).toEqual( | ||
| 2972 | expect.arrayContaining([ | ||
| 2973 | expect.objectContaining({ | ||
| 2974 | kind: "phase_started", | ||
| 2975 | phase: "commit", | ||
| 2976 | }), | ||
| 2977 | expect.objectContaining({ | ||
| 2978 | kind: "hook_started", | ||
| 2979 | hookName: "pre-commit", | ||
| 2980 | }), | ||
| 2981 | expect.objectContaining({ | ||
| 2982 | kind: "hook_output", | ||
| 2983 | text: "hook: start", | ||
| 2984 | }), | ||
| 2985 | expect.objectContaining({ | ||
| 2986 | kind: "hook_output", | ||
| 2987 | text: "hook: end", | ||
| 2988 | }), | ||
| 2989 | expect.objectContaining({ | ||
| 2990 | kind: "hook_finished", | ||
| 2991 | hookName: "pre-commit", | ||
| 2992 | }), | ||
| 2993 | expect.objectContaining({ | ||
| 2994 | kind: "action_finished", | ||
| 2995 | }), | ||
| 2996 | ]), | ||
| 2997 | ); | ||
| 2998 | }), | ||
| 2999 | ); | ||
| 3000 | |||
| 3001 | it.effect("emits action_failed when a commit hook rejects", () => | ||
| 3002 | Effect.gen(function* () { | ||
| 3003 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 3004 | yield* initRepo(repoDir); | ||
| 3005 | fs.writeFileSync(path.join(repoDir, "hook-failure.txt"), "broken\n"); | ||
| 3006 | fs.writeFileSync( | ||
| 3007 | path.join(repoDir, ".git", "hooks", "pre-commit"), | ||
| 3008 | '#!/bin/sh\necho "hook: fail" >&2\nexit 1\n', | ||
| 3009 | { mode: 0o755 }, | ||
| 3010 | ); | ||
| 3011 | |||
| 3012 | const { manager } = yield* makeManager(); | ||
| 3013 | const events: GitActionProgressEvent[] = []; | ||
| 3014 | |||
| 3015 | const errorMessage = yield* runStackedAction( | ||
| 3016 | manager, | ||
| 3017 | { | ||
| 3018 | cwd: repoDir, | ||
| 3019 | action: "commit", | ||
| 3020 | }, | ||
| 3021 | { | ||
| 3022 | actionId: "action-2", | ||
| 3023 | progressReporter: { | ||
| 3024 | publish: (event) => | ||
| 3025 | Effect.sync(() => { | ||
| 3026 | events.push(event); | ||
| 3027 | }), | ||
| 3028 | }, | ||
| 3029 | }, | ||
| 3030 | ).pipe( | ||
| 3031 | Effect.flip, | ||
| 3032 | Effect.map((error) => error.message), | ||
| 3033 | ); | ||
| 3034 | |||
| 3035 | expect(errorMessage).toContain("hook: fail"); | ||
| 3036 | expect(events).toEqual( | ||
| 3037 | expect.arrayContaining([ | ||
| 3038 | expect.objectContaining({ | ||
| 3039 | kind: "hook_started", | ||
| 3040 | hookName: "pre-commit", | ||
| 3041 | }), | ||
| 3042 | expect.objectContaining({ | ||
| 3043 | kind: "action_failed", | ||
| 3044 | phase: "commit", | ||
| 3045 | }), | ||
| 3046 | ]), | ||
| 3047 | ); | ||
| 3048 | }), | ||
| 3049 | ); | ||
| 3050 | |||
| 3051 | it.effect("create_pr emits only the PR phase when the branch is already pushed", () => | ||
| 3052 | Effect.gen(function* () { | ||
| 3053 | const repoDir = yield* makeTempDir("t3code-git-manager-"); | ||
| 3054 | yield* initRepo(repoDir); | ||
| 3055 | yield* runGit(repoDir, ["checkout", "-b", "feature/pr-only-follow-up"]); | ||
| 3056 | const remoteDir = yield* createBareRemote(); | ||
| 3057 | yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); | ||
| 3058 | fs.writeFileSync(path.join(repoDir, "pr-only.txt"), "pr only\n"); | ||
| 3059 | yield* runGit(repoDir, ["add", "pr-only.txt"]); | ||
| 3060 | yield* runGit(repoDir, ["commit", "-m", "PR only branch"]); | ||
| 3061 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-only-follow-up"]); | ||
| 3062 | |||
| 3063 | const { manager } = yield* makeManager({ | ||
| 3064 | ghScenario: { | ||
| 3065 | prListSequence: [ | ||
| 3066 | JSON.stringify([]), | ||
| 3067 | JSON.stringify([ | ||
| 3068 | { | ||
| 3069 | number: 201, | ||
| 3070 | title: "PR only branch", | ||
| 3071 | url: "https://github.com/pingdotgg/codething-mvp/pull/201", | ||
| 3072 | baseRefName: "main", | ||
| 3073 | headRefName: "feature/pr-only-follow-up", | ||
| 3074 | state: "OPEN", | ||
| 3075 | isCrossRepository: false, | ||
| 3076 | }, | ||
| 3077 | ]), | ||
| 3078 | ], | ||
| 3079 | }, | ||
| 3080 | }); | ||
| 3081 | const events: GitActionProgressEvent[] = []; | ||
| 3082 | |||
| 3083 | const result = yield* runStackedAction( | ||
| 3084 | manager, | ||
| 3085 | { | ||
| 3086 | cwd: repoDir, | ||
| 3087 | action: "create_pr", | ||
| 3088 | }, | ||
| 3089 | { | ||
| 3090 | actionId: "action-pr-only", | ||
| 3091 | progressReporter: { | ||
| 3092 | publish: (event) => | ||
| 3093 | Effect.sync(() => { | ||
| 3094 | events.push(event); | ||
| 3095 | }), | ||
| 3096 | }, | ||
| 3097 | }, | ||
| 3098 | ); | ||
| 3099 | |||
| 3100 | expect(result.commit.status).toBe("skipped_not_requested"); | ||
| 3101 | expect(result.push.status).toBe("skipped_not_requested"); | ||
| 3102 | expect(result.pr.status).toBe("created"); | ||
| 3103 | expect( | ||
| 3104 | events.filter( | ||
| 3105 | (event): event is Extract<GitActionProgressEvent, { kind: "phase_started" }> => | ||
| 3106 | event.kind === "phase_started", | ||
| 3107 | ), | ||
| 3108 | ).toEqual([ | ||
| 3109 | expect.objectContaining({ | ||
| 3110 | kind: "phase_started", | ||
| 3111 | phase: "pr", | ||
| 3112 | label: "Preparing PR...", | ||
| 3113 | }), | ||
| 3114 | expect.objectContaining({ | ||
| 3115 | kind: "phase_started", | ||
| 3116 | phase: "pr", | ||
| 3117 | label: "Generating PR content...", | ||
| 3118 | }), | ||
| 3119 | expect.objectContaining({ | ||
| 3120 | kind: "phase_started", | ||
| 3121 | phase: "pr", | ||
| 3122 | label: "Creating GitHub pull request...", | ||
| 3123 | }), | ||
| 3124 | ]); | ||
| 3125 | }), | ||
| 3126 | ); | ||
| 3127 | }); | ||
apps/server/src/git/Layers/GitManager.ts deleted-1740| ... | @@ -1,1740 +0,0 @@ | ||
| 1 | import { randomUUID } from "node:crypto"; | ||
| 2 | import { realpathSync } from "node:fs"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | Cache, | ||
| 6 | Duration, | ||
| 7 | Effect, | ||
| 8 | Exit, | ||
| 9 | FileSystem, | ||
| 10 | Layer, | ||
| 11 | Option, | ||
| 12 | Path, | ||
| 13 | Ref, | ||
| 14 | Result, | ||
| 15 | } from "effect"; | ||
| 16 | import { | ||
| 17 | GitActionProgressEvent, | ||
| 18 | GitActionProgressPhase, | ||
| 19 | GitCommandError, | ||
| 20 | GitRunStackedActionResult, | ||
| 21 | GitStackedAction, | ||
| 22 | type GitStatusLocalResult, | ||
| 23 | type GitStatusRemoteResult, | ||
| 24 | ModelSelection, | ||
| 25 | } from "@t3tools/contracts"; | ||
| 26 | import { | ||
| 27 | detectGitHostingProviderFromRemoteUrl, | ||
| 28 | mergeGitStatusParts, | ||
| 29 | resolveAutoFeatureBranchName, | ||
| 30 | sanitizeBranchFragment, | ||
| 31 | sanitizeFeatureBranchName, | ||
| 32 | } from "@t3tools/shared/git"; | ||
| 33 | |||
| 34 | import { GitManagerError } from "@t3tools/contracts"; | ||
| 35 | import { | ||
| 36 | GitManager, | ||
| 37 | type GitActionProgressReporter, | ||
| 38 | type GitManagerShape, | ||
| 39 | type GitRunStackedActionOptions, | ||
| 40 | } from "../Services/GitManager.ts"; | ||
| 41 | import { GitCore } from "../Services/GitCore.ts"; | ||
| 42 | import type { GitStatusDetails } from "../Services/GitCore.ts"; | ||
| 43 | import { GitHubCli, type GitHubPullRequestSummary } from "../Services/GitHubCli.ts"; | ||
| 44 | import { TextGeneration } from "../Services/TextGeneration.ts"; | ||
| 45 | import { ProjectSetupScriptRunner } from "../../project/Services/ProjectSetupScriptRunner.ts"; | ||
| 46 | import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts"; | ||
| 47 | import { ServerSettingsService } from "../../serverSettings.ts"; | ||
| 48 | import type { GitManagerServiceError } from "@t3tools/contracts"; | ||
| 49 | import { | ||
| 50 | decodeGitHubPullRequestListJson, | ||
| 51 | formatGitHubJsonDecodeError, | ||
| 52 | } from "../githubPullRequests.ts"; | ||
| 53 | |||
| 54 | const COMMIT_TIMEOUT_MS = 10 * 60_000; | ||
| 55 | const MAX_PROGRESS_TEXT_LENGTH = 500; | ||
| 56 | const SHORT_SHA_LENGTH = 7; | ||
| 57 | const TOAST_DESCRIPTION_MAX = 72; | ||
| 58 | const STATUS_RESULT_CACHE_TTL = Duration.seconds(1); | ||
| 59 | const STATUS_RESULT_CACHE_CAPACITY = 2_048; | ||
| 60 | type StripProgressContext<T> = T extends any ? Omit<T, "actionId" | "cwd" | "action"> : never; | ||
| 61 | type GitActionProgressPayload = StripProgressContext<GitActionProgressEvent>; | ||
| 62 | type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect<void, never>; | ||
| 63 | |||
| 64 | function isNotGitRepositoryError(error: GitCommandError): boolean { | ||
| 65 | return error.message.toLowerCase().includes("not a git repository"); | ||
| 66 | } | ||
| 67 | |||
| 68 | interface OpenPrInfo { | ||
| 69 | number: number; | ||
| 70 | title: string; | ||
| 71 | url: string; | ||
| 72 | baseRefName: string; | ||
| 73 | headRefName: string; | ||
| 74 | } | ||
| 75 | |||
| 76 | interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { | ||
| 77 | state: "open" | "closed" | "merged"; | ||
| 78 | updatedAt: string | null; | ||
| 79 | } | ||
| 80 | |||
| 81 | interface ResolvedPullRequest { | ||
| 82 | number: number; | ||
| 83 | title: string; | ||
| 84 | url: string; | ||
| 85 | baseBranch: string; | ||
| 86 | headBranch: string; | ||
| 87 | state: "open" | "closed" | "merged"; | ||
| 88 | } | ||
| 89 | |||
| 90 | interface PullRequestHeadRemoteInfo { | ||
| 91 | isCrossRepository?: boolean; | ||
| 92 | headRepositoryNameWithOwner?: string | null; | ||
| 93 | headRepositoryOwnerLogin?: string | null; | ||
| 94 | } | ||
| 95 | |||
| 96 | interface BranchHeadContext { | ||
| 97 | localBranch: string; | ||
| 98 | headBranch: string; | ||
| 99 | headSelectors: ReadonlyArray<string>; | ||
| 100 | preferredHeadSelector: string; | ||
| 101 | remoteName: string | null; | ||
| 102 | headRepositoryNameWithOwner: string | null; | ||
| 103 | headRepositoryOwnerLogin: string | null; | ||
| 104 | isCrossRepository: boolean; | ||
| 105 | } | ||
| 106 | |||
| 107 | function parseRepositoryNameFromPullRequestUrl(url: string): string | null { | ||
| 108 | const trimmed = url.trim(); | ||
| 109 | const match = /^https:\/\/github\.com\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed); | ||
| 110 | const repositoryName = match?.[1]?.trim() ?? ""; | ||
| 111 | return repositoryName.length > 0 ? repositoryName : null; | ||
| 112 | } | ||
| 113 | |||
| 114 | function resolveHeadRepositoryNameWithOwner( | ||
| 115 | pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, | ||
| 116 | ): string | null { | ||
| 117 | const explicitRepository = pullRequest.headRepositoryNameWithOwner?.trim() ?? ""; | ||
| 118 | if (explicitRepository.length > 0) { | ||
| 119 | return explicitRepository; | ||
| 120 | } | ||
| 121 | |||
| 122 | if (!pullRequest.isCrossRepository) { | ||
| 123 | return null; | ||
| 124 | } | ||
| 125 | |||
| 126 | const ownerLogin = pullRequest.headRepositoryOwnerLogin?.trim() ?? ""; | ||
| 127 | const repositoryName = parseRepositoryNameFromPullRequestUrl(pullRequest.url); | ||
| 128 | if (ownerLogin.length === 0 || !repositoryName) { | ||
| 129 | return null; | ||
| 130 | } | ||
| 131 | |||
| 132 | return `${ownerLogin}/${repositoryName}`; | ||
| 133 | } | ||
| 134 | |||
| 135 | function resolvePullRequestWorktreeLocalBranchName( | ||
| 136 | pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, | ||
| 137 | ): string { | ||
| 138 | if (!pullRequest.isCrossRepository) { | ||
| 139 | return pullRequest.headBranch; | ||
| 140 | } | ||
| 141 | |||
| 142 | const sanitizedHeadBranch = sanitizeBranchFragment(pullRequest.headBranch).trim(); | ||
| 143 | const suffix = sanitizedHeadBranch.length > 0 ? sanitizedHeadBranch : "head"; | ||
| 144 | return `t3code/pr-${pullRequest.number}/${suffix}`; | ||
| 145 | } | ||
| 146 | |||
| 147 | function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { | ||
| 148 | const trimmed = url?.trim() ?? ""; | ||
| 149 | if (trimmed.length === 0) { | ||
| 150 | return null; | ||
| 151 | } | ||
| 152 | |||
| 153 | const match = | ||
| 154 | /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( | ||
| 155 | trimmed, | ||
| 156 | ); | ||
| 157 | const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; | ||
| 158 | return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null; | ||
| 159 | } | ||
| 160 | |||
| 161 | function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null { | ||
| 162 | const trimmed = nameWithOwner?.trim() ?? ""; | ||
| 163 | if (trimmed.length === 0) { | ||
| 164 | return null; | ||
| 165 | } | ||
| 166 | const [ownerLogin] = trimmed.split("/"); | ||
| 167 | const normalizedOwnerLogin = ownerLogin?.trim() ?? ""; | ||
| 168 | return normalizedOwnerLogin.length > 0 ? normalizedOwnerLogin : null; | ||
| 169 | } | ||
| 170 | |||
| 171 | function normalizeOptionalString(value: string | null | undefined): string | null { | ||
| 172 | const trimmed = value?.trim() ?? ""; | ||
| 173 | return trimmed.length > 0 ? trimmed : null; | ||
| 174 | } | ||
| 175 | |||
| 176 | function normalizeOptionalRepositoryNameWithOwner(value: string | null | undefined): string | null { | ||
| 177 | const normalized = normalizeOptionalString(value); | ||
| 178 | return normalized ? normalized.toLowerCase() : null; | ||
| 179 | } | ||
| 180 | |||
| 181 | function normalizeOptionalOwnerLogin(value: string | null | undefined): string | null { | ||
| 182 | const normalized = normalizeOptionalString(value); | ||
| 183 | return normalized ? normalized.toLowerCase() : null; | ||
| 184 | } | ||
| 185 | |||
| 186 | function resolvePullRequestHeadRepositoryNameWithOwner( | ||
| 187 | pr: PullRequestHeadRemoteInfo & { url: string }, | ||
| 188 | ) { | ||
| 189 | const explicitRepository = normalizeOptionalString(pr.headRepositoryNameWithOwner); | ||
| 190 | if (explicitRepository) { | ||
| 191 | return explicitRepository; | ||
| 192 | } | ||
| 193 | |||
| 194 | if (!pr.isCrossRepository) { | ||
| 195 | return null; | ||
| 196 | } | ||
| 197 | |||
| 198 | const ownerLogin = normalizeOptionalString(pr.headRepositoryOwnerLogin); | ||
| 199 | const repositoryName = parseRepositoryNameFromPullRequestUrl(pr.url); | ||
| 200 | if (!ownerLogin || !repositoryName) { | ||
| 201 | return null; | ||
| 202 | } | ||
| 203 | |||
| 204 | return `${ownerLogin}/${repositoryName}`; | ||
| 205 | } | ||
| 206 | |||
| 207 | function matchesBranchHeadContext( | ||
| 208 | pr: PullRequestInfo, | ||
| 209 | headContext: Pick< | ||
| 210 | BranchHeadContext, | ||
| 211 | "headBranch" | "headRepositoryNameWithOwner" | "headRepositoryOwnerLogin" | "isCrossRepository" | ||
| 212 | >, | ||
| 213 | ): boolean { | ||
| 214 | if (pr.headRefName !== headContext.headBranch) { | ||
| 215 | return false; | ||
| 216 | } | ||
| 217 | |||
| 218 | const expectedHeadRepository = normalizeOptionalRepositoryNameWithOwner( | ||
| 219 | headContext.headRepositoryNameWithOwner, | ||
| 220 | ); | ||
| 221 | const expectedHeadOwner = | ||
| 222 | normalizeOptionalOwnerLogin(headContext.headRepositoryOwnerLogin) ?? | ||
| 223 | parseRepositoryOwnerLogin(expectedHeadRepository); | ||
| 224 | const prHeadRepository = normalizeOptionalRepositoryNameWithOwner( | ||
| 225 | resolvePullRequestHeadRepositoryNameWithOwner(pr), | ||
| 226 | ); | ||
| 227 | const prHeadOwner = | ||
| 228 | normalizeOptionalOwnerLogin(pr.headRepositoryOwnerLogin) ?? | ||
| 229 | parseRepositoryOwnerLogin(prHeadRepository); | ||
| 230 | |||
| 231 | if (headContext.isCrossRepository) { | ||
| 232 | if (pr.isCrossRepository === false) { | ||
| 233 | return false; | ||
| 234 | } | ||
| 235 | if ((expectedHeadRepository || expectedHeadOwner) && !prHeadRepository && !prHeadOwner) { | ||
| 236 | return false; | ||
| 237 | } | ||
| 238 | if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) { | ||
| 239 | return false; | ||
| 240 | } | ||
| 241 | if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) { | ||
| 242 | return false; | ||
| 243 | } | ||
| 244 | return true; | ||
| 245 | } | ||
| 246 | |||
| 247 | if (pr.isCrossRepository === true) { | ||
| 248 | return false; | ||
| 249 | } | ||
| 250 | if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) { | ||
| 251 | return false; | ||
| 252 | } | ||
| 253 | if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) { | ||
| 254 | return false; | ||
| 255 | } | ||
| 256 | return true; | ||
| 257 | } | ||
| 258 | |||
| 259 | function toPullRequestInfo(summary: GitHubPullRequestSummary): PullRequestInfo { | ||
| 260 | return { | ||
| 261 | number: summary.number, | ||
| 262 | title: summary.title, | ||
| 263 | url: summary.url, | ||
| 264 | baseRefName: summary.baseRefName, | ||
| 265 | headRefName: summary.headRefName, | ||
| 266 | state: summary.state ?? "open", | ||
| 267 | updatedAt: null, | ||
| 268 | ...(summary.isCrossRepository !== undefined | ||
| 269 | ? { isCrossRepository: summary.isCrossRepository } | ||
| 270 | : {}), | ||
| 271 | ...(summary.headRepositoryNameWithOwner !== undefined | ||
| 272 | ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } | ||
| 273 | : {}), | ||
| 274 | ...(summary.headRepositoryOwnerLogin !== undefined | ||
| 275 | ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } | ||
| 276 | : {}), | ||
| 277 | }; | ||
| 278 | } | ||
| 279 | |||
| 280 | function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError { | ||
| 281 | return new GitManagerError({ | ||
| 282 | operation, | ||
| 283 | detail, | ||
| 284 | ...(cause !== undefined ? { cause } : {}), | ||
| 285 | }); | ||
| 286 | } | ||
| 287 | |||
| 288 | function limitContext(value: string, maxChars: number): string { | ||
| 289 | if (value.length <= maxChars) return value; | ||
| 290 | return `${value.slice(0, maxChars)}\n\n[truncated]`; | ||
| 291 | } | ||
| 292 | |||
| 293 | function shortenSha(sha: string | undefined): string | null { | ||
| 294 | if (!sha) return null; | ||
| 295 | return sha.slice(0, SHORT_SHA_LENGTH); | ||
| 296 | } | ||
| 297 | |||
| 298 | function truncateText( | ||
| 299 | value: string | undefined, | ||
| 300 | maxLength = TOAST_DESCRIPTION_MAX, | ||
| 301 | ): string | undefined { | ||
| 302 | if (!value) return undefined; | ||
| 303 | if (value.length <= maxLength) return value; | ||
| 304 | if (maxLength <= 3) return "...".slice(0, maxLength); | ||
| 305 | return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; | ||
| 306 | } | ||
| 307 | |||
| 308 | function withDescription(title: string, description: string | undefined) { | ||
| 309 | return description ? { title, description } : { title }; | ||
| 310 | } | ||
| 311 | |||
| 312 | function summarizeGitActionResult( | ||
| 313 | result: Pick<GitRunStackedActionResult, "commit" | "push" | "pr">, | ||
| 314 | ): { | ||
| 315 | title: string; | ||
| 316 | description?: string; | ||
| 317 | } { | ||
| 318 | if (result.pr.status === "created" || result.pr.status === "opened_existing") { | ||
| 319 | const prNumber = result.pr.number ? ` #${result.pr.number}` : ""; | ||
| 320 | const title = `${result.pr.status === "created" ? "Created PR" : "Opened PR"}${prNumber}`; | ||
| 321 | return withDescription(title, truncateText(result.pr.title)); | ||
| 322 | } | ||
| 323 | |||
| 324 | if (result.push.status === "pushed") { | ||
| 325 | const shortSha = shortenSha(result.commit.commitSha); | ||
| 326 | const branch = result.push.upstreamBranch ?? result.push.branch; | ||
| 327 | const pushedCommitPart = shortSha ? ` ${shortSha}` : ""; | ||
| 328 | const branchPart = branch ? ` to ${branch}` : ""; | ||
| 329 | return withDescription( | ||
| 330 | `Pushed${pushedCommitPart}${branchPart}`, | ||
| 331 | truncateText(result.commit.subject), | ||
| 332 | ); | ||
| 333 | } | ||
| 334 | |||
| 335 | if (result.commit.status === "created") { | ||
| 336 | const shortSha = shortenSha(result.commit.commitSha); | ||
| 337 | const title = shortSha ? `Committed ${shortSha}` : "Committed changes"; | ||
| 338 | return withDescription(title, truncateText(result.commit.subject)); | ||
| 339 | } | ||
| 340 | |||
| 341 | return { title: "Done" }; | ||
| 342 | } | ||
| 343 | |||
| 344 | function sanitizeCommitMessage(generated: { | ||
| 345 | subject: string; | ||
| 346 | body: string; | ||
| 347 | branch?: string | undefined; | ||
| 348 | }): { | ||
| 349 | subject: string; | ||
| 350 | body: string; | ||
| 351 | branch?: string | undefined; | ||
| 352 | } { | ||
| 353 | const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? ""; | ||
| 354 | const subject = rawSubject.replace(/[.]+$/g, "").trim(); | ||
| 355 | const safeSubject = subject.length > 0 ? subject.slice(0, 72).trimEnd() : "Update project files"; | ||
| 356 | return { | ||
| 357 | subject: safeSubject, | ||
| 358 | body: generated.body.trim(), | ||
| 359 | ...(generated.branch !== undefined ? { branch: generated.branch } : {}), | ||
| 360 | }; | ||
| 361 | } | ||
| 362 | |||
| 363 | function sanitizeProgressText(value: string): string | null { | ||
| 364 | const trimmed = value.trim(); | ||
| 365 | if (trimmed.length === 0) { | ||
| 366 | return null; | ||
| 367 | } | ||
| 368 | if (trimmed.length <= MAX_PROGRESS_TEXT_LENGTH) { | ||
| 369 | return trimmed; | ||
| 370 | } | ||
| 371 | return trimmed.slice(0, MAX_PROGRESS_TEXT_LENGTH).trimEnd(); | ||
| 372 | } | ||
| 373 | |||
| 374 | interface CommitAndBranchSuggestion { | ||
| 375 | subject: string; | ||
| 376 | body: string; | ||
| 377 | branch?: string | undefined; | ||
| 378 | commitMessage: string; | ||
| 379 | } | ||
| 380 | |||
| 381 | function isCommitAction( | ||
| 382 | action: GitStackedAction, | ||
| 383 | ): action is "commit" | "commit_push" | "commit_push_pr" { | ||
| 384 | return action === "commit" || action === "commit_push" || action === "commit_push_pr"; | ||
| 385 | } | ||
| 386 | |||
| 387 | function formatCommitMessage(subject: string, body: string): string { | ||
| 388 | const trimmedBody = body.trim(); | ||
| 389 | if (trimmedBody.length === 0) { | ||
| 390 | return subject; | ||
| 391 | } | ||
| 392 | return `${subject}\n\n${trimmedBody}`; | ||
| 393 | } | ||
| 394 | |||
| 395 | function parseCustomCommitMessage(raw: string): { subject: string; body: string } | null { | ||
| 396 | const normalized = raw.replace(/\r\n/g, "\n").trim(); | ||
| 397 | if (normalized.length === 0) { | ||
| 398 | return null; | ||
| 399 | } | ||
| 400 | |||
| 401 | const [firstLine, ...rest] = normalized.split("\n"); | ||
| 402 | const subject = firstLine?.trim() ?? ""; | ||
| 403 | if (subject.length === 0) { | ||
| 404 | return null; | ||
| 405 | } | ||
| 406 | |||
| 407 | return { | ||
| 408 | subject, | ||
| 409 | body: rest.join("\n").trim(), | ||
| 410 | }; | ||
| 411 | } | ||
| 412 | |||
| 413 | function appendUnique(values: string[], next: string | null | undefined): void { | ||
| 414 | const trimmed = next?.trim() ?? ""; | ||
| 415 | if (trimmed.length === 0 || values.includes(trimmed)) { | ||
| 416 | return; | ||
| 417 | } | ||
| 418 | values.push(trimmed); | ||
| 419 | } | ||
| 420 | |||
| 421 | function toStatusPr(pr: PullRequestInfo): { | ||
| 422 | number: number; | ||
| 423 | title: string; | ||
| 424 | url: string; | ||
| 425 | baseBranch: string; | ||
| 426 | headBranch: string; | ||
| 427 | state: "open" | "closed" | "merged"; | ||
| 428 | } { | ||
| 429 | return { | ||
| 430 | number: pr.number, | ||
| 431 | title: pr.title, | ||
| 432 | url: pr.url, | ||
| 433 | baseBranch: pr.baseRefName, | ||
| 434 | headBranch: pr.headRefName, | ||
| 435 | state: pr.state, | ||
| 436 | }; | ||
| 437 | } | ||
| 438 | |||
| 439 | function normalizePullRequestReference(reference: string): string { | ||
| 440 | const trimmed = reference.trim(); | ||
| 441 | const hashNumber = /^#(\d+)$/.exec(trimmed); | ||
| 442 | return hashNumber?.[1] ?? trimmed; | ||
| 443 | } | ||
| 444 | |||
| 445 | function canonicalizeExistingPath(value: string): string { | ||
| 446 | try { | ||
| 447 | return realpathSync.native(value); | ||
| 448 | } catch { | ||
| 449 | return value; | ||
| 450 | } | ||
| 451 | } | ||
| 452 | |||
| 453 | function toResolvedPullRequest(pr: { | ||
| 454 | number: number; | ||
| 455 | title: string; | ||
| 456 | url: string; | ||
| 457 | baseRefName: string; | ||
| 458 | headRefName: string; | ||
| 459 | state?: "open" | "closed" | "merged"; | ||
| 460 | }): ResolvedPullRequest { | ||
| 461 | return { | ||
| 462 | number: pr.number, | ||
| 463 | title: pr.title, | ||
| 464 | url: pr.url, | ||
| 465 | baseBranch: pr.baseRefName, | ||
| 466 | headBranch: pr.headRefName, | ||
| 467 | state: pr.state ?? "open", | ||
| 468 | }; | ||
| 469 | } | ||
| 470 | |||
| 471 | function shouldPreferSshRemote(url: string | null): boolean { | ||
| 472 | if (!url) return false; | ||
| 473 | const trimmed = url.trim(); | ||
| 474 | return trimmed.startsWith("git@") || trimmed.startsWith("ssh://"); | ||
| 475 | } | ||
| 476 | |||
| 477 | function toPullRequestHeadRemoteInfo(pr: { | ||
| 478 | isCrossRepository?: boolean; | ||
| 479 | headRepositoryNameWithOwner?: string | null; | ||
| 480 | headRepositoryOwnerLogin?: string | null; | ||
| 481 | }): PullRequestHeadRemoteInfo { | ||
| 482 | return { | ||
| 483 | ...(pr.isCrossRepository !== undefined ? { isCrossRepository: pr.isCrossRepository } : {}), | ||
| 484 | ...(pr.headRepositoryNameWithOwner !== undefined | ||
| 485 | ? { headRepositoryNameWithOwner: pr.headRepositoryNameWithOwner } | ||
| 486 | : {}), | ||
| 487 | ...(pr.headRepositoryOwnerLogin !== undefined | ||
| 488 | ? { headRepositoryOwnerLogin: pr.headRepositoryOwnerLogin } | ||
| 489 | : {}), | ||
| 490 | }; | ||
| 491 | } | ||
| 492 | |||
| 493 | export const makeGitManager = Effect.fn("makeGitManager")(function* () { | ||
| 494 | const gitCore = yield* GitCore; | ||
| 495 | const gitHubCli = yield* GitHubCli; | ||
| 496 | const textGeneration = yield* TextGeneration; | ||
| 497 | const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; | ||
| 498 | const serverSettingsService = yield* ServerSettingsService; | ||
| 499 | |||
| 500 | const createProgressEmitter = ( | ||
| 501 | input: { cwd: string; action: GitStackedAction }, | ||
| 502 | options?: GitRunStackedActionOptions, | ||
| 503 | ) => { | ||
| 504 | const actionId = options?.actionId ?? randomUUID(); | ||
| 505 | const reporter = options?.progressReporter; | ||
| 506 | |||
| 507 | const emit = (event: GitActionProgressPayload) => | ||
| 508 | reporter | ||
| 509 | ? reporter.publish({ | ||
| 510 | actionId, | ||
| 511 | cwd: input.cwd, | ||
| 512 | action: input.action, | ||
| 513 | ...event, | ||
| 514 | } as GitActionProgressEvent) | ||
| 515 | : Effect.void; | ||
| 516 | |||
| 517 | return { | ||
| 518 | actionId, | ||
| 519 | emit, | ||
| 520 | }; | ||
| 521 | }; | ||
| 522 | |||
| 523 | const configurePullRequestHeadUpstreamBase = Effect.fn("configurePullRequestHeadUpstream")( | ||
| 524 | function* ( | ||
| 525 | cwd: string, | ||
| 526 | pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, | ||
| 527 | localBranch = pullRequest.headBranch, | ||
| 528 | ) { | ||
| 529 | const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; | ||
| 530 | if (repositoryNameWithOwner.length === 0) { | ||
| 531 | return; | ||
| 532 | } | ||
| 533 | |||
| 534 | const cloneUrls = yield* gitHubCli.getRepositoryCloneUrls({ | ||
| 535 | cwd, | ||
| 536 | repository: repositoryNameWithOwner, | ||
| 537 | }); | ||
| 538 | const originRemoteUrl = yield* gitCore.readConfigValue(cwd, "remote.origin.url"); | ||
| 539 | const remoteUrl = shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url; | ||
| 540 | const preferredRemoteName = | ||
| 541 | pullRequest.headRepositoryOwnerLogin?.trim() || | ||
| 542 | repositoryNameWithOwner.split("/")[0]?.trim() || | ||
| 543 | "fork"; | ||
| 544 | const remoteName = yield* gitCore.ensureRemote({ | ||
| 545 | cwd, | ||
| 546 | preferredName: preferredRemoteName, | ||
| 547 | url: remoteUrl, | ||
| 548 | }); | ||
| 549 | |||
| 550 | yield* gitCore.setBranchUpstream({ | ||
| 551 | cwd, | ||
| 552 | branch: localBranch, | ||
| 553 | remoteName, | ||
| 554 | remoteBranch: pullRequest.headBranch, | ||
| 555 | }); | ||
| 556 | }, | ||
| 557 | ); | ||
| 558 | |||
| 559 | const configurePullRequestHeadUpstream = ( | ||
| 560 | cwd: string, | ||
| 561 | pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, | ||
| 562 | localBranch = pullRequest.headBranch, | ||
| 563 | ) => | ||
| 564 | configurePullRequestHeadUpstreamBase(cwd, pullRequest, localBranch).pipe( | ||
| 565 | Effect.catch((error) => | ||
| 566 | Effect.logWarning( | ||
| 567 | `GitManager.configurePullRequestHeadUpstream: failed to configure upstream for ${localBranch} -> ${pullRequest.headBranch} in ${cwd}: ${error.message}`, | ||
| 568 | ).pipe(Effect.asVoid), | ||
| 569 | ), | ||
| 570 | ); | ||
| 571 | |||
| 572 | const materializePullRequestHeadBranchBase = Effect.fn("materializePullRequestHeadBranch")( | ||
| 573 | function* ( | ||
| 574 | cwd: string, | ||
| 575 | pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, | ||
| 576 | localBranch = pullRequest.headBranch, | ||
| 577 | ) { | ||
| 578 | const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; | ||
| 579 | |||
| 580 | if (repositoryNameWithOwner.length === 0) { | ||
| 581 | yield* gitCore.fetchPullRequestBranch({ | ||
| 582 | cwd, | ||
| 583 | prNumber: pullRequest.number, | ||
| 584 | branch: localBranch, | ||
| 585 | }); | ||
| 586 | return; | ||
| 587 | } | ||
| 588 | |||
| 589 | const cloneUrls = yield* gitHubCli.getRepositoryCloneUrls({ | ||
| 590 | cwd, | ||
| 591 | repository: repositoryNameWithOwner, | ||
| 592 | }); | ||
| 593 | const originRemoteUrl = yield* gitCore.readConfigValue(cwd, "remote.origin.url"); | ||
| 594 | const remoteUrl = shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url; | ||
| 595 | const preferredRemoteName = | ||
| 596 | pullRequest.headRepositoryOwnerLogin?.trim() || | ||
| 597 | repositoryNameWithOwner.split("/")[0]?.trim() || | ||
| 598 | "fork"; | ||
| 599 | const remoteName = yield* gitCore.ensureRemote({ | ||
| 600 | cwd, | ||
| 601 | preferredName: preferredRemoteName, | ||
| 602 | url: remoteUrl, | ||
| 603 | }); | ||
| 604 | |||
| 605 | yield* gitCore.fetchRemoteBranch({ | ||
| 606 | cwd, | ||
| 607 | remoteName, | ||
| 608 | remoteBranch: pullRequest.headBranch, | ||
| 609 | localBranch, | ||
| 610 | }); | ||
| 611 | yield* gitCore.setBranchUpstream({ | ||
| 612 | cwd, | ||
| 613 | branch: localBranch, | ||
| 614 | remoteName, | ||
| 615 | remoteBranch: pullRequest.headBranch, | ||
| 616 | }); | ||
| 617 | }, | ||
| 618 | ); | ||
| 619 | |||
| 620 | const materializePullRequestHeadBranch = ( | ||
| 621 | cwd: string, | ||
| 622 | pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, | ||
| 623 | localBranch = pullRequest.headBranch, | ||
| 624 | ) => | ||
| 625 | materializePullRequestHeadBranchBase(cwd, pullRequest, localBranch).pipe( | ||
| 626 | Effect.catch(() => | ||
| 627 | gitCore.fetchPullRequestBranch({ | ||
| 628 | cwd, | ||
| 629 | prNumber: pullRequest.number, | ||
| 630 | branch: localBranch, | ||
| 631 | }), | ||
| 632 | ), | ||
| 633 | ); | ||
| 634 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 635 | const path = yield* Path.Path; | ||
| 636 | |||
| 637 | const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; | ||
| 638 | const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd); | ||
| 639 | const nonRepositoryStatusDetails = { | ||
| 640 | isRepo: false, | ||
| 641 | hasOriginRemote: false, | ||
| 642 | isDefaultBranch: false, | ||
| 643 | branch: null, | ||
| 644 | upstreamRef: null, | ||
| 645 | hasWorkingTreeChanges: false, | ||
| 646 | workingTree: { files: [], insertions: 0, deletions: 0 }, | ||
| 647 | hasUpstream: false, | ||
| 648 | aheadCount: 0, | ||
| 649 | behindCount: 0, | ||
| 650 | } satisfies GitStatusDetails; | ||
| 651 | const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) { | ||
| 652 | const details = yield* gitCore | ||
| 653 | .statusDetailsLocal(cwd) | ||
| 654 | .pipe( | ||
| 655 | Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)), | ||
| 656 | ); | ||
| 657 | const hostingProvider = details.isRepo | ||
| 658 | ? yield* resolveHostingProvider(cwd, details.branch) | ||
| 659 | : null; | ||
| 660 | |||
| 661 | return { | ||
| 662 | isRepo: details.isRepo, | ||
| 663 | ...(hostingProvider ? { hostingProvider } : {}), | ||
| 664 | hasOriginRemote: details.hasOriginRemote, | ||
| 665 | isDefaultBranch: details.isDefaultBranch, | ||
| 666 | branch: details.branch, | ||
| 667 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, | ||
| 668 | workingTree: details.workingTree, | ||
| 669 | } satisfies GitStatusLocalResult; | ||
| 670 | }); | ||
| 671 | const localStatusResultCache = yield* Cache.makeWith(readLocalStatus, { | ||
| 672 | capacity: STATUS_RESULT_CACHE_CAPACITY, | ||
| 673 | timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), | ||
| 674 | }); | ||
| 675 | const invalidateLocalStatusResultCache = (cwd: string) => | ||
| 676 | Cache.invalidate(localStatusResultCache, normalizeStatusCacheKey(cwd)); | ||
| 677 | const readRemoteStatus = Effect.fn("readRemoteStatus")(function* (cwd: string) { | ||
| 678 | const details = yield* gitCore | ||
| 679 | .statusDetails(cwd) | ||
| 680 | .pipe(Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(null))); | ||
| 681 | if (details === null || !details.isRepo) { | ||
| 682 | return null; | ||
| 683 | } | ||
| 684 | |||
| 685 | const pr = | ||
| 686 | details.branch !== null | ||
| 687 | ? yield* findLatestPr(cwd, { | ||
| 688 | branch: details.branch, | ||
| 689 | upstreamRef: details.upstreamRef, | ||
| 690 | }).pipe( | ||
| 691 | Effect.map((latest) => { | ||
| 692 | if (!latest) return null; | ||
| 693 | // On the default branch, only surface open PRs. | ||
| 694 | // Merged/closed matches are usually reverse-merge history, not the thread's PR context. | ||
| 695 | if (details.isDefaultBranch && latest.state !== "open") return null; | ||
| 696 | return toStatusPr(latest); | ||
| 697 | }), | ||
| 698 | Effect.catch(() => Effect.succeed(null)), | ||
| 699 | ) | ||
| 700 | : null; | ||
| 701 | |||
| 702 | return { | ||
| 703 | hasUpstream: details.hasUpstream, | ||
| 704 | aheadCount: details.aheadCount, | ||
| 705 | behindCount: details.behindCount, | ||
| 706 | pr, | ||
| 707 | } satisfies GitStatusRemoteResult; | ||
| 708 | }); | ||
| 709 | const remoteStatusResultCache = yield* Cache.makeWith(readRemoteStatus, { | ||
| 710 | capacity: STATUS_RESULT_CACHE_CAPACITY, | ||
| 711 | timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), | ||
| 712 | }); | ||
| 713 | const invalidateRemoteStatusResultCache = (cwd: string) => | ||
| 714 | Cache.invalidate(remoteStatusResultCache, normalizeStatusCacheKey(cwd)); | ||
| 715 | |||
| 716 | const readConfigValueNullable = (cwd: string, key: string) => | ||
| 717 | gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null))); | ||
| 718 | |||
| 719 | const resolveHostingProvider = Effect.fn("resolveHostingProvider")(function* ( | ||
| 720 | cwd: string, | ||
| 721 | branch: string | null, | ||
| 722 | ) { | ||
| 723 | const preferredRemoteName = | ||
| 724 | branch === null | ||
| 725 | ? "origin" | ||
| 726 | : ((yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)) ?? "origin"); | ||
| 727 | const remoteUrl = | ||
| 728 | (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ?? | ||
| 729 | (yield* readConfigValueNullable(cwd, "remote.origin.url")); | ||
| 730 | |||
| 731 | return remoteUrl ? detectGitHostingProviderFromRemoteUrl(remoteUrl) : null; | ||
| 732 | }); | ||
| 733 | |||
| 734 | const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* ( | ||
| 735 | cwd: string, | ||
| 736 | remoteName: string | null, | ||
| 737 | ) { | ||
| 738 | if (!remoteName) { | ||
| 739 | return { | ||
| 740 | repositoryNameWithOwner: null, | ||
| 741 | ownerLogin: null, | ||
| 742 | }; | ||
| 743 | } | ||
| 744 | |||
| 745 | const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`); | ||
| 746 | const repositoryNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); | ||
| 747 | return { | ||
| 748 | repositoryNameWithOwner, | ||
| 749 | ownerLogin: parseRepositoryOwnerLogin(repositoryNameWithOwner), | ||
| 750 | }; | ||
| 751 | }); | ||
| 752 | |||
| 753 | const resolveBranchHeadContext = Effect.fn("resolveBranchHeadContext")(function* ( | ||
| 754 | cwd: string, | ||
| 755 | details: { branch: string; upstreamRef: string | null }, | ||
| 756 | ) { | ||
| 757 | const remoteName = yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`); | ||
| 758 | const headBranchFromUpstream = details.upstreamRef | ||
| 759 | ? extractBranchNameFromRemoteRef(details.upstreamRef, { remoteName }) | ||
| 760 | : ""; | ||
| 761 | const headBranch = headBranchFromUpstream.length > 0 ? headBranchFromUpstream : details.branch; | ||
| 762 | const shouldProbeLocalBranchSelector = | ||
| 763 | headBranchFromUpstream.length === 0 || headBranch === details.branch; | ||
| 764 | |||
| 765 | const [remoteRepository, originRepository] = yield* Effect.all( | ||
| 766 | [ | ||
| 767 | resolveRemoteRepositoryContext(cwd, remoteName), | ||
| 768 | resolveRemoteRepositoryContext(cwd, "origin"), | ||
| 769 | ], | ||
| 770 | { concurrency: "unbounded" }, | ||
| 771 | ); | ||
| 772 | |||
| 773 | const isCrossRepository = | ||
| 774 | remoteRepository.repositoryNameWithOwner !== null && | ||
| 775 | originRepository.repositoryNameWithOwner !== null | ||
| 776 | ? remoteRepository.repositoryNameWithOwner.toLowerCase() !== | ||
| 777 | originRepository.repositoryNameWithOwner.toLowerCase() | ||
| 778 | : remoteName !== null && | ||
| 779 | remoteName !== "origin" && | ||
| 780 | remoteRepository.repositoryNameWithOwner !== null; | ||
| 781 | |||
| 782 | const ownerHeadSelector = | ||
| 783 | remoteRepository.ownerLogin && headBranch.length > 0 | ||
| 784 | ? `${remoteRepository.ownerLogin}:${headBranch}` | ||
| 785 | : null; | ||
| 786 | const remoteAliasHeadSelector = | ||
| 787 | remoteName && headBranch.length > 0 ? `${remoteName}:${headBranch}` : null; | ||
| 788 | const shouldProbeRemoteOwnedSelectors = | ||
| 789 | isCrossRepository || (remoteName !== null && remoteName !== "origin"); | ||
| 790 | |||
| 791 | const headSelectors: string[] = []; | ||
| 792 | if (isCrossRepository && shouldProbeRemoteOwnedSelectors) { | ||
| 793 | appendUnique(headSelectors, ownerHeadSelector); | ||
| 794 | appendUnique( | ||
| 795 | headSelectors, | ||
| 796 | remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null, | ||
| 797 | ); | ||
| 798 | } | ||
| 799 | if (shouldProbeLocalBranchSelector) { | ||
| 800 | appendUnique(headSelectors, details.branch); | ||
| 801 | } | ||
| 802 | appendUnique(headSelectors, headBranch !== details.branch ? headBranch : null); | ||
| 803 | if (!isCrossRepository && shouldProbeRemoteOwnedSelectors) { | ||
| 804 | appendUnique(headSelectors, ownerHeadSelector); | ||
| 805 | appendUnique( | ||
| 806 | headSelectors, | ||
| 807 | remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null, | ||
| 808 | ); | ||
| 809 | } | ||
| 810 | |||
| 811 | return { | ||
| 812 | localBranch: details.branch, | ||
| 813 | headBranch, | ||
| 814 | headSelectors, | ||
| 815 | preferredHeadSelector: | ||
| 816 | ownerHeadSelector && isCrossRepository ? ownerHeadSelector : headBranch, | ||
| 817 | remoteName, | ||
| 818 | headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner, | ||
| 819 | headRepositoryOwnerLogin: remoteRepository.ownerLogin, | ||
| 820 | isCrossRepository, | ||
| 821 | } satisfies BranchHeadContext; | ||
| 822 | }); | ||
| 823 | |||
| 824 | const findOpenPr = Effect.fn("findOpenPr")(function* ( | ||
| 825 | cwd: string, | ||
| 826 | headContext: Pick< | ||
| 827 | BranchHeadContext, | ||
| 828 | | "headBranch" | ||
| 829 | | "headSelectors" | ||
| 830 | | "headRepositoryNameWithOwner" | ||
| 831 | | "headRepositoryOwnerLogin" | ||
| 832 | | "isCrossRepository" | ||
| 833 | >, | ||
| 834 | ) { | ||
| 835 | for (const headSelector of headContext.headSelectors) { | ||
| 836 | const pullRequests = yield* gitHubCli.listOpenPullRequests({ | ||
| 837 | cwd, | ||
| 838 | headSelector, | ||
| 839 | limit: 1, | ||
| 840 | }); | ||
| 841 | const normalizedPullRequests = pullRequests.map(toPullRequestInfo); | ||
| 842 | |||
| 843 | const firstPullRequest = normalizedPullRequests.find((pullRequest) => | ||
| 844 | matchesBranchHeadContext(pullRequest, headContext), | ||
| 845 | ); | ||
| 846 | if (firstPullRequest) { | ||
| 847 | return { | ||
| 848 | number: firstPullRequest.number, | ||
| 849 | title: firstPullRequest.title, | ||
| 850 | url: firstPullRequest.url, | ||
| 851 | baseRefName: firstPullRequest.baseRefName, | ||
| 852 | headRefName: firstPullRequest.headRefName, | ||
| 853 | state: "open", | ||
| 854 | updatedAt: null, | ||
| 855 | } satisfies PullRequestInfo; | ||
| 856 | } | ||
| 857 | } | ||
| 858 | |||
| 859 | return null; | ||
| 860 | }); | ||
| 861 | |||
| 862 | const findLatestPr = Effect.fn("findLatestPr")(function* ( | ||
| 863 | cwd: string, | ||
| 864 | details: { branch: string; upstreamRef: string | null }, | ||
| 865 | ) { | ||
| 866 | const headContext = yield* resolveBranchHeadContext(cwd, details); | ||
| 867 | const parsedByNumber = new Map<number, PullRequestInfo>(); | ||
| 868 | |||
| 869 | for (const headSelector of headContext.headSelectors) { | ||
| 870 | const stdout = yield* gitHubCli | ||
| 871 | .execute({ | ||
| 872 | cwd, | ||
| 873 | args: [ | ||
| 874 | "pr", | ||
| 875 | "list", | ||
| 876 | "--head", | ||
| 877 | headSelector, | ||
| 878 | "--state", | ||
| 879 | "all", | ||
| 880 | "--limit", | ||
| 881 | "20", | ||
| 882 | "--json", | ||
| 883 | "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | ||
| 884 | ], | ||
| 885 | }) | ||
| 886 | .pipe(Effect.map((result) => result.stdout)); | ||
| 887 | |||
| 888 | const raw = stdout.trim(); | ||
| 889 | if (raw.length === 0) { | ||
| 890 | continue; | ||
| 891 | } | ||
| 892 | |||
| 893 | const pullRequests = yield* Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( | ||
| 894 | Effect.flatMap((decoded) => { | ||
| 895 | if (!Result.isSuccess(decoded)) { | ||
| 896 | return Effect.fail( | ||
| 897 | gitManagerError( | ||
| 898 | "findLatestPr", | ||
| 899 | `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, | ||
| 900 | decoded.failure, | ||
| 901 | ), | ||
| 902 | ); | ||
| 903 | } | ||
| 904 | |||
| 905 | return Effect.succeed(decoded.success); | ||
| 906 | }), | ||
| 907 | ); | ||
| 908 | |||
| 909 | for (const pr of pullRequests) { | ||
| 910 | if (!matchesBranchHeadContext(pr, headContext)) { | ||
| 911 | continue; | ||
| 912 | } | ||
| 913 | parsedByNumber.set(pr.number, pr); | ||
| 914 | } | ||
| 915 | } | ||
| 916 | |||
| 917 | const parsed = Array.from(parsedByNumber.values()).toSorted((a, b) => { | ||
| 918 | const left = a.updatedAt ? Date.parse(a.updatedAt) : 0; | ||
| 919 | const right = b.updatedAt ? Date.parse(b.updatedAt) : 0; | ||
| 920 | return right - left; | ||
| 921 | }); | ||
| 922 | |||
| 923 | const latestOpenPr = parsed.find((pr) => pr.state === "open"); | ||
| 924 | if (latestOpenPr) { | ||
| 925 | return latestOpenPr; | ||
| 926 | } | ||
| 927 | return parsed[0] ?? null; | ||
| 928 | }); | ||
| 929 | |||
| 930 | const buildCompletionToast = Effect.fn("buildCompletionToast")(function* ( | ||
| 931 | cwd: string, | ||
| 932 | result: Pick<GitRunStackedActionResult, "action" | "branch" | "commit" | "push" | "pr">, | ||
| 933 | ) { | ||
| 934 | const summary = summarizeGitActionResult(result); | ||
| 935 | let latestOpenPr: PullRequestInfo | null = null; | ||
| 936 | let currentBranchIsDefault = false; | ||
| 937 | let finalBranchContext: { | ||
| 938 | branch: string; | ||
| 939 | upstreamRef: string | null; | ||
| 940 | hasUpstream: boolean; | ||
| 941 | } | null = null; | ||
| 942 | |||
| 943 | if (result.action !== "commit") { | ||
| 944 | const finalStatus = yield* gitCore.statusDetails(cwd); | ||
| 945 | if (finalStatus.branch) { | ||
| 946 | finalBranchContext = { | ||
| 947 | branch: finalStatus.branch, | ||
| 948 | upstreamRef: finalStatus.upstreamRef, | ||
| 949 | hasUpstream: finalStatus.hasUpstream, | ||
| 950 | }; | ||
| 951 | currentBranchIsDefault = finalStatus.isDefaultBranch; | ||
| 952 | } | ||
| 953 | } | ||
| 954 | |||
| 955 | const explicitResultPr = | ||
| 956 | (result.pr.status === "created" || result.pr.status === "opened_existing") && result.pr.url | ||
| 957 | ? { | ||
| 958 | url: result.pr.url, | ||
| 959 | state: "open" as const, | ||
| 960 | } | ||
| 961 | : null; | ||
| 962 | const shouldLookupExistingOpenPr = | ||
| 963 | (result.action === "commit_push" || result.action === "push") && | ||
| 964 | result.push.status === "pushed" && | ||
| 965 | result.branch.status !== "created" && | ||
| 966 | !currentBranchIsDefault && | ||
| 967 | explicitResultPr === null && | ||
| 968 | finalBranchContext?.hasUpstream === true; | ||
| 969 | |||
| 970 | if (shouldLookupExistingOpenPr && finalBranchContext) { | ||
| 971 | latestOpenPr = yield* resolveBranchHeadContext(cwd, { | ||
| 972 | branch: finalBranchContext.branch, | ||
| 973 | upstreamRef: finalBranchContext.upstreamRef, | ||
| 974 | }).pipe( | ||
| 975 | Effect.flatMap((headContext) => findOpenPr(cwd, headContext)), | ||
| 976 | Effect.catch(() => Effect.succeed(null)), | ||
| 977 | ); | ||
| 978 | } | ||
| 979 | |||
| 980 | const openPr = latestOpenPr ?? explicitResultPr; | ||
| 981 | |||
| 982 | const cta = | ||
| 983 | result.action === "commit" && result.commit.status === "created" | ||
| 984 | ? { | ||
| 985 | kind: "run_action" as const, | ||
| 986 | label: "Push", | ||
| 987 | action: { kind: "push" as const }, | ||
| 988 | } | ||
| 989 | : (result.action === "push" || | ||
| 990 | result.action === "create_pr" || | ||
| 991 | result.action === "commit_push" || | ||
| 992 | result.action === "commit_push_pr") && | ||
| 993 | openPr?.url && | ||
| 994 | (!currentBranchIsDefault || | ||
| 995 | result.pr.status === "created" || | ||
| 996 | result.pr.status === "opened_existing") | ||
| 997 | ? { | ||
| 998 | kind: "open_pr" as const, | ||
| 999 | label: "View PR", | ||
| 1000 | url: openPr.url, | ||
| 1001 | } | ||
| 1002 | : (result.action === "push" || result.action === "commit_push") && | ||
| 1003 | result.push.status === "pushed" && | ||
| 1004 | !currentBranchIsDefault | ||
| 1005 | ? { | ||
| 1006 | kind: "run_action" as const, | ||
| 1007 | label: "Create PR", | ||
| 1008 | action: { kind: "create_pr" as const }, | ||
| 1009 | } | ||
| 1010 | : { | ||
| 1011 | kind: "none" as const, | ||
| 1012 | }; | ||
| 1013 | |||
| 1014 | return { | ||
| 1015 | ...summary, | ||
| 1016 | cta, | ||
| 1017 | }; | ||
| 1018 | }); | ||
| 1019 | |||
| 1020 | const resolveBaseBranch = Effect.fn("resolveBaseBranch")(function* ( | ||
| 1021 | cwd: string, | ||
| 1022 | branch: string, | ||
| 1023 | upstreamRef: string | null, | ||
| 1024 | headContext: Pick<BranchHeadContext, "isCrossRepository" | "remoteName">, | ||
| 1025 | ) { | ||
| 1026 | const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`); | ||
| 1027 | if (configured) return configured; | ||
| 1028 | |||
| 1029 | if (upstreamRef && !headContext.isCrossRepository) { | ||
| 1030 | const upstreamBranch = extractBranchNameFromRemoteRef(upstreamRef, { | ||
| 1031 | remoteName: headContext.remoteName, | ||
| 1032 | }); | ||
| 1033 | if (upstreamBranch.length > 0 && upstreamBranch !== branch) { | ||
| 1034 | return upstreamBranch; | ||
| 1035 | } | ||
| 1036 | } | ||
| 1037 | |||
| 1038 | const defaultFromGh = yield* gitHubCli | ||
| 1039 | .getDefaultBranch({ cwd }) | ||
| 1040 | .pipe(Effect.catch(() => Effect.succeed(null))); | ||
| 1041 | if (defaultFromGh) { | ||
| 1042 | return defaultFromGh; | ||
| 1043 | } | ||
| 1044 | |||
| 1045 | return "main"; | ||
| 1046 | }); | ||
| 1047 | |||
| 1048 | const resolveCommitAndBranchSuggestion = Effect.fn("resolveCommitAndBranchSuggestion")( | ||
| 1049 | function* (input: { | ||
| 1050 | cwd: string; | ||
| 1051 | branch: string | null; | ||
| 1052 | commitMessage?: string; | ||
| 1053 | /** When true, also produce a semantic feature branch name. */ | ||
| 1054 | includeBranch?: boolean; | ||
| 1055 | filePaths?: readonly string[]; | ||
| 1056 | modelSelection: ModelSelection; | ||
| 1057 | }) { | ||
| 1058 | const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths); | ||
| 1059 | if (!context) { | ||
| 1060 | return null; | ||
| 1061 | } | ||
| 1062 | |||
| 1063 | const customCommit = parseCustomCommitMessage(input.commitMessage ?? ""); | ||
| 1064 | if (customCommit) { | ||
| 1065 | return { | ||
| 1066 | subject: customCommit.subject, | ||
| 1067 | body: customCommit.body, | ||
| 1068 | ...(input.includeBranch | ||
| 1069 | ? { branch: sanitizeFeatureBranchName(customCommit.subject) } | ||
| 1070 | : {}), | ||
| 1071 | commitMessage: formatCommitMessage(customCommit.subject, customCommit.body), | ||
| 1072 | }; | ||
| 1073 | } | ||
| 1074 | |||
| 1075 | const generated = yield* textGeneration | ||
| 1076 | .generateCommitMessage({ | ||
| 1077 | cwd: input.cwd, | ||
| 1078 | branch: input.branch, | ||
| 1079 | stagedSummary: limitContext(context.stagedSummary, 8_000), | ||
| 1080 | stagedPatch: limitContext(context.stagedPatch, 50_000), | ||
| 1081 | ...(input.includeBranch ? { includeBranch: true } : {}), | ||
| 1082 | modelSelection: input.modelSelection, | ||
| 1083 | }) | ||
| 1084 | .pipe(Effect.map((result) => sanitizeCommitMessage(result))); | ||
| 1085 | |||
| 1086 | return { | ||
| 1087 | subject: generated.subject, | ||
| 1088 | body: generated.body, | ||
| 1089 | ...(generated.branch !== undefined ? { branch: generated.branch } : {}), | ||
| 1090 | commitMessage: formatCommitMessage(generated.subject, generated.body), | ||
| 1091 | }; | ||
| 1092 | }, | ||
| 1093 | ); | ||
| 1094 | |||
| 1095 | const runCommitStep = Effect.fn("runCommitStep")(function* ( | ||
| 1096 | modelSelection: ModelSelection, | ||
| 1097 | cwd: string, | ||
| 1098 | action: "commit" | "commit_push" | "commit_push_pr", | ||
| 1099 | branch: string | null, | ||
| 1100 | commitMessage?: string, | ||
| 1101 | preResolvedSuggestion?: CommitAndBranchSuggestion, | ||
| 1102 | filePaths?: readonly string[], | ||
| 1103 | progressReporter?: GitActionProgressReporter, | ||
| 1104 | actionId?: string, | ||
| 1105 | ) { | ||
| 1106 | const emit = (event: GitActionProgressPayload) => | ||
| 1107 | progressReporter && actionId | ||
| 1108 | ? progressReporter.publish({ | ||
| 1109 | actionId, | ||
| 1110 | cwd, | ||
| 1111 | action, | ||
| 1112 | ...event, | ||
| 1113 | } as GitActionProgressEvent) | ||
| 1114 | : Effect.void; | ||
| 1115 | |||
| 1116 | let suggestion: CommitAndBranchSuggestion | null | undefined = preResolvedSuggestion; | ||
| 1117 | if (!suggestion) { | ||
| 1118 | const needsGeneration = !commitMessage?.trim(); | ||
| 1119 | if (needsGeneration) { | ||
| 1120 | yield* emit({ | ||
| 1121 | kind: "phase_started", | ||
| 1122 | phase: "commit", | ||
| 1123 | label: "Generating commit message...", | ||
| 1124 | }); | ||
| 1125 | } | ||
| 1126 | suggestion = yield* resolveCommitAndBranchSuggestion({ | ||
| 1127 | cwd, | ||
| 1128 | branch, | ||
| 1129 | ...(commitMessage ? { commitMessage } : {}), | ||
| 1130 | ...(filePaths ? { filePaths } : {}), | ||
| 1131 | modelSelection, | ||
| 1132 | }); | ||
| 1133 | } | ||
| 1134 | if (!suggestion) { | ||
| 1135 | return { status: "skipped_no_changes" as const }; | ||
| 1136 | } | ||
| 1137 | |||
| 1138 | yield* emit({ | ||
| 1139 | kind: "phase_started", | ||
| 1140 | phase: "commit", | ||
| 1141 | label: "Committing...", | ||
| 1142 | }); | ||
| 1143 | |||
| 1144 | let currentHookName: string | null = null; | ||
| 1145 | const commitProgress = | ||
| 1146 | progressReporter && actionId | ||
| 1147 | ? { | ||
| 1148 | onOutputLine: ({ stream, text }: { stream: "stdout" | "stderr"; text: string }) => { | ||
| 1149 | const sanitized = sanitizeProgressText(text); | ||
| 1150 | if (!sanitized) { | ||
| 1151 | return Effect.void; | ||
| 1152 | } | ||
| 1153 | return emit({ | ||
| 1154 | kind: "hook_output", | ||
| 1155 | hookName: currentHookName, | ||
| 1156 | stream, | ||
| 1157 | text: sanitized, | ||
| 1158 | }); | ||
| 1159 | }, | ||
| 1160 | onHookStarted: (hookName: string) => { | ||
| 1161 | currentHookName = hookName; | ||
| 1162 | return emit({ | ||
| 1163 | kind: "hook_started", | ||
| 1164 | hookName, | ||
| 1165 | }); | ||
| 1166 | }, | ||
| 1167 | onHookFinished: ({ | ||
| 1168 | hookName, | ||
| 1169 | exitCode, | ||
| 1170 | durationMs, | ||
| 1171 | }: { | ||
| 1172 | hookName: string; | ||
| 1173 | exitCode: number | null; | ||
| 1174 | durationMs: number | null; | ||
| 1175 | }) => { | ||
| 1176 | if (currentHookName === hookName) { | ||
| 1177 | currentHookName = null; | ||
| 1178 | } | ||
| 1179 | return emit({ | ||
| 1180 | kind: "hook_finished", | ||
| 1181 | hookName, | ||
| 1182 | exitCode, | ||
| 1183 | durationMs, | ||
| 1184 | }); | ||
| 1185 | }, | ||
| 1186 | } | ||
| 1187 | : null; | ||
| 1188 | const { commitSha } = yield* gitCore.commit(cwd, suggestion.subject, suggestion.body, { | ||
| 1189 | timeoutMs: COMMIT_TIMEOUT_MS, | ||
| 1190 | ...(commitProgress ? { progress: commitProgress } : {}), | ||
| 1191 | }); | ||
| 1192 | if (currentHookName !== null) { | ||
| 1193 | yield* emit({ | ||
| 1194 | kind: "hook_finished", | ||
| 1195 | hookName: currentHookName, | ||
| 1196 | exitCode: 0, | ||
| 1197 | durationMs: null, | ||
| 1198 | }); | ||
| 1199 | currentHookName = null; | ||
| 1200 | } | ||
| 1201 | return { | ||
| 1202 | status: "created" as const, | ||
| 1203 | commitSha, | ||
| 1204 | subject: suggestion.subject, | ||
| 1205 | }; | ||
| 1206 | }); | ||
| 1207 | |||
| 1208 | const runPrStep = Effect.fn("runPrStep")(function* ( | ||
| 1209 | modelSelection: ModelSelection, | ||
| 1210 | cwd: string, | ||
| 1211 | fallbackBranch: string | null, | ||
| 1212 | emit: GitActionProgressEmitter, | ||
| 1213 | ) { | ||
| 1214 | const details = yield* gitCore.statusDetails(cwd); | ||
| 1215 | const branch = details.branch ?? fallbackBranch; | ||
| 1216 | if (!branch) { | ||
| 1217 | return yield* gitManagerError( | ||
| 1218 | "runPrStep", | ||
| 1219 | "Cannot create a pull request from detached HEAD.", | ||
| 1220 | ); | ||
| 1221 | } | ||
| 1222 | if (!details.hasUpstream) { | ||
| 1223 | return yield* gitManagerError( | ||
| 1224 | "runPrStep", | ||
| 1225 | "Current branch has not been pushed. Push before creating a PR.", | ||
| 1226 | ); | ||
| 1227 | } | ||
| 1228 | |||
| 1229 | const headContext = yield* resolveBranchHeadContext(cwd, { | ||
| 1230 | branch, | ||
| 1231 | upstreamRef: details.upstreamRef, | ||
| 1232 | }); | ||
| 1233 | |||
| 1234 | const existing = yield* findOpenPr(cwd, headContext); | ||
| 1235 | if (existing) { | ||
| 1236 | return { | ||
| 1237 | status: "opened_existing" as const, | ||
| 1238 | url: existing.url, | ||
| 1239 | number: existing.number, | ||
| 1240 | baseBranch: existing.baseRefName, | ||
| 1241 | headBranch: existing.headRefName, | ||
| 1242 | title: existing.title, | ||
| 1243 | }; | ||
| 1244 | } | ||
| 1245 | |||
| 1246 | const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef, headContext); | ||
| 1247 | yield* emit({ | ||
| 1248 | kind: "phase_started", | ||
| 1249 | phase: "pr", | ||
| 1250 | label: "Generating PR content...", | ||
| 1251 | }); | ||
| 1252 | const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch); | ||
| 1253 | |||
| 1254 | const generated = yield* textGeneration.generatePrContent({ | ||
| 1255 | cwd, | ||
| 1256 | baseBranch, | ||
| 1257 | headBranch: headContext.headBranch, | ||
| 1258 | commitSummary: limitContext(rangeContext.commitSummary, 20_000), | ||
| 1259 | diffSummary: limitContext(rangeContext.diffSummary, 20_000), | ||
| 1260 | diffPatch: limitContext(rangeContext.diffPatch, 60_000), | ||
| 1261 | modelSelection, | ||
| 1262 | }); | ||
| 1263 | |||
| 1264 | const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${randomUUID()}.md`); | ||
| 1265 | yield* fileSystem | ||
| 1266 | .writeFileString(bodyFile, generated.body) | ||
| 1267 | .pipe( | ||
| 1268 | Effect.mapError((cause) => | ||
| 1269 | gitManagerError("runPrStep", "Failed to write pull request body temp file.", cause), | ||
| 1270 | ), | ||
| 1271 | ); | ||
| 1272 | yield* emit({ | ||
| 1273 | kind: "phase_started", | ||
| 1274 | phase: "pr", | ||
| 1275 | label: "Creating GitHub pull request...", | ||
| 1276 | }); | ||
| 1277 | yield* gitHubCli | ||
| 1278 | .createPullRequest({ | ||
| 1279 | cwd, | ||
| 1280 | baseBranch, | ||
| 1281 | headSelector: headContext.preferredHeadSelector, | ||
| 1282 | title: generated.title, | ||
| 1283 | bodyFile, | ||
| 1284 | }) | ||
| 1285 | .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void)))); | ||
| 1286 | |||
| 1287 | const created = yield* findOpenPr(cwd, headContext); | ||
| 1288 | if (!created) { | ||
| 1289 | return { | ||
| 1290 | status: "created" as const, | ||
| 1291 | baseBranch, | ||
| 1292 | headBranch: headContext.headBranch, | ||
| 1293 | title: generated.title, | ||
| 1294 | }; | ||
| 1295 | } | ||
| 1296 | |||
| 1297 | return { | ||
| 1298 | status: "created" as const, | ||
| 1299 | url: created.url, | ||
| 1300 | number: created.number, | ||
| 1301 | baseBranch: created.baseRefName, | ||
| 1302 | headBranch: created.headRefName, | ||
| 1303 | title: created.title, | ||
| 1304 | }; | ||
| 1305 | }); | ||
| 1306 | |||
| 1307 | const localStatus: GitManagerShape["localStatus"] = Effect.fn("localStatus")(function* (input) { | ||
| 1308 | return yield* Cache.get(localStatusResultCache, normalizeStatusCacheKey(input.cwd)); | ||
| 1309 | }); | ||
| 1310 | const remoteStatus: GitManagerShape["remoteStatus"] = Effect.fn("remoteStatus")( | ||
| 1311 | function* (input) { | ||
| 1312 | return yield* Cache.get(remoteStatusResultCache, normalizeStatusCacheKey(input.cwd)); | ||
| 1313 | }, | ||
| 1314 | ); | ||
| 1315 | const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) { | ||
| 1316 | const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)]); | ||
| 1317 | return mergeGitStatusParts(local, remote); | ||
| 1318 | }); | ||
| 1319 | const invalidateLocalStatus: GitManagerShape["invalidateLocalStatus"] = Effect.fn( | ||
| 1320 | "invalidateLocalStatus", | ||
| 1321 | )(function* (cwd) { | ||
| 1322 | yield* invalidateLocalStatusResultCache(cwd); | ||
| 1323 | }); | ||
| 1324 | const invalidateRemoteStatus: GitManagerShape["invalidateRemoteStatus"] = Effect.fn( | ||
| 1325 | "invalidateRemoteStatus", | ||
| 1326 | )(function* (cwd) { | ||
| 1327 | yield* invalidateRemoteStatusResultCache(cwd); | ||
| 1328 | }); | ||
| 1329 | const invalidateStatus: GitManagerShape["invalidateStatus"] = Effect.fn("invalidateStatus")( | ||
| 1330 | function* (cwd) { | ||
| 1331 | yield* invalidateLocalStatusResultCache(cwd); | ||
| 1332 | yield* invalidateRemoteStatusResultCache(cwd); | ||
| 1333 | }, | ||
| 1334 | ); | ||
| 1335 | |||
| 1336 | const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")( | ||
| 1337 | function* (input) { | ||
| 1338 | const pullRequest = yield* gitHubCli | ||
| 1339 | .getPullRequest({ | ||
| 1340 | cwd: input.cwd, | ||
| 1341 | reference: normalizePullRequestReference(input.reference), | ||
| 1342 | }) | ||
| 1343 | .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved))); | ||
| 1344 | |||
| 1345 | return { pullRequest }; | ||
| 1346 | }, | ||
| 1347 | ); | ||
| 1348 | |||
| 1349 | const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fn( | ||
| 1350 | "preparePullRequestThread", | ||
| 1351 | )(function* (input) { | ||
| 1352 | const maybeRunSetupScript = (worktreePath: string) => { | ||
| 1353 | if (!input.threadId) { | ||
| 1354 | return Effect.void; | ||
| 1355 | } | ||
| 1356 | return projectSetupScriptRunner | ||
| 1357 | .runForThread({ | ||
| 1358 | threadId: input.threadId, | ||
| 1359 | projectCwd: input.cwd, | ||
| 1360 | worktreePath, | ||
| 1361 | }) | ||
| 1362 | .pipe( | ||
| 1363 | Effect.catch((error) => | ||
| 1364 | Effect.logWarning( | ||
| 1365 | `GitManager.preparePullRequestThread: failed to launch worktree setup script for thread ${input.threadId} in ${worktreePath}: ${error.message}`, | ||
| 1366 | ).pipe(Effect.asVoid), | ||
| 1367 | ), | ||
| 1368 | ); | ||
| 1369 | }; | ||
| 1370 | return yield* Effect.gen(function* () { | ||
| 1371 | const normalizedReference = normalizePullRequestReference(input.reference); | ||
| 1372 | const rootWorktreePath = canonicalizeExistingPath(input.cwd); | ||
| 1373 | const pullRequestSummary = yield* gitHubCli.getPullRequest({ | ||
| 1374 | cwd: input.cwd, | ||
| 1375 | reference: normalizedReference, | ||
| 1376 | }); | ||
| 1377 | const pullRequest = toResolvedPullRequest(pullRequestSummary); | ||
| 1378 | |||
| 1379 | if (input.mode === "local") { | ||
| 1380 | yield* gitHubCli.checkoutPullRequest({ | ||
| 1381 | cwd: input.cwd, | ||
| 1382 | reference: normalizedReference, | ||
| 1383 | force: true, | ||
| 1384 | }); | ||
| 1385 | const details = yield* gitCore.statusDetails(input.cwd); | ||
| 1386 | yield* configurePullRequestHeadUpstream( | ||
| 1387 | input.cwd, | ||
| 1388 | { | ||
| 1389 | ...pullRequest, | ||
| 1390 | ...toPullRequestHeadRemoteInfo(pullRequestSummary), | ||
| 1391 | }, | ||
| 1392 | details.branch ?? pullRequest.headBranch, | ||
| 1393 | ); | ||
| 1394 | return { | ||
| 1395 | pullRequest, | ||
| 1396 | branch: details.branch ?? pullRequest.headBranch, | ||
| 1397 | worktreePath: null, | ||
| 1398 | }; | ||
| 1399 | } | ||
| 1400 | |||
| 1401 | const ensureExistingWorktreeUpstream = Effect.fn("ensureExistingWorktreeUpstream")(function* ( | ||
| 1402 | worktreePath: string, | ||
| 1403 | ) { | ||
| 1404 | const details = yield* gitCore.statusDetails(worktreePath); | ||
| 1405 | yield* configurePullRequestHeadUpstream( | ||
| 1406 | worktreePath, | ||
| 1407 | { | ||
| 1408 | ...pullRequest, | ||
| 1409 | ...toPullRequestHeadRemoteInfo(pullRequestSummary), | ||
| 1410 | }, | ||
| 1411 | details.branch ?? pullRequest.headBranch, | ||
| 1412 | ); | ||
| 1413 | }); | ||
| 1414 | |||
| 1415 | const pullRequestWithRemoteInfo = { | ||
| 1416 | ...pullRequest, | ||
| 1417 | ...toPullRequestHeadRemoteInfo(pullRequestSummary), | ||
| 1418 | } as const; | ||
| 1419 | const localPullRequestBranch = | ||
| 1420 | resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo); | ||
| 1421 | |||
| 1422 | const findLocalHeadBranch = (cwd: string) => | ||
| 1423 | gitCore.listBranches({ cwd }).pipe( | ||
| 1424 | Effect.map((result) => { | ||
| 1425 | const localBranch = result.branches.find( | ||
| 1426 | (branch) => !branch.isRemote && branch.name === localPullRequestBranch, | ||
| 1427 | ); | ||
| 1428 | if (localBranch) { | ||
| 1429 | return localBranch; | ||
| 1430 | } | ||
| 1431 | if (localPullRequestBranch === pullRequest.headBranch) { | ||
| 1432 | return null; | ||
| 1433 | } | ||
| 1434 | return ( | ||
| 1435 | result.branches.find( | ||
| 1436 | (branch) => | ||
| 1437 | !branch.isRemote && | ||
| 1438 | branch.name === pullRequest.headBranch && | ||
| 1439 | branch.worktreePath !== null && | ||
| 1440 | canonicalizeExistingPath(branch.worktreePath) !== rootWorktreePath, | ||
| 1441 | ) ?? null | ||
| 1442 | ); | ||
| 1443 | }), | ||
| 1444 | ); | ||
| 1445 | |||
| 1446 | const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd); | ||
| 1447 | const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath | ||
| 1448 | ? canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath) | ||
| 1449 | : null; | ||
| 1450 | if ( | ||
| 1451 | existingBranchBeforeFetch?.worktreePath && | ||
| 1452 | existingBranchBeforeFetchPath !== rootWorktreePath | ||
| 1453 | ) { | ||
| 1454 | yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath); | ||
| 1455 | return { | ||
| 1456 | pullRequest, | ||
| 1457 | branch: localPullRequestBranch, | ||
| 1458 | worktreePath: existingBranchBeforeFetch.worktreePath, | ||
| 1459 | }; | ||
| 1460 | } | ||
| 1461 | if (existingBranchBeforeFetchPath === rootWorktreePath) { | ||
| 1462 | return yield* gitManagerError( | ||
| 1463 | "preparePullRequestThread", | ||
| 1464 | "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", | ||
| 1465 | ); | ||
| 1466 | } | ||
| 1467 | |||
| 1468 | yield* materializePullRequestHeadBranch( | ||
| 1469 | input.cwd, | ||
| 1470 | pullRequestWithRemoteInfo, | ||
| 1471 | localPullRequestBranch, | ||
| 1472 | ); | ||
| 1473 | |||
| 1474 | const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd); | ||
| 1475 | const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath | ||
| 1476 | ? canonicalizeExistingPath(existingBranchAfterFetch.worktreePath) | ||
| 1477 | : null; | ||
| 1478 | if ( | ||
| 1479 | existingBranchAfterFetch?.worktreePath && | ||
| 1480 | existingBranchAfterFetchPath !== rootWorktreePath | ||
| 1481 | ) { | ||
| 1482 | yield* ensureExistingWorktreeUpstream(existingBranchAfterFetch.worktreePath); | ||
| 1483 | return { | ||
| 1484 | pullRequest, | ||
| 1485 | branch: localPullRequestBranch, | ||
| 1486 | worktreePath: existingBranchAfterFetch.worktreePath, | ||
| 1487 | }; | ||
| 1488 | } | ||
| 1489 | if (existingBranchAfterFetchPath === rootWorktreePath) { | ||
| 1490 | return yield* gitManagerError( | ||
| 1491 | "preparePullRequestThread", | ||
| 1492 | "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", | ||
| 1493 | ); | ||
| 1494 | } | ||
| 1495 | |||
| 1496 | const worktree = yield* gitCore.createWorktree({ | ||
| 1497 | cwd: input.cwd, | ||
| 1498 | branch: localPullRequestBranch, | ||
| 1499 | path: null, | ||
| 1500 | }); | ||
| 1501 | yield* ensureExistingWorktreeUpstream(worktree.worktree.path); | ||
| 1502 | yield* maybeRunSetupScript(worktree.worktree.path); | ||
| 1503 | |||
| 1504 | return { | ||
| 1505 | pullRequest, | ||
| 1506 | branch: worktree.worktree.branch, | ||
| 1507 | worktreePath: worktree.worktree.path, | ||
| 1508 | }; | ||
| 1509 | }).pipe(Effect.ensuring(invalidateStatus(input.cwd))); | ||
| 1510 | }); | ||
| 1511 | |||
| 1512 | const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* ( | ||
| 1513 | modelSelection: ModelSelection, | ||
| 1514 | cwd: string, | ||
| 1515 | branch: string | null, | ||
| 1516 | commitMessage?: string, | ||
| 1517 | filePaths?: readonly string[], | ||
| 1518 | ) { | ||
| 1519 | const suggestion = yield* resolveCommitAndBranchSuggestion({ | ||
| 1520 | cwd, | ||
| 1521 | branch, | ||
| 1522 | ...(commitMessage ? { commitMessage } : {}), | ||
| 1523 | ...(filePaths ? { filePaths } : {}), | ||
| 1524 | includeBranch: true, | ||
| 1525 | modelSelection, | ||
| 1526 | }); | ||
| 1527 | if (!suggestion) { | ||
| 1528 | return yield* gitManagerError( | ||
| 1529 | "runFeatureBranchStep", | ||
| 1530 | "Cannot create a feature branch because there are no changes to commit.", | ||
| 1531 | ); | ||
| 1532 | } | ||
| 1533 | |||
| 1534 | const preferredBranch = suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject); | ||
| 1535 | const existingBranchNames = yield* gitCore.listLocalBranchNames(cwd); | ||
| 1536 | const resolvedBranch = resolveAutoFeatureBranchName(existingBranchNames, preferredBranch); | ||
| 1537 | |||
| 1538 | yield* gitCore.createBranch({ cwd, branch: resolvedBranch }); | ||
| 1539 | yield* Effect.scoped(gitCore.checkoutBranch({ cwd, branch: resolvedBranch })); | ||
| 1540 | |||
| 1541 | return { | ||
| 1542 | branchStep: { status: "created" as const, name: resolvedBranch }, | ||
| 1543 | resolvedCommitMessage: suggestion.commitMessage, | ||
| 1544 | resolvedCommitSuggestion: suggestion, | ||
| 1545 | }; | ||
| 1546 | }); | ||
| 1547 | |||
| 1548 | const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fn("runStackedAction")( | ||
| 1549 | function* (input, options) { | ||
| 1550 | const progress = createProgressEmitter(input, options); | ||
| 1551 | const currentPhase = yield* Ref.make<Option.Option<GitActionProgressPhase>>(Option.none()); | ||
| 1552 | |||
| 1553 | const runAction = Effect.fn("runStackedAction.runAction")(function* (): Effect.fn.Return< | ||
| 1554 | GitRunStackedActionResult, | ||
| 1555 | GitManagerServiceError | ||
| 1556 | > { | ||
| 1557 | const initialStatus = yield* gitCore.statusDetails(input.cwd); | ||
| 1558 | const wantsCommit = isCommitAction(input.action); | ||
| 1559 | const wantsPush = | ||
| 1560 | input.action === "push" || | ||
| 1561 | input.action === "commit_push" || | ||
| 1562 | input.action === "commit_push_pr" || | ||
| 1563 | (input.action === "create_pr" && | ||
| 1564 | (!initialStatus.hasUpstream || initialStatus.aheadCount > 0)); | ||
| 1565 | const wantsPr = input.action === "create_pr" || input.action === "commit_push_pr"; | ||
| 1566 | |||
| 1567 | if (input.featureBranch && !wantsCommit) { | ||
| 1568 | return yield* gitManagerError( | ||
| 1569 | "runStackedAction", | ||
| 1570 | "Feature-branch checkout is only supported for commit actions.", | ||
| 1571 | ); | ||
| 1572 | } | ||
| 1573 | if (input.action === "push" && initialStatus.hasWorkingTreeChanges) { | ||
| 1574 | return yield* gitManagerError( | ||
| 1575 | "runStackedAction", | ||
| 1576 | "Commit or stash local changes before pushing.", | ||
| 1577 | ); | ||
| 1578 | } | ||
| 1579 | if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) { | ||
| 1580 | return yield* gitManagerError( | ||
| 1581 | "runStackedAction", | ||
| 1582 | "Commit local changes before creating a PR.", | ||
| 1583 | ); | ||
| 1584 | } | ||
| 1585 | |||
| 1586 | const phases: GitActionProgressPhase[] = [ | ||
| 1587 | ...(input.featureBranch ? (["branch"] as const) : []), | ||
| 1588 | ...(wantsCommit ? (["commit"] as const) : []), | ||
| 1589 | ...(wantsPush ? (["push"] as const) : []), | ||
| 1590 | ...(wantsPr ? (["pr"] as const) : []), | ||
| 1591 | ]; | ||
| 1592 | |||
| 1593 | yield* progress.emit({ | ||
| 1594 | kind: "action_started", | ||
| 1595 | phases, | ||
| 1596 | }); | ||
| 1597 | |||
| 1598 | if (!input.featureBranch && wantsPush && !initialStatus.branch) { | ||
| 1599 | return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD."); | ||
| 1600 | } | ||
| 1601 | if (!input.featureBranch && wantsPr && !initialStatus.branch) { | ||
| 1602 | return yield* gitManagerError( | ||
| 1603 | "runStackedAction", | ||
| 1604 | "Cannot create a pull request from detached HEAD.", | ||
| 1605 | ); | ||
| 1606 | } | ||
| 1607 | |||
| 1608 | let branchStep: { status: "created" | "skipped_not_requested"; name?: string }; | ||
| 1609 | let commitMessageForStep = input.commitMessage; | ||
| 1610 | let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined; | ||
| 1611 | |||
| 1612 | const modelSelection = yield* serverSettingsService.getSettings.pipe( | ||
| 1613 | Effect.map((settings) => settings.textGenerationModelSelection), | ||
| 1614 | Effect.mapError((cause) => | ||
| 1615 | gitManagerError("runStackedAction", "Failed to get server settings.", cause), | ||
| 1616 | ), | ||
| 1617 | ); | ||
| 1618 | |||
| 1619 | if (input.featureBranch) { | ||
| 1620 | yield* Ref.set(currentPhase, Option.some("branch")); | ||
| 1621 | yield* progress.emit({ | ||
| 1622 | kind: "phase_started", | ||
| 1623 | phase: "branch", | ||
| 1624 | label: "Preparing feature branch...", | ||
| 1625 | }); | ||
| 1626 | const result = yield* runFeatureBranchStep( | ||
| 1627 | modelSelection, | ||
| 1628 | input.cwd, | ||
| 1629 | initialStatus.branch, | ||
| 1630 | input.commitMessage, | ||
| 1631 | input.filePaths, | ||
| 1632 | ); | ||
| 1633 | branchStep = result.branchStep; | ||
| 1634 | commitMessageForStep = result.resolvedCommitMessage; | ||
| 1635 | preResolvedCommitSuggestion = result.resolvedCommitSuggestion; | ||
| 1636 | } else { | ||
| 1637 | branchStep = { status: "skipped_not_requested" as const }; | ||
| 1638 | } | ||
| 1639 | |||
| 1640 | const currentBranch = branchStep.name ?? initialStatus.branch; | ||
| 1641 | const commitAction = isCommitAction(input.action) ? input.action : null; | ||
| 1642 | |||
| 1643 | const commit = commitAction | ||
| 1644 | ? yield* Ref.set(currentPhase, Option.some("commit")).pipe( | ||
| 1645 | Effect.flatMap(() => | ||
| 1646 | runCommitStep( | ||
| 1647 | modelSelection, | ||
| 1648 | input.cwd, | ||
| 1649 | commitAction, | ||
| 1650 | currentBranch, | ||
| 1651 | commitMessageForStep, | ||
| 1652 | preResolvedCommitSuggestion, | ||
| 1653 | input.filePaths, | ||
| 1654 | options?.progressReporter, | ||
| 1655 | progress.actionId, | ||
| 1656 | ), | ||
| 1657 | ), | ||
| 1658 | ) | ||
| 1659 | : { status: "skipped_not_requested" as const }; | ||
| 1660 | |||
| 1661 | const push = wantsPush | ||
| 1662 | ? yield* progress | ||
| 1663 | .emit({ | ||
| 1664 | kind: "phase_started", | ||
| 1665 | phase: "push", | ||
| 1666 | label: "Pushing...", | ||
| 1667 | }) | ||
| 1668 | .pipe( | ||
| 1669 | Effect.tap(() => Ref.set(currentPhase, Option.some("push"))), | ||
| 1670 | Effect.flatMap(() => gitCore.pushCurrentBranch(input.cwd, currentBranch)), | ||
| 1671 | ) | ||
| 1672 | : { status: "skipped_not_requested" as const }; | ||
| 1673 | |||
| 1674 | const pr = wantsPr | ||
| 1675 | ? yield* progress | ||
| 1676 | .emit({ | ||
| 1677 | kind: "phase_started", | ||
| 1678 | phase: "pr", | ||
| 1679 | label: "Preparing PR...", | ||
| 1680 | }) | ||
| 1681 | .pipe( | ||
| 1682 | Effect.tap(() => Ref.set(currentPhase, Option.some("pr"))), | ||
| 1683 | Effect.flatMap(() => | ||
| 1684 | runPrStep(modelSelection, input.cwd, currentBranch, progress.emit), | ||
| 1685 | ), | ||
| 1686 | ) | ||
| 1687 | : { status: "skipped_not_requested" as const }; | ||
| 1688 | |||
| 1689 | const toast = yield* buildCompletionToast(input.cwd, { | ||
| 1690 | action: input.action, | ||
| 1691 | branch: branchStep, | ||
| 1692 | commit, | ||
| 1693 | push, | ||
| 1694 | pr, | ||
| 1695 | }); | ||
| 1696 | |||
| 1697 | const result = { | ||
| 1698 | action: input.action, | ||
| 1699 | branch: branchStep, | ||
| 1700 | commit, | ||
| 1701 | push, | ||
| 1702 | pr, | ||
| 1703 | toast, | ||
| 1704 | }; | ||
| 1705 | yield* progress.emit({ | ||
| 1706 | kind: "action_finished", | ||
| 1707 | result, | ||
| 1708 | }); | ||
| 1709 | return result; | ||
| 1710 | }); | ||
| 1711 | |||
| 1712 | return yield* runAction().pipe( | ||
| 1713 | Effect.ensuring(invalidateStatus(input.cwd)), | ||
| 1714 | Effect.tapError((error) => | ||
| 1715 | Effect.flatMap(Ref.get(currentPhase), (phase) => | ||
| 1716 | progress.emit({ | ||
| 1717 | kind: "action_failed", | ||
| 1718 | phase: Option.getOrNull(phase), | ||
| 1719 | message: error.message, | ||
| 1720 | }), | ||
| 1721 | ), | ||
| 1722 | ), | ||
| 1723 | ); | ||
| 1724 | }, | ||
| 1725 | ); | ||
| 1726 | |||
| 1727 | return { | ||
| 1728 | localStatus, | ||
| 1729 | remoteStatus, | ||
| 1730 | status, | ||
| 1731 | invalidateLocalStatus, | ||
| 1732 | invalidateRemoteStatus, | ||
| 1733 | invalidateStatus, | ||
| 1734 | resolvePullRequest, | ||
| 1735 | preparePullRequestThread, | ||
| 1736 | runStackedAction, | ||
| 1737 | } satisfies GitManagerShape; | ||
| 1738 | }); | ||
| 1739 | |||
| 1740 | export const GitManagerLive = Layer.effect(GitManager, makeGitManager()); | ||
apps/server/src/git/Layers/GitStatusBroadcaster.test.ts deleted-312| ... | @@ -1,312 +0,0 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import { Deferred, Effect, Exit, Layer, Option, Scope, Stream } from "effect"; | ||
| 3 | import type { | ||
| 4 | GitStatusLocalResult, | ||
| 5 | GitStatusRemoteResult, | ||
| 6 | GitStatusResult, | ||
| 7 | GitStatusStreamEvent, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import { describe } from "vitest"; | ||
| 10 | |||
| 11 | import { GitStatusBroadcaster } from "../Services/GitStatusBroadcaster.ts"; | ||
| 12 | import { GitStatusBroadcasterLive } from "./GitStatusBroadcaster.ts"; | ||
| 13 | import { type GitManagerShape, GitManager } from "../Services/GitManager.ts"; | ||
| 14 | |||
| 15 | const baseLocalStatus: GitStatusLocalResult = { | ||
| 16 | isRepo: true, | ||
| 17 | hostingProvider: { | ||
| 18 | kind: "github", | ||
| 19 | name: "GitHub", | ||
| 20 | baseUrl: "https://github.com", | ||
| 21 | }, | ||
| 22 | hasOriginRemote: true, | ||
| 23 | isDefaultBranch: false, | ||
| 24 | branch: "feature/status-broadcast", | ||
| 25 | hasWorkingTreeChanges: false, | ||
| 26 | workingTree: { files: [], insertions: 0, deletions: 0 }, | ||
| 27 | }; | ||
| 28 | |||
| 29 | const baseRemoteStatus: GitStatusRemoteResult = { | ||
| 30 | hasUpstream: true, | ||
| 31 | aheadCount: 0, | ||
| 32 | behindCount: 0, | ||
| 33 | pr: null, | ||
| 34 | }; | ||
| 35 | |||
| 36 | const baseStatus: GitStatusResult = { | ||
| 37 | ...baseLocalStatus, | ||
| 38 | ...baseRemoteStatus, | ||
| 39 | }; | ||
| 40 | |||
| 41 | function makeTestLayer(state: { | ||
| 42 | currentLocalStatus: GitStatusLocalResult; | ||
| 43 | currentRemoteStatus: GitStatusRemoteResult | null; | ||
| 44 | localStatusCalls: number; | ||
| 45 | remoteStatusCalls: number; | ||
| 46 | localInvalidationCalls: number; | ||
| 47 | remoteInvalidationCalls: number; | ||
| 48 | }) { | ||
| 49 | const gitManager: GitManagerShape = { | ||
| 50 | localStatus: () => | ||
| 51 | Effect.sync(() => { | ||
| 52 | state.localStatusCalls += 1; | ||
| 53 | return state.currentLocalStatus; | ||
| 54 | }), | ||
| 55 | remoteStatus: () => | ||
| 56 | Effect.sync(() => { | ||
| 57 | state.remoteStatusCalls += 1; | ||
| 58 | return state.currentRemoteStatus; | ||
| 59 | }), | ||
| 60 | status: () => Effect.die("status should not be called in this test"), | ||
| 61 | invalidateLocalStatus: () => | ||
| 62 | Effect.sync(() => { | ||
| 63 | state.localInvalidationCalls += 1; | ||
| 64 | }), | ||
| 65 | invalidateRemoteStatus: () => | ||
| 66 | Effect.sync(() => { | ||
| 67 | state.remoteInvalidationCalls += 1; | ||
| 68 | }), | ||
| 69 | invalidateStatus: () => Effect.die("invalidateStatus should not be called in this test"), | ||
| 70 | resolvePullRequest: () => Effect.die("resolvePullRequest should not be called in this test"), | ||
| 71 | preparePullRequestThread: () => | ||
| 72 | Effect.die("preparePullRequestThread should not be called in this test"), | ||
| 73 | runStackedAction: () => Effect.die("runStackedAction should not be called in this test"), | ||
| 74 | }; | ||
| 75 | |||
| 76 | return GitStatusBroadcasterLive.pipe(Layer.provide(Layer.succeed(GitManager, gitManager))); | ||
| 77 | } | ||
| 78 | |||
| 79 | describe("GitStatusBroadcasterLive", () => { | ||
| 80 | it.effect("reuses the cached git status across repeated reads", () => { | ||
| 81 | const state = { | ||
| 82 | currentLocalStatus: baseLocalStatus, | ||
| 83 | currentRemoteStatus: baseRemoteStatus, | ||
| 84 | localStatusCalls: 0, | ||
| 85 | remoteStatusCalls: 0, | ||
| 86 | localInvalidationCalls: 0, | ||
| 87 | remoteInvalidationCalls: 0, | ||
| 88 | }; | ||
| 89 | |||
| 90 | return Effect.gen(function* () { | ||
| 91 | const broadcaster = yield* GitStatusBroadcaster; | ||
| 92 | |||
| 93 | const first = yield* broadcaster.getStatus({ cwd: "/repo" }); | ||
| 94 | const second = yield* broadcaster.getStatus({ cwd: "/repo" }); | ||
| 95 | |||
| 96 | assert.deepStrictEqual(first, baseStatus); | ||
| 97 | assert.deepStrictEqual(second, baseStatus); | ||
| 98 | assert.equal(state.localStatusCalls, 1); | ||
| 99 | assert.equal(state.remoteStatusCalls, 1); | ||
| 100 | assert.equal(state.localInvalidationCalls, 0); | ||
| 101 | assert.equal(state.remoteInvalidationCalls, 0); | ||
| 102 | }).pipe(Effect.provide(makeTestLayer(state))); | ||
| 103 | }); | ||
| 104 | |||
| 105 | it.effect("refreshes the cached snapshot after explicit invalidation", () => { | ||
| 106 | const state = { | ||
| 107 | currentLocalStatus: baseLocalStatus, | ||
| 108 | currentRemoteStatus: baseRemoteStatus, | ||
| 109 | localStatusCalls: 0, | ||
| 110 | remoteStatusCalls: 0, | ||
| 111 | localInvalidationCalls: 0, | ||
| 112 | remoteInvalidationCalls: 0, | ||
| 113 | }; | ||
| 114 | |||
| 115 | return Effect.gen(function* () { | ||
| 116 | const broadcaster = yield* GitStatusBroadcaster; | ||
| 117 | const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); | ||
| 118 | |||
| 119 | state.currentLocalStatus = { | ||
| 120 | ...baseLocalStatus, | ||
| 121 | branch: "feature/updated-status", | ||
| 122 | }; | ||
| 123 | state.currentRemoteStatus = { | ||
| 124 | ...baseRemoteStatus, | ||
| 125 | aheadCount: 2, | ||
| 126 | }; | ||
| 127 | const refreshed = yield* broadcaster.refreshStatus("/repo"); | ||
| 128 | const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); | ||
| 129 | |||
| 130 | assert.deepStrictEqual(initial, baseStatus); | ||
| 131 | assert.deepStrictEqual(refreshed, { | ||
| 132 | ...state.currentLocalStatus, | ||
| 133 | ...state.currentRemoteStatus, | ||
| 134 | }); | ||
| 135 | assert.deepStrictEqual(cached, { | ||
| 136 | ...state.currentLocalStatus, | ||
| 137 | ...state.currentRemoteStatus, | ||
| 138 | }); | ||
| 139 | assert.equal(state.localStatusCalls, 2); | ||
| 140 | assert.equal(state.remoteStatusCalls, 2); | ||
| 141 | assert.equal(state.localInvalidationCalls, 1); | ||
| 142 | assert.equal(state.remoteInvalidationCalls, 1); | ||
| 143 | }).pipe(Effect.provide(makeTestLayer(state))); | ||
| 144 | }); | ||
| 145 | |||
| 146 | it.effect("refreshes only the cached local snapshot when requested", () => { | ||
| 147 | const state = { | ||
| 148 | currentLocalStatus: baseLocalStatus, | ||
| 149 | currentRemoteStatus: baseRemoteStatus, | ||
| 150 | localStatusCalls: 0, | ||
| 151 | remoteStatusCalls: 0, | ||
| 152 | localInvalidationCalls: 0, | ||
| 153 | remoteInvalidationCalls: 0, | ||
| 154 | }; | ||
| 155 | |||
| 156 | return Effect.gen(function* () { | ||
| 157 | const broadcaster = yield* GitStatusBroadcaster; | ||
| 158 | const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); | ||
| 159 | |||
| 160 | state.currentLocalStatus = { | ||
| 161 | ...baseLocalStatus, | ||
| 162 | branch: "feature/local-only-refresh", | ||
| 163 | hasWorkingTreeChanges: true, | ||
| 164 | }; | ||
| 165 | |||
| 166 | const refreshedLocal = yield* broadcaster.refreshLocalStatus("/repo"); | ||
| 167 | const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); | ||
| 168 | |||
| 169 | assert.deepStrictEqual(initial, baseStatus); | ||
| 170 | assert.deepStrictEqual(refreshedLocal, state.currentLocalStatus); | ||
| 171 | assert.deepStrictEqual(cached, { | ||
| 172 | ...state.currentLocalStatus, | ||
| 173 | ...baseRemoteStatus, | ||
| 174 | }); | ||
| 175 | assert.equal(state.localStatusCalls, 2); | ||
| 176 | assert.equal(state.remoteStatusCalls, 1); | ||
| 177 | assert.equal(state.localInvalidationCalls, 1); | ||
| 178 | assert.equal(state.remoteInvalidationCalls, 0); | ||
| 179 | }).pipe(Effect.provide(makeTestLayer(state))); | ||
| 180 | }); | ||
| 181 | |||
| 182 | it.effect("streams a local snapshot first and remote updates later", () => { | ||
| 183 | const state = { | ||
| 184 | currentLocalStatus: baseLocalStatus, | ||
| 185 | currentRemoteStatus: baseRemoteStatus, | ||
| 186 | localStatusCalls: 0, | ||
| 187 | remoteStatusCalls: 0, | ||
| 188 | localInvalidationCalls: 0, | ||
| 189 | remoteInvalidationCalls: 0, | ||
| 190 | }; | ||
| 191 | |||
| 192 | return Effect.gen(function* () { | ||
| 193 | const broadcaster = yield* GitStatusBroadcaster; | ||
| 194 | const snapshotDeferred = yield* Deferred.make<GitStatusStreamEvent>(); | ||
| 195 | const remoteUpdatedDeferred = yield* Deferred.make<GitStatusStreamEvent>(); | ||
| 196 | yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => { | ||
| 197 | if (event._tag === "snapshot") { | ||
| 198 | return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); | ||
| 199 | } | ||
| 200 | if (event._tag === "remoteUpdated") { | ||
| 201 | return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore); | ||
| 202 | } | ||
| 203 | return Effect.void; | ||
| 204 | }).pipe(Effect.forkScoped); | ||
| 205 | |||
| 206 | const snapshot = yield* Deferred.await(snapshotDeferred); | ||
| 207 | yield* broadcaster.refreshStatus("/repo"); | ||
| 208 | const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred); | ||
| 209 | |||
| 210 | assert.deepStrictEqual(snapshot, { | ||
| 211 | _tag: "snapshot", | ||
| 212 | local: baseLocalStatus, | ||
| 213 | remote: null, | ||
| 214 | } satisfies GitStatusStreamEvent); | ||
| 215 | assert.deepStrictEqual(remoteUpdated, { | ||
| 216 | _tag: "remoteUpdated", | ||
| 217 | remote: baseRemoteStatus, | ||
| 218 | } satisfies GitStatusStreamEvent); | ||
| 219 | }).pipe(Effect.provide(makeTestLayer(state))); | ||
| 220 | }); | ||
| 221 | |||
| 222 | it.effect("stops the remote poller after the last stream subscriber disconnects", () => { | ||
| 223 | const state = { | ||
| 224 | currentLocalStatus: baseLocalStatus, | ||
| 225 | currentRemoteStatus: baseRemoteStatus, | ||
| 226 | localStatusCalls: 0, | ||
| 227 | remoteStatusCalls: 0, | ||
| 228 | localInvalidationCalls: 0, | ||
| 229 | remoteInvalidationCalls: 0, | ||
| 230 | }; | ||
| 231 | let remoteInterruptedDeferred: Deferred.Deferred<void, never> | null = null; | ||
| 232 | let remoteStartedDeferred: Deferred.Deferred<void, never> | null = null; | ||
| 233 | const testLayer = GitStatusBroadcasterLive.pipe( | ||
| 234 | Layer.provide( | ||
| 235 | Layer.succeed(GitManager, { | ||
| 236 | localStatus: () => | ||
| 237 | Effect.sync(() => { | ||
| 238 | state.localStatusCalls += 1; | ||
| 239 | return state.currentLocalStatus; | ||
| 240 | }), | ||
| 241 | remoteStatus: () => | ||
| 242 | Effect.sync(() => { | ||
| 243 | state.remoteStatusCalls += 1; | ||
| 244 | }).pipe( | ||
| 245 | Effect.andThen( | ||
| 246 | remoteStartedDeferred | ||
| 247 | ? Deferred.succeed(remoteStartedDeferred, undefined).pipe(Effect.ignore) | ||
| 248 | : Effect.void, | ||
| 249 | ), | ||
| 250 | Effect.andThen(Effect.never as Effect.Effect<GitStatusRemoteResult | null, never>), | ||
| 251 | Effect.onInterrupt(() => | ||
| 252 | remoteInterruptedDeferred | ||
| 253 | ? Deferred.succeed(remoteInterruptedDeferred, undefined).pipe(Effect.ignore) | ||
| 254 | : Effect.void, | ||
| 255 | ), | ||
| 256 | ), | ||
| 257 | status: () => Effect.die("status should not be called in this test"), | ||
| 258 | invalidateLocalStatus: () => | ||
| 259 | Effect.sync(() => { | ||
| 260 | state.localInvalidationCalls += 1; | ||
| 261 | }), | ||
| 262 | invalidateRemoteStatus: () => | ||
| 263 | Effect.sync(() => { | ||
| 264 | state.remoteInvalidationCalls += 1; | ||
| 265 | }), | ||
| 266 | invalidateStatus: () => Effect.die("invalidateStatus should not be called in this test"), | ||
| 267 | resolvePullRequest: () => | ||
| 268 | Effect.die("resolvePullRequest should not be called in this test"), | ||
| 269 | preparePullRequestThread: () => | ||
| 270 | Effect.die("preparePullRequestThread should not be called in this test"), | ||
| 271 | runStackedAction: () => Effect.die("runStackedAction should not be called in this test"), | ||
| 272 | } satisfies GitManagerShape), | ||
| 273 | ), | ||
| 274 | ); | ||
| 275 | |||
| 276 | return Effect.gen(function* () { | ||
| 277 | const remoteInterrupted = yield* Deferred.make<void>(); | ||
| 278 | const remoteStarted = yield* Deferred.make<void>(); | ||
| 279 | remoteInterruptedDeferred = remoteInterrupted; | ||
| 280 | remoteStartedDeferred = remoteStarted; | ||
| 281 | |||
| 282 | const broadcaster = yield* GitStatusBroadcaster; | ||
| 283 | const firstSnapshot = yield* Deferred.make<GitStatusStreamEvent>(); | ||
| 284 | const secondSnapshot = yield* Deferred.make<GitStatusStreamEvent>(); | ||
| 285 | const firstScope = yield* Scope.make(); | ||
| 286 | const secondScope = yield* Scope.make(); | ||
| 287 | yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => | ||
| 288 | event._tag === "snapshot" | ||
| 289 | ? Deferred.succeed(firstSnapshot, event).pipe(Effect.ignore) | ||
| 290 | : Effect.void, | ||
| 291 | ).pipe(Effect.forkIn(firstScope)); | ||
| 292 | yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => | ||
| 293 | event._tag === "snapshot" | ||
| 294 | ? Deferred.succeed(secondSnapshot, event).pipe(Effect.ignore) | ||
| 295 | : Effect.void, | ||
| 296 | ).pipe(Effect.forkIn(secondScope)); | ||
| 297 | |||
| 298 | yield* Deferred.await(firstSnapshot); | ||
| 299 | yield* Deferred.await(secondSnapshot); | ||
| 300 | yield* Deferred.await(remoteStarted); | ||
| 301 | |||
| 302 | assert.equal(state.remoteStatusCalls, 1); | ||
| 303 | |||
| 304 | yield* Scope.close(firstScope, Exit.void); | ||
| 305 | assert.equal(Option.isNone(yield* Deferred.poll(remoteInterrupted)), true); | ||
| 306 | |||
| 307 | yield* Scope.close(secondScope, Exit.void).pipe(Effect.forkScoped); | ||
| 308 | yield* Deferred.await(remoteInterrupted); | ||
| 309 | assert.equal(Option.isSome(yield* Deferred.poll(remoteInterrupted)), true); | ||
| 310 | }).pipe(Effect.provide(testLayer)); | ||
| 311 | }); | ||
| 312 | }); | ||
apps/server/src/git/Layers/GitStatusBroadcaster.ts deleted-311| ... | @@ -1,311 +0,0 @@ | ||
| 1 | import { realpathSync } from "node:fs"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | Duration, | ||
| 5 | Effect, | ||
| 6 | Exit, | ||
| 7 | Fiber, | ||
| 8 | Layer, | ||
| 9 | PubSub, | ||
| 10 | Ref, | ||
| 11 | Scope, | ||
| 12 | Stream, | ||
| 13 | SynchronizedRef, | ||
| 14 | } from "effect"; | ||
| 15 | import type { | ||
| 16 | GitStatusInput, | ||
| 17 | GitStatusLocalResult, | ||
| 18 | GitStatusRemoteResult, | ||
| 19 | GitStatusStreamEvent, | ||
| 20 | } from "@t3tools/contracts"; | ||
| 21 | import { mergeGitStatusParts } from "@t3tools/shared/git"; | ||
| 22 | |||
| 23 | import { | ||
| 24 | GitStatusBroadcaster, | ||
| 25 | type GitStatusBroadcasterShape, | ||
| 26 | } from "../Services/GitStatusBroadcaster.ts"; | ||
| 27 | import { GitManager } from "../Services/GitManager.ts"; | ||
| 28 | |||
| 29 | const GIT_STATUS_REFRESH_INTERVAL = Duration.seconds(30); | ||
| 30 | |||
| 31 | interface GitStatusChange { | ||
| 32 | readonly cwd: string; | ||
| 33 | readonly event: GitStatusStreamEvent; | ||
| 34 | } | ||
| 35 | |||
| 36 | interface CachedValue<T> { | ||
| 37 | readonly fingerprint: string; | ||
| 38 | readonly value: T; | ||
| 39 | } | ||
| 40 | |||
| 41 | interface CachedGitStatus { | ||
| 42 | readonly local: CachedValue<GitStatusLocalResult> | null; | ||
| 43 | readonly remote: CachedValue<GitStatusRemoteResult | null> | null; | ||
| 44 | } | ||
| 45 | |||
| 46 | interface ActiveRemotePoller { | ||
| 47 | readonly fiber: Fiber.Fiber<void, never>; | ||
| 48 | readonly subscriberCount: number; | ||
| 49 | } | ||
| 50 | |||
| 51 | function normalizeCwd(cwd: string): string { | ||
| 52 | try { | ||
| 53 | return realpathSync.native(cwd); | ||
| 54 | } catch { | ||
| 55 | return cwd; | ||
| 56 | } | ||
| 57 | } | ||
| 58 | |||
| 59 | function fingerprintStatusPart(status: unknown): string { | ||
| 60 | return JSON.stringify(status); | ||
| 61 | } | ||
| 62 | |||
| 63 | export const GitStatusBroadcasterLive = Layer.effect( | ||
| 64 | GitStatusBroadcaster, | ||
| 65 | Effect.gen(function* () { | ||
| 66 | const gitManager = yield* GitManager; | ||
| 67 | const changesPubSub = yield* Effect.acquireRelease( | ||
| 68 | PubSub.unbounded<GitStatusChange>(), | ||
| 69 | (pubsub) => PubSub.shutdown(pubsub), | ||
| 70 | ); | ||
| 71 | const broadcasterScope = yield* Effect.acquireRelease(Scope.make(), (scope) => | ||
| 72 | Scope.close(scope, Exit.void), | ||
| 73 | ); | ||
| 74 | const cacheRef = yield* Ref.make(new Map<string, CachedGitStatus>()); | ||
| 75 | const pollersRef = yield* SynchronizedRef.make(new Map<string, ActiveRemotePoller>()); | ||
| 76 | |||
| 77 | const getCachedStatus = Effect.fn("getCachedStatus")(function* (cwd: string) { | ||
| 78 | return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null)); | ||
| 79 | }); | ||
| 80 | |||
| 81 | const updateCachedLocalStatus = Effect.fn("updateCachedLocalStatus")(function* ( | ||
| 82 | cwd: string, | ||
| 83 | local: GitStatusLocalResult, | ||
| 84 | options?: { publish?: boolean }, | ||
| 85 | ) { | ||
| 86 | const nextLocal = { | ||
| 87 | fingerprint: fingerprintStatusPart(local), | ||
| 88 | value: local, | ||
| 89 | } satisfies CachedValue<GitStatusLocalResult>; | ||
| 90 | const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { | ||
| 91 | const previous = cache.get(cwd) ?? { local: null, remote: null }; | ||
| 92 | const nextCache = new Map(cache); | ||
| 93 | nextCache.set(cwd, { | ||
| 94 | ...previous, | ||
| 95 | local: nextLocal, | ||
| 96 | }); | ||
| 97 | return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const; | ||
| 98 | }); | ||
| 99 | |||
| 100 | if (options?.publish && shouldPublish) { | ||
| 101 | yield* PubSub.publish(changesPubSub, { | ||
| 102 | cwd, | ||
| 103 | event: { | ||
| 104 | _tag: "localUpdated", | ||
| 105 | local, | ||
| 106 | }, | ||
| 107 | }); | ||
| 108 | } | ||
| 109 | |||
| 110 | return local; | ||
| 111 | }); | ||
| 112 | |||
| 113 | const updateCachedRemoteStatus = Effect.fn("updateCachedRemoteStatus")(function* ( | ||
| 114 | cwd: string, | ||
| 115 | remote: GitStatusRemoteResult | null, | ||
| 116 | options?: { publish?: boolean }, | ||
| 117 | ) { | ||
| 118 | const nextRemote = { | ||
| 119 | fingerprint: fingerprintStatusPart(remote), | ||
| 120 | value: remote, | ||
| 121 | } satisfies CachedValue<GitStatusRemoteResult | null>; | ||
| 122 | const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { | ||
| 123 | const previous = cache.get(cwd) ?? { local: null, remote: null }; | ||
| 124 | const nextCache = new Map(cache); | ||
| 125 | nextCache.set(cwd, { | ||
| 126 | ...previous, | ||
| 127 | remote: nextRemote, | ||
| 128 | }); | ||
| 129 | return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const; | ||
| 130 | }); | ||
| 131 | |||
| 132 | if (options?.publish && shouldPublish) { | ||
| 133 | yield* PubSub.publish(changesPubSub, { | ||
| 134 | cwd, | ||
| 135 | event: { | ||
| 136 | _tag: "remoteUpdated", | ||
| 137 | remote, | ||
| 138 | }, | ||
| 139 | }); | ||
| 140 | } | ||
| 141 | |||
| 142 | return remote; | ||
| 143 | }); | ||
| 144 | |||
| 145 | const loadLocalStatus = Effect.fn("loadLocalStatus")(function* (cwd: string) { | ||
| 146 | const local = yield* gitManager.localStatus({ cwd }); | ||
| 147 | return yield* updateCachedLocalStatus(cwd, local); | ||
| 148 | }); | ||
| 149 | |||
| 150 | const loadRemoteStatus = Effect.fn("loadRemoteStatus")(function* (cwd: string) { | ||
| 151 | const remote = yield* gitManager.remoteStatus({ cwd }); | ||
| 152 | return yield* updateCachedRemoteStatus(cwd, remote); | ||
| 153 | }); | ||
| 154 | |||
| 155 | const getOrLoadLocalStatus = Effect.fn("getOrLoadLocalStatus")(function* (cwd: string) { | ||
| 156 | const cached = yield* getCachedStatus(cwd); | ||
| 157 | if (cached?.local) { | ||
| 158 | return cached.local.value; | ||
| 159 | } | ||
| 160 | return yield* loadLocalStatus(cwd); | ||
| 161 | }); | ||
| 162 | |||
| 163 | const getOrLoadRemoteStatus = Effect.fn("getOrLoadRemoteStatus")(function* (cwd: string) { | ||
| 164 | const cached = yield* getCachedStatus(cwd); | ||
| 165 | if (cached?.remote) { | ||
| 166 | return cached.remote.value; | ||
| 167 | } | ||
| 168 | return yield* loadRemoteStatus(cwd); | ||
| 169 | }); | ||
| 170 | |||
| 171 | const getStatus: GitStatusBroadcasterShape["getStatus"] = Effect.fn("getStatus")(function* ( | ||
| 172 | input: GitStatusInput, | ||
| 173 | ) { | ||
| 174 | const normalizedCwd = normalizeCwd(input.cwd); | ||
| 175 | const [local, remote] = yield* Effect.all([ | ||
| 176 | getOrLoadLocalStatus(normalizedCwd), | ||
| 177 | getOrLoadRemoteStatus(normalizedCwd), | ||
| 178 | ]); | ||
| 179 | return mergeGitStatusParts(local, remote); | ||
| 180 | }); | ||
| 181 | |||
| 182 | const refreshLocalStatus: GitStatusBroadcasterShape["refreshLocalStatus"] = Effect.fn( | ||
| 183 | "refreshLocalStatus", | ||
| 184 | )(function* (cwd) { | ||
| 185 | const normalizedCwd = normalizeCwd(cwd); | ||
| 186 | yield* gitManager.invalidateLocalStatus(normalizedCwd); | ||
| 187 | const local = yield* gitManager.localStatus({ cwd: normalizedCwd }); | ||
| 188 | return yield* updateCachedLocalStatus(normalizedCwd, local, { publish: true }); | ||
| 189 | }); | ||
| 190 | |||
| 191 | const refreshRemoteStatus = Effect.fn("refreshRemoteStatus")(function* (cwd: string) { | ||
| 192 | yield* gitManager.invalidateRemoteStatus(cwd); | ||
| 193 | const remote = yield* gitManager.remoteStatus({ cwd }); | ||
| 194 | return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); | ||
| 195 | }); | ||
| 196 | |||
| 197 | const refreshStatus: GitStatusBroadcasterShape["refreshStatus"] = Effect.fn("refreshStatus")( | ||
| 198 | function* (cwd) { | ||
| 199 | const normalizedCwd = normalizeCwd(cwd); | ||
| 200 | const [local, remote] = yield* Effect.all([ | ||
| 201 | refreshLocalStatus(normalizedCwd), | ||
| 202 | refreshRemoteStatus(normalizedCwd), | ||
| 203 | ]); | ||
| 204 | return mergeGitStatusParts(local, remote); | ||
| 205 | }, | ||
| 206 | ); | ||
| 207 | |||
| 208 | const makeRemoteRefreshLoop = (cwd: string) => { | ||
| 209 | const logRefreshFailure = (error: Error) => | ||
| 210 | Effect.logWarning("git remote status refresh failed", { | ||
| 211 | cwd, | ||
| 212 | detail: error.message, | ||
| 213 | }); | ||
| 214 | |||
| 215 | return refreshRemoteStatus(cwd).pipe( | ||
| 216 | Effect.catch(logRefreshFailure), | ||
| 217 | Effect.andThen( | ||
| 218 | Effect.forever( | ||
| 219 | Effect.sleep(GIT_STATUS_REFRESH_INTERVAL).pipe( | ||
| 220 | Effect.andThen(refreshRemoteStatus(cwd).pipe(Effect.catch(logRefreshFailure))), | ||
| 221 | ), | ||
| 222 | ), | ||
| 223 | ), | ||
| 224 | ); | ||
| 225 | }; | ||
| 226 | |||
| 227 | const retainRemotePoller = Effect.fn("retainRemotePoller")(function* (cwd: string) { | ||
| 228 | yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { | ||
| 229 | const existing = activePollers.get(cwd); | ||
| 230 | if (existing) { | ||
| 231 | const nextPollers = new Map(activePollers); | ||
| 232 | nextPollers.set(cwd, { | ||
| 233 | ...existing, | ||
| 234 | subscriberCount: existing.subscriberCount + 1, | ||
| 235 | }); | ||
| 236 | return Effect.succeed([undefined, nextPollers] as const); | ||
| 237 | } | ||
| 238 | |||
| 239 | return makeRemoteRefreshLoop(cwd).pipe( | ||
| 240 | Effect.forkIn(broadcasterScope), | ||
| 241 | Effect.map((fiber) => { | ||
| 242 | const nextPollers = new Map(activePollers); | ||
| 243 | nextPollers.set(cwd, { | ||
| 244 | fiber, | ||
| 245 | subscriberCount: 1, | ||
| 246 | }); | ||
| 247 | return [undefined, nextPollers] as const; | ||
| 248 | }), | ||
| 249 | ); | ||
| 250 | }); | ||
| 251 | }); | ||
| 252 | |||
| 253 | const releaseRemotePoller = Effect.fn("releaseRemotePoller")(function* (cwd: string) { | ||
| 254 | const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => { | ||
| 255 | const existing = activePollers.get(cwd); | ||
| 256 | if (!existing) { | ||
| 257 | return [null, activePollers] as const; | ||
| 258 | } | ||
| 259 | |||
| 260 | if (existing.subscriberCount > 1) { | ||
| 261 | const nextPollers = new Map(activePollers); | ||
| 262 | nextPollers.set(cwd, { | ||
| 263 | ...existing, | ||
| 264 | subscriberCount: existing.subscriberCount - 1, | ||
| 265 | }); | ||
| 266 | return [null, nextPollers] as const; | ||
| 267 | } | ||
| 268 | |||
| 269 | const nextPollers = new Map(activePollers); | ||
| 270 | nextPollers.delete(cwd); | ||
| 271 | return [existing.fiber, nextPollers] as const; | ||
| 272 | }); | ||
| 273 | |||
| 274 | if (pollerToInterrupt) { | ||
| 275 | yield* Fiber.interrupt(pollerToInterrupt).pipe(Effect.ignore); | ||
| 276 | } | ||
| 277 | }); | ||
| 278 | |||
| 279 | const streamStatus: GitStatusBroadcasterShape["streamStatus"] = (input) => | ||
| 280 | Stream.unwrap( | ||
| 281 | Effect.gen(function* () { | ||
| 282 | const normalizedCwd = normalizeCwd(input.cwd); | ||
| 283 | const subscription = yield* PubSub.subscribe(changesPubSub); | ||
| 284 | const initialLocal = yield* getOrLoadLocalStatus(normalizedCwd); | ||
| 285 | const initialRemote = (yield* getCachedStatus(normalizedCwd))?.remote?.value ?? null; | ||
| 286 | yield* retainRemotePoller(normalizedCwd); | ||
| 287 | |||
| 288 | const release = releaseRemotePoller(normalizedCwd).pipe(Effect.ignore, Effect.asVoid); | ||
| 289 | |||
| 290 | return Stream.concat( | ||
| 291 | Stream.make({ | ||
| 292 | _tag: "snapshot" as const, | ||
| 293 | local: initialLocal, | ||
| 294 | remote: initialRemote, | ||
| 295 | }), | ||
| 296 | Stream.fromSubscription(subscription).pipe( | ||
| 297 | Stream.filter((event) => event.cwd === normalizedCwd), | ||
| 298 | Stream.map((event) => event.event), | ||
| 299 | ), | ||
| 300 | ).pipe(Stream.ensuring(release)); | ||
| 301 | }), | ||
| 302 | ); | ||
| 303 | |||
| 304 | return { | ||
| 305 | getStatus, | ||
| 306 | refreshLocalStatus, | ||
| 307 | refreshStatus, | ||
| 308 | streamStatus, | ||
| 309 | } satisfies GitStatusBroadcasterShape; | ||
| 310 | }), | ||
| 311 | ); | ||
apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts deleted-346| ... | @@ -1,346 +0,0 @@ | ||
| 1 | import { OpenCodeSettings, ProviderInstanceId } from "@t3tools/contracts"; | ||
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 3 | import { it } from "@effect/vitest"; | ||
| 4 | import { Duration, Effect, Layer, Schema } from "effect"; | ||
| 5 | import { TestClock } from "effect/testing"; | ||
| 6 | import { NetService } from "@t3tools/shared/Net"; | ||
| 7 | import { beforeEach, expect } from "vitest"; | ||
| 8 | |||
| 9 | import { ServerConfig } from "../../config.ts"; | ||
| 10 | import { | ||
| 11 | OpenCodeRuntime, | ||
| 12 | OpenCodeRuntimeError, | ||
| 13 | type OpenCodeRuntimeShape, | ||
| 14 | } from "../../provider/opencodeRuntime.ts"; | ||
| 15 | import { type TextGenerationShape } from "../Services/TextGeneration.ts"; | ||
| 16 | import { makeOpenCodeTextGeneration } from "./OpenCodeTextGeneration.ts"; | ||
| 17 | |||
| 18 | const runtimeMock = { | ||
| 19 | state: { | ||
| 20 | startCalls: [] as string[], | ||
| 21 | promptUrls: [] as string[], | ||
| 22 | authHeaders: [] as Array<string | null>, | ||
| 23 | closeCalls: [] as string[], | ||
| 24 | promptResult: undefined as | ||
| 25 | | { data?: { info?: { error?: unknown }; parts?: Array<{ type: string; text?: string }> } } | ||
| 26 | | undefined, | ||
| 27 | }, | ||
| 28 | reset() { | ||
| 29 | this.state.startCalls.length = 0; | ||
| 30 | this.state.promptUrls.length = 0; | ||
| 31 | this.state.authHeaders.length = 0; | ||
| 32 | this.state.closeCalls.length = 0; | ||
| 33 | this.state.promptResult = undefined; | ||
| 34 | }, | ||
| 35 | }; | ||
| 36 | |||
| 37 | const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { | ||
| 38 | startOpenCodeServerProcess: ({ binaryPath }) => | ||
| 39 | Effect.gen(function* () { | ||
| 40 | const index = runtimeMock.state.startCalls.length + 1; | ||
| 41 | const url = `http://127.0.0.1:${4_300 + index}`; | ||
| 42 | runtimeMock.state.startCalls.push(binaryPath); | ||
| 43 | // The production runtime binds server lifetime to the caller's scope. | ||
| 44 | // Mirror that here so the closeCalls probe observes scope close. | ||
| 45 | yield* Effect.addFinalizer(() => | ||
| 46 | Effect.sync(() => { | ||
| 47 | runtimeMock.state.closeCalls.push(url); | ||
| 48 | }), | ||
| 49 | ); | ||
| 50 | return { | ||
| 51 | url, | ||
| 52 | exitCode: Effect.never, | ||
| 53 | }; | ||
| 54 | }), | ||
| 55 | connectToOpenCodeServer: ({ serverUrl }) => | ||
| 56 | Effect.succeed({ | ||
| 57 | url: serverUrl ?? "http://127.0.0.1:4301", | ||
| 58 | exitCode: null, | ||
| 59 | external: Boolean(serverUrl), | ||
| 60 | }), | ||
| 61 | runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), | ||
| 62 | createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => | ||
| 63 | ({ | ||
| 64 | session: { | ||
| 65 | create: async () => ({ data: { id: `${baseUrl}/session` } }), | ||
| 66 | prompt: async () => { | ||
| 67 | runtimeMock.state.promptUrls.push(baseUrl); | ||
| 68 | runtimeMock.state.authHeaders.push( | ||
| 69 | serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, | ||
| 70 | ); | ||
| 71 | return ( | ||
| 72 | runtimeMock.state.promptResult ?? { | ||
| 73 | data: { | ||
| 74 | parts: [ | ||
| 75 | { | ||
| 76 | type: "text", | ||
| 77 | text: JSON.stringify({ | ||
| 78 | subject: "Improve OpenCode reuse", | ||
| 79 | body: "Reuse one server for the full action.", | ||
| 80 | }), | ||
| 81 | }, | ||
| 82 | ], | ||
| 83 | }, | ||
| 84 | } | ||
| 85 | ); | ||
| 86 | }, | ||
| 87 | }, | ||
| 88 | }) as unknown as ReturnType<OpenCodeRuntimeShape["createOpenCodeSdkClient"]>, | ||
| 89 | loadOpenCodeInventory: () => | ||
| 90 | Effect.fail( | ||
| 91 | new OpenCodeRuntimeError({ | ||
| 92 | operation: "loadOpenCodeInventory", | ||
| 93 | detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventory not used in this test", | ||
| 94 | cause: null, | ||
| 95 | }), | ||
| 96 | ), | ||
| 97 | }; | ||
| 98 | |||
| 99 | const DEFAULT_TEST_MODEL_SELECTION = { | ||
| 100 | instanceId: ProviderInstanceId.make("opencode"), | ||
| 101 | model: "openai/gpt-5", | ||
| 102 | }; | ||
| 103 | |||
| 104 | const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; | ||
| 105 | |||
| 106 | const OpenCodeTextGenerationTestLayer = Layer.succeed( | ||
| 107 | OpenCodeRuntime, | ||
| 108 | OpenCodeRuntimeTestDouble, | ||
| 109 | ).pipe( | ||
| 110 | Layer.provideMerge( | ||
| 111 | ServerConfig.layerTest(process.cwd(), { | ||
| 112 | prefix: "t3code-opencode-text-generation-test-", | ||
| 113 | }), | ||
| 114 | ), | ||
| 115 | Layer.provideMerge(NetService.layer), | ||
| 116 | Layer.provideMerge(NodeServices.layer), | ||
| 117 | ); | ||
| 118 | |||
| 119 | const OpenCodeTextGenerationExistingServerTestLayer = Layer.succeed( | ||
| 120 | OpenCodeRuntime, | ||
| 121 | OpenCodeRuntimeTestDouble, | ||
| 122 | ).pipe( | ||
| 123 | Layer.provideMerge( | ||
| 124 | ServerConfig.layerTest(process.cwd(), { | ||
| 125 | prefix: "t3code-opencode-text-generation-existing-server-test-", | ||
| 126 | }), | ||
| 127 | ), | ||
| 128 | Layer.provideMerge(NetService.layer), | ||
| 129 | Layer.provideMerge(NodeServices.layer), | ||
| 130 | ); | ||
| 131 | |||
| 132 | const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ | ||
| 133 | binaryPath: "fake-opencode", | ||
| 134 | }); | ||
| 135 | const EXISTING_SERVER_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ | ||
| 136 | binaryPath: "fake-opencode", | ||
| 137 | serverUrl: "http://127.0.0.1:9999", | ||
| 138 | serverPassword: "secret-password", | ||
| 139 | }); | ||
| 140 | |||
| 141 | function withOpenCodeTextGeneration<A, E, R>( | ||
| 142 | settings: OpenCodeSettings, | ||
| 143 | effectFn: (textGeneration: TextGenerationShape) => Effect.Effect<A, E, R>, | ||
| 144 | ) { | ||
| 145 | return Effect.gen(function* () { | ||
| 146 | const textGeneration = yield* makeOpenCodeTextGeneration(settings); | ||
| 147 | return yield* effectFn(textGeneration); | ||
| 148 | }).pipe(Effect.scoped); | ||
| 149 | } | ||
| 150 | |||
| 151 | beforeEach(() => { | ||
| 152 | runtimeMock.reset(); | ||
| 153 | }); | ||
| 154 | |||
| 155 | const advanceIdleClock = Effect.gen(function* () { | ||
| 156 | yield* Effect.yieldNow; | ||
| 157 | yield* TestClock.adjust(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS + 1)); | ||
| 158 | yield* Effect.yieldNow; | ||
| 159 | }); | ||
| 160 | |||
| 161 | it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGenerationLive", (it) => { | ||
| 162 | it.effect("reuses a warm server across back-to-back requests and closes it after idling", () => | ||
| 163 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => | ||
| 164 | Effect.gen(function* () { | ||
| 165 | yield* textGeneration.generateCommitMessage({ | ||
| 166 | cwd: process.cwd(), | ||
| 167 | branch: "feature/opencode-reuse", | ||
| 168 | stagedSummary: "M README.md", | ||
| 169 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 170 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 171 | }); | ||
| 172 | yield* textGeneration.generateCommitMessage({ | ||
| 173 | cwd: process.cwd(), | ||
| 174 | branch: "feature/opencode-reuse", | ||
| 175 | stagedSummary: "M README.md", | ||
| 176 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 177 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 178 | }); | ||
| 179 | |||
| 180 | expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]); | ||
| 181 | expect(runtimeMock.state.promptUrls).toEqual([ | ||
| 182 | "http://127.0.0.1:4301", | ||
| 183 | "http://127.0.0.1:4301", | ||
| 184 | ]); | ||
| 185 | expect(runtimeMock.state.closeCalls).toEqual([]); | ||
| 186 | |||
| 187 | yield* advanceIdleClock; | ||
| 188 | |||
| 189 | expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); | ||
| 190 | }), | ||
| 191 | ).pipe(Effect.provide(TestClock.layer())), | ||
| 192 | ); | ||
| 193 | |||
| 194 | it.effect("starts a new server after the warm server idles out", () => | ||
| 195 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => | ||
| 196 | Effect.gen(function* () { | ||
| 197 | yield* textGeneration.generateCommitMessage({ | ||
| 198 | cwd: process.cwd(), | ||
| 199 | branch: "feature/opencode-reuse", | ||
| 200 | stagedSummary: "M README.md", | ||
| 201 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 202 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 203 | }); | ||
| 204 | |||
| 205 | yield* advanceIdleClock; | ||
| 206 | |||
| 207 | yield* textGeneration.generateCommitMessage({ | ||
| 208 | cwd: process.cwd(), | ||
| 209 | branch: "feature/opencode-reuse", | ||
| 210 | stagedSummary: "M README.md", | ||
| 211 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 212 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 213 | }); | ||
| 214 | |||
| 215 | expect(runtimeMock.state.startCalls).toEqual(["fake-opencode", "fake-opencode"]); | ||
| 216 | expect(runtimeMock.state.promptUrls).toEqual([ | ||
| 217 | "http://127.0.0.1:4301", | ||
| 218 | "http://127.0.0.1:4302", | ||
| 219 | ]); | ||
| 220 | expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); | ||
| 221 | }), | ||
| 222 | ).pipe(Effect.provide(TestClock.layer())), | ||
| 223 | ); | ||
| 224 | |||
| 225 | it.effect("returns a typed empty-output error when OpenCode returns no text parts", () => | ||
| 226 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => | ||
| 227 | Effect.gen(function* () { | ||
| 228 | runtimeMock.state.promptResult = { data: {} }; | ||
| 229 | |||
| 230 | const error = yield* textGeneration | ||
| 231 | .generateCommitMessage({ | ||
| 232 | cwd: process.cwd(), | ||
| 233 | branch: "feature/opencode-reuse", | ||
| 234 | stagedSummary: "M README.md", | ||
| 235 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 236 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 237 | }) | ||
| 238 | .pipe(Effect.flip); | ||
| 239 | |||
| 240 | expect(error.message).toContain("OpenCode returned empty output."); | ||
| 241 | }), | ||
| 242 | ), | ||
| 243 | ); | ||
| 244 | |||
| 245 | it.effect("parses JSON returned as plain text output", () => | ||
| 246 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => | ||
| 247 | Effect.gen(function* () { | ||
| 248 | runtimeMock.state.promptResult = { | ||
| 249 | data: { | ||
| 250 | parts: [ | ||
| 251 | { | ||
| 252 | type: "text", | ||
| 253 | text: 'Here is the result:\n{"subject":"Tighten OpenCode parsing","body":"Handle JSON text output locally."}', | ||
| 254 | }, | ||
| 255 | ], | ||
| 256 | }, | ||
| 257 | }; | ||
| 258 | |||
| 259 | const result = yield* textGeneration.generateCommitMessage({ | ||
| 260 | cwd: process.cwd(), | ||
| 261 | branch: "feature/opencode-reuse", | ||
| 262 | stagedSummary: "M README.md", | ||
| 263 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 264 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 265 | }); | ||
| 266 | |||
| 267 | expect(result).toEqual({ | ||
| 268 | subject: "Tighten OpenCode parsing", | ||
| 269 | body: "Handle JSON text output locally.", | ||
| 270 | }); | ||
| 271 | }), | ||
| 272 | ), | ||
| 273 | ); | ||
| 274 | |||
| 275 | it.effect("surfaces the upstream OpenCode structured-output error message", () => | ||
| 276 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => | ||
| 277 | Effect.gen(function* () { | ||
| 278 | runtimeMock.state.promptResult = { | ||
| 279 | data: { | ||
| 280 | info: { | ||
| 281 | error: { | ||
| 282 | name: "StructuredOutputError", | ||
| 283 | data: { | ||
| 284 | message: "Model did not produce structured output", | ||
| 285 | retries: 2, | ||
| 286 | }, | ||
| 287 | }, | ||
| 288 | }, | ||
| 289 | }, | ||
| 290 | }; | ||
| 291 | |||
| 292 | const error = yield* textGeneration | ||
| 293 | .generateCommitMessage({ | ||
| 294 | cwd: process.cwd(), | ||
| 295 | branch: "feature/opencode-reuse", | ||
| 296 | stagedSummary: "M README.md", | ||
| 297 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 298 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 299 | }) | ||
| 300 | .pipe(Effect.flip); | ||
| 301 | |||
| 302 | expect(error.message).toContain("Model did not produce structured output"); | ||
| 303 | }), | ||
| 304 | ), | ||
| 305 | ); | ||
| 306 | }); | ||
| 307 | |||
| 308 | it.layer(OpenCodeTextGenerationExistingServerTestLayer)( | ||
| 309 | "OpenCodeTextGenerationLive with configured server URL", | ||
| 310 | (it) => { | ||
| 311 | it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () => | ||
| 312 | withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => | ||
| 313 | Effect.gen(function* () { | ||
| 314 | yield* textGeneration.generateCommitMessage({ | ||
| 315 | cwd: process.cwd(), | ||
| 316 | branch: "feature/opencode-reuse", | ||
| 317 | stagedSummary: "M README.md", | ||
| 318 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 319 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 320 | }); | ||
| 321 | yield* textGeneration.generateCommitMessage({ | ||
| 322 | cwd: process.cwd(), | ||
| 323 | branch: "feature/opencode-reuse", | ||
| 324 | stagedSummary: "M README.md", | ||
| 325 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 326 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 327 | }); | ||
| 328 | |||
| 329 | expect(runtimeMock.state.startCalls).toEqual([]); | ||
| 330 | expect(runtimeMock.state.promptUrls).toEqual([ | ||
| 331 | "http://127.0.0.1:9999", | ||
| 332 | "http://127.0.0.1:9999", | ||
| 333 | ]); | ||
| 334 | expect(runtimeMock.state.authHeaders).toEqual([ | ||
| 335 | `Basic ${btoa("opencode:secret-password")}`, | ||
| 336 | `Basic ${btoa("opencode:secret-password")}`, | ||
| 337 | ]); | ||
| 338 | |||
| 339 | yield* advanceIdleClock; | ||
| 340 | |||
| 341 | expect(runtimeMock.state.closeCalls).toEqual([]); | ||
| 342 | }), | ||
| 343 | ).pipe(Effect.provide(TestClock.layer())), | ||
| 344 | ); | ||
| 345 | }, | ||
| 346 | ); | ||
apps/server/src/git/Layers/OpenCodeTextGeneration.ts deleted-464| ... | @@ -1,464 +0,0 @@ | ||
| 1 | import { Effect, Exit, Fiber, Schema, Scope } from "effect"; | ||
| 2 | import * as Semaphore from "effect/Semaphore"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | TextGenerationError, | ||
| 6 | type ChatAttachment, | ||
| 7 | type ModelSelection, | ||
| 8 | type OpenCodeSettings, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | ||
| 11 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | ||
| 12 | |||
| 13 | import { ServerConfig } from "../../config.ts"; | ||
| 14 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | ||
| 15 | import { | ||
| 16 | buildBranchNamePrompt, | ||
| 17 | buildCommitMessagePrompt, | ||
| 18 | buildPrContentPrompt, | ||
| 19 | buildThreadTitlePrompt, | ||
| 20 | } from "../Prompts.ts"; | ||
| 21 | import { type TextGenerationShape } from "../Services/TextGeneration.ts"; | ||
| 22 | import { | ||
| 23 | extractJsonObject, | ||
| 24 | sanitizeCommitSubject, | ||
| 25 | sanitizePrTitle, | ||
| 26 | sanitizeThreadTitle, | ||
| 27 | } from "../Utils.ts"; | ||
| 28 | import { | ||
| 29 | OpenCodeRuntime, | ||
| 30 | type OpenCodeServerConnection, | ||
| 31 | type OpenCodeServerProcess, | ||
| 32 | openCodeRuntimeErrorDetail, | ||
| 33 | parseOpenCodeModelSlug, | ||
| 34 | toOpenCodeFileParts, | ||
| 35 | } from "../../provider/opencodeRuntime.ts"; | ||
| 36 | |||
| 37 | const OPENCODE_TEXT_GENERATION_IDLE_TTL = "30 seconds"; | ||
| 38 | |||
| 39 | function getOpenCodePromptErrorMessage(error: unknown): string | null { | ||
| 40 | if (!error || typeof error !== "object") { | ||
| 41 | return null; | ||
| 42 | } | ||
| 43 | |||
| 44 | const message = | ||
| 45 | "data" in error && | ||
| 46 | error.data && | ||
| 47 | typeof error.data === "object" && | ||
| 48 | "message" in error.data && | ||
| 49 | typeof error.data.message === "string" | ||
| 50 | ? error.data.message.trim() | ||
| 51 | : ""; | ||
| 52 | if (message.length > 0) { | ||
| 53 | return message; | ||
| 54 | } | ||
| 55 | |||
| 56 | if ("name" in error && typeof error.name === "string") { | ||
| 57 | const name = error.name.trim(); | ||
| 58 | return name.length > 0 ? name : null; | ||
| 59 | } | ||
| 60 | |||
| 61 | return null; | ||
| 62 | } | ||
| 63 | |||
| 64 | function getOpenCodeTextResponse(parts: ReadonlyArray<unknown> | undefined): string { | ||
| 65 | return (parts ?? []) | ||
| 66 | .flatMap((part) => { | ||
| 67 | if (!part || typeof part !== "object") { | ||
| 68 | return []; | ||
| 69 | } | ||
| 70 | if (!("type" in part) || part.type !== "text") { | ||
| 71 | return []; | ||
| 72 | } | ||
| 73 | if (!("text" in part) || typeof part.text !== "string") { | ||
| 74 | return []; | ||
| 75 | } | ||
| 76 | return [part.text]; | ||
| 77 | }) | ||
| 78 | .join("") | ||
| 79 | .trim(); | ||
| 80 | } | ||
| 81 | |||
| 82 | interface SharedOpenCodeTextGenerationServerState { | ||
| 83 | server: OpenCodeServerProcess | null; | ||
| 84 | /** | ||
| 85 | * The scope that owns the shared server's lifetime. Closing this scope | ||
| 86 | * terminates the OpenCode child process and interrupts any fibers the | ||
| 87 | * runtime forked during startup. We don't hold a `close()` function on | ||
| 88 | * the server handle anymore — the scope is the only lifecycle handle. | ||
| 89 | */ | ||
| 90 | serverScope: Scope.Closeable | null; | ||
| 91 | binaryPath: string | null; | ||
| 92 | activeRequests: number; | ||
| 93 | idleCloseFiber: Fiber.Fiber<void, never> | null; | ||
| 94 | } | ||
| 95 | |||
| 96 | export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* ( | ||
| 97 | openCodeSettings: OpenCodeSettings, | ||
| 98 | environment: NodeJS.ProcessEnv = process.env, | ||
| 99 | ) { | ||
| 100 | const serverConfig = yield* ServerConfig; | ||
| 101 | const openCodeRuntime = yield* OpenCodeRuntime; | ||
| 102 | const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => | ||
| 103 | Scope.close(scope, Exit.void), | ||
| 104 | ); | ||
| 105 | const sharedServerMutex = yield* Semaphore.make(1); | ||
| 106 | const sharedServerState: SharedOpenCodeTextGenerationServerState = { | ||
| 107 | server: null, | ||
| 108 | serverScope: null, | ||
| 109 | binaryPath: null, | ||
| 110 | activeRequests: 0, | ||
| 111 | idleCloseFiber: null, | ||
| 112 | }; | ||
| 113 | |||
| 114 | const closeSharedServer = Effect.fn("closeSharedServer")(function* () { | ||
| 115 | const scope = sharedServerState.serverScope; | ||
| 116 | sharedServerState.server = null; | ||
| 117 | sharedServerState.serverScope = null; | ||
| 118 | sharedServerState.binaryPath = null; | ||
| 119 | if (scope !== null) { | ||
| 120 | yield* Scope.close(scope, Exit.void).pipe(Effect.ignore); | ||
| 121 | } | ||
| 122 | }); | ||
| 123 | |||
| 124 | const cancelIdleCloseFiber = Effect.fn("cancelIdleCloseFiber")(function* () { | ||
| 125 | const idleCloseFiber = sharedServerState.idleCloseFiber; | ||
| 126 | sharedServerState.idleCloseFiber = null; | ||
| 127 | if (idleCloseFiber !== null) { | ||
| 128 | yield* Fiber.interrupt(idleCloseFiber).pipe(Effect.ignore); | ||
| 129 | } | ||
| 130 | }); | ||
| 131 | |||
| 132 | const scheduleIdleClose = Effect.fn("scheduleIdleClose")(function* ( | ||
| 133 | server: OpenCodeServerProcess, | ||
| 134 | ) { | ||
| 135 | yield* cancelIdleCloseFiber(); | ||
| 136 | const fiber = yield* Effect.sleep(OPENCODE_TEXT_GENERATION_IDLE_TTL).pipe( | ||
| 137 | Effect.andThen( | ||
| 138 | sharedServerMutex.withPermit( | ||
| 139 | Effect.gen(function* () { | ||
| 140 | if (sharedServerState.server !== server || sharedServerState.activeRequests > 0) { | ||
| 141 | return; | ||
| 142 | } | ||
| 143 | sharedServerState.idleCloseFiber = null; | ||
| 144 | yield* closeSharedServer(); | ||
| 145 | }), | ||
| 146 | ), | ||
| 147 | ), | ||
| 148 | Effect.forkIn(idleFiberScope), | ||
| 149 | ); | ||
| 150 | sharedServerState.idleCloseFiber = fiber; | ||
| 151 | }); | ||
| 152 | |||
| 153 | const acquireSharedServer = (input: { | ||
| 154 | readonly binaryPath: string; | ||
| 155 | readonly operation: | ||
| 156 | | "generateCommitMessage" | ||
| 157 | | "generatePrContent" | ||
| 158 | | "generateBranchName" | ||
| 159 | | "generateThreadTitle"; | ||
| 160 | }) => | ||
| 161 | sharedServerMutex.withPermit( | ||
| 162 | Effect.gen(function* () { | ||
| 163 | yield* cancelIdleCloseFiber(); | ||
| 164 | |||
| 165 | const existingServer = sharedServerState.server; | ||
| 166 | if (existingServer !== null) { | ||
| 167 | if ( | ||
| 168 | sharedServerState.binaryPath !== input.binaryPath && | ||
| 169 | sharedServerState.activeRequests === 0 | ||
| 170 | ) { | ||
| 171 | yield* closeSharedServer(); | ||
| 172 | } else { | ||
| 173 | if (sharedServerState.binaryPath !== input.binaryPath) { | ||
| 174 | yield* Effect.logWarning( | ||
| 175 | "OpenCode shared server binary path mismatch: requested " + | ||
| 176 | input.binaryPath + | ||
| 177 | " but active server uses " + | ||
| 178 | sharedServerState.binaryPath + | ||
| 179 | "; reusing existing server because there are active requests", | ||
| 180 | ); | ||
| 181 | } | ||
| 182 | sharedServerState.activeRequests += 1; | ||
| 183 | return existingServer; | ||
| 184 | } | ||
| 185 | } | ||
| 186 | |||
| 187 | // Create a fresh scope that owns this shared server. The runtime | ||
| 188 | // will attach its child-process and fiber finalizers to this scope; | ||
| 189 | // closing it kills the server and interrupts those fibers. | ||
| 190 | // | ||
| 191 | // The `Scope.make` / spawn / record-or-close transitions run inside | ||
| 192 | // `uninterruptibleMask` so an interrupt arriving between any two | ||
| 193 | // steps can't orphan the scope (and the child process attached to | ||
| 194 | // it) before we either close it on failure or hand ownership to | ||
| 195 | // `sharedServerState`. `restore` keeps the actual spawn | ||
| 196 | // interruptible; an interrupt during the spawn is captured by | ||
| 197 | // `Effect.exit` and drives us through the failure branch that | ||
| 198 | // closes the fresh scope. | ||
| 199 | return yield* Effect.uninterruptibleMask((restore) => | ||
| 200 | Effect.gen(function* () { | ||
| 201 | const serverScope = yield* Scope.make(); | ||
| 202 | const startedExit = yield* Effect.exit( | ||
| 203 | restore( | ||
| 204 | openCodeRuntime | ||
| 205 | .startOpenCodeServerProcess({ | ||
| 206 | binaryPath: input.binaryPath, | ||
| 207 | environment, | ||
| 208 | }) | ||
| 209 | .pipe( | ||
| 210 | Effect.provideService(Scope.Scope, serverScope), | ||
| 211 | Effect.mapError( | ||
| 212 | (cause) => | ||
| 213 | new TextGenerationError({ | ||
| 214 | operation: input.operation, | ||
| 215 | detail: openCodeRuntimeErrorDetail(cause), | ||
| 216 | cause, | ||
| 217 | }), | ||
| 218 | ), | ||
| 219 | ), | ||
| 220 | ), | ||
| 221 | ); | ||
| 222 | if (startedExit._tag === "Failure") { | ||
| 223 | yield* Scope.close(serverScope, Exit.void).pipe(Effect.ignore); | ||
| 224 | return yield* Effect.failCause(startedExit.cause); | ||
| 225 | } | ||
| 226 | |||
| 227 | const server = startedExit.value; | ||
| 228 | sharedServerState.server = server; | ||
| 229 | sharedServerState.serverScope = serverScope; | ||
| 230 | sharedServerState.binaryPath = input.binaryPath; | ||
| 231 | sharedServerState.activeRequests = 1; | ||
| 232 | return server; | ||
| 233 | }), | ||
| 234 | ); | ||
| 235 | }), | ||
| 236 | ); | ||
| 237 | |||
| 238 | const releaseSharedServer = (server: OpenCodeServerProcess) => | ||
| 239 | sharedServerMutex.withPermit( | ||
| 240 | Effect.gen(function* () { | ||
| 241 | if (sharedServerState.server !== server) { | ||
| 242 | return; | ||
| 243 | } | ||
| 244 | sharedServerState.activeRequests = Math.max(0, sharedServerState.activeRequests - 1); | ||
| 245 | if (sharedServerState.activeRequests === 0) { | ||
| 246 | yield* scheduleIdleClose(server); | ||
| 247 | } | ||
| 248 | }), | ||
| 249 | ); | ||
| 250 | |||
| 251 | // Module-level finalizer: on layer shutdown, cancel the idle close fiber | ||
| 252 | // and close the shared server scope. Consumers therefore cannot leak | ||
| 253 | // the shared OpenCode server by forgetting to call anything. | ||
| 254 | yield* Effect.addFinalizer(() => | ||
| 255 | sharedServerMutex.withPermit( | ||
| 256 | Effect.gen(function* () { | ||
| 257 | yield* cancelIdleCloseFiber(); | ||
| 258 | sharedServerState.activeRequests = 0; | ||
| 259 | yield* closeSharedServer(); | ||
| 260 | }), | ||
| 261 | ), | ||
| 262 | ); | ||
| 263 | |||
| 264 | const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* <S extends Schema.Top>(input: { | ||
| 265 | readonly operation: | ||
| 266 | | "generateCommitMessage" | ||
| 267 | | "generatePrContent" | ||
| 268 | | "generateBranchName" | ||
| 269 | | "generateThreadTitle"; | ||
| 270 | readonly cwd: string; | ||
| 271 | readonly prompt: string; | ||
| 272 | readonly outputSchemaJson: S; | ||
| 273 | readonly modelSelection: ModelSelection; | ||
| 274 | readonly attachments?: ReadonlyArray<ChatAttachment> | undefined; | ||
| 275 | }) { | ||
| 276 | const parsedModel = parseOpenCodeModelSlug(input.modelSelection.model); | ||
| 277 | if (!parsedModel) { | ||
| 278 | return yield* new TextGenerationError({ | ||
| 279 | operation: input.operation, | ||
| 280 | detail: "OpenCode model selection must use the 'provider/model' format.", | ||
| 281 | }); | ||
| 282 | } | ||
| 283 | |||
| 284 | const fileParts = toOpenCodeFileParts({ | ||
| 285 | attachments: input.attachments, | ||
| 286 | resolveAttachmentPath: (attachment) => | ||
| 287 | resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), | ||
| 288 | }); | ||
| 289 | |||
| 290 | const runAgainstServer = (server: Pick<OpenCodeServerConnection, "url">) => | ||
| 291 | Effect.tryPromise({ | ||
| 292 | try: async () => { | ||
| 293 | const client = openCodeRuntime.createOpenCodeSdkClient({ | ||
| 294 | baseUrl: server.url, | ||
| 295 | directory: input.cwd, | ||
| 296 | ...(openCodeSettings.serverUrl.length > 0 && openCodeSettings.serverPassword | ||
| 297 | ? { serverPassword: openCodeSettings.serverPassword } | ||
| 298 | : {}), | ||
| 299 | }); | ||
| 300 | const session = await client.session.create({ | ||
| 301 | title: `T3 Code ${input.operation}`, | ||
| 302 | permission: [{ permission: "*", pattern: "*", action: "deny" }], | ||
| 303 | }); | ||
| 304 | if (!session.data) { | ||
| 305 | throw new Error("OpenCode session.create returned no session payload."); | ||
| 306 | } | ||
| 307 | const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); | ||
| 308 | const selectedVariant = getModelSelectionStringOptionValue( | ||
| 309 | input.modelSelection, | ||
| 310 | "variant", | ||
| 311 | ); | ||
| 312 | |||
| 313 | const result = await client.session.prompt({ | ||
| 314 | sessionID: session.data.id, | ||
| 315 | model: parsedModel, | ||
| 316 | ...(selectedAgent ? { agent: selectedAgent } : {}), | ||
| 317 | ...(selectedVariant ? { variant: selectedVariant } : {}), | ||
| 318 | parts: [{ type: "text", text: input.prompt }, ...fileParts], | ||
| 319 | }); | ||
| 320 | const info = result.data?.info; | ||
| 321 | const errorMessage = getOpenCodePromptErrorMessage(info?.error); | ||
| 322 | if (errorMessage) { | ||
| 323 | throw new Error(errorMessage); | ||
| 324 | } | ||
| 325 | const rawText = getOpenCodeTextResponse(result.data?.parts); | ||
| 326 | if (rawText.length === 0) { | ||
| 327 | throw new Error("OpenCode returned empty output."); | ||
| 328 | } | ||
| 329 | return rawText; | ||
| 330 | }, | ||
| 331 | catch: (cause) => | ||
| 332 | new TextGenerationError({ | ||
| 333 | operation: input.operation, | ||
| 334 | detail: openCodeRuntimeErrorDetail(cause), | ||
| 335 | cause, | ||
| 336 | }), | ||
| 337 | }); | ||
| 338 | |||
| 339 | const rawOutput = | ||
| 340 | openCodeSettings.serverUrl.length > 0 | ||
| 341 | ? yield* runAgainstServer({ url: openCodeSettings.serverUrl }) | ||
| 342 | : yield* Effect.acquireUseRelease( | ||
| 343 | acquireSharedServer({ | ||
| 344 | binaryPath: openCodeSettings.binaryPath, | ||
| 345 | operation: input.operation, | ||
| 346 | }), | ||
| 347 | runAgainstServer, | ||
| 348 | releaseSharedServer, | ||
| 349 | ); | ||
| 350 | |||
| 351 | return yield* Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson))( | ||
| 352 | extractJsonObject(rawOutput), | ||
| 353 | ).pipe( | ||
| 354 | Effect.catchTag("SchemaError", (cause) => | ||
| 355 | Effect.fail( | ||
| 356 | new TextGenerationError({ | ||
| 357 | operation: input.operation, | ||
| 358 | detail: "OpenCode returned invalid structured output.", | ||
| 359 | cause, | ||
| 360 | }), | ||
| 361 | ), | ||
| 362 | ), | ||
| 363 | ); | ||
| 364 | }); | ||
| 365 | |||
| 366 | const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( | ||
| 367 | "OpenCodeTextGeneration.generateCommitMessage", | ||
| 368 | )(function* (input) { | ||
| 369 | const { prompt, outputSchema } = buildCommitMessagePrompt({ | ||
| 370 | branch: input.branch, | ||
| 371 | stagedSummary: input.stagedSummary, | ||
| 372 | stagedPatch: input.stagedPatch, | ||
| 373 | includeBranch: input.includeBranch === true, | ||
| 374 | }); | ||
| 375 | const generated = yield* runOpenCodeJson({ | ||
| 376 | operation: "generateCommitMessage", | ||
| 377 | cwd: input.cwd, | ||
| 378 | prompt, | ||
| 379 | outputSchemaJson: outputSchema, | ||
| 380 | modelSelection: input.modelSelection, | ||
| 381 | }); | ||
| 382 | |||
| 383 | return { | ||
| 384 | subject: sanitizeCommitSubject(generated.subject), | ||
| 385 | body: generated.body.trim(), | ||
| 386 | ...("branch" in generated && typeof generated.branch === "string" | ||
| 387 | ? { branch: sanitizeFeatureBranchName(generated.branch) } | ||
| 388 | : {}), | ||
| 389 | }; | ||
| 390 | }); | ||
| 391 | |||
| 392 | const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( | ||
| 393 | "OpenCodeTextGeneration.generatePrContent", | ||
| 394 | )(function* (input) { | ||
| 395 | const { prompt, outputSchema } = buildPrContentPrompt({ | ||
| 396 | baseBranch: input.baseBranch, | ||
| 397 | headBranch: input.headBranch, | ||
| 398 | commitSummary: input.commitSummary, | ||
| 399 | diffSummary: input.diffSummary, | ||
| 400 | diffPatch: input.diffPatch, | ||
| 401 | }); | ||
| 402 | const generated = yield* runOpenCodeJson({ | ||
| 403 | operation: "generatePrContent", | ||
| 404 | cwd: input.cwd, | ||
| 405 | prompt, | ||
| 406 | outputSchemaJson: outputSchema, | ||
| 407 | modelSelection: input.modelSelection, | ||
| 408 | }); | ||
| 409 | |||
| 410 | return { | ||
| 411 | title: sanitizePrTitle(generated.title), | ||
| 412 | body: generated.body.trim(), | ||
| 413 | }; | ||
| 414 | }); | ||
| 415 | |||
| 416 | const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( | ||
| 417 | "OpenCodeTextGeneration.generateBranchName", | ||
| 418 | )(function* (input) { | ||
| 419 | const { prompt, outputSchema } = buildBranchNamePrompt({ | ||
| 420 | message: input.message, | ||
| 421 | attachments: input.attachments, | ||
| 422 | }); | ||
| 423 | const generated = yield* runOpenCodeJson({ | ||
| 424 | operation: "generateBranchName", | ||
| 425 | cwd: input.cwd, | ||
| 426 | prompt, | ||
| 427 | outputSchemaJson: outputSchema, | ||
| 428 | modelSelection: input.modelSelection, | ||
| 429 | attachments: input.attachments, | ||
| 430 | }); | ||
| 431 | |||
| 432 | return { | ||
| 433 | branch: sanitizeBranchFragment(generated.branch), | ||
| 434 | }; | ||
| 435 | }); | ||
| 436 | |||
| 437 | const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( | ||
| 438 | "OpenCodeTextGeneration.generateThreadTitle", | ||
| 439 | )(function* (input) { | ||
| 440 | const { prompt, outputSchema } = buildThreadTitlePrompt({ | ||
| 441 | message: input.message, | ||
| 442 | attachments: input.attachments, | ||
| 443 | }); | ||
| 444 | const generated = yield* runOpenCodeJson({ | ||
| 445 | operation: "generateThreadTitle", | ||
| 446 | cwd: input.cwd, | ||
| 447 | prompt, | ||
| 448 | outputSchemaJson: outputSchema, | ||
| 449 | modelSelection: input.modelSelection, | ||
| 450 | attachments: input.attachments, | ||
| 451 | }); | ||
| 452 | |||
| 453 | return { | ||
| 454 | title: sanitizeThreadTitle(generated.title), | ||
| 455 | }; | ||
| 456 | }); | ||
| 457 | |||
| 458 | return { | ||
| 459 | generateCommitMessage, | ||
| 460 | generatePrContent, | ||
| 461 | generateBranchName, | ||
| 462 | generateThreadTitle, | ||
| 463 | } satisfies TextGenerationShape; | ||
| 464 | }); | ||
apps/server/src/git/Layers/TextGenerationLive.test.ts deleted-117| ... | @@ -1,117 +0,0 @@ | ||
| 1 | import { it } from "@effect/vitest"; | ||
| 2 | import { Effect, PubSub, Result, Stream } from "effect"; | ||
| 3 | import { describe, expect } from "vitest"; | ||
| 4 | |||
| 5 | import { ProviderInstanceId } from "@t3tools/contracts"; | ||
| 6 | import { createModelSelection } from "@t3tools/shared/model"; | ||
| 7 | |||
| 8 | import type { ProviderInstance } from "../../provider/ProviderDriver.ts"; | ||
| 9 | import type { ProviderInstanceRegistryShape } from "../../provider/Services/ProviderInstanceRegistry.ts"; | ||
| 10 | import type { TextGenerationShape } from "../Services/TextGeneration.ts"; | ||
| 11 | |||
| 12 | import { makeTextGenerationFromRegistry } from "./TextGenerationLive.ts"; | ||
| 13 | |||
| 14 | const makeStubTextGeneration = (overrides: Partial<TextGenerationShape>): TextGenerationShape => ({ | ||
| 15 | generateCommitMessage: () => | ||
| 16 | Effect.die("generateCommitMessage stub not configured for this test"), | ||
| 17 | generatePrContent: () => Effect.die("generatePrContent stub not configured for this test"), | ||
| 18 | generateBranchName: () => Effect.die("generateBranchName stub not configured for this test"), | ||
| 19 | generateThreadTitle: () => Effect.die("generateThreadTitle stub not configured for this test"), | ||
| 20 | ...overrides, | ||
| 21 | }); | ||
| 22 | |||
| 23 | const makeStubInstance = ( | ||
| 24 | instanceId: ProviderInstanceId, | ||
| 25 | textGeneration: TextGenerationShape, | ||
| 26 | ): ProviderInstance => | ||
| 27 | ({ | ||
| 28 | instanceId, | ||
| 29 | driverKind: instanceId as unknown as ProviderInstance["driverKind"], | ||
| 30 | continuationIdentity: { | ||
| 31 | driverKind: instanceId as unknown as ProviderInstance["driverKind"], | ||
| 32 | continuationKey: `${instanceId}:test`, | ||
| 33 | }, | ||
| 34 | displayName: undefined, | ||
| 35 | enabled: true, | ||
| 36 | snapshot: {} as ProviderInstance["snapshot"], | ||
| 37 | adapter: {} as ProviderInstance["adapter"], | ||
| 38 | textGeneration, | ||
| 39 | }) satisfies ProviderInstance; | ||
| 40 | |||
| 41 | const makeStubRegistry = ( | ||
| 42 | instances: ReadonlyArray<ProviderInstance>, | ||
| 43 | ): ProviderInstanceRegistryShape => { | ||
| 44 | const byId = new Map(instances.map((instance) => [instance.instanceId, instance] as const)); | ||
| 45 | return { | ||
| 46 | getInstance: (id) => Effect.succeed(byId.get(id)), | ||
| 47 | listInstances: Effect.succeed(instances), | ||
| 48 | listUnavailable: Effect.succeed([]), | ||
| 49 | streamChanges: Stream.empty, | ||
| 50 | // Tests never drive changes through this stub; acquire a throwaway | ||
| 51 | // subscription on an unused PubSub so the shape is satisfied. | ||
| 52 | subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), (pubsub) => | ||
| 53 | PubSub.subscribe(pubsub), | ||
| 54 | ), | ||
| 55 | }; | ||
| 56 | }; | ||
| 57 | |||
| 58 | describe("makeTextGenerationFromRegistry", () => { | ||
| 59 | it.effect("delegates to the matching instance's textGeneration closure", () => | ||
| 60 | Effect.gen(function* () { | ||
| 61 | const personalId = ProviderInstanceId.make("codex_personal"); | ||
| 62 | const personalCalls: string[] = []; | ||
| 63 | const personal = makeStubInstance( | ||
| 64 | personalId, | ||
| 65 | makeStubTextGeneration({ | ||
| 66 | generateBranchName: (input) => { | ||
| 67 | personalCalls.push(input.message); | ||
| 68 | return Effect.succeed({ branch: "personal-branch" }); | ||
| 69 | }, | ||
| 70 | }), | ||
| 71 | ); | ||
| 72 | |||
| 73 | const workId = ProviderInstanceId.make("codex_work"); | ||
| 74 | const work = makeStubInstance( | ||
| 75 | workId, | ||
| 76 | makeStubTextGeneration({ | ||
| 77 | generateBranchName: () => Effect.succeed({ branch: "work-branch" }), | ||
| 78 | }), | ||
| 79 | ); | ||
| 80 | |||
| 81 | const tg = makeTextGenerationFromRegistry(makeStubRegistry([personal, work])); | ||
| 82 | |||
| 83 | const result = yield* tg.generateBranchName({ | ||
| 84 | cwd: process.cwd(), | ||
| 85 | message: "Refactor the routing layer", | ||
| 86 | modelSelection: createModelSelection(ProviderInstanceId.make("codex_personal"), "gpt-5"), | ||
| 87 | }); | ||
| 88 | |||
| 89 | expect(result.branch).toBe("personal-branch"); | ||
| 90 | expect(personalCalls).toEqual(["Refactor the routing layer"]); | ||
| 91 | }), | ||
| 92 | ); | ||
| 93 | |||
| 94 | it.effect("fails with TextGenerationError when the instance is unknown", () => | ||
| 95 | Effect.gen(function* () { | ||
| 96 | const tg = makeTextGenerationFromRegistry(makeStubRegistry([])); | ||
| 97 | |||
| 98 | const result = yield* tg | ||
| 99 | .generateBranchName({ | ||
| 100 | cwd: process.cwd(), | ||
| 101 | message: "anything", | ||
| 102 | modelSelection: createModelSelection( | ||
| 103 | ProviderInstanceId.make("missing_instance"), | ||
| 104 | "gpt-5", | ||
| 105 | ), | ||
| 106 | }) | ||
| 107 | .pipe(Effect.result); | ||
| 108 | |||
| 109 | expect(Result.isFailure(result)).toBe(true); | ||
| 110 | if (Result.isFailure(result)) { | ||
| 111 | expect(result.failure._tag).toBe("TextGenerationError"); | ||
| 112 | expect(result.failure.operation).toBe("generateBranchName"); | ||
| 113 | expect(result.failure.detail).toContain("missing_instance"); | ||
| 114 | } | ||
| 115 | }), | ||
| 116 | ); | ||
| 117 | }); | ||
apps/server/src/git/Layers/TextGenerationLive.ts deleted-101| ... | @@ -1,101 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * TextGenerationLive — registry-backed implementation of the `TextGeneration` | ||
| 3 | * service tag. | ||
| 4 | * | ||
| 5 | * The `TextGeneration` tag is kept as a thin facade over | ||
| 6 | * `ProviderInstanceRegistry`. Every op pulls `modelSelection.instanceId`, | ||
| 7 | * looks up the matching `ProviderInstance`, and delegates to that instance's | ||
| 8 | * own `textGeneration` closure (built by its driver's `create()`). | ||
| 9 | * | ||
| 10 | * There is deliberately no per-driver dispatch here — the registry already | ||
| 11 | * knows which driver backs each instance, and each `ProviderInstance` | ||
| 12 | * carries the fully-bound `TextGenerationShape` produced by its driver. | ||
| 13 | * That means: | ||
| 14 | * | ||
| 15 | * - Multiple instances of the same driver (e.g. `codex_personal`, | ||
| 16 | * `codex_work`) each get their own text-generation closure bound to | ||
| 17 | * their own settings — the routing is by instance, not by driver. | ||
| 18 | * - Unknown or disabled instances surface a `TextGenerationError` with | ||
| 19 | * the missing `instanceId`, instead of silently falling back to a | ||
| 20 | * default. | ||
| 21 | * | ||
| 22 | * This replaces the old `RoutingTextGenerationLive`, which tried to route | ||
| 23 | * by driver-kind and misused `modelSelection.instanceId` as a driver-id | ||
| 24 | * literal. | ||
| 25 | * | ||
| 26 | * @module git/Layers/TextGenerationLive | ||
| 27 | */ | ||
| 28 | import { Effect, Layer } from "effect"; | ||
| 29 | |||
| 30 | import { TextGenerationError } from "@t3tools/contracts"; | ||
| 31 | import type { ProviderInstanceId } from "@t3tools/contracts"; | ||
| 32 | |||
| 33 | import { | ||
| 34 | ProviderInstanceRegistry, | ||
| 35 | type ProviderInstanceRegistryShape, | ||
| 36 | } from "../../provider/Services/ProviderInstanceRegistry.ts"; | ||
| 37 | import type { ProviderInstance } from "../../provider/ProviderDriver.ts"; | ||
| 38 | import { TextGeneration, type TextGenerationShape } from "../Services/TextGeneration.ts"; | ||
| 39 | |||
| 40 | type TextGenerationOp = | ||
| 41 | | "generateCommitMessage" | ||
| 42 | | "generatePrContent" | ||
| 43 | | "generateBranchName" | ||
| 44 | | "generateThreadTitle"; | ||
| 45 | |||
| 46 | const resolveInstance = ( | ||
| 47 | registry: ProviderInstanceRegistryShape, | ||
| 48 | operation: TextGenerationOp, | ||
| 49 | instanceId: ProviderInstanceId, | ||
| 50 | ): Effect.Effect<ProviderInstance["textGeneration"], TextGenerationError> => | ||
| 51 | registry.getInstance(instanceId).pipe( | ||
| 52 | Effect.flatMap((instance) => | ||
| 53 | instance | ||
| 54 | ? Effect.succeed(instance.textGeneration) | ||
| 55 | : Effect.fail( | ||
| 56 | new TextGenerationError({ | ||
| 57 | operation, | ||
| 58 | detail: `No provider instance registered for id '${instanceId}'.`, | ||
| 59 | }), | ||
| 60 | ), | ||
| 61 | ), | ||
| 62 | ); | ||
| 63 | |||
| 64 | /** | ||
| 65 | * Build a `TextGenerationShape` that routes every call through the | ||
| 66 | * registry. Exposed separately from the Layer so tests can construct it | ||
| 67 | * against a stub registry without layering gymnastics. | ||
| 68 | */ | ||
| 69 | export const makeTextGenerationFromRegistry = ( | ||
| 70 | registry: ProviderInstanceRegistryShape, | ||
| 71 | ): TextGenerationShape => ({ | ||
| 72 | generateCommitMessage: (input) => | ||
| 73 | resolveInstance(registry, "generateCommitMessage", input.modelSelection.instanceId).pipe( | ||
| 74 | Effect.flatMap((tg) => tg.generateCommitMessage(input)), | ||
| 75 | ), | ||
| 76 | generatePrContent: (input) => | ||
| 77 | resolveInstance(registry, "generatePrContent", input.modelSelection.instanceId).pipe( | ||
| 78 | Effect.flatMap((tg) => tg.generatePrContent(input)), | ||
| 79 | ), | ||
| 80 | generateBranchName: (input) => | ||
| 81 | resolveInstance(registry, "generateBranchName", input.modelSelection.instanceId).pipe( | ||
| 82 | Effect.flatMap((tg) => tg.generateBranchName(input)), | ||
| 83 | ), | ||
| 84 | generateThreadTitle: (input) => | ||
| 85 | resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe( | ||
| 86 | Effect.flatMap((tg) => tg.generateThreadTitle(input)), | ||
| 87 | ), | ||
| 88 | }); | ||
| 89 | |||
| 90 | /** | ||
| 91 | * `TextGeneration` Layer wired to the `ProviderInstanceRegistry`. The rest | ||
| 92 | * of the server keeps using `yield* TextGeneration` — only the underlying | ||
| 93 | * wiring changed from kind-based routing to instance-based routing. | ||
| 94 | */ | ||
| 95 | export const TextGenerationLive = Layer.effect( | ||
| 96 | TextGeneration, | ||
| 97 | Effect.gen(function* () { | ||
| 98 | const registry = yield* ProviderInstanceRegistry; | ||
| 99 | return makeTextGenerationFromRegistry(registry); | ||
| 100 | }), | ||
| 101 | ); | ||
apps/server/src/git/Prompts.test.ts deleted-193| ... | @@ -1,193 +0,0 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | buildBranchNamePrompt, | ||
| 5 | buildCommitMessagePrompt, | ||
| 6 | buildPrContentPrompt, | ||
| 7 | buildThreadTitlePrompt, | ||
| 8 | } from "./Prompts.ts"; | ||
| 9 | import { normalizeCliError, sanitizeThreadTitle } from "./Utils.ts"; | ||
| 10 | import { TextGenerationError } from "@t3tools/contracts"; | ||
| 11 | |||
| 12 | describe("buildCommitMessagePrompt", () => { | ||
| 13 | it("includes staged patch and summary in the prompt", () => { | ||
| 14 | const result = buildCommitMessagePrompt({ | ||
| 15 | branch: "main", | ||
| 16 | stagedSummary: "M README.md", | ||
| 17 | stagedPatch: "diff --git a/README.md b/README.md\n+hello", | ||
| 18 | includeBranch: false, | ||
| 19 | }); | ||
| 20 | |||
| 21 | expect(result.prompt).toContain("Staged files:"); | ||
| 22 | expect(result.prompt).toContain("M README.md"); | ||
| 23 | expect(result.prompt).toContain("Staged patch:"); | ||
| 24 | expect(result.prompt).toContain("diff --git a/README.md b/README.md"); | ||
| 25 | expect(result.prompt).toContain("Branch: main"); | ||
| 26 | // Should NOT include the branch generation instruction | ||
| 27 | expect(result.prompt).not.toContain("branch must be a short semantic git branch fragment"); | ||
| 28 | }); | ||
| 29 | |||
| 30 | it("includes branch generation instruction when includeBranch is true", () => { | ||
| 31 | const result = buildCommitMessagePrompt({ | ||
| 32 | branch: "feature/foo", | ||
| 33 | stagedSummary: "M README.md", | ||
| 34 | stagedPatch: "diff", | ||
| 35 | includeBranch: true, | ||
| 36 | }); | ||
| 37 | |||
| 38 | expect(result.prompt).toContain("branch must be a short semantic git branch fragment"); | ||
| 39 | expect(result.prompt).toContain("Return a JSON object with keys: subject, body, branch."); | ||
| 40 | }); | ||
| 41 | |||
| 42 | it("shows (detached) when branch is null", () => { | ||
| 43 | const result = buildCommitMessagePrompt({ | ||
| 44 | branch: null, | ||
| 45 | stagedSummary: "M a.ts", | ||
| 46 | stagedPatch: "diff", | ||
| 47 | includeBranch: false, | ||
| 48 | }); | ||
| 49 | |||
| 50 | expect(result.prompt).toContain("Branch: (detached)"); | ||
| 51 | }); | ||
| 52 | }); | ||
| 53 | |||
| 54 | describe("buildPrContentPrompt", () => { | ||
| 55 | it("includes branch names, commits, and diff in the prompt", () => { | ||
| 56 | const result = buildPrContentPrompt({ | ||
| 57 | baseBranch: "main", | ||
| 58 | headBranch: "feature/auth", | ||
| 59 | commitSummary: "feat: add login page", | ||
| 60 | diffSummary: "3 files changed", | ||
| 61 | diffPatch: "diff --git a/auth.ts b/auth.ts\n+export function login()", | ||
| 62 | }); | ||
| 63 | |||
| 64 | expect(result.prompt).toContain("Base branch: main"); | ||
| 65 | expect(result.prompt).toContain("Head branch: feature/auth"); | ||
| 66 | expect(result.prompt).toContain("Commits:"); | ||
| 67 | expect(result.prompt).toContain("feat: add login page"); | ||
| 68 | expect(result.prompt).toContain("Diff stat:"); | ||
| 69 | expect(result.prompt).toContain("3 files changed"); | ||
| 70 | expect(result.prompt).toContain("Diff patch:"); | ||
| 71 | expect(result.prompt).toContain("export function login()"); | ||
| 72 | }); | ||
| 73 | }); | ||
| 74 | |||
| 75 | describe("buildBranchNamePrompt", () => { | ||
| 76 | it("includes the user message in the prompt", () => { | ||
| 77 | const result = buildBranchNamePrompt({ | ||
| 78 | message: "Fix the login timeout bug", | ||
| 79 | }); | ||
| 80 | |||
| 81 | expect(result.prompt).toContain("User message:"); | ||
| 82 | expect(result.prompt).toContain("Fix the login timeout bug"); | ||
| 83 | expect(result.prompt).not.toContain("Attachment metadata:"); | ||
| 84 | }); | ||
| 85 | |||
| 86 | it("includes attachment metadata when attachments are provided", () => { | ||
| 87 | const result = buildBranchNamePrompt({ | ||
| 88 | message: "Fix the layout from screenshot", | ||
| 89 | attachments: [ | ||
| 90 | { | ||
| 91 | type: "image" as const, | ||
| 92 | id: "att-123", | ||
| 93 | name: "screenshot.png", | ||
| 94 | mimeType: "image/png", | ||
| 95 | sizeBytes: 12345, | ||
| 96 | }, | ||
| 97 | ], | ||
| 98 | }); | ||
| 99 | |||
| 100 | expect(result.prompt).toContain("Attachment metadata:"); | ||
| 101 | expect(result.prompt).toContain("screenshot.png"); | ||
| 102 | expect(result.prompt).toContain("image/png"); | ||
| 103 | expect(result.prompt).toContain("12345 bytes"); | ||
| 104 | }); | ||
| 105 | }); | ||
| 106 | |||
| 107 | describe("buildThreadTitlePrompt", () => { | ||
| 108 | it("includes the user message in the prompt", () => { | ||
| 109 | const result = buildThreadTitlePrompt({ | ||
| 110 | message: "Investigate reconnect regressions after session restore", | ||
| 111 | }); | ||
| 112 | |||
| 113 | expect(result.prompt).toContain("User message:"); | ||
| 114 | expect(result.prompt).toContain("Investigate reconnect regressions after session restore"); | ||
| 115 | expect(result.prompt).not.toContain("Attachment metadata:"); | ||
| 116 | }); | ||
| 117 | |||
| 118 | it("includes attachment metadata when attachments are provided", () => { | ||
| 119 | const result = buildThreadTitlePrompt({ | ||
| 120 | message: "Name this thread from the screenshot", | ||
| 121 | attachments: [ | ||
| 122 | { | ||
| 123 | type: "image" as const, | ||
| 124 | id: "att-456", | ||
| 125 | name: "thread.png", | ||
| 126 | mimeType: "image/png", | ||
| 127 | sizeBytes: 67890, | ||
| 128 | }, | ||
| 129 | ], | ||
| 130 | }); | ||
| 131 | |||
| 132 | expect(result.prompt).toContain("Attachment metadata:"); | ||
| 133 | expect(result.prompt).toContain("thread.png"); | ||
| 134 | expect(result.prompt).toContain("image/png"); | ||
| 135 | expect(result.prompt).toContain("67890 bytes"); | ||
| 136 | }); | ||
| 137 | }); | ||
| 138 | |||
| 139 | describe("sanitizeThreadTitle", () => { | ||
| 140 | it("truncates long titles with the shared sidebar-safe limit", () => { | ||
| 141 | expect( | ||
| 142 | sanitizeThreadTitle( | ||
| 143 | ' "Reconnect failures after restart because the session state does not recover" ', | ||
| 144 | ), | ||
| 145 | ).toBe("Reconnect failures after restart because the se..."); | ||
| 146 | }); | ||
| 147 | }); | ||
| 148 | |||
| 149 | describe("normalizeCliError", () => { | ||
| 150 | it("detects 'Command not found' and includes CLI name in the message", () => { | ||
| 151 | const error = normalizeCliError( | ||
| 152 | "claude", | ||
| 153 | "generateCommitMessage", | ||
| 154 | new Error("Command not found: claude"), | ||
| 155 | "Something went wrong", | ||
| 156 | ); | ||
| 157 | |||
| 158 | expect(error).toBeInstanceOf(TextGenerationError); | ||
| 159 | expect(error.detail).toContain("Claude CLI"); | ||
| 160 | expect(error.detail).toContain("not available on PATH"); | ||
| 161 | }); | ||
| 162 | |||
| 163 | it("uses the CLI name from the first argument for codex", () => { | ||
| 164 | const error = normalizeCliError( | ||
| 165 | "codex", | ||
| 166 | "generateBranchName", | ||
| 167 | new Error("Command not found: codex"), | ||
| 168 | "Something went wrong", | ||
| 169 | ); | ||
| 170 | |||
| 171 | expect(error).toBeInstanceOf(TextGenerationError); | ||
| 172 | expect(error.detail).toContain("Codex CLI"); | ||
| 173 | expect(error.detail).toContain("not available on PATH"); | ||
| 174 | }); | ||
| 175 | |||
| 176 | it("returns the error as-is if it is already a TextGenerationError", () => { | ||
| 177 | const existing = new TextGenerationError({ | ||
| 178 | operation: "generatePrContent", | ||
| 179 | detail: "Already wrapped", | ||
| 180 | }); | ||
| 181 | |||
| 182 | const result = normalizeCliError("claude", "generatePrContent", existing, "fallback"); | ||
| 183 | |||
| 184 | expect(result).toBe(existing); | ||
| 185 | }); | ||
| 186 | |||
| 187 | it("wraps unknown non-Error values with the fallback message", () => { | ||
| 188 | const result = normalizeCliError("codex", "generateCommitMessage", "string error", "fallback"); | ||
| 189 | |||
| 190 | expect(result).toBeInstanceOf(TextGenerationError); | ||
| 191 | expect(result.detail).toBe("fallback"); | ||
| 192 | }); | ||
| 193 | }); | ||
apps/server/src/git/Prompts.ts deleted-202| ... | @@ -1,202 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * Shared prompt builders for text generation providers. | ||
| 3 | * | ||
| 4 | * Extracts the prompt construction logic that is identical across | ||
| 5 | * Codex, Claude, and any future CLI-based text generation backends. | ||
| 6 | * | ||
| 7 | * @module textGenerationPrompts | ||
| 8 | */ | ||
| 9 | import { Schema } from "effect"; | ||
| 10 | import type { ChatAttachment } from "@t3tools/contracts"; | ||
| 11 | |||
| 12 | import { limitSection } from "./Utils.ts"; | ||
| 13 | |||
| 14 | // --------------------------------------------------------------------------- | ||
| 15 | // Commit message | ||
| 16 | // --------------------------------------------------------------------------- | ||
| 17 | |||
| 18 | export interface CommitMessagePromptInput { | ||
| 19 | branch: string | null; | ||
| 20 | stagedSummary: string; | ||
| 21 | stagedPatch: string; | ||
| 22 | includeBranch: boolean; | ||
| 23 | } | ||
| 24 | |||
| 25 | export function buildCommitMessagePrompt(input: CommitMessagePromptInput) { | ||
| 26 | const wantsBranch = input.includeBranch; | ||
| 27 | |||
| 28 | const prompt = [ | ||
| 29 | "You write concise git commit messages.", | ||
| 30 | wantsBranch | ||
| 31 | ? "Return a JSON object with keys: subject, body, branch." | ||
| 32 | : "Return a JSON object with keys: subject, body.", | ||
| 33 | "Rules:", | ||
| 34 | "- subject must be imperative, <= 72 chars, and no trailing period", | ||
| 35 | "- body can be empty string or short bullet points", | ||
| 36 | ...(wantsBranch | ||
| 37 | ? ["- branch must be a short semantic git branch fragment for this change"] | ||
| 38 | : []), | ||
| 39 | "- capture the primary user-visible or developer-visible change", | ||
| 40 | "", | ||
| 41 | `Branch: ${input.branch ?? "(detached)"}`, | ||
| 42 | "", | ||
| 43 | "Staged files:", | ||
| 44 | limitSection(input.stagedSummary, 6_000), | ||
| 45 | "", | ||
| 46 | "Staged patch:", | ||
| 47 | limitSection(input.stagedPatch, 40_000), | ||
| 48 | ].join("\n"); | ||
| 49 | |||
| 50 | if (wantsBranch) { | ||
| 51 | return { | ||
| 52 | prompt, | ||
| 53 | outputSchema: Schema.Struct({ | ||
| 54 | subject: Schema.String, | ||
| 55 | body: Schema.String, | ||
| 56 | branch: Schema.String, | ||
| 57 | }), | ||
| 58 | }; | ||
| 59 | } | ||
| 60 | |||
| 61 | return { | ||
| 62 | prompt, | ||
| 63 | outputSchema: Schema.Struct({ | ||
| 64 | subject: Schema.String, | ||
| 65 | body: Schema.String, | ||
| 66 | }), | ||
| 67 | }; | ||
| 68 | } | ||
| 69 | |||
| 70 | // --------------------------------------------------------------------------- | ||
| 71 | // PR content | ||
| 72 | // --------------------------------------------------------------------------- | ||
| 73 | |||
| 74 | export interface PrContentPromptInput { | ||
| 75 | baseBranch: string; | ||
| 76 | headBranch: string; | ||
| 77 | commitSummary: string; | ||
| 78 | diffSummary: string; | ||
| 79 | diffPatch: string; | ||
| 80 | } | ||
| 81 | |||
| 82 | export function buildPrContentPrompt(input: PrContentPromptInput) { | ||
| 83 | const prompt = [ | ||
| 84 | "You write GitHub pull request content.", | ||
| 85 | "Return a JSON object with keys: title, body.", | ||
| 86 | "Rules:", | ||
| 87 | "- title should be concise and specific", | ||
| 88 | "- body must be markdown and include headings '## Summary' and '## Testing'", | ||
| 89 | "- under Summary, provide short bullet points", | ||
| 90 | "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate", | ||
| 91 | "", | ||
| 92 | `Base branch: ${input.baseBranch}`, | ||
| 93 | `Head branch: ${input.headBranch}`, | ||
| 94 | "", | ||
| 95 | "Commits:", | ||
| 96 | limitSection(input.commitSummary, 12_000), | ||
| 97 | "", | ||
| 98 | "Diff stat:", | ||
| 99 | limitSection(input.diffSummary, 12_000), | ||
| 100 | "", | ||
| 101 | "Diff patch:", | ||
| 102 | limitSection(input.diffPatch, 40_000), | ||
| 103 | ].join("\n"); | ||
| 104 | |||
| 105 | const outputSchema = Schema.Struct({ | ||
| 106 | title: Schema.String, | ||
| 107 | body: Schema.String, | ||
| 108 | }); | ||
| 109 | |||
| 110 | return { prompt, outputSchema }; | ||
| 111 | } | ||
| 112 | |||
| 113 | // --------------------------------------------------------------------------- | ||
| 114 | // Branch name | ||
| 115 | // --------------------------------------------------------------------------- | ||
| 116 | |||
| 117 | export interface BranchNamePromptInput { | ||
| 118 | message: string; | ||
| 119 | attachments?: ReadonlyArray<ChatAttachment> | undefined; | ||
| 120 | } | ||
| 121 | |||
| 122 | interface PromptFromMessageInput { | ||
| 123 | instruction: string; | ||
| 124 | responseShape: string; | ||
| 125 | rules: ReadonlyArray<string>; | ||
| 126 | message: string; | ||
| 127 | attachments?: ReadonlyArray<ChatAttachment> | undefined; | ||
| 128 | } | ||
| 129 | |||
| 130 | function buildPromptFromMessage(input: PromptFromMessageInput): string { | ||
| 131 | const attachmentLines = (input.attachments ?? []).map( | ||
| 132 | (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, | ||
| 133 | ); | ||
| 134 | |||
| 135 | const promptSections = [ | ||
| 136 | input.instruction, | ||
| 137 | input.responseShape, | ||
| 138 | "Rules:", | ||
| 139 | ...input.rules.map((rule) => `- ${rule}`), | ||
| 140 | "", | ||
| 141 | "User message:", | ||
| 142 | limitSection(input.message, 8_000), | ||
| 143 | ]; | ||
| 144 | if (attachmentLines.length > 0) { | ||
| 145 | promptSections.push( | ||
| 146 | "", | ||
| 147 | "Attachment metadata:", | ||
| 148 | limitSection(attachmentLines.join("\n"), 4_000), | ||
| 149 | ); | ||
| 150 | } | ||
| 151 | |||
| 152 | return promptSections.join("\n"); | ||
| 153 | } | ||
| 154 | |||
| 155 | export function buildBranchNamePrompt(input: BranchNamePromptInput) { | ||
| 156 | const prompt = buildPromptFromMessage({ | ||
| 157 | instruction: "You generate concise git branch names.", | ||
| 158 | responseShape: "Return a JSON object with key: branch.", | ||
| 159 | rules: [ | ||
| 160 | "Branch should describe the requested work from the user message.", | ||
| 161 | "Keep it short and specific (2-6 words).", | ||
| 162 | "Use plain words only, no issue prefixes and no punctuation-heavy text.", | ||
| 163 | "If images are attached, use them as primary context for visual/UI issues.", | ||
| 164 | ], | ||
| 165 | message: input.message, | ||
| 166 | attachments: input.attachments, | ||
| 167 | }); | ||
| 168 | const outputSchema = Schema.Struct({ | ||
| 169 | branch: Schema.String, | ||
| 170 | }); | ||
| 171 | |||
| 172 | return { prompt, outputSchema }; | ||
| 173 | } | ||
| 174 | |||
| 175 | // --------------------------------------------------------------------------- | ||
| 176 | // Thread title | ||
| 177 | // --------------------------------------------------------------------------- | ||
| 178 | |||
| 179 | export interface ThreadTitlePromptInput { | ||
| 180 | message: string; | ||
| 181 | attachments?: ReadonlyArray<ChatAttachment> | undefined; | ||
| 182 | } | ||
| 183 | |||
| 184 | export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) { | ||
| 185 | const prompt = buildPromptFromMessage({ | ||
| 186 | instruction: "You write concise thread titles for coding conversations.", | ||
| 187 | responseShape: "Return a JSON object with key: title.", | ||
| 188 | rules: [ | ||
| 189 | "Title should summarize the user's request, not restate it verbatim.", | ||
| 190 | "Keep it short and specific (3-8 words).", | ||
| 191 | "Avoid quotes, filler, prefixes, and trailing punctuation.", | ||
| 192 | "If images are attached, use them as primary context for visual/UI issues.", | ||
| 193 | ], | ||
| 194 | message: input.message, | ||
| 195 | attachments: input.attachments, | ||
| 196 | }); | ||
| 197 | const outputSchema = Schema.Struct({ | ||
| 198 | title: Schema.String, | ||
| 199 | }); | ||
| 200 | |||
| 201 | return { prompt, outputSchema }; | ||
| 202 | } | ||
apps/server/src/git/Services/GitCore.ts deleted-313| ... | @@ -1,313 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * GitCore - Effect service contract for low-level Git operations. | ||
| 3 | * | ||
| 4 | * Wraps core repository primitives used by higher-level orchestration | ||
| 5 | * services and WebSocket routes. | ||
| 6 | * | ||
| 7 | * @module GitCore | ||
| 8 | */ | ||
| 9 | import { Context } from "effect"; | ||
| 10 | import type { Effect } from "effect"; | ||
| 11 | import type { | ||
| 12 | GitCheckoutInput, | ||
| 13 | GitCheckoutResult, | ||
| 14 | GitCreateBranchInput, | ||
| 15 | GitCreateBranchResult, | ||
| 16 | GitCreateWorktreeInput, | ||
| 17 | GitCreateWorktreeResult, | ||
| 18 | GitInitInput, | ||
| 19 | GitListBranchesInput, | ||
| 20 | GitListBranchesResult, | ||
| 21 | GitPullResult, | ||
| 22 | GitRemoveWorktreeInput, | ||
| 23 | GitStatusInput, | ||
| 24 | GitStatusResult, | ||
| 25 | } from "@t3tools/contracts"; | ||
| 26 | |||
| 27 | import type { GitCommandError } from "@t3tools/contracts"; | ||
| 28 | |||
| 29 | export interface ExecuteGitInput { | ||
| 30 | readonly operation: string; | ||
| 31 | readonly cwd: string; | ||
| 32 | readonly args: ReadonlyArray<string>; | ||
| 33 | readonly stdin?: string; | ||
| 34 | readonly env?: NodeJS.ProcessEnv; | ||
| 35 | readonly allowNonZeroExit?: boolean; | ||
| 36 | readonly timeoutMs?: number; | ||
| 37 | readonly maxOutputBytes?: number; | ||
| 38 | readonly truncateOutputAtMaxBytes?: boolean; | ||
| 39 | readonly progress?: ExecuteGitProgress; | ||
| 40 | } | ||
| 41 | |||
| 42 | export interface ExecuteGitResult { | ||
| 43 | readonly code: number; | ||
| 44 | readonly stdout: string; | ||
| 45 | readonly stderr: string; | ||
| 46 | readonly stdoutTruncated: boolean; | ||
| 47 | readonly stderrTruncated: boolean; | ||
| 48 | } | ||
| 49 | |||
| 50 | export interface GitStatusDetails extends Omit<GitStatusResult, "pr"> { | ||
| 51 | upstreamRef: string | null; | ||
| 52 | } | ||
| 53 | |||
| 54 | export interface GitPreparedCommitContext { | ||
| 55 | stagedSummary: string; | ||
| 56 | stagedPatch: string; | ||
| 57 | } | ||
| 58 | |||
| 59 | export interface ExecuteGitProgress { | ||
| 60 | readonly onStdoutLine?: (line: string) => Effect.Effect<void, never>; | ||
| 61 | readonly onStderrLine?: (line: string) => Effect.Effect<void, never>; | ||
| 62 | readonly onHookStarted?: (hookName: string) => Effect.Effect<void, never>; | ||
| 63 | readonly onHookFinished?: (input: { | ||
| 64 | hookName: string; | ||
| 65 | exitCode: number | null; | ||
| 66 | durationMs: number | null; | ||
| 67 | }) => Effect.Effect<void, never>; | ||
| 68 | } | ||
| 69 | |||
| 70 | export interface GitCommitProgress { | ||
| 71 | readonly onOutputLine?: (input: { | ||
| 72 | stream: "stdout" | "stderr"; | ||
| 73 | text: string; | ||
| 74 | }) => Effect.Effect<void, never>; | ||
| 75 | readonly onHookStarted?: (hookName: string) => Effect.Effect<void, never>; | ||
| 76 | readonly onHookFinished?: (input: { | ||
| 77 | hookName: string; | ||
| 78 | exitCode: number | null; | ||
| 79 | durationMs: number | null; | ||
| 80 | }) => Effect.Effect<void, never>; | ||
| 81 | } | ||
| 82 | |||
| 83 | export interface GitCommitOptions { | ||
| 84 | readonly timeoutMs?: number; | ||
| 85 | readonly progress?: GitCommitProgress; | ||
| 86 | } | ||
| 87 | |||
| 88 | export interface GitPushResult { | ||
| 89 | status: "pushed" | "skipped_up_to_date"; | ||
| 90 | branch: string; | ||
| 91 | upstreamBranch?: string | undefined; | ||
| 92 | setUpstream?: boolean | undefined; | ||
| 93 | } | ||
| 94 | |||
| 95 | export interface GitRangeContext { | ||
| 96 | commitSummary: string; | ||
| 97 | diffSummary: string; | ||
| 98 | diffPatch: string; | ||
| 99 | } | ||
| 100 | |||
| 101 | export interface GitListWorkspaceFilesResult { | ||
| 102 | readonly paths: ReadonlyArray<string>; | ||
| 103 | readonly truncated: boolean; | ||
| 104 | } | ||
| 105 | |||
| 106 | export interface GitRenameBranchInput { | ||
| 107 | cwd: string; | ||
| 108 | oldBranch: string; | ||
| 109 | newBranch: string; | ||
| 110 | } | ||
| 111 | |||
| 112 | export interface GitRenameBranchResult { | ||
| 113 | branch: string; | ||
| 114 | } | ||
| 115 | |||
| 116 | export interface GitFetchPullRequestBranchInput { | ||
| 117 | cwd: string; | ||
| 118 | prNumber: number; | ||
| 119 | branch: string; | ||
| 120 | } | ||
| 121 | |||
| 122 | export interface GitEnsureRemoteInput { | ||
| 123 | cwd: string; | ||
| 124 | preferredName: string; | ||
| 125 | url: string; | ||
| 126 | } | ||
| 127 | |||
| 128 | export interface GitFetchRemoteBranchInput { | ||
| 129 | cwd: string; | ||
| 130 | remoteName: string; | ||
| 131 | remoteBranch: string; | ||
| 132 | localBranch: string; | ||
| 133 | } | ||
| 134 | |||
| 135 | export interface GitSetBranchUpstreamInput { | ||
| 136 | cwd: string; | ||
| 137 | branch: string; | ||
| 138 | remoteName: string; | ||
| 139 | remoteBranch: string; | ||
| 140 | } | ||
| 141 | |||
| 142 | /** | ||
| 143 | * GitCoreShape - Service API for low-level Git repository interactions. | ||
| 144 | */ | ||
| 145 | export interface GitCoreShape { | ||
| 146 | /** | ||
| 147 | * Execute a raw Git command. | ||
| 148 | */ | ||
| 149 | readonly execute: (input: ExecuteGitInput) => Effect.Effect<ExecuteGitResult, GitCommandError>; | ||
| 150 | |||
| 151 | /** | ||
| 152 | * Read Git status for a repository. | ||
| 153 | */ | ||
| 154 | readonly status: (input: GitStatusInput) => Effect.Effect<GitStatusResult, GitCommandError>; | ||
| 155 | |||
| 156 | /** | ||
| 157 | * Read detailed working tree / branch status for a repository. | ||
| 158 | */ | ||
| 159 | readonly statusDetails: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>; | ||
| 160 | |||
| 161 | /** | ||
| 162 | * Read detailed working tree / branch status without refreshing remote tracking refs. | ||
| 163 | */ | ||
| 164 | readonly statusDetailsLocal: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>; | ||
| 165 | |||
| 166 | /** | ||
| 167 | * Build staged change context for commit generation. | ||
| 168 | */ | ||
| 169 | readonly prepareCommitContext: ( | ||
| 170 | cwd: string, | ||
| 171 | filePaths?: readonly string[], | ||
| 172 | ) => Effect.Effect<GitPreparedCommitContext | null, GitCommandError>; | ||
| 173 | |||
| 174 | /** | ||
| 175 | * Create a commit with provided subject/body. | ||
| 176 | */ | ||
| 177 | readonly commit: ( | ||
| 178 | cwd: string, | ||
| 179 | subject: string, | ||
| 180 | body: string, | ||
| 181 | options?: GitCommitOptions, | ||
| 182 | ) => Effect.Effect<{ commitSha: string }, GitCommandError>; | ||
| 183 | |||
| 184 | /** | ||
| 185 | * Push current branch, setting upstream if needed. | ||
| 186 | */ | ||
| 187 | readonly pushCurrentBranch: ( | ||
| 188 | cwd: string, | ||
| 189 | fallbackBranch: string | null, | ||
| 190 | ) => Effect.Effect<GitPushResult, GitCommandError>; | ||
| 191 | |||
| 192 | /** | ||
| 193 | * Collect commit/diff context between base branch and current HEAD. | ||
| 194 | */ | ||
| 195 | readonly readRangeContext: ( | ||
| 196 | cwd: string, | ||
| 197 | baseBranch: string, | ||
| 198 | ) => Effect.Effect<GitRangeContext, GitCommandError>; | ||
| 199 | |||
| 200 | /** | ||
| 201 | * Read a Git config value from the local repository. | ||
| 202 | */ | ||
| 203 | readonly readConfigValue: ( | ||
| 204 | cwd: string, | ||
| 205 | key: string, | ||
| 206 | ) => Effect.Effect<string | null, GitCommandError>; | ||
| 207 | |||
| 208 | /** | ||
| 209 | * Determine whether the provided cwd is inside a git work tree. | ||
| 210 | */ | ||
| 211 | readonly isInsideWorkTree: (cwd: string) => Effect.Effect<boolean, GitCommandError>; | ||
| 212 | |||
| 213 | /** | ||
| 214 | * List tracked and untracked workspace file paths relative to cwd. | ||
| 215 | */ | ||
| 216 | readonly listWorkspaceFiles: ( | ||
| 217 | cwd: string, | ||
| 218 | ) => Effect.Effect<GitListWorkspaceFilesResult, GitCommandError>; | ||
| 219 | |||
| 220 | /** | ||
| 221 | * Remove gitignored paths from a relative path list. | ||
| 222 | */ | ||
| 223 | readonly filterIgnoredPaths: ( | ||
| 224 | cwd: string, | ||
| 225 | relativePaths: ReadonlyArray<string>, | ||
| 226 | ) => Effect.Effect<ReadonlyArray<string>, GitCommandError>; | ||
| 227 | |||
| 228 | /** | ||
| 229 | * List local + remote branches and branch metadata. | ||
| 230 | */ | ||
| 231 | readonly listBranches: ( | ||
| 232 | input: GitListBranchesInput, | ||
| 233 | ) => Effect.Effect<GitListBranchesResult, GitCommandError>; | ||
| 234 | |||
| 235 | /** | ||
| 236 | * Pull current branch from upstream using fast-forward only. | ||
| 237 | */ | ||
| 238 | readonly pullCurrentBranch: (cwd: string) => Effect.Effect<GitPullResult, GitCommandError>; | ||
| 239 | |||
| 240 | /** | ||
| 241 | * Create a worktree and branch from a base branch. | ||
| 242 | */ | ||
| 243 | readonly createWorktree: ( | ||
| 244 | input: GitCreateWorktreeInput, | ||
| 245 | ) => Effect.Effect<GitCreateWorktreeResult, GitCommandError>; | ||
| 246 | |||
| 247 | /** | ||
| 248 | * Materialize a GitHub pull request head as a local branch without switching checkout. | ||
| 249 | */ | ||
| 250 | readonly fetchPullRequestBranch: ( | ||
| 251 | input: GitFetchPullRequestBranchInput, | ||
| 252 | ) => Effect.Effect<void, GitCommandError>; | ||
| 253 | |||
| 254 | /** | ||
| 255 | * Ensure a named remote exists for the provided URL, returning the reused or created remote name. | ||
| 256 | */ | ||
| 257 | readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect<string, GitCommandError>; | ||
| 258 | |||
| 259 | /** | ||
| 260 | * Fetch a remote branch into a local branch without checkout. | ||
| 261 | */ | ||
| 262 | readonly fetchRemoteBranch: ( | ||
| 263 | input: GitFetchRemoteBranchInput, | ||
| 264 | ) => Effect.Effect<void, GitCommandError>; | ||
| 265 | |||
| 266 | /** | ||
| 267 | * Set the upstream tracking branch for a local branch. | ||
| 268 | */ | ||
| 269 | readonly setBranchUpstream: ( | ||
| 270 | input: GitSetBranchUpstreamInput, | ||
| 271 | ) => Effect.Effect<void, GitCommandError>; | ||
| 272 | |||
| 273 | /** | ||
| 274 | * Remove an existing worktree. | ||
| 275 | */ | ||
| 276 | readonly removeWorktree: (input: GitRemoveWorktreeInput) => Effect.Effect<void, GitCommandError>; | ||
| 277 | |||
| 278 | /** | ||
| 279 | * Rename an existing local branch. | ||
| 280 | */ | ||
| 281 | readonly renameBranch: ( | ||
| 282 | input: GitRenameBranchInput, | ||
| 283 | ) => Effect.Effect<GitRenameBranchResult, GitCommandError>; | ||
| 284 | |||
| 285 | /** | ||
| 286 | * Create a local branch. | ||
| 287 | */ | ||
| 288 | readonly createBranch: ( | ||
| 289 | input: GitCreateBranchInput, | ||
| 290 | ) => Effect.Effect<GitCreateBranchResult, GitCommandError>; | ||
| 291 | |||
| 292 | /** | ||
| 293 | * Checkout an existing branch and refresh its upstream metadata in background. | ||
| 294 | */ | ||
| 295 | readonly checkoutBranch: ( | ||
| 296 | input: GitCheckoutInput, | ||
| 297 | ) => Effect.Effect<GitCheckoutResult, GitCommandError>; | ||
| 298 | |||
| 299 | /** | ||
| 300 | * Initialize a repository in the provided directory. | ||
| 301 | */ | ||
| 302 | readonly initRepo: (input: GitInitInput) => Effect.Effect<void, GitCommandError>; | ||
| 303 | |||
| 304 | /** | ||
| 305 | * List local branch names (short format). | ||
| 306 | */ | ||
| 307 | readonly listLocalBranchNames: (cwd: string) => Effect.Effect<string[], GitCommandError>; | ||
| 308 | } | ||
| 309 | |||
| 310 | /** | ||
| 311 | * GitCore - Service tag for low-level Git repository operations. | ||
| 312 | */ | ||
| 313 | export class GitCore extends Context.Service<GitCore, GitCoreShape>()("t3/git/Services/GitCore") {} | ||
apps/server/src/git/Services/GitHubCli.ts deleted-103| ... | @@ -1,103 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * GitHubCli - Effect service contract for `gh` process interactions. | ||
| 3 | * | ||
| 4 | * Provides thin command execution helpers used by Git workflow orchestration. | ||
| 5 | * | ||
| 6 | * @module GitHubCli | ||
| 7 | */ | ||
| 8 | import { Context } from "effect"; | ||
| 9 | import type { Effect } from "effect"; | ||
| 10 | |||
| 11 | import type { ProcessRunResult } from "../../processRunner.ts"; | ||
| 12 | import type { GitHubCliError } from "@t3tools/contracts"; | ||
| 13 | |||
| 14 | export interface GitHubPullRequestSummary { | ||
| 15 | readonly number: number; | ||
| 16 | readonly title: string; | ||
| 17 | readonly url: string; | ||
| 18 | readonly baseRefName: string; | ||
| 19 | readonly headRefName: string; | ||
| 20 | readonly state?: "open" | "closed" | "merged"; | ||
| 21 | readonly isCrossRepository?: boolean; | ||
| 22 | readonly headRepositoryNameWithOwner?: string | null; | ||
| 23 | readonly headRepositoryOwnerLogin?: string | null; | ||
| 24 | } | ||
| 25 | |||
| 26 | export interface GitHubRepositoryCloneUrls { | ||
| 27 | readonly nameWithOwner: string; | ||
| 28 | readonly url: string; | ||
| 29 | readonly sshUrl: string; | ||
| 30 | } | ||
| 31 | |||
| 32 | /** | ||
| 33 | * GitHubCliShape - Service API for executing GitHub CLI commands. | ||
| 34 | */ | ||
| 35 | export interface GitHubCliShape { | ||
| 36 | /** | ||
| 37 | * Execute a GitHub CLI command and return full process output. | ||
| 38 | */ | ||
| 39 | readonly execute: (input: { | ||
| 40 | readonly cwd: string; | ||
| 41 | readonly args: ReadonlyArray<string>; | ||
| 42 | readonly timeoutMs?: number; | ||
| 43 | }) => Effect.Effect<ProcessRunResult, GitHubCliError>; | ||
| 44 | |||
| 45 | /** | ||
| 46 | * List open pull requests for a head branch. | ||
| 47 | */ | ||
| 48 | readonly listOpenPullRequests: (input: { | ||
| 49 | readonly cwd: string; | ||
| 50 | readonly headSelector: string; | ||
| 51 | readonly limit?: number; | ||
| 52 | }) => Effect.Effect<ReadonlyArray<GitHubPullRequestSummary>, GitHubCliError>; | ||
| 53 | |||
| 54 | /** | ||
| 55 | * Resolve a pull request by URL, number, or branch-ish identifier. | ||
| 56 | */ | ||
| 57 | readonly getPullRequest: (input: { | ||
| 58 | readonly cwd: string; | ||
| 59 | readonly reference: string; | ||
| 60 | }) => Effect.Effect<GitHubPullRequestSummary, GitHubCliError>; | ||
| 61 | |||
| 62 | /** | ||
| 63 | * Resolve clone URLs for a GitHub repository. | ||
| 64 | */ | ||
| 65 | readonly getRepositoryCloneUrls: (input: { | ||
| 66 | readonly cwd: string; | ||
| 67 | readonly repository: string; | ||
| 68 | }) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubCliError>; | ||
| 69 | |||
| 70 | /** | ||
| 71 | * Create a pull request from branch context and body file. | ||
| 72 | */ | ||
| 73 | readonly createPullRequest: (input: { | ||
| 74 | readonly cwd: string; | ||
| 75 | readonly baseBranch: string; | ||
| 76 | readonly headSelector: string; | ||
| 77 | readonly title: string; | ||
| 78 | readonly bodyFile: string; | ||
| 79 | }) => Effect.Effect<void, GitHubCliError>; | ||
| 80 | |||
| 81 | /** | ||
| 82 | * Resolve repository default branch through GitHub metadata. | ||
| 83 | */ | ||
| 84 | readonly getDefaultBranch: (input: { | ||
| 85 | readonly cwd: string; | ||
| 86 | }) => Effect.Effect<string | null, GitHubCliError>; | ||
| 87 | |||
| 88 | /** | ||
| 89 | * Checkout a pull request into the current repository worktree. | ||
| 90 | */ | ||
| 91 | readonly checkoutPullRequest: (input: { | ||
| 92 | readonly cwd: string; | ||
| 93 | readonly reference: string; | ||
| 94 | readonly force?: boolean; | ||
| 95 | }) => Effect.Effect<void, GitHubCliError>; | ||
| 96 | } | ||
| 97 | |||
| 98 | /** | ||
| 99 | * GitHubCli - Service tag for GitHub CLI process execution. | ||
| 100 | */ | ||
| 101 | export class GitHubCli extends Context.Service<GitHubCli, GitHubCliShape>()( | ||
| 102 | "t3/git/Services/GitHubCli", | ||
| 103 | ) {} | ||
apps/server/src/git/Services/GitManager.ts deleted-104| ... | @@ -1,104 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * GitManager - Effect service contract for stacked Git workflows. | ||
| 3 | * | ||
| 4 | * Orchestrates status inspection and commit/push/PR flows by composing | ||
| 5 | * lower-level Git and external tool services. | ||
| 6 | * | ||
| 7 | * @module GitManager | ||
| 8 | */ | ||
| 9 | import { | ||
| 10 | GitActionProgressEvent, | ||
| 11 | GitPreparePullRequestThreadInput, | ||
| 12 | GitPreparePullRequestThreadResult, | ||
| 13 | GitPullRequestRefInput, | ||
| 14 | GitResolvePullRequestResult, | ||
| 15 | GitRunStackedActionInput, | ||
| 16 | GitRunStackedActionResult, | ||
| 17 | GitStatusLocalResult, | ||
| 18 | GitStatusRemoteResult, | ||
| 19 | GitStatusInput, | ||
| 20 | GitStatusResult, | ||
| 21 | } from "@t3tools/contracts"; | ||
| 22 | import { Context } from "effect"; | ||
| 23 | import type { Effect } from "effect"; | ||
| 24 | import type { GitManagerServiceError } from "@t3tools/contracts"; | ||
| 25 | |||
| 26 | export interface GitActionProgressReporter { | ||
| 27 | readonly publish: (event: GitActionProgressEvent) => Effect.Effect<void, never>; | ||
| 28 | } | ||
| 29 | |||
| 30 | export interface GitRunStackedActionOptions { | ||
| 31 | readonly actionId?: string; | ||
| 32 | readonly progressReporter?: GitActionProgressReporter; | ||
| 33 | } | ||
| 34 | |||
| 35 | /** | ||
| 36 | * GitManagerShape - Service API for high-level Git workflow actions. | ||
| 37 | */ | ||
| 38 | export interface GitManagerShape { | ||
| 39 | /** | ||
| 40 | * Read current repository Git status plus open PR metadata when available. | ||
| 41 | */ | ||
| 42 | readonly status: ( | ||
| 43 | input: GitStatusInput, | ||
| 44 | ) => Effect.Effect<GitStatusResult, GitManagerServiceError>; | ||
| 45 | |||
| 46 | /** | ||
| 47 | * Read local repository status without remote hosting enrichment. | ||
| 48 | */ | ||
| 49 | readonly localStatus: ( | ||
| 50 | input: GitStatusInput, | ||
| 51 | ) => Effect.Effect<GitStatusLocalResult, GitManagerServiceError>; | ||
| 52 | |||
| 53 | /** | ||
| 54 | * Read remote tracking / PR status for a repository. | ||
| 55 | */ | ||
| 56 | readonly remoteStatus: ( | ||
| 57 | input: GitStatusInput, | ||
| 58 | ) => Effect.Effect<GitStatusRemoteResult | null, GitManagerServiceError>; | ||
| 59 | |||
| 60 | /** | ||
| 61 | * Clear any cached local status snapshot for a repository. | ||
| 62 | */ | ||
| 63 | readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>; | ||
| 64 | |||
| 65 | /** | ||
| 66 | * Clear any cached remote status snapshot for a repository. | ||
| 67 | */ | ||
| 68 | readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>; | ||
| 69 | |||
| 70 | /** | ||
| 71 | * Clear any cached status snapshot for a repository so the next read is fresh. | ||
| 72 | */ | ||
| 73 | readonly invalidateStatus: (cwd: string) => Effect.Effect<void, never>; | ||
| 74 | |||
| 75 | /** | ||
| 76 | * Resolve a pull request by URL/number against the current repository. | ||
| 77 | */ | ||
| 78 | readonly resolvePullRequest: ( | ||
| 79 | input: GitPullRequestRefInput, | ||
| 80 | ) => Effect.Effect<GitResolvePullRequestResult, GitManagerServiceError>; | ||
| 81 | |||
| 82 | /** | ||
| 83 | * Prepare a new thread workspace from a pull request in local or worktree mode. | ||
| 84 | */ | ||
| 85 | readonly preparePullRequestThread: ( | ||
| 86 | input: GitPreparePullRequestThreadInput, | ||
| 87 | ) => Effect.Effect<GitPreparePullRequestThreadResult, GitManagerServiceError>; | ||
| 88 | |||
| 89 | /** | ||
| 90 | * Run a Git action (`commit`, `push`, `create_pr`, `commit_push`, `commit_push_pr`). | ||
| 91 | * When `featureBranch` is set, creates and checks out a feature branch first. | ||
| 92 | */ | ||
| 93 | readonly runStackedAction: ( | ||
| 94 | input: GitRunStackedActionInput, | ||
| 95 | options?: GitRunStackedActionOptions, | ||
| 96 | ) => Effect.Effect<GitRunStackedActionResult, GitManagerServiceError>; | ||
| 97 | } | ||
| 98 | |||
| 99 | /** | ||
| 100 | * GitManager - Service tag for stacked Git workflow orchestration. | ||
| 101 | */ | ||
| 102 | export class GitManager extends Context.Service<GitManager, GitManagerShape>()( | ||
| 103 | "t3/git/Services/GitManager", | ||
| 104 | ) {} | ||
apps/server/src/git/Services/GitStatusBroadcaster.ts deleted-27| ... | @@ -1,27 +0,0 @@ | ||
| 1 | import { Context } from "effect"; | ||
| 2 | import type { Effect, Stream } from "effect"; | ||
| 3 | import type { | ||
| 4 | GitManagerServiceError, | ||
| 5 | GitStatusInput, | ||
| 6 | GitStatusLocalResult, | ||
| 7 | GitStatusResult, | ||
| 8 | GitStatusStreamEvent, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | |||
| 11 | export interface GitStatusBroadcasterShape { | ||
| 12 | readonly getStatus: ( | ||
| 13 | input: GitStatusInput, | ||
| 14 | ) => Effect.Effect<GitStatusResult, GitManagerServiceError>; | ||
| 15 | readonly refreshLocalStatus: ( | ||
| 16 | cwd: string, | ||
| 17 | ) => Effect.Effect<GitStatusLocalResult, GitManagerServiceError>; | ||
| 18 | readonly refreshStatus: (cwd: string) => Effect.Effect<GitStatusResult, GitManagerServiceError>; | ||
| 19 | readonly streamStatus: ( | ||
| 20 | input: GitStatusInput, | ||
| 21 | ) => Stream.Stream<GitStatusStreamEvent, GitManagerServiceError>; | ||
| 22 | } | ||
| 23 | |||
| 24 | export class GitStatusBroadcaster extends Context.Service< | ||
| 25 | GitStatusBroadcaster, | ||
| 26 | GitStatusBroadcasterShape | ||
| 27 | >()("t3/git/Services/GitStatusBroadcaster") {} | ||
apps/server/src/git/Services/TextGeneration.ts deleted-123| ... | @@ -1,123 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * TextGeneration - Effect service contract for AI-generated Git content. | ||
| 3 | * | ||
| 4 | * Generates commit messages and pull request titles/bodies from repository | ||
| 5 | * context prepared by Git services. | ||
| 6 | * | ||
| 7 | * @module TextGeneration | ||
| 8 | */ | ||
| 9 | import { Context } from "effect"; | ||
| 10 | import type { Effect } from "effect"; | ||
| 11 | import type { ChatAttachment, ModelSelection } from "@t3tools/contracts"; | ||
| 12 | |||
| 13 | import type { TextGenerationError } from "@t3tools/contracts"; | ||
| 14 | |||
| 15 | /** Providers that support git text generation (commit messages, PR content, branch names). */ | ||
| 16 | export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "opencode"; | ||
| 17 | |||
| 18 | export interface CommitMessageGenerationInput { | ||
| 19 | cwd: string; | ||
| 20 | branch: string | null; | ||
| 21 | stagedSummary: string; | ||
| 22 | stagedPatch: string; | ||
| 23 | /** When true, the model also returns a semantic branch name for the change. */ | ||
| 24 | includeBranch?: boolean; | ||
| 25 | /** What model and provider to use for generation. */ | ||
| 26 | modelSelection: ModelSelection; | ||
| 27 | } | ||
| 28 | |||
| 29 | export interface CommitMessageGenerationResult { | ||
| 30 | subject: string; | ||
| 31 | body: string; | ||
| 32 | /** Only present when `includeBranch` was set on the input. */ | ||
| 33 | branch?: string | undefined; | ||
| 34 | } | ||
| 35 | |||
| 36 | export interface PrContentGenerationInput { | ||
| 37 | cwd: string; | ||
| 38 | baseBranch: string; | ||
| 39 | headBranch: string; | ||
| 40 | commitSummary: string; | ||
| 41 | diffSummary: string; | ||
| 42 | diffPatch: string; | ||
| 43 | /** What model and provider to use for generation. */ | ||
| 44 | modelSelection: ModelSelection; | ||
| 45 | } | ||
| 46 | |||
| 47 | export interface PrContentGenerationResult { | ||
| 48 | title: string; | ||
| 49 | body: string; | ||
| 50 | } | ||
| 51 | |||
| 52 | export interface BranchNameGenerationInput { | ||
| 53 | cwd: string; | ||
| 54 | message: string; | ||
| 55 | attachments?: ReadonlyArray<ChatAttachment> | undefined; | ||
| 56 | /** What model and provider to use for generation. */ | ||
| 57 | modelSelection: ModelSelection; | ||
| 58 | } | ||
| 59 | |||
| 60 | export interface BranchNameGenerationResult { | ||
| 61 | branch: string; | ||
| 62 | } | ||
| 63 | |||
| 64 | export interface ThreadTitleGenerationInput { | ||
| 65 | cwd: string; | ||
| 66 | message: string; | ||
| 67 | attachments?: ReadonlyArray<ChatAttachment> | undefined; | ||
| 68 | /** What model and provider to use for generation. */ | ||
| 69 | modelSelection: ModelSelection; | ||
| 70 | } | ||
| 71 | |||
| 72 | export interface ThreadTitleGenerationResult { | ||
| 73 | title: string; | ||
| 74 | } | ||
| 75 | |||
| 76 | export interface TextGenerationService { | ||
| 77 | generateCommitMessage( | ||
| 78 | input: CommitMessageGenerationInput, | ||
| 79 | ): Promise<CommitMessageGenerationResult>; | ||
| 80 | generatePrContent(input: PrContentGenerationInput): Promise<PrContentGenerationResult>; | ||
| 81 | generateBranchName(input: BranchNameGenerationInput): Promise<BranchNameGenerationResult>; | ||
| 82 | generateThreadTitle(input: ThreadTitleGenerationInput): Promise<ThreadTitleGenerationResult>; | ||
| 83 | } | ||
| 84 | |||
| 85 | /** | ||
| 86 | * TextGenerationShape - Service API for commit/PR text generation. | ||
| 87 | */ | ||
| 88 | export interface TextGenerationShape { | ||
| 89 | /** | ||
| 90 | * Generate a commit message from staged change context. | ||
| 91 | */ | ||
| 92 | readonly generateCommitMessage: ( | ||
| 93 | input: CommitMessageGenerationInput, | ||
| 94 | ) => Effect.Effect<CommitMessageGenerationResult, TextGenerationError>; | ||
| 95 | |||
| 96 | /** | ||
| 97 | * Generate pull request title/body from branch and diff context. | ||
| 98 | */ | ||
| 99 | readonly generatePrContent: ( | ||
| 100 | input: PrContentGenerationInput, | ||
| 101 | ) => Effect.Effect<PrContentGenerationResult, TextGenerationError>; | ||
| 102 | |||
| 103 | /** | ||
| 104 | * Generate a concise branch name from a user message. | ||
| 105 | */ | ||
| 106 | readonly generateBranchName: ( | ||
| 107 | input: BranchNameGenerationInput, | ||
| 108 | ) => Effect.Effect<BranchNameGenerationResult, TextGenerationError>; | ||
| 109 | |||
| 110 | /** | ||
| 111 | * Generate a concise thread title from a user's first message. | ||
| 112 | */ | ||
| 113 | readonly generateThreadTitle: ( | ||
| 114 | input: ThreadTitleGenerationInput, | ||
| 115 | ) => Effect.Effect<ThreadTitleGenerationResult, TextGenerationError>; | ||
| 116 | } | ||
| 117 | |||
| 118 | /** | ||
| 119 | * TextGeneration - Service tag for commit and PR text generation. | ||
| 120 | */ | ||
| 121 | export class TextGeneration extends Context.Service<TextGeneration, TextGenerationShape>()( | ||
| 122 | "t3/git/Services/TextGeneration", | ||
| 123 | ) {} | ||
apps/server/src/git/Utils.ts-165| ... | @@ -1,171 +1,6 @@ | ... | @@ -1,171 +1,6 @@ |
| 1 | /** | ||
| 2 | * Shared utilities for text generation layers (Codex, Claude, etc.). | ||
| 3 | * | ||
| 4 | * @module textGenerationUtils | ||
| 5 | */ | ||
| 6 | import { Schema } from "effect"; | ||
| 7 | |||
| 8 | import { TextGenerationError } from "@t3tools/contracts"; | ||
| 9 | |||
| 10 | import { existsSync } from "node:fs"; | 1 | import { existsSync } from "node:fs"; |
| 11 | import { join } from "node:path"; | 2 | import { join } from "node:path"; |
| 12 | 3 | ||
| 13 | export function isGitRepository(cwd: string): boolean { | 4 | export function isGitRepository(cwd: string): boolean { |
| 14 | return existsSync(join(cwd, ".git")); | 5 | return existsSync(join(cwd, ".git")); |
| 15 | } | 6 | } |
| 16 | |||
| 17 | /** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */ | ||
| 18 | export function toJsonSchemaObject(schema: Schema.Top): unknown { | ||
| 19 | const document = Schema.toJsonSchemaDocument(schema); | ||
| 20 | if (document.definitions && Object.keys(document.definitions).length > 0) { | ||
| 21 | return { ...document.schema, $defs: document.definitions }; | ||
| 22 | } | ||
| 23 | return document.schema; | ||
| 24 | } | ||
| 25 | |||
| 26 | /** Truncate a text section to `maxChars`, appending a `[truncated]` marker when needed. */ | ||
| 27 | export function limitSection(value: string, maxChars: number): string { | ||
| 28 | if (value.length <= maxChars) return value; | ||
| 29 | const truncated = value.slice(0, maxChars); | ||
| 30 | return `${truncated}\n\n[truncated]`; | ||
| 31 | } | ||
| 32 | |||
| 33 | export function extractJsonObject(raw: string): string { | ||
| 34 | const trimmed = raw.trim(); | ||
| 35 | if (trimmed.length === 0) { | ||
| 36 | return trimmed; | ||
| 37 | } | ||
| 38 | |||
| 39 | const start = trimmed.indexOf("{"); | ||
| 40 | if (start < 0) { | ||
| 41 | return trimmed; | ||
| 42 | } | ||
| 43 | |||
| 44 | let depth = 0; | ||
| 45 | let inString = false; | ||
| 46 | let escaping = false; | ||
| 47 | for (let index = start; index < trimmed.length; index += 1) { | ||
| 48 | const char = trimmed[index]; | ||
| 49 | if (inString) { | ||
| 50 | if (escaping) { | ||
| 51 | escaping = false; | ||
| 52 | } else if (char === "\\") { | ||
| 53 | escaping = true; | ||
| 54 | } else if (char === '"') { | ||
| 55 | inString = false; | ||
| 56 | } | ||
| 57 | continue; | ||
| 58 | } | ||
| 59 | |||
| 60 | if (char === '"') { | ||
| 61 | inString = true; | ||
| 62 | continue; | ||
| 63 | } | ||
| 64 | |||
| 65 | if (char === "{") { | ||
| 66 | depth += 1; | ||
| 67 | continue; | ||
| 68 | } | ||
| 69 | |||
| 70 | if (char === "}") { | ||
| 71 | depth -= 1; | ||
| 72 | if (depth === 0) { | ||
| 73 | return trimmed.slice(start, index + 1); | ||
| 74 | } | ||
| 75 | } | ||
| 76 | } | ||
| 77 | |||
| 78 | return trimmed.slice(start); | ||
| 79 | } | ||
| 80 | |||
| 81 | /** Normalise a raw commit subject to imperative-mood, ≤72 chars, no trailing period. */ | ||
| 82 | export function sanitizeCommitSubject(raw: string): string { | ||
| 83 | const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; | ||
| 84 | const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim(); | ||
| 85 | if (withoutTrailingPeriod.length === 0) { | ||
| 86 | return "Update project files"; | ||
| 87 | } | ||
| 88 | |||
| 89 | if (withoutTrailingPeriod.length <= 72) { | ||
| 90 | return withoutTrailingPeriod; | ||
| 91 | } | ||
| 92 | return withoutTrailingPeriod.slice(0, 72).trimEnd(); | ||
| 93 | } | ||
| 94 | |||
| 95 | /** Normalise a raw PR title to a single line with a sensible fallback. */ | ||
| 96 | export function sanitizePrTitle(raw: string): string { | ||
| 97 | const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; | ||
| 98 | if (singleLine.length > 0) { | ||
| 99 | return singleLine; | ||
| 100 | } | ||
| 101 | return "Update project changes"; | ||
| 102 | } | ||
| 103 | |||
| 104 | /** Normalise a raw thread title to a compact single-line sidebar-safe label. */ | ||
| 105 | export function sanitizeThreadTitle(raw: string): string { | ||
| 106 | const normalized = raw | ||
| 107 | .trim() | ||
| 108 | .split(/\r?\n/g)[0] | ||
| 109 | ?.trim() | ||
| 110 | .replace(/^['"`]+|['"`]+$/g, "") | ||
| 111 | .trim() | ||
| 112 | .replace(/\s+/g, " "); | ||
| 113 | |||
| 114 | if (!normalized || normalized.trim().length === 0) { | ||
| 115 | return "New thread"; | ||
| 116 | } | ||
| 117 | |||
| 118 | if (normalized.length <= 50) { | ||
| 119 | return normalized; | ||
| 120 | } | ||
| 121 | |||
| 122 | return `${normalized.slice(0, 47).trimEnd()}...`; | ||
| 123 | } | ||
| 124 | |||
| 125 | /** CLI name to human-readable label, e.g. "codex" → "Codex CLI (`codex`)" */ | ||
| 126 | function cliLabel(cliName: string): string { | ||
| 127 | const capitalized = cliName.charAt(0).toUpperCase() + cliName.slice(1); | ||
| 128 | return `${capitalized} CLI (\`${cliName}\`)`; | ||
| 129 | } | ||
| 130 | |||
| 131 | /** | ||
| 132 | * Normalize an unknown error from a CLI text generation process into a | ||
| 133 | * typed `TextGenerationError`. Parameterized by CLI name so both Codex | ||
| 134 | * and Claude (and future providers) can share the same logic. | ||
| 135 | */ | ||
| 136 | export function normalizeCliError( | ||
| 137 | cliName: string, | ||
| 138 | operation: string, | ||
| 139 | error: unknown, | ||
| 140 | fallback: string, | ||
| 141 | ): TextGenerationError { | ||
| 142 | if (Schema.is(TextGenerationError)(error)) { | ||
| 143 | return error; | ||
| 144 | } | ||
| 145 | |||
| 146 | if (error instanceof Error) { | ||
| 147 | const lower = error.message.toLowerCase(); | ||
| 148 | if ( | ||
| 149 | error.message.includes(`Command not found: ${cliName}`) || | ||
| 150 | lower.includes(`spawn ${cliName}`) || | ||
| 151 | lower.includes("enoent") | ||
| 152 | ) { | ||
| 153 | return new TextGenerationError({ | ||
| 154 | operation, | ||
| 155 | detail: `${cliLabel(cliName)} is required but not available on PATH.`, | ||
| 156 | cause: error, | ||
| 157 | }); | ||
| 158 | } | ||
| 159 | return new TextGenerationError({ | ||
| 160 | operation, | ||
| 161 | detail: `${fallback}: ${error.message}`, | ||
| 162 | cause: error, | ||
| 163 | }); | ||
| 164 | } | ||
| 165 | |||
| 166 | return new TextGenerationError({ | ||
| 167 | operation, | ||
| 168 | detail: fallback, | ||
| 169 | cause: error, | ||
| 170 | }); | ||
| 171 | } |
apps/server/src/git/githubPullRequests.ts+4-5| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { Cause, Exit, Result, Schema } from "effect"; | 1 | import { Cause, DateTime, Exit, Option, Result, Schema } from "effect"; |
| 2 | import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; | 2 | import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; |
| 3 | import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; | 3 | import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; |
| 4 | 4 | ||
| ... | @@ -9,7 +9,7 @@ export interface NormalizedGitHubPullRequestRecord { | ... | @@ -9,7 +9,7 @@ export interface NormalizedGitHubPullRequestRecord { |
| 9 | readonly baseRefName: string; | 9 | readonly baseRefName: string; |
| 10 | readonly headRefName: string; | 10 | readonly headRefName: string; |
| 11 | readonly state: "open" | "closed" | "merged"; | 11 | readonly state: "open" | "closed" | "merged"; |
| 12 | readonly updatedAt: string | null; | 12 | readonly updatedAt: Option.Option<DateTime.Utc>; |
| 13 | readonly isCrossRepository?: boolean; | 13 | readonly isCrossRepository?: boolean; |
| 14 | readonly headRepositoryNameWithOwner?: string | null; | 14 | readonly headRepositoryNameWithOwner?: string | null; |
| 15 | readonly headRepositoryOwnerLogin?: string | null; | 15 | readonly headRepositoryOwnerLogin?: string | null; |
| ... | @@ -23,7 +23,7 @@ const GitHubPullRequestSchema = Schema.Struct({ | ... | @@ -23,7 +23,7 @@ const GitHubPullRequestSchema = Schema.Struct({ |
| 23 | headRefName: TrimmedNonEmptyString, | 23 | headRefName: TrimmedNonEmptyString, |
| 24 | state: Schema.optional(Schema.NullOr(Schema.String)), | 24 | state: Schema.optional(Schema.NullOr(Schema.String)), |
| 25 | mergedAt: Schema.optional(Schema.NullOr(Schema.String)), | 25 | mergedAt: Schema.optional(Schema.NullOr(Schema.String)), |
| 26 | updatedAt: Schema.optional(Schema.NullOr(Schema.String)), | 26 | updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), |
| 27 | isCrossRepository: Schema.optional(Schema.Boolean), | 27 | isCrossRepository: Schema.optional(Schema.Boolean), |
| 28 | headRepository: Schema.optional( | 28 | headRepository: Schema.optional( |
| 29 | Schema.NullOr( | 29 | Schema.NullOr( |
| ... | @@ -80,8 +80,7 @@ function normalizeGitHubPullRequestRecord( | ... | @@ -80,8 +80,7 @@ function normalizeGitHubPullRequestRecord( |
| 80 | baseRefName: raw.baseRefName, | 80 | baseRefName: raw.baseRefName, |
| 81 | headRefName: raw.headRefName, | 81 | headRefName: raw.headRefName, |
| 82 | state: normalizeGitHubPullRequestState(raw), | 82 | state: normalizeGitHubPullRequestState(raw), |
| 83 | updatedAt: | 83 | updatedAt: raw.updatedAt ?? Option.none(), |
| 84 | typeof raw.updatedAt === "string" && raw.updatedAt.trim().length > 0 ? raw.updatedAt : null, | ||
| 85 | ...(typeof raw.isCrossRepository === "boolean" | 84 | ...(typeof raw.isCrossRepository === "boolean" |
| 86 | ? { isCrossRepository: raw.isCrossRepository } | 85 | ? { isCrossRepository: raw.isCrossRepository } |
| 87 | : {}), | 86 | : {}), |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+16-10| ... | @@ -24,8 +24,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; | ... | @@ -24,8 +24,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; |
| 24 | 24 | ||
| 25 | import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; | 25 | import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; |
| 26 | import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; | 26 | import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; |
| 27 | import { GitCoreLive } from "../../git/Layers/GitCore.ts"; | 27 | import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; |
| 28 | import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts"; | 28 | import * as VcsProcess from "../../vcs/VcsProcess.ts"; |
| 29 | import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; | ||
| 29 | import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; | 30 | import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; |
| 30 | import { CheckpointReactorLive } from "./CheckpointReactor.ts"; | 31 | import { CheckpointReactorLive } from "./CheckpointReactor.ts"; |
| 31 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; | 32 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| ... | @@ -282,7 +283,7 @@ describe("CheckpointReactor", () => { | ... | @@ -282,7 +283,7 @@ describe("CheckpointReactor", () => { |
| 282 | const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { | 283 | const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { |
| 283 | prefix: "t3-checkpoint-reactor-test-", | 284 | prefix: "t3-checkpoint-reactor-test-", |
| 284 | }); | 285 | }); |
| 285 | const gitStatusBroadcasterLayer = Layer.succeed(GitStatusBroadcaster, { | 286 | const vcsStatusBroadcasterLayer = Layer.succeed(VcsStatusBroadcaster, { |
| 286 | getStatus: () => Effect.die("getStatus should not be called in this test"), | 287 | getStatus: () => Effect.die("getStatus should not be called in this test"), |
| 287 | refreshLocalStatus: (cwd: string) => | 288 | refreshLocalStatus: (cwd: string) => |
| 288 | Effect.sync(() => { | 289 | Effect.sync(() => { |
| ... | @@ -290,9 +291,9 @@ describe("CheckpointReactor", () => { | ... | @@ -290,9 +291,9 @@ describe("CheckpointReactor", () => { |
| 290 | }).pipe( | 291 | }).pipe( |
| 291 | Effect.as({ | 292 | Effect.as({ |
| 292 | isRepo: true, | 293 | isRepo: true, |
| 293 | hasOriginRemote: false, | 294 | hasPrimaryRemote: false, |
| 294 | isDefaultBranch: true, | 295 | isDefaultRef: true, |
| 295 | branch: "main", | 296 | refName: "main", |
| 296 | hasWorkingTreeChanges: false, | 297 | hasWorkingTreeChanges: false, |
| 297 | workingTree: { files: [], insertions: 0, deletions: 0 }, | 298 | workingTree: { files: [], insertions: 0, deletions: 0 }, |
| 298 | }), | 299 | }), |
| ... | @@ -305,11 +306,16 @@ describe("CheckpointReactor", () => { | ... | @@ -305,11 +306,16 @@ describe("CheckpointReactor", () => { |
| 305 | Layer.provideMerge(orchestrationLayer), | 306 | Layer.provideMerge(orchestrationLayer), |
| 306 | Layer.provideMerge(RuntimeReceiptBusLive), | 307 | Layer.provideMerge(RuntimeReceiptBusLive), |
| 307 | Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), | 308 | Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), |
| 308 | Layer.provideMerge(gitStatusBroadcasterLayer), | 309 | Layer.provideMerge(vcsStatusBroadcasterLayer), |
| 309 | Layer.provideMerge(CheckpointStoreLive), | 310 | Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer))), |
| 310 | Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), | 311 | Layer.provideMerge( |
| 312 | WorkspaceEntriesLive.pipe( | ||
| 313 | Layer.provide(WorkspacePathsLive), | ||
| 314 | Layer.provideMerge(VcsDriverRegistry.layer), | ||
| 315 | ), | ||
| 316 | ), | ||
| 311 | Layer.provideMerge(WorkspacePathsLive), | 317 | Layer.provideMerge(WorkspacePathsLive), |
| 312 | Layer.provideMerge(GitCoreLive), | 318 | Layer.provideMerge(VcsProcess.layer), |
| 313 | Layer.provideMerge(ServerConfigLayer), | 319 | Layer.provideMerge(ServerConfigLayer), |
| 314 | Layer.provideMerge(NodeServices.layer), | 320 | Layer.provideMerge(NodeServices.layer), |
| 315 | ); | 321 | ); |
apps/server/src/orchestration/Layers/CheckpointReactor.ts+3-3| ... | @@ -24,7 +24,7 @@ import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts"; | ... | @@ -24,7 +24,7 @@ import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts"; |
| 24 | import type { CheckpointStoreError } from "../../checkpointing/Errors.ts"; | 24 | import type { CheckpointStoreError } from "../../checkpointing/Errors.ts"; |
| 25 | import type { OrchestrationDispatchError } from "../Errors.ts"; | 25 | import type { OrchestrationDispatchError } from "../Errors.ts"; |
| 26 | import { isGitRepository } from "../../git/Utils.ts"; | 26 | import { isGitRepository } from "../../git/Utils.ts"; |
| 27 | import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts"; | 27 | import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; |
| 28 | import { WorkspaceEntries } from "../../workspace/Services/WorkspaceEntries.ts"; | 28 | import { WorkspaceEntries } from "../../workspace/Services/WorkspaceEntries.ts"; |
| 29 | 29 | ||
| 30 | type ReactorInput = | 30 | type ReactorInput = |
| ... | @@ -70,7 +70,7 @@ const make = Effect.gen(function* () { | ... | @@ -70,7 +70,7 @@ const make = Effect.gen(function* () { |
| 70 | const checkpointStore = yield* CheckpointStore; | 70 | const checkpointStore = yield* CheckpointStore; |
| 71 | const receiptBus = yield* RuntimeReceiptBus; | 71 | const receiptBus = yield* RuntimeReceiptBus; |
| 72 | const workspaceEntries = yield* WorkspaceEntries; | 72 | const workspaceEntries = yield* WorkspaceEntries; |
| 73 | const gitStatusBroadcaster = yield* GitStatusBroadcaster; | 73 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; |
| 74 | 74 | ||
| 75 | const appendRevertFailureActivity = (input: { | 75 | const appendRevertFailureActivity = (input: { |
| 76 | readonly threadId: ThreadId; | 76 | readonly threadId: ThreadId; |
| ... | @@ -508,7 +508,7 @@ const make = Effect.gen(function* () { | ... | @@ -508,7 +508,7 @@ const make = Effect.gen(function* () { |
| 508 | return; | 508 | return; |
| 509 | } | 509 | } |
| 510 | 510 | ||
| 511 | yield* gitStatusBroadcaster.refreshLocalStatus(sessionRuntime.value.cwd).pipe( | 511 | yield* vcsStatusBroadcaster.refreshLocalStatus(sessionRuntime.value.cwd).pipe( |
| 512 | Effect.catch((error) => | 512 | Effect.catch((error) => |
| 513 | Effect.logWarning("failed to refresh local git status after turn completion", { | 513 | Effect.logWarning("failed to refresh local git status after turn completion", { |
| 514 | threadId: event.threadId, | 514 | threadId: event.threadId, |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+13-12| ... | @@ -33,12 +33,7 @@ import { | ... | @@ -33,12 +33,7 @@ import { |
| 33 | ProviderService, | 33 | ProviderService, |
| 34 | type ProviderServiceShape, | 34 | type ProviderServiceShape, |
| 35 | } from "../../provider/Services/ProviderService.ts"; | 35 | } from "../../provider/Services/ProviderService.ts"; |
| 36 | import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.ts"; | 36 | import { TextGeneration, type TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; |
| 37 | import { | ||
| 38 | GitStatusBroadcaster, | ||
| 39 | type GitStatusBroadcasterShape, | ||
| 40 | } from "../../git/Services/GitStatusBroadcaster.ts"; | ||
| 41 | import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts"; | ||
| 42 | import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; | 37 | import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; |
| 43 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; | 38 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 44 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; | 39 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| ... | @@ -52,6 +47,8 @@ import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; | ... | @@ -52,6 +47,8 @@ import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 52 | import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; | 47 | import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; |
| 53 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 48 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 54 | import { ServerSettingsService } from "../../serverSettings.ts"; | 49 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 50 | import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; | ||
| 51 | import { GitWorkflowService, type GitWorkflowServiceShape } from "../../git/GitWorkflowService.ts"; | ||
| 55 | 52 | ||
| 56 | const asProjectId = (value: string): ProjectId => ProjectId.make(value); | 53 | const asProjectId = (value: string): ProjectId => ProjectId.make(value); |
| 57 | const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value); | 54 | const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value); |
| ... | @@ -243,9 +240,9 @@ describe("ProviderCommandReactor", () => { | ... | @@ -243,9 +240,9 @@ describe("ProviderCommandReactor", () => { |
| 243 | const refreshStatus = vi.fn((_: string) => | 240 | const refreshStatus = vi.fn((_: string) => |
| 244 | Effect.succeed({ | 241 | Effect.succeed({ |
| 245 | isRepo: true, | 242 | isRepo: true, |
| 246 | hasOriginRemote: true, | 243 | hasPrimaryRemote: true, |
| 247 | isDefaultBranch: false, | 244 | isDefaultRef: false, |
| 248 | branch: "renamed-branch", | 245 | refName: "renamed-branch", |
| 249 | hasWorkingTreeChanges: false, | 246 | hasWorkingTreeChanges: false, |
| 250 | workingTree: { | 247 | workingTree: { |
| 251 | files: [], | 248 | files: [], |
| ... | @@ -324,15 +321,19 @@ describe("ProviderCommandReactor", () => { | ... | @@ -324,15 +321,19 @@ describe("ProviderCommandReactor", () => { |
| 324 | const layer = ProviderCommandReactorLive.pipe( | 321 | const layer = ProviderCommandReactorLive.pipe( |
| 325 | Layer.provideMerge(orchestrationLayer), | 322 | Layer.provideMerge(orchestrationLayer), |
| 326 | Layer.provideMerge(Layer.succeed(ProviderService, service)), | 323 | Layer.provideMerge(Layer.succeed(ProviderService, service)), |
| 327 | Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)), | ||
| 328 | Layer.provideMerge( | 324 | Layer.provideMerge( |
| 329 | Layer.succeed(GitStatusBroadcaster, { | 325 | Layer.mock(GitWorkflowService)({ |
| 326 | renameBranch, | ||
| 327 | } satisfies Partial<GitWorkflowServiceShape>), | ||
| 328 | ), | ||
| 329 | Layer.provideMerge( | ||
| 330 | Layer.succeed(VcsStatusBroadcaster, { | ||
| 330 | getStatus: () => Effect.die("getStatus should not be called in this test"), | 331 | getStatus: () => Effect.die("getStatus should not be called in this test"), |
| 331 | refreshLocalStatus: () => | 332 | refreshLocalStatus: () => |
| 332 | Effect.die("refreshLocalStatus should not be called in this test"), | 333 | Effect.die("refreshLocalStatus should not be called in this test"), |
| 333 | refreshStatus, | 334 | refreshStatus, |
| 334 | streamStatus: () => Stream.die("streamStatus should not be called in this test"), | 335 | streamStatus: () => Stream.die("streamStatus should not be called in this test"), |
| 335 | } satisfies GitStatusBroadcasterShape), | 336 | }), |
| 336 | ), | 337 | ), |
| 337 | Layer.provideMerge( | 338 | Layer.provideMerge( |
| 338 | Layer.mock(TextGeneration, { | 339 | Layer.mock(TextGeneration, { |
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+7-7| ... | @@ -16,12 +16,10 @@ import { Cache, Cause, Duration, Effect, Equal, Layer, Option, Schema, Stream } | ... | @@ -16,12 +16,10 @@ import { Cache, Cause, Duration, Effect, Equal, Layer, Option, Schema, Stream } |
| 16 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; | 16 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; |
| 17 | 17 | ||
| 18 | import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; | 18 | import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; |
| 19 | import { GitCore } from "../../git/Services/GitCore.ts"; | ||
| 20 | import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts"; | ||
| 21 | import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; | 19 | import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; |
| 22 | import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; | 20 | import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; |
| 23 | import type { ProviderServiceError } from "../../provider/Errors.ts"; | 21 | import type { ProviderServiceError } from "../../provider/Errors.ts"; |
| 24 | import { TextGeneration } from "../../git/Services/TextGeneration.ts"; | 22 | import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; |
| 25 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; | 23 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; |
| 26 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; | 24 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 27 | import { | 25 | import { |
| ... | @@ -29,6 +27,8 @@ import { | ... | @@ -29,6 +27,8 @@ import { |
| 29 | type ProviderCommandReactorShape, | 27 | type ProviderCommandReactorShape, |
| 30 | } from "../Services/ProviderCommandReactor.ts"; | 28 | } from "../Services/ProviderCommandReactor.ts"; |
| 31 | import { ServerSettingsService } from "../../serverSettings.ts"; | 29 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 30 | import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; | ||
| 31 | import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; | ||
| 32 | 32 | ||
| 33 | type ProviderIntentEvent = Extract< | 33 | type ProviderIntentEvent = Extract< |
| 34 | OrchestrationEvent, | 34 | OrchestrationEvent, |
| ... | @@ -168,8 +168,8 @@ function buildGeneratedWorktreeBranchName(raw: string): string { | ... | @@ -168,8 +168,8 @@ function buildGeneratedWorktreeBranchName(raw: string): string { |
| 168 | const make = Effect.gen(function* () { | 168 | const make = Effect.gen(function* () { |
| 169 | const orchestrationEngine = yield* OrchestrationEngineService; | 169 | const orchestrationEngine = yield* OrchestrationEngineService; |
| 170 | const providerService = yield* ProviderService; | 170 | const providerService = yield* ProviderService; |
| 171 | const git = yield* GitCore; | 171 | const gitWorkflow = yield* GitWorkflowService; |
| 172 | const gitStatusBroadcaster = yield* GitStatusBroadcaster; | 172 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; |
| 173 | const textGeneration = yield* TextGeneration; | 173 | const textGeneration = yield* TextGeneration; |
| 174 | const serverSettingsService = yield* ServerSettingsService; | 174 | const serverSettingsService = yield* ServerSettingsService; |
| 175 | const handledTurnStartKeys = yield* Cache.make<string, true>({ | 175 | const handledTurnStartKeys = yield* Cache.make<string, true>({ |
| ... | @@ -587,7 +587,7 @@ const make = Effect.gen(function* () { | ... | @@ -587,7 +587,7 @@ const make = Effect.gen(function* () { |
| 587 | const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); | 587 | const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); |
| 588 | if (targetBranch === oldBranch) return; | 588 | if (targetBranch === oldBranch) return; |
| 589 | 589 | ||
| 590 | const renamed = yield* git.renameBranch({ cwd, oldBranch, newBranch: targetBranch }); | 590 | const renamed = yield* gitWorkflow.renameBranch({ cwd, oldBranch, newBranch: targetBranch }); |
| 591 | yield* orchestrationEngine.dispatch({ | 591 | yield* orchestrationEngine.dispatch({ |
| 592 | type: "thread.meta.update", | 592 | type: "thread.meta.update", |
| 593 | commandId: serverCommandId("worktree-branch-rename"), | 593 | commandId: serverCommandId("worktree-branch-rename"), |
| ... | @@ -595,7 +595,7 @@ const make = Effect.gen(function* () { | ... | @@ -595,7 +595,7 @@ const make = Effect.gen(function* () { |
| 595 | branch: renamed.branch, | 595 | branch: renamed.branch, |
| 596 | worktreePath: cwd, | 596 | worktreePath: cwd, |
| 597 | }); | 597 | }); |
| 598 | yield* gitStatusBroadcaster.refreshStatus(cwd).pipe(Effect.ignoreCause({ log: true })); | 598 | yield* vcsStatusBroadcaster.refreshStatus(cwd).pipe(Effect.ignoreCause({ log: true })); |
| 599 | }).pipe( | 599 | }).pipe( |
| 600 | Effect.catchCause((cause) => | 600 | Effect.catchCause((cause) => |
| 601 | Effect.logWarning("provider command reactor failed to generate or rename worktree branch", { | 601 | Effect.logWarning("provider command reactor failed to generate or rename worktree branch", { |
apps/server/src/project/Layers/RepositoryIdentityResolver.ts+6-3| ... | @@ -1,6 +1,9 @@ | ... | @@ -1,6 +1,9 @@ |
| 1 | import type { RepositoryIdentity } from "@t3tools/contracts"; | 1 | import type { RepositoryIdentity } from "@t3tools/contracts"; |
| 2 | import { Cache, Duration, Effect, Exit, Layer } from "effect"; | 2 | import { Cache, Duration, Effect, Exit, Layer } from "effect"; |
| 3 | import { detectGitHostingProviderFromRemoteUrl, normalizeGitRemoteUrl } from "@t3tools/shared/git"; | 3 | import { |
| 4 | detectSourceControlProviderFromGitRemoteUrl, | ||
| 5 | normalizeGitRemoteUrl, | ||
| 6 | } from "@t3tools/shared/git"; | ||
| 4 | 7 | ||
| 5 | import { runProcess } from "../../processRunner.ts"; | 8 | import { runProcess } from "../../processRunner.ts"; |
| 6 | import { | 9 | import { |
| ... | @@ -45,7 +48,7 @@ function buildRepositoryIdentity(input: { | ... | @@ -45,7 +48,7 @@ function buildRepositoryIdentity(input: { |
| 45 | readonly rootPath: string; | 48 | readonly rootPath: string; |
| 46 | }): RepositoryIdentity { | 49 | }): RepositoryIdentity { |
| 47 | const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); | 50 | const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); |
| 48 | const hostingProvider = detectGitHostingProviderFromRemoteUrl(input.remoteUrl); | 51 | const sourceControlProvider = detectSourceControlProviderFromGitRemoteUrl(input.remoteUrl); |
| 49 | const repositoryPath = canonicalKey.split("/").slice(1).join("/"); | 52 | const repositoryPath = canonicalKey.split("/").slice(1).join("/"); |
| 50 | const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0); | 53 | const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0); |
| 51 | const [owner] = repositoryPathSegments; | 54 | const [owner] = repositoryPathSegments; |
| ... | @@ -60,7 +63,7 @@ function buildRepositoryIdentity(input: { | ... | @@ -60,7 +63,7 @@ function buildRepositoryIdentity(input: { |
| 60 | }, | 63 | }, |
| 61 | rootPath: input.rootPath, | 64 | rootPath: input.rootPath, |
| 62 | ...(repositoryPath ? { displayName: repositoryPath } : {}), | 65 | ...(repositoryPath ? { displayName: repositoryPath } : {}), |
| 63 | ...(hostingProvider ? { provider: hostingProvider.kind } : {}), | 66 | ...(sourceControlProvider ? { provider: sourceControlProvider.kind } : {}), |
| 64 | ...(owner ? { owner } : {}), | 67 | ...(owner ? { owner } : {}), |
| 65 | ...(repositoryName ? { name: repositoryName } : {}), | 68 | ...(repositoryName ? { name: repositoryName } : {}), |
| 66 | }; | 69 | }; |
apps/server/src/provider/Drivers/ClaudeDriver.ts+1-1| ... | @@ -16,7 +16,7 @@ import { ClaudeSettings, ProviderDriverKind, type ServerProvider } from "@t3tool | ... | @@ -16,7 +16,7 @@ import { ClaudeSettings, ProviderDriverKind, type ServerProvider } from "@t3tool |
| 16 | import { Cache, Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; | 16 | import { Cache, Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; |
| 17 | import { ChildProcessSpawner } from "effect/unstable/process"; | 17 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 18 | 18 | ||
| 19 | import { makeClaudeTextGeneration } from "../../git/Layers/ClaudeTextGeneration.ts"; | 19 | import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; |
| 20 | import { ServerConfig } from "../../config.ts"; | 20 | import { ServerConfig } from "../../config.ts"; |
| 21 | import { ProviderDriverError } from "../Errors.ts"; | 21 | import { ProviderDriverError } from "../Errors.ts"; |
| 22 | import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; | 22 | import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; |
apps/server/src/provider/Drivers/CodexDriver.ts+1-1| ... | @@ -25,7 +25,7 @@ import { CodexSettings, ProviderDriverKind, type ServerProvider } from "@t3tools | ... | @@ -25,7 +25,7 @@ import { CodexSettings, ProviderDriverKind, type ServerProvider } from "@t3tools |
| 25 | import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; | 25 | import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; |
| 26 | import { ChildProcessSpawner } from "effect/unstable/process"; | 26 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 27 | 27 | ||
| 28 | import { makeCodexTextGeneration } from "../../git/Layers/CodexTextGeneration.ts"; | 28 | import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; |
| 29 | import { ServerConfig } from "../../config.ts"; | 29 | import { ServerConfig } from "../../config.ts"; |
| 30 | import { ProviderDriverError } from "../Errors.ts"; | 30 | import { ProviderDriverError } from "../Errors.ts"; |
| 31 | import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; | 31 | import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; |
apps/server/src/provider/Drivers/CursorDriver.ts+1-1| ... | @@ -17,7 +17,7 @@ import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; | ... | @@ -17,7 +17,7 @@ import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; |
| 17 | import { ChildProcessSpawner } from "effect/unstable/process"; | 17 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 18 | 18 | ||
| 19 | import { ServerConfig } from "../../config.ts"; | 19 | import { ServerConfig } from "../../config.ts"; |
| 20 | import { makeCursorTextGeneration } from "../../git/Layers/CursorTextGeneration.ts"; | 20 | import { makeCursorTextGeneration } from "../../textGeneration/CursorTextGeneration.ts"; |
| 21 | import { ProviderDriverError } from "../Errors.ts"; | 21 | import { ProviderDriverError } from "../Errors.ts"; |
| 22 | import { makeCursorAdapter } from "../Layers/CursorAdapter.ts"; | 22 | import { makeCursorAdapter } from "../Layers/CursorAdapter.ts"; |
| 23 | import { | 23 | import { |
apps/server/src/provider/Drivers/OpenCodeDriver.ts+1-1| ... | @@ -16,7 +16,7 @@ import { OpenCodeSettings, ProviderDriverKind, type ServerProvider } from "@t3to | ... | @@ -16,7 +16,7 @@ import { OpenCodeSettings, ProviderDriverKind, type ServerProvider } from "@t3to |
| 16 | import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; | 16 | import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; |
| 17 | import { ChildProcessSpawner } from "effect/unstable/process"; | 17 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 18 | 18 | ||
| 19 | import { makeOpenCodeTextGeneration } from "../../git/Layers/OpenCodeTextGeneration.ts"; | 19 | import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts"; |
| 20 | import { ServerConfig } from "../../config.ts"; | 20 | import { ServerConfig } from "../../config.ts"; |
| 21 | import { ProviderDriverError } from "../Errors.ts"; | 21 | import { ProviderDriverError } from "../Errors.ts"; |
| 22 | import { makeOpenCodeAdapter } from "../Layers/OpenCodeAdapter.ts"; | 22 | import { makeOpenCodeAdapter } from "../Layers/OpenCodeAdapter.ts"; |
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+1-1| ... | @@ -14,7 +14,7 @@ import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; | ... | @@ -14,7 +14,7 @@ import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; |
| 14 | import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; | 14 | import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; |
| 15 | import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; | 15 | import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; |
| 16 | import type { ProviderInstance } from "../ProviderDriver.ts"; | 16 | import type { ProviderInstance } from "../ProviderDriver.ts"; |
| 17 | import type { TextGenerationShape } from "../../git/Services/TextGeneration.ts"; | 17 | import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; |
| 18 | import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; | 18 | import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; |
| 19 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 19 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 20 | 20 |
apps/server/src/provider/ProviderDriver.ts+1-1| ... | @@ -28,7 +28,7 @@ import type { | ... | @@ -28,7 +28,7 @@ import type { |
| 28 | } from "@t3tools/contracts"; | 28 | } from "@t3tools/contracts"; |
| 29 | import type { Effect, Schema, Scope } from "effect"; | 29 | import type { Effect, Schema, Scope } from "effect"; |
| 30 | 30 | ||
| 31 | import type { TextGenerationShape } from "../git/Services/TextGeneration.ts"; | 31 | import type { TextGenerationShape } from "../textGeneration/TextGeneration.ts"; |
| 32 | import type { ProviderAdapterError, ProviderDriverError } from "./Errors.ts"; | 32 | import type { ProviderAdapterError, ProviderDriverError } from "./Errors.ts"; |
| 33 | import type { ProviderAdapterShape } from "./Services/ProviderAdapter.ts"; | 33 | import type { ProviderAdapterShape } from "./Services/ProviderAdapter.ts"; |
| 34 | import type { ServerProviderShape } from "./Services/ServerProvider.ts"; | 34 | import type { ServerProviderShape } from "./Services/ServerProvider.ts"; |
apps/server/src/server.test.ts+242-125| ... | @@ -29,6 +29,7 @@ import { assert, it } from "@effect/vitest"; | ... | @@ -29,6 +29,7 @@ import { assert, it } from "@effect/vitest"; |
| 29 | import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; | 29 | import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; |
| 30 | import { | 30 | import { |
| 31 | Deferred, | 31 | Deferred, |
| 32 | DateTime, | ||
| 32 | Duration, | 33 | Duration, |
| 33 | Effect, | 34 | Effect, |
| 34 | FileSystem, | 35 | FileSystem, |
| ... | @@ -38,6 +39,7 @@ import { | ... | @@ -38,6 +39,7 @@ import { |
| 38 | Path, | 39 | Path, |
| 39 | Stream, | 40 | Stream, |
| 40 | } from "effect"; | 41 | } from "effect"; |
| 42 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 41 | import { | 43 | import { |
| 42 | FetchHttpClient, | 44 | FetchHttpClient, |
| 43 | HttpBody, | 45 | HttpBody, |
| ... | @@ -50,6 +52,8 @@ import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; | ... | @@ -50,6 +52,8 @@ import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; |
| 50 | import * as Socket from "effect/unstable/socket/Socket"; | 52 | import * as Socket from "effect/unstable/socket/Socket"; |
| 51 | import { vi } from "vitest"; | 53 | import { vi } from "vitest"; |
| 52 | 54 | ||
| 55 | const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); | ||
| 56 | |||
| 53 | import type { ServerConfigShape } from "./config.ts"; | 57 | import type { ServerConfigShape } from "./config.ts"; |
| 54 | import { deriveServerPaths, ServerConfig } from "./config.ts"; | 58 | import { deriveServerPaths, ServerConfig } from "./config.ts"; |
| 55 | import { makeRoutesLayer } from "./server.ts"; | 59 | import { makeRoutesLayer } from "./server.ts"; |
| ... | @@ -58,13 +62,7 @@ import { | ... | @@ -58,13 +62,7 @@ import { |
| 58 | CheckpointDiffQuery, | 62 | CheckpointDiffQuery, |
| 59 | type CheckpointDiffQueryShape, | 63 | type CheckpointDiffQueryShape, |
| 60 | } from "./checkpointing/Services/CheckpointDiffQuery.ts"; | 64 | } from "./checkpointing/Services/CheckpointDiffQuery.ts"; |
| 61 | import { GitCore, type GitCoreShape } from "./git/Services/GitCore.ts"; | 65 | import { GitManager, type GitManagerShape } from "./git/GitManager.ts"; |
| 62 | import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; | ||
| 63 | import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster.ts"; | ||
| 64 | import { | ||
| 65 | GitStatusBroadcaster, | ||
| 66 | type GitStatusBroadcasterShape, | ||
| 67 | } from "./git/Services/GitStatusBroadcaster.ts"; | ||
| 68 | import { Keybindings, type KeybindingsShape } from "./keybindings.ts"; | 66 | import { Keybindings, type KeybindingsShape } from "./keybindings.ts"; |
| 69 | import { Open, type OpenShape } from "./open.ts"; | 67 | import { Open, type OpenShape } from "./open.ts"; |
| 70 | import { | 68 | import { |
| ... | @@ -105,6 +103,20 @@ import { | ... | @@ -105,6 +103,20 @@ import { |
| 105 | import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; | 103 | import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; |
| 106 | import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; | 104 | import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; |
| 107 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; | 105 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; |
| 106 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; | ||
| 107 | import type { VcsDriverShape } from "./vcs/VcsDriver.ts"; | ||
| 108 | import { | ||
| 109 | VcsStatusBroadcaster, | ||
| 110 | type VcsStatusBroadcasterShape, | ||
| 111 | layer as VcsStatusBroadcasterLayer, | ||
| 112 | } from "./vcs/VcsStatusBroadcaster.ts"; | ||
| 113 | import { | ||
| 114 | VcsDriverRegistry, | ||
| 115 | type VcsDriverRegistryShape, | ||
| 116 | type VcsDriverHandle, | ||
| 117 | } from "./vcs/VcsDriverRegistry.ts"; | ||
| 118 | import { layer as VcsProvisioningServiceLayer } from "./vcs/VcsProvisioningService.ts"; | ||
| 119 | import { layer as GitWorkflowServiceLayer } from "./git/GitWorkflowService.ts"; | ||
| 108 | import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; | 120 | import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; |
| 109 | import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; | 121 | import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; |
| 110 | 122 | ||
| ... | @@ -195,16 +207,6 @@ const makeDefaultOrchestrationThreadShell = ( | ... | @@ -195,16 +207,6 @@ const makeDefaultOrchestrationThreadShell = ( |
| 195 | }; | 207 | }; |
| 196 | }; | 208 | }; |
| 197 | 209 | ||
| 198 | const workspaceAndProjectServicesLayer = Layer.mergeAll( | ||
| 199 | WorkspacePathsLive, | ||
| 200 | WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive)), | ||
| 201 | WorkspaceFileSystemLive.pipe( | ||
| 202 | Layer.provide(WorkspacePathsLive), | ||
| 203 | Layer.provide(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), | ||
| 204 | ), | ||
| 205 | ProjectFaviconResolverLive, | ||
| 206 | ); | ||
| 207 | |||
| 208 | const browserOtlpTracingLayer = Layer.mergeAll( | 210 | const browserOtlpTracingLayer = Layer.mergeAll( |
| 209 | FetchHttpClient.layer, | 211 | FetchHttpClient.layer, |
| 210 | OtlpSerialization.layerJson, | 212 | OtlpSerialization.layerJson, |
| ... | @@ -324,9 +326,11 @@ const buildAppUnderTest = (options?: { | ... | @@ -324,9 +326,11 @@ const buildAppUnderTest = (options?: { |
| 324 | providerRegistry?: Partial<ProviderRegistryShape>; | 326 | providerRegistry?: Partial<ProviderRegistryShape>; |
| 325 | serverSettings?: Partial<ServerSettingsShape>; | 327 | serverSettings?: Partial<ServerSettingsShape>; |
| 326 | open?: Partial<OpenShape>; | 328 | open?: Partial<OpenShape>; |
| 327 | gitCore?: Partial<GitCoreShape>; | 329 | vcsDriver?: Partial<VcsDriverShape>; |
| 330 | vcsDriverRegistry?: Partial<VcsDriverRegistryShape>; | ||
| 331 | gitVcsDriver?: Partial<GitVcsDriver.GitVcsDriverShape>; | ||
| 328 | gitManager?: Partial<GitManagerShape>; | 332 | gitManager?: Partial<GitManagerShape>; |
| 329 | gitStatusBroadcaster?: Partial<GitStatusBroadcasterShape>; | 333 | vcsStatusBroadcaster?: Partial<VcsStatusBroadcasterShape>; |
| 330 | projectSetupScriptRunner?: Partial<ProjectSetupScriptRunnerShape>; | 334 | projectSetupScriptRunner?: Partial<ProjectSetupScriptRunnerShape>; |
| 331 | terminalManager?: Partial<TerminalManagerShape>; | 335 | terminalManager?: Partial<TerminalManagerShape>; |
| 332 | orchestrationEngine?: Partial<OrchestrationEngineShape>; | 336 | orchestrationEngine?: Partial<OrchestrationEngineShape>; |
| ... | @@ -372,22 +376,110 @@ const buildAppUnderTest = (options?: { | ... | @@ -372,22 +376,110 @@ const buildAppUnderTest = (options?: { |
| 372 | ...options?.config, | 376 | ...options?.config, |
| 373 | }; | 377 | }; |
| 374 | const layerConfig = Layer.succeed(ServerConfig, config); | 378 | const layerConfig = Layer.succeed(ServerConfig, config); |
| 375 | const gitCoreLayer = Layer.mock(GitCore)({ | 379 | const defaultVcsDriver: VcsDriverShape = { |
| 380 | capabilities: { | ||
| 381 | kind: "git", | ||
| 382 | supportsWorktrees: true, | ||
| 383 | supportsBookmarks: false, | ||
| 384 | supportsAtomicSnapshot: false, | ||
| 385 | supportsPushDefaultRemote: true, | ||
| 386 | ignoreClassifier: "native", | ||
| 387 | }, | ||
| 388 | execute: () => | ||
| 389 | Effect.succeed({ | ||
| 390 | exitCode: ChildProcessSpawner.ExitCode(0), | ||
| 391 | stdout: "", | ||
| 392 | stderr: "", | ||
| 393 | stdoutTruncated: false, | ||
| 394 | stderrTruncated: false, | ||
| 395 | }), | ||
| 396 | detectRepository: () => Effect.succeed(null), | ||
| 376 | isInsideWorkTree: () => Effect.succeed(false), | 397 | isInsideWorkTree: () => Effect.succeed(false), |
| 377 | listWorkspaceFiles: () => | 398 | listWorkspaceFiles: () => |
| 378 | Effect.succeed({ | 399 | Effect.succeed({ |
| 379 | paths: [], | 400 | paths: [], |
| 380 | truncated: false, | 401 | truncated: false, |
| 402 | freshness: { | ||
| 403 | source: "live-local", | ||
| 404 | observedAt: TEST_EPOCH, | ||
| 405 | expiresAt: Option.none(), | ||
| 406 | }, | ||
| 407 | }), | ||
| 408 | listRemotes: () => | ||
| 409 | Effect.succeed({ | ||
| 410 | remotes: [], | ||
| 411 | freshness: { | ||
| 412 | source: "live-local", | ||
| 413 | observedAt: TEST_EPOCH, | ||
| 414 | expiresAt: Option.none(), | ||
| 415 | }, | ||
| 381 | }), | 416 | }), |
| 382 | filterIgnoredPaths: (_cwd, relativePaths) => Effect.succeed(relativePaths), | 417 | filterIgnoredPaths: (_cwd, relativePaths) => Effect.succeed(relativePaths), |
| 383 | ...options?.layers?.gitCore, | 418 | initRepository: () => Effect.void, |
| 419 | ...options?.layers?.vcsDriver, | ||
| 420 | }; | ||
| 421 | const vcsDriverRegistryLayer = Layer.mock(VcsDriverRegistry)({ | ||
| 422 | get: () => Effect.succeed(defaultVcsDriver), | ||
| 423 | detect: (input) => | ||
| 424 | defaultVcsDriver.detectRepository(input.cwd).pipe( | ||
| 425 | Effect.flatMap((repository) => | ||
| 426 | repository | ||
| 427 | ? Effect.succeed(repository) | ||
| 428 | : defaultVcsDriver.isInsideWorkTree(input.cwd).pipe( | ||
| 429 | Effect.map((isInsideWorkTree) => | ||
| 430 | isInsideWorkTree | ||
| 431 | ? { | ||
| 432 | kind: "git" as const, | ||
| 433 | rootPath: input.cwd, | ||
| 434 | metadataPath: null, | ||
| 435 | freshness: { | ||
| 436 | source: "live-local" as const, | ||
| 437 | observedAt: TEST_EPOCH, | ||
| 438 | expiresAt: Option.none(), | ||
| 439 | }, | ||
| 440 | } | ||
| 441 | : null, | ||
| 442 | ), | ||
| 443 | ), | ||
| 444 | ), | ||
| 445 | Effect.map((repository) => | ||
| 446 | repository | ||
| 447 | ? ({ | ||
| 448 | kind: repository.kind, | ||
| 449 | repository, | ||
| 450 | driver: defaultVcsDriver, | ||
| 451 | } satisfies VcsDriverHandle) | ||
| 452 | : null, | ||
| 453 | ), | ||
| 454 | ), | ||
| 455 | resolve: (input) => | ||
| 456 | Effect.succeed({ | ||
| 457 | kind: | ||
| 458 | input.requestedKind === "auto" || !input.requestedKind ? "git" : input.requestedKind, | ||
| 459 | repository: { | ||
| 460 | kind: | ||
| 461 | input.requestedKind === "auto" || !input.requestedKind ? "git" : input.requestedKind, | ||
| 462 | rootPath: input.cwd, | ||
| 463 | metadataPath: null, | ||
| 464 | freshness: { | ||
| 465 | source: "live-local", | ||
| 466 | observedAt: TEST_EPOCH, | ||
| 467 | expiresAt: Option.none(), | ||
| 468 | }, | ||
| 469 | }, | ||
| 470 | driver: defaultVcsDriver, | ||
| 471 | }), | ||
| 472 | ...options?.layers?.vcsDriverRegistry, | ||
| 473 | }); | ||
| 474 | const gitVcsDriverLayer = Layer.mock(GitVcsDriver.GitVcsDriver)({ | ||
| 475 | ...options?.layers?.gitVcsDriver, | ||
| 384 | }); | 476 | }); |
| 385 | const gitManagerLayer = Layer.mock(GitManager)({ | 477 | const gitManagerLayer = Layer.mock(GitManager)({ |
| 386 | ...options?.layers?.gitManager, | 478 | ...options?.layers?.gitManager, |
| 387 | }); | 479 | }); |
| 388 | const workspaceEntriesLayer = WorkspaceEntriesLive.pipe( | 480 | const workspaceEntriesLayer = WorkspaceEntriesLive.pipe( |
| 389 | Layer.provide(WorkspacePathsLive), | 481 | Layer.provide(WorkspacePathsLive), |
| 390 | Layer.provideMerge(gitCoreLayer), | 482 | Layer.provideMerge(vcsDriverRegistryLayer), |
| 391 | ); | 483 | ); |
| 392 | const workspaceAndProjectServicesLayer = Layer.mergeAll( | 484 | const workspaceAndProjectServicesLayer = Layer.mergeAll( |
| 393 | WorkspacePathsLive, | 485 | WorkspacePathsLive, |
| ... | @@ -398,11 +490,19 @@ const buildAppUnderTest = (options?: { | ... | @@ -398,11 +490,19 @@ const buildAppUnderTest = (options?: { |
| 398 | ), | 490 | ), |
| 399 | ProjectFaviconResolverLive, | 491 | ProjectFaviconResolverLive, |
| 400 | ); | 492 | ); |
| 401 | const gitStatusBroadcasterLayer = options?.layers?.gitStatusBroadcaster | 493 | const gitWorkflowLayer = GitWorkflowServiceLayer.pipe( |
| 402 | ? Layer.mock(GitStatusBroadcaster)({ | 494 | Layer.provideMerge(vcsDriverRegistryLayer), |
| 403 | ...options.layers.gitStatusBroadcaster, | 495 | Layer.provideMerge(gitVcsDriverLayer), |
| 496 | Layer.provideMerge(gitManagerLayer), | ||
| 497 | ); | ||
| 498 | const vcsProvisioningLayer = VcsProvisioningServiceLayer.pipe( | ||
| 499 | Layer.provide(vcsDriverRegistryLayer), | ||
| 500 | ); | ||
| 501 | const vcsStatusBroadcasterLayer = options?.layers?.vcsStatusBroadcaster | ||
| 502 | ? Layer.mock(VcsStatusBroadcaster)({ | ||
| 503 | ...options.layers.vcsStatusBroadcaster, | ||
| 404 | }) | 504 | }) |
| 405 | : GitStatusBroadcasterLive.pipe(Layer.provide(gitManagerLayer)); | 505 | : VcsStatusBroadcasterLayer.pipe(Layer.provide(gitWorkflowLayer)); |
| 406 | 506 | ||
| 407 | const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, { | 507 | const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, { |
| 408 | disableListenLog: true, | 508 | disableListenLog: true, |
| ... | @@ -441,9 +541,11 @@ const buildAppUnderTest = (options?: { | ... | @@ -441,9 +541,11 @@ const buildAppUnderTest = (options?: { |
| 441 | ...options?.layers?.open, | 541 | ...options?.layers?.open, |
| 442 | }), | 542 | }), |
| 443 | ), | 543 | ), |
| 444 | Layer.provide(gitCoreLayer), | ||
| 445 | Layer.provide(gitManagerLayer), | 544 | Layer.provide(gitManagerLayer), |
| 446 | Layer.provideMerge(gitStatusBroadcasterLayer), | 545 | Layer.provide(gitVcsDriverLayer), |
| 546 | Layer.provide(gitWorkflowLayer), | ||
| 547 | Layer.provide(vcsProvisioningLayer), | ||
| 548 | Layer.provideMerge(vcsStatusBroadcasterLayer), | ||
| 447 | Layer.provide( | 549 | Layer.provide( |
| 448 | Layer.mock(ProjectSetupScriptRunner)({ | 550 | Layer.mock(ProjectSetupScriptRunner)({ |
| 449 | runForThread: () => Effect.succeed({ status: "no-script" as const }), | 551 | runForThread: () => Effect.succeed({ status: "no-script" as const }), |
| ... | @@ -2068,12 +2170,17 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2068,12 +2170,17 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2068 | 2170 | ||
| 2069 | yield* buildAppUnderTest({ | 2171 | yield* buildAppUnderTest({ |
| 2070 | layers: { | 2172 | layers: { |
| 2071 | gitCore: { | 2173 | vcsDriver: { |
| 2072 | isInsideWorkTree: () => Effect.succeed(true), | 2174 | isInsideWorkTree: () => Effect.succeed(true), |
| 2073 | listWorkspaceFiles: () => | 2175 | listWorkspaceFiles: () => |
| 2074 | Effect.succeed({ | 2176 | Effect.succeed({ |
| 2075 | paths: ["src/tracked.ts"], | 2177 | paths: ["src/tracked.ts"], |
| 2076 | truncated: false, | 2178 | truncated: false, |
| 2179 | freshness: { | ||
| 2180 | source: "live-local", | ||
| 2181 | observedAt: TEST_EPOCH, | ||
| 2182 | expiresAt: Option.none(), | ||
| 2183 | }, | ||
| 2077 | }), | 2184 | }), |
| 2078 | filterIgnoredPaths: (_cwd, relativePaths) => | 2185 | filterIgnoredPaths: (_cwd, relativePaths) => |
| 2079 | Effect.succeed( | 2186 | Effect.succeed( |
| ... | @@ -2273,9 +2380,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2273,9 +2380,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2273 | localStatus: () => | 2380 | localStatus: () => |
| 2274 | Effect.succeed({ | 2381 | Effect.succeed({ |
| 2275 | isRepo: true, | 2382 | isRepo: true, |
| 2276 | hasOriginRemote: true, | 2383 | hasPrimaryRemote: true, |
| 2277 | isDefaultBranch: true, | 2384 | isDefaultRef: true, |
| 2278 | branch: "main", | 2385 | refName: "main", |
| 2279 | hasWorkingTreeChanges: false, | 2386 | hasWorkingTreeChanges: false, |
| 2280 | workingTree: { files: [], insertions: 0, deletions: 0 }, | 2387 | workingTree: { files: [], insertions: 0, deletions: 0 }, |
| 2281 | }), | 2388 | }), |
| ... | @@ -2289,9 +2396,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2289,9 +2396,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2289 | status: () => | 2396 | status: () => |
| 2290 | Effect.succeed({ | 2397 | Effect.succeed({ |
| 2291 | isRepo: true, | 2398 | isRepo: true, |
| 2292 | hasOriginRemote: true, | 2399 | hasPrimaryRemote: true, |
| 2293 | isDefaultBranch: true, | 2400 | isDefaultRef: true, |
| 2294 | branch: "main", | 2401 | refName: "main", |
| 2295 | hasWorkingTreeChanges: false, | 2402 | hasWorkingTreeChanges: false, |
| 2296 | workingTree: { files: [], insertions: 0, deletions: 0 }, | 2403 | workingTree: { files: [], insertions: 0, deletions: 0 }, |
| 2297 | hasUpstream: true, | 2404 | hasUpstream: true, |
| ... | @@ -2372,16 +2479,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2372,16 +2479,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2372 | worktreePath: null, | 2479 | worktreePath: null, |
| 2373 | }), | 2480 | }), |
| 2374 | }, | 2481 | }, |
| 2375 | gitCore: { | 2482 | gitVcsDriver: { |
| 2376 | pullCurrentBranch: () => | 2483 | pullCurrentBranch: () => |
| 2377 | Effect.succeed({ | 2484 | Effect.succeed({ |
| 2378 | status: "pulled", | 2485 | status: "pulled", |
| 2379 | branch: "main", | 2486 | refName: "main", |
| 2380 | upstreamBranch: "origin/main", | 2487 | upstreamRef: "origin/main", |
| 2381 | }), | 2488 | }), |
| 2382 | listBranches: () => | 2489 | listRefs: () => |
| 2383 | Effect.succeed({ | 2490 | Effect.succeed({ |
| 2384 | branches: [ | 2491 | refs: [ |
| 2385 | { | 2492 | { |
| 2386 | name: "main", | 2493 | name: "main", |
| 2387 | current: true, | 2494 | current: true, |
| ... | @@ -2390,18 +2497,20 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2390,18 +2497,20 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2390 | }, | 2497 | }, |
| 2391 | ], | 2498 | ], |
| 2392 | isRepo: true, | 2499 | isRepo: true, |
| 2393 | hasOriginRemote: true, | 2500 | hasPrimaryRemote: true, |
| 2394 | nextCursor: null, | 2501 | nextCursor: null, |
| 2395 | totalCount: 1, | 2502 | totalCount: 1, |
| 2396 | }), | 2503 | }), |
| 2397 | createWorktree: () => | 2504 | createWorktree: () => |
| 2398 | Effect.succeed({ | 2505 | Effect.succeed({ |
| 2399 | worktree: { path: "/tmp/wt", branch: "feature/demo" }, | 2506 | worktree: { path: "/tmp/wt", refName: "feature/demo" }, |
| 2400 | }), | 2507 | }), |
| 2401 | removeWorktree: () => Effect.void, | 2508 | removeWorktree: () => Effect.void, |
| 2402 | createBranch: (input) => Effect.succeed({ branch: input.branch }), | 2509 | createRef: (input) => Effect.succeed({ refName: input.refName }), |
| 2403 | checkoutBranch: (input) => Effect.succeed({ branch: input.branch }), | 2510 | switchRef: (input) => Effect.succeed({ refName: input.refName }), |
| 2404 | initRepo: () => Effect.void, | 2511 | }, |
| 2512 | vcsDriver: { | ||
| 2513 | isInsideWorkTree: () => Effect.succeed(true), | ||
| 2405 | }, | 2514 | }, |
| 2406 | }, | 2515 | }, |
| 2407 | }); | 2516 | }); |
| ... | @@ -2409,13 +2518,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2409,13 +2518,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2409 | const wsUrl = yield* getWsServerUrl("/ws"); | 2518 | const wsUrl = yield* getWsServerUrl("/ws"); |
| 2410 | 2519 | ||
| 2411 | const pull = yield* Effect.scoped( | 2520 | const pull = yield* Effect.scoped( |
| 2412 | withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })), | 2521 | withWsRpcClient(wsUrl, (client) => client[WS_METHODS.vcsPull]({ cwd: "/tmp/repo" })), |
| 2413 | ); | 2522 | ); |
| 2414 | assert.equal(pull.status, "pulled"); | 2523 | assert.equal(pull.status, "pulled"); |
| 2415 | 2524 | ||
| 2416 | const refreshedStatus = yield* Effect.scoped( | 2525 | const refreshedStatus = yield* Effect.scoped( |
| 2417 | withWsRpcClient(wsUrl, (client) => | 2526 | withWsRpcClient(wsUrl, (client) => |
| 2418 | client[WS_METHODS.gitRefreshStatus]({ cwd: "/tmp/repo" }), | 2527 | client[WS_METHODS.vcsRefreshStatus]({ cwd: "/tmp/repo" }), |
| 2419 | ), | 2528 | ), |
| 2420 | ); | 2529 | ); |
| 2421 | assert.equal(refreshedStatus.isRepo, true); | 2530 | assert.equal(refreshedStatus.isRepo, true); |
| ... | @@ -2459,27 +2568,25 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2459,27 +2568,25 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2459 | ); | 2568 | ); |
| 2460 | assert.equal(prepared.branch, "feature/demo"); | 2569 | assert.equal(prepared.branch, "feature/demo"); |
| 2461 | 2570 | ||
| 2462 | const branches = yield* Effect.scoped( | 2571 | const refs = yield* Effect.scoped( |
| 2463 | withWsRpcClient(wsUrl, (client) => | 2572 | withWsRpcClient(wsUrl, (client) => client[WS_METHODS.vcsListRefs]({ cwd: "/tmp/repo" })), |
| 2464 | client[WS_METHODS.gitListBranches]({ cwd: "/tmp/repo" }), | ||
| 2465 | ), | ||
| 2466 | ); | 2573 | ); |
| 2467 | assert.equal(branches.branches[0]?.name, "main"); | 2574 | assert.equal(refs.refs[0]?.name, "main"); |
| 2468 | 2575 | ||
| 2469 | const worktree = yield* Effect.scoped( | 2576 | const worktree = yield* Effect.scoped( |
| 2470 | withWsRpcClient(wsUrl, (client) => | 2577 | withWsRpcClient(wsUrl, (client) => |
| 2471 | client[WS_METHODS.gitCreateWorktree]({ | 2578 | client[WS_METHODS.vcsCreateWorktree]({ |
| 2472 | cwd: "/tmp/repo", | 2579 | cwd: "/tmp/repo", |
| 2473 | branch: "main", | 2580 | refName: "main", |
| 2474 | path: null, | 2581 | path: null, |
| 2475 | }), | 2582 | }), |
| 2476 | ), | 2583 | ), |
| 2477 | ); | 2584 | ); |
| 2478 | assert.equal(worktree.worktree.branch, "feature/demo"); | 2585 | assert.equal(worktree.worktree.refName, "feature/demo"); |
| 2479 | 2586 | ||
| 2480 | yield* Effect.scoped( | 2587 | yield* Effect.scoped( |
| 2481 | withWsRpcClient(wsUrl, (client) => | 2588 | withWsRpcClient(wsUrl, (client) => |
| 2482 | client[WS_METHODS.gitRemoveWorktree]({ | 2589 | client[WS_METHODS.vcsRemoveWorktree]({ |
| 2483 | cwd: "/tmp/repo", | 2590 | cwd: "/tmp/repo", |
| 2484 | path: "/tmp/wt", | 2591 | path: "/tmp/wt", |
| 2485 | }), | 2592 | }), |
| ... | @@ -2488,25 +2595,25 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2488,25 +2595,25 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2488 | 2595 | ||
| 2489 | yield* Effect.scoped( | 2596 | yield* Effect.scoped( |
| 2490 | withWsRpcClient(wsUrl, (client) => | 2597 | withWsRpcClient(wsUrl, (client) => |
| 2491 | client[WS_METHODS.gitCreateBranch]({ | 2598 | client[WS_METHODS.vcsCreateRef]({ |
| 2492 | cwd: "/tmp/repo", | 2599 | cwd: "/tmp/repo", |
| 2493 | branch: "feature/new", | 2600 | refName: "feature/new", |
| 2494 | }), | 2601 | }), |
| 2495 | ), | 2602 | ), |
| 2496 | ); | 2603 | ); |
| 2497 | 2604 | ||
| 2498 | yield* Effect.scoped( | 2605 | yield* Effect.scoped( |
| 2499 | withWsRpcClient(wsUrl, (client) => | 2606 | withWsRpcClient(wsUrl, (client) => |
| 2500 | client[WS_METHODS.gitCheckout]({ | 2607 | client[WS_METHODS.vcsSwitchRef]({ |
| 2501 | cwd: "/tmp/repo", | 2608 | cwd: "/tmp/repo", |
| 2502 | branch: "main", | 2609 | refName: "main", |
| 2503 | }), | 2610 | }), |
| 2504 | ), | 2611 | ), |
| 2505 | ); | 2612 | ); |
| 2506 | 2613 | ||
| 2507 | yield* Effect.scoped( | 2614 | yield* Effect.scoped( |
| 2508 | withWsRpcClient(wsUrl, (client) => | 2615 | withWsRpcClient(wsUrl, (client) => |
| 2509 | client[WS_METHODS.gitInit]({ | 2616 | client[WS_METHODS.vcsInit]({ |
| 2510 | cwd: "/tmp/repo", | 2617 | cwd: "/tmp/repo", |
| 2511 | }), | 2618 | }), |
| 2512 | ), | 2619 | ), |
| ... | @@ -2526,7 +2633,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2526,7 +2633,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2526 | let statusCalls = 0; | 2633 | let statusCalls = 0; |
| 2527 | yield* buildAppUnderTest({ | 2634 | yield* buildAppUnderTest({ |
| 2528 | layers: { | 2635 | layers: { |
| 2529 | gitCore: { | 2636 | gitVcsDriver: { |
| 2530 | pullCurrentBranch: () => Effect.fail(gitError), | 2637 | pullCurrentBranch: () => Effect.fail(gitError), |
| 2531 | }, | 2638 | }, |
| 2532 | gitManager: { | 2639 | gitManager: { |
| ... | @@ -2545,9 +2652,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2545,9 +2652,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2545 | localStatus: () => | 2652 | localStatus: () => |
| 2546 | Effect.succeed({ | 2653 | Effect.succeed({ |
| 2547 | isRepo: true, | 2654 | isRepo: true, |
| 2548 | hasOriginRemote: true, | 2655 | hasPrimaryRemote: true, |
| 2549 | isDefaultBranch: true, | 2656 | isDefaultRef: true, |
| 2550 | branch: "main", | 2657 | refName: "main", |
| 2551 | hasWorkingTreeChanges: true, | 2658 | hasWorkingTreeChanges: true, |
| 2552 | workingTree: { files: [], insertions: 0, deletions: 0 }, | 2659 | workingTree: { files: [], insertions: 0, deletions: 0 }, |
| 2553 | }), | 2660 | }), |
| ... | @@ -2566,9 +2673,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2566,9 +2673,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2566 | statusCalls += 1; | 2673 | statusCalls += 1; |
| 2567 | return { | 2674 | return { |
| 2568 | isRepo: true, | 2675 | isRepo: true, |
| 2569 | hasOriginRemote: true, | 2676 | hasPrimaryRemote: true, |
| 2570 | isDefaultBranch: true, | 2677 | isDefaultRef: true, |
| 2571 | branch: "main", | 2678 | refName: "main", |
| 2572 | hasWorkingTreeChanges: true, | 2679 | hasWorkingTreeChanges: true, |
| 2573 | workingTree: { files: [], insertions: 0, deletions: 0 }, | 2680 | workingTree: { files: [], insertions: 0, deletions: 0 }, |
| 2574 | hasUpstream: true, | 2681 | hasUpstream: true, |
| ... | @@ -2583,7 +2690,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2583,7 +2690,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2583 | 2690 | ||
| 2584 | const wsUrl = yield* getWsServerUrl("/ws"); | 2691 | const wsUrl = yield* getWsServerUrl("/ws"); |
| 2585 | const result = yield* Effect.scoped( | 2692 | const result = yield* Effect.scoped( |
| 2586 | withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })).pipe( | 2693 | withWsRpcClient(wsUrl, (client) => client[WS_METHODS.vcsPull]({ cwd: "/tmp/repo" })).pipe( |
| 2587 | Effect.result, | 2694 | Effect.result, |
| 2588 | ), | 2695 | ), |
| 2589 | ); | 2696 | ); |
| ... | @@ -2622,9 +2729,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2622,9 +2729,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2622 | localStatus: () => | 2729 | localStatus: () => |
| 2623 | Effect.succeed({ | 2730 | Effect.succeed({ |
| 2624 | isRepo: true, | 2731 | isRepo: true, |
| 2625 | hasOriginRemote: true, | 2732 | hasPrimaryRemote: true, |
| 2626 | isDefaultBranch: false, | 2733 | isDefaultRef: false, |
| 2627 | branch: "feature/demo", | 2734 | refName: "feature/demo", |
| 2628 | hasWorkingTreeChanges: true, | 2735 | hasWorkingTreeChanges: true, |
| 2629 | workingTree: { files: [], insertions: 0, deletions: 0 }, | 2736 | workingTree: { files: [], insertions: 0, deletions: 0 }, |
| 2630 | }), | 2737 | }), |
| ... | @@ -2643,9 +2750,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2643,9 +2750,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2643 | statusCalls += 1; | 2750 | statusCalls += 1; |
| 2644 | return { | 2751 | return { |
| 2645 | isRepo: true, | 2752 | isRepo: true, |
| 2646 | hasOriginRemote: true, | 2753 | hasPrimaryRemote: true, |
| 2647 | isDefaultBranch: false, | 2754 | isDefaultRef: false, |
| 2648 | branch: "feature/demo", | 2755 | refName: "feature/demo", |
| 2649 | hasWorkingTreeChanges: true, | 2756 | hasWorkingTreeChanges: true, |
| 2650 | workingTree: { files: [], insertions: 0, deletions: 0 }, | 2757 | workingTree: { files: [], insertions: 0, deletions: 0 }, |
| 2651 | hasUpstream: true, | 2758 | hasUpstream: true, |
| ... | @@ -2680,12 +2787,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2680,12 +2787,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2680 | Effect.gen(function* () { | 2787 | Effect.gen(function* () { |
| 2681 | yield* buildAppUnderTest({ | 2788 | yield* buildAppUnderTest({ |
| 2682 | layers: { | 2789 | layers: { |
| 2683 | gitCore: { | 2790 | gitVcsDriver: { |
| 2684 | pullCurrentBranch: () => | 2791 | pullCurrentBranch: () => |
| 2685 | Effect.succeed({ | 2792 | Effect.succeed({ |
| 2686 | status: "pulled" as const, | 2793 | status: "pulled" as const, |
| 2687 | branch: "main", | 2794 | refName: "main", |
| 2688 | upstreamBranch: "origin/main", | 2795 | upstreamRef: "origin/main", |
| 2689 | }), | 2796 | }), |
| 2690 | }, | 2797 | }, |
| 2691 | gitManager: { | 2798 | gitManager: { |
| ... | @@ -2694,9 +2801,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2694,9 +2801,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2694 | localStatus: () => | 2801 | localStatus: () => |
| 2695 | Effect.succeed({ | 2802 | Effect.succeed({ |
| 2696 | isRepo: true, | 2803 | isRepo: true, |
| 2697 | hasOriginRemote: true, | 2804 | hasPrimaryRemote: true, |
| 2698 | isDefaultBranch: true, | 2805 | isDefaultRef: true, |
| 2699 | branch: "main", | 2806 | refName: "main", |
| 2700 | hasWorkingTreeChanges: false, | 2807 | hasWorkingTreeChanges: false, |
| 2701 | workingTree: { files: [], insertions: 0, deletions: 0 }, | 2808 | workingTree: { files: [], insertions: 0, deletions: 0 }, |
| 2702 | }), | 2809 | }), |
| ... | @@ -2716,7 +2823,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2716,7 +2823,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2716 | const wsUrl = yield* getWsServerUrl("/ws"); | 2823 | const wsUrl = yield* getWsServerUrl("/ws"); |
| 2717 | const startedAt = Date.now(); | 2824 | const startedAt = Date.now(); |
| 2718 | const result = yield* Effect.scoped( | 2825 | const result = yield* Effect.scoped( |
| 2719 | withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })), | 2826 | withWsRpcClient(wsUrl, (client) => client[WS_METHODS.vcsPull]({ cwd: "/tmp/repo" })), |
| 2720 | ); | 2827 | ); |
| 2721 | const elapsedMs = Date.now() - startedAt; | 2828 | const elapsedMs = Date.now() - startedAt; |
| 2722 | 2829 | ||
| ... | @@ -2731,15 +2838,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2731,15 +2838,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2731 | Effect.gen(function* () { | 2838 | Effect.gen(function* () { |
| 2732 | yield* buildAppUnderTest({ | 2839 | yield* buildAppUnderTest({ |
| 2733 | layers: { | 2840 | layers: { |
| 2841 | vcsDriver: { | ||
| 2842 | isInsideWorkTree: () => Effect.succeed(true), | ||
| 2843 | }, | ||
| 2734 | gitManager: { | 2844 | gitManager: { |
| 2735 | invalidateLocalStatus: () => Effect.void, | 2845 | invalidateLocalStatus: () => Effect.void, |
| 2736 | invalidateRemoteStatus: () => Effect.void, | 2846 | invalidateRemoteStatus: () => Effect.void, |
| 2737 | localStatus: () => | 2847 | localStatus: () => |
| 2738 | Effect.succeed({ | 2848 | Effect.succeed({ |
| 2739 | isRepo: true, | 2849 | isRepo: true, |
| 2740 | hasOriginRemote: true, | 2850 | hasPrimaryRemote: true, |
| 2741 | isDefaultBranch: false, | 2851 | isDefaultRef: false, |
| 2742 | branch: "feature/demo", | 2852 | refName: "feature/demo", |
| 2743 | hasWorkingTreeChanges: false, | 2853 | hasWorkingTreeChanges: false, |
| 2744 | workingTree: { files: [], insertions: 0, deletions: 0 }, | 2854 | workingTree: { files: [], insertions: 0, deletions: 0 }, |
| 2745 | }), | 2855 | }), |
| ... | @@ -2804,6 +2914,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2804,6 +2914,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2804 | 2914 | ||
| 2805 | yield* buildAppUnderTest({ | 2915 | yield* buildAppUnderTest({ |
| 2806 | layers: { | 2916 | layers: { |
| 2917 | vcsDriver: { | ||
| 2918 | isInsideWorkTree: () => Effect.succeed(true), | ||
| 2919 | }, | ||
| 2807 | gitManager: { | 2920 | gitManager: { |
| 2808 | invalidateLocalStatus: () => Effect.void, | 2921 | invalidateLocalStatus: () => Effect.void, |
| 2809 | invalidateRemoteStatus: () => Effect.void, | 2922 | invalidateRemoteStatus: () => Effect.void, |
| ... | @@ -2813,9 +2926,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -2813,9 +2926,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 2813 | Effect.andThen( | 2926 | Effect.andThen( |
| 2814 | Effect.succeed({ | 2927 | Effect.succeed({ |
| 2815 | isRepo: true, | 2928 | isRepo: true, |
| 2816 | hasOriginRemote: true, | 2929 | hasPrimaryRemote: true, |
| 2817 | isDefaultBranch: false, | 2930 | isDefaultRef: false, |
| 2818 | branch: "feature/demo", | 2931 | refName: "feature/demo", |
| 2819 | hasWorkingTreeChanges: false, | 2932 | hasWorkingTreeChanges: false, |
| 2820 | workingTree: { files: [], insertions: 0, deletions: 0 }, | 2933 | workingTree: { files: [], insertions: 0, deletions: 0 }, |
| 2821 | }), | 2934 | }), |
| ... | @@ -3479,9 +3592,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -3479,9 +3592,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 3479 | const refreshStatus = vi.fn((_: string) => | 3592 | const refreshStatus = vi.fn((_: string) => |
| 3480 | Effect.succeed({ | 3593 | Effect.succeed({ |
| 3481 | isRepo: true, | 3594 | isRepo: true, |
| 3482 | hasOriginRemote: true, | 3595 | hasPrimaryRemote: true, |
| 3483 | isDefaultBranch: false, | 3596 | isDefaultRef: false, |
| 3484 | branch: "t3code/bootstrap-branch", | 3597 | refName: "t3code/bootstrap-refName", |
| 3485 | hasWorkingTreeChanges: false, | 3598 | hasWorkingTreeChanges: false, |
| 3486 | workingTree: { | 3599 | workingTree: { |
| 3487 | files: [], | 3600 | files: [], |
| ... | @@ -3494,13 +3607,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -3494,13 +3607,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 3494 | pr: null, | 3607 | pr: null, |
| 3495 | }), | 3608 | }), |
| 3496 | ); | 3609 | ); |
| 3497 | const createWorktree = vi.fn((_: Parameters<GitCoreShape["createWorktree"]>[0]) => | 3610 | const createWorktree = vi.fn( |
| 3498 | Effect.succeed({ | 3611 | (_: Parameters<GitVcsDriver.GitVcsDriverShape["createWorktree"]>[0]) => |
| 3499 | worktree: { | 3612 | Effect.succeed({ |
| 3500 | branch: "t3code/bootstrap-branch", | 3613 | worktree: { |
| 3501 | path: "/tmp/bootstrap-worktree", | 3614 | refName: "t3code/bootstrap-refName", |
| 3502 | }, | 3615 | path: "/tmp/bootstrap-worktree", |
| 3503 | }), | 3616 | }, |
| 3617 | }), | ||
| 3504 | ); | 3618 | ); |
| 3505 | const runForThread = vi.fn( | 3619 | const runForThread = vi.fn( |
| 3506 | (_: Parameters<ProjectSetupScriptRunnerShape["runForThread"]>[0]) => | 3620 | (_: Parameters<ProjectSetupScriptRunnerShape["runForThread"]>[0]) => |
| ... | @@ -3515,10 +3629,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -3515,10 +3629,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 3515 | 3629 | ||
| 3516 | yield* buildAppUnderTest({ | 3630 | yield* buildAppUnderTest({ |
| 3517 | layers: { | 3631 | layers: { |
| 3518 | gitCore: { | 3632 | gitVcsDriver: { |
| 3519 | createWorktree, | 3633 | createWorktree, |
| 3520 | }, | 3634 | }, |
| 3521 | gitStatusBroadcaster: { | 3635 | vcsStatusBroadcaster: { |
| 3522 | refreshStatus, | 3636 | refreshStatus, |
| 3523 | }, | 3637 | }, |
| 3524 | orchestrationEngine: { | 3638 | orchestrationEngine: { |
| ... | @@ -3566,7 +3680,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -3566,7 +3680,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 3566 | prepareWorktree: { | 3680 | prepareWorktree: { |
| 3567 | projectCwd: "/tmp/project", | 3681 | projectCwd: "/tmp/project", |
| 3568 | baseBranch: "main", | 3682 | baseBranch: "main", |
| 3569 | branch: "t3code/bootstrap-branch", | 3683 | branch: "t3code/bootstrap-refName", |
| 3570 | }, | 3684 | }, |
| 3571 | runSetupScript: true, | 3685 | runSetupScript: true, |
| 3572 | }, | 3686 | }, |
| ... | @@ -3588,8 +3702,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -3588,8 +3702,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 3588 | ); | 3702 | ); |
| 3589 | assert.deepEqual(createWorktree.mock.calls[0]?.[0], { | 3703 | assert.deepEqual(createWorktree.mock.calls[0]?.[0], { |
| 3590 | cwd: "/tmp/project", | 3704 | cwd: "/tmp/project", |
| 3591 | branch: "main", | 3705 | refName: "main", |
| 3592 | newBranch: "t3code/bootstrap-branch", | 3706 | newRefName: "t3code/bootstrap-refName", |
| 3593 | path: null, | 3707 | path: null, |
| 3594 | }); | 3708 | }); |
| 3595 | assert.deepEqual(runForThread.mock.calls[0]?.[0], { | 3709 | assert.deepEqual(runForThread.mock.calls[0]?.[0], { |
| ... | @@ -3619,13 +3733,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -3619,13 +3733,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 3619 | it.effect("records setup-script failures without aborting bootstrap turn start", () => | 3733 | it.effect("records setup-script failures without aborting bootstrap turn start", () => |
| 3620 | Effect.gen(function* () { | 3734 | Effect.gen(function* () { |
| 3621 | const dispatchedCommands: Array<OrchestrationCommand> = []; | 3735 | const dispatchedCommands: Array<OrchestrationCommand> = []; |
| 3622 | const createWorktree = vi.fn((_: Parameters<GitCoreShape["createWorktree"]>[0]) => | 3736 | const createWorktree = vi.fn( |
| 3623 | Effect.succeed({ | 3737 | (_: Parameters<GitVcsDriver.GitVcsDriverShape["createWorktree"]>[0]) => |
| 3624 | worktree: { | 3738 | Effect.succeed({ |
| 3625 | branch: "t3code/bootstrap-branch", | 3739 | worktree: { |
| 3626 | path: "/tmp/bootstrap-worktree", | 3740 | refName: "t3code/bootstrap-refName", |
| 3627 | }, | 3741 | path: "/tmp/bootstrap-worktree", |
| 3628 | }), | 3742 | }, |
| 3743 | }), | ||
| 3629 | ); | 3744 | ); |
| 3630 | const runForThread = vi.fn( | 3745 | const runForThread = vi.fn( |
| 3631 | (_: Parameters<ProjectSetupScriptRunnerShape["runForThread"]>[0]) => | 3746 | (_: Parameters<ProjectSetupScriptRunnerShape["runForThread"]>[0]) => |
| ... | @@ -3634,7 +3749,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -3634,7 +3749,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 3634 | 3749 | ||
| 3635 | yield* buildAppUnderTest({ | 3750 | yield* buildAppUnderTest({ |
| 3636 | layers: { | 3751 | layers: { |
| 3637 | gitCore: { | 3752 | gitVcsDriver: { |
| 3638 | createWorktree, | 3753 | createWorktree, |
| 3639 | }, | 3754 | }, |
| 3640 | orchestrationEngine: { | 3755 | orchestrationEngine: { |
| ... | @@ -3682,7 +3797,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -3682,7 +3797,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 3682 | prepareWorktree: { | 3797 | prepareWorktree: { |
| 3683 | projectCwd: "/tmp/project", | 3798 | projectCwd: "/tmp/project", |
| 3684 | baseBranch: "main", | 3799 | baseBranch: "main", |
| 3685 | branch: "t3code/bootstrap-branch", | 3800 | branch: "t3code/bootstrap-refName", |
| 3686 | }, | 3801 | }, |
| 3687 | runSetupScript: true, | 3802 | runSetupScript: true, |
| 3688 | }, | 3803 | }, |
| ... | @@ -3712,13 +3827,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -3712,13 +3827,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 3712 | it.effect("does not misattribute setup activity dispatch failures as setup launch failures", () => | 3827 | it.effect("does not misattribute setup activity dispatch failures as setup launch failures", () => |
| 3713 | Effect.gen(function* () { | 3828 | Effect.gen(function* () { |
| 3714 | const dispatchedCommands: Array<OrchestrationCommand> = []; | 3829 | const dispatchedCommands: Array<OrchestrationCommand> = []; |
| 3715 | const createWorktree = vi.fn((_: Parameters<GitCoreShape["createWorktree"]>[0]) => | 3830 | const createWorktree = vi.fn( |
| 3716 | Effect.succeed({ | 3831 | (_: Parameters<GitVcsDriver.GitVcsDriverShape["createWorktree"]>[0]) => |
| 3717 | worktree: { | 3832 | Effect.succeed({ |
| 3718 | branch: "t3code/bootstrap-branch", | 3833 | worktree: { |
| 3719 | path: "/tmp/bootstrap-worktree", | 3834 | refName: "t3code/bootstrap-refName", |
| 3720 | }, | 3835 | path: "/tmp/bootstrap-worktree", |
| 3721 | }), | 3836 | }, |
| 3837 | }), | ||
| 3722 | ); | 3838 | ); |
| 3723 | const runForThread = vi.fn( | 3839 | const runForThread = vi.fn( |
| 3724 | (_: Parameters<ProjectSetupScriptRunnerShape["runForThread"]>[0]) => | 3840 | (_: Parameters<ProjectSetupScriptRunnerShape["runForThread"]>[0]) => |
| ... | @@ -3734,7 +3850,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -3734,7 +3850,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 3734 | 3850 | ||
| 3735 | yield* buildAppUnderTest({ | 3851 | yield* buildAppUnderTest({ |
| 3736 | layers: { | 3852 | layers: { |
| 3737 | gitCore: { | 3853 | gitVcsDriver: { |
| 3738 | createWorktree, | 3854 | createWorktree, |
| 3739 | }, | 3855 | }, |
| 3740 | orchestrationEngine: { | 3856 | orchestrationEngine: { |
| ... | @@ -3798,7 +3914,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -3798,7 +3914,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 3798 | prepareWorktree: { | 3914 | prepareWorktree: { |
| 3799 | projectCwd: "/tmp/project", | 3915 | projectCwd: "/tmp/project", |
| 3800 | baseBranch: "main", | 3916 | baseBranch: "main", |
| 3801 | branch: "t3code/bootstrap-branch", | 3917 | branch: "t3code/bootstrap-refName", |
| 3802 | }, | 3918 | }, |
| 3803 | runSetupScript: true, | 3919 | runSetupScript: true, |
| 3804 | }, | 3920 | }, |
| ... | @@ -3830,13 +3946,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -3830,13 +3946,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 3830 | it.effect("cleans up created bootstrap threads when worktree creation defects", () => | 3946 | it.effect("cleans up created bootstrap threads when worktree creation defects", () => |
| 3831 | Effect.gen(function* () { | 3947 | Effect.gen(function* () { |
| 3832 | const dispatchedCommands: Array<OrchestrationCommand> = []; | 3948 | const dispatchedCommands: Array<OrchestrationCommand> = []; |
| 3833 | const createWorktree = vi.fn((_: Parameters<GitCoreShape["createWorktree"]>[0]) => | 3949 | const createWorktree = vi.fn( |
| 3834 | Effect.die(new Error("worktree exploded")), | 3950 | (_: Parameters<GitVcsDriver.GitVcsDriverShape["createWorktree"]>[0]) => |
| 3951 | Effect.die(new Error("worktree exploded")), | ||
| 3835 | ); | 3952 | ); |
| 3836 | 3953 | ||
| 3837 | yield* buildAppUnderTest({ | 3954 | yield* buildAppUnderTest({ |
| 3838 | layers: { | 3955 | layers: { |
| 3839 | gitCore: { | 3956 | gitVcsDriver: { |
| 3840 | createWorktree, | 3957 | createWorktree, |
| 3841 | }, | 3958 | }, |
| 3842 | orchestrationEngine: { | 3959 | orchestrationEngine: { |
| ... | @@ -3881,7 +3998,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -3881,7 +3998,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 3881 | prepareWorktree: { | 3998 | prepareWorktree: { |
| 3882 | projectCwd: "/tmp/project", | 3999 | projectCwd: "/tmp/project", |
| 3883 | baseBranch: "main", | 4000 | baseBranch: "main", |
| 3884 | branch: "t3code/bootstrap-branch", | 4001 | branch: "t3code/bootstrap-refName", |
| 3885 | }, | 4002 | }, |
| 3886 | runSetupScript: false, | 4003 | runSetupScript: false, |
| 3887 | }, | 4004 | }, |
apps/server/src/server.ts+49-18| ... | @@ -25,13 +25,11 @@ import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReap | ... | @@ -25,13 +25,11 @@ import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReap |
| 25 | import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts"; | 25 | import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts"; |
| 26 | import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts"; | 26 | import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts"; |
| 27 | import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts"; | 27 | import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts"; |
| 28 | import { GitCoreLive } from "./git/Layers/GitCore.ts"; | 28 | import * as GitHubCli from "./sourceControl/GitHubCli.ts"; |
| 29 | import { GitHubCliLive } from "./git/Layers/GitHubCli.ts"; | 29 | import * as TextGeneration from "./textGeneration/TextGeneration.ts"; |
| 30 | import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster.ts"; | ||
| 31 | import { TextGenerationLive } from "./git/Layers/TextGenerationLive.ts"; | ||
| 32 | import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; | 30 | import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; |
| 33 | import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; | 31 | import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; |
| 34 | import { GitManagerLive } from "./git/Layers/GitManager.ts"; | 32 | import * as GitManager from "./git/GitManager.ts"; |
| 35 | import { KeybindingsLive } from "./keybindings.ts"; | 33 | import { KeybindingsLive } from "./keybindings.ts"; |
| 36 | import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup.ts"; | 34 | import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup.ts"; |
| 37 | import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor.ts"; | 35 | import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor.ts"; |
| ... | @@ -47,6 +45,14 @@ import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdent | ... | @@ -47,6 +45,14 @@ import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdent |
| 47 | import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; | 45 | import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; |
| 48 | import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; | 46 | import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; |
| 49 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; | 47 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; |
| 48 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; | ||
| 49 | import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; | ||
| 50 | import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; | ||
| 51 | import * as VcsProcess from "./vcs/VcsProcess.ts"; | ||
| 52 | import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; | ||
| 53 | import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; | ||
| 54 | import * as GitWorkflowService from "./git/GitWorkflowService.ts"; | ||
| 55 | import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; | ||
| 50 | import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts"; | 56 | import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts"; |
| 51 | import { ObservabilityLive } from "./observability/Layers/Observability.ts"; | 57 | import { ObservabilityLive } from "./observability/Layers/Observability.ts"; |
| 52 | import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts"; | 58 | import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts"; |
| ... | @@ -133,11 +139,6 @@ const ReactorLayerLive = Layer.empty.pipe( | ... | @@ -133,11 +139,6 @@ const ReactorLayerLive = Layer.empty.pipe( |
| 133 | Layer.provideMerge(RuntimeReceiptBusLive), | 139 | Layer.provideMerge(RuntimeReceiptBusLive), |
| 134 | ); | 140 | ); |
| 135 | 141 | ||
| 136 | const CheckpointingLayerLive = Layer.empty.pipe( | ||
| 137 | Layer.provideMerge(CheckpointDiffQueryLive), | ||
| 138 | Layer.provideMerge(CheckpointStoreLive), | ||
| 139 | ); | ||
| 140 | |||
| 141 | const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( | 142 | const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( |
| 142 | Layer.provide(ProviderSessionRuntimeRepositoryLive), | 143 | Layer.provide(ProviderSessionRuntimeRepositoryLive), |
| 143 | ); | 144 | ); |
| ... | @@ -155,24 +156,50 @@ const ProviderLayerLive = ProviderServiceLive.pipe( | ... | @@ -155,24 +156,50 @@ const ProviderLayerLive = ProviderServiceLive.pipe( |
| 155 | 156 | ||
| 156 | const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); | 157 | const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); |
| 157 | 158 | ||
| 158 | const GitManagerLayerLive = GitManagerLive.pipe( | 159 | const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( |
| 160 | Layer.provide(VcsProjectConfig.layer), | ||
| 161 | ); | ||
| 162 | |||
| 163 | const GitManagerLayerLive = GitManager.layer.pipe( | ||
| 159 | Layer.provideMerge(ProjectSetupScriptRunnerLive), | 164 | Layer.provideMerge(ProjectSetupScriptRunnerLive), |
| 160 | Layer.provideMerge(GitCoreLive), | 165 | Layer.provideMerge(GitVcsDriver.layer), |
| 161 | Layer.provideMerge(GitHubCliLive), | 166 | Layer.provideMerge( |
| 162 | Layer.provideMerge(TextGenerationLive), | 167 | SourceControlProviderRegistry.layer.pipe( |
| 168 | Layer.provide(GitHubCli.layer), | ||
| 169 | Layer.provideMerge(VcsDriverRegistryLayerLive), | ||
| 170 | ), | ||
| 171 | ), | ||
| 172 | Layer.provideMerge(TextGeneration.layer), | ||
| 163 | ); | 173 | ); |
| 164 | 174 | ||
| 165 | const GitLayerLive = Layer.empty.pipe( | 175 | const GitLayerLive = Layer.empty.pipe( |
| 166 | Layer.provideMerge(GitManagerLayerLive), | 176 | Layer.provideMerge(GitManagerLayerLive), |
| 167 | Layer.provideMerge(GitStatusBroadcasterLive.pipe(Layer.provide(GitManagerLayerLive))), | 177 | Layer.provideMerge(GitVcsDriver.layer), |
| 168 | Layer.provideMerge(GitCoreLive), | 178 | ); |
| 179 | |||
| 180 | const GitWorkflowLayerLive = GitWorkflowService.layer.pipe( | ||
| 181 | Layer.provideMerge(VcsDriverRegistryLayerLive), | ||
| 182 | Layer.provideMerge(GitLayerLive), | ||
| 183 | ); | ||
| 184 | |||
| 185 | const VcsLayerLive = Layer.empty.pipe( | ||
| 186 | Layer.provideMerge(VcsProjectConfig.layer), | ||
| 187 | Layer.provideMerge(VcsDriverRegistryLayerLive), | ||
| 188 | Layer.provideMerge(VcsProvisioningService.layer.pipe(Layer.provide(VcsDriverRegistryLayerLive))), | ||
| 189 | Layer.provideMerge(GitWorkflowLayerLive), | ||
| 190 | Layer.provideMerge(VcsStatusBroadcaster.layer.pipe(Layer.provide(GitWorkflowLayerLive))), | ||
| 191 | ); | ||
| 192 | |||
| 193 | const CheckpointingLayerLive = Layer.empty.pipe( | ||
| 194 | Layer.provideMerge(CheckpointDiffQueryLive), | ||
| 195 | Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))), | ||
| 169 | ); | 196 | ); |
| 170 | 197 | ||
| 171 | const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); | 198 | const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); |
| 172 | 199 | ||
| 173 | const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( | 200 | const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( |
| 174 | Layer.provide(WorkspacePathsLive), | 201 | Layer.provide(WorkspacePathsLive), |
| 175 | Layer.provideMerge(GitCoreLive), | 202 | Layer.provideMerge(VcsDriverRegistryLayerLive), |
| 176 | ); | 203 | ); |
| 177 | 204 | ||
| 178 | const WorkspaceFileSystemLayerLive = WorkspaceFileSystemLive.pipe( | 205 | const WorkspaceFileSystemLayerLive = WorkspaceFileSystemLive.pipe( |
| ... | @@ -196,10 +223,11 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( | ... | @@ -196,10 +223,11 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( |
| 196 | Layer.provideMerge(OrchestrationLayerLive), | 223 | Layer.provideMerge(OrchestrationLayerLive), |
| 197 | ); | 224 | ); |
| 198 | 225 | ||
| 199 | const RuntimeDependenciesLive = ReactorLayerLive.pipe( | 226 | const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( |
| 200 | // Core Services | 227 | // Core Services |
| 201 | Layer.provideMerge(CheckpointingLayerLive), | 228 | Layer.provideMerge(CheckpointingLayerLive), |
| 202 | Layer.provideMerge(GitLayerLive), | 229 | Layer.provideMerge(GitLayerLive), |
| 230 | Layer.provideMerge(VcsLayerLive), | ||
| 203 | Layer.provideMerge(ProviderRuntimeLayerLive), | 231 | Layer.provideMerge(ProviderRuntimeLayerLive), |
| 204 | Layer.provideMerge(TerminalLayerLive), | 232 | Layer.provideMerge(TerminalLayerLive), |
| 205 | Layer.provideMerge(PersistenceLayerLive), | 233 | Layer.provideMerge(PersistenceLayerLive), |
| ... | @@ -229,7 +257,9 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe( | ... | @@ -229,7 +257,9 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe( |
| 229 | Layer.provideMerge(RepositoryIdentityResolverLive), | 257 | Layer.provideMerge(RepositoryIdentityResolverLive), |
| 230 | Layer.provideMerge(ServerEnvironmentLive), | 258 | Layer.provideMerge(ServerEnvironmentLive), |
| 231 | Layer.provideMerge(AuthLayerLive), | 259 | Layer.provideMerge(AuthLayerLive), |
| 260 | ); | ||
| 232 | 261 | ||
| 262 | const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( | ||
| 233 | // Misc. | 263 | // Misc. |
| 234 | Layer.provideMerge(AnalyticsServiceLayerLive), | 264 | Layer.provideMerge(AnalyticsServiceLayerLive), |
| 235 | Layer.provideMerge(OpenLive), | 265 | Layer.provideMerge(OpenLive), |
| ... | @@ -310,6 +340,7 @@ export const makeServerLayer = Layer.unwrap( | ... | @@ -310,6 +340,7 @@ export const makeServerLayer = Layer.unwrap( |
| 310 | Layer.provideMerge(HttpServerLive), | 340 | Layer.provideMerge(HttpServerLive), |
| 311 | Layer.provide(ObservabilityLive), | 341 | Layer.provide(ObservabilityLive), |
| 312 | Layer.provideMerge(FetchHttpClient.layer), | 342 | Layer.provideMerge(FetchHttpClient.layer), |
| 343 | Layer.provideMerge(VcsProcess.layer), | ||
| 313 | Layer.provideMerge(PlatformServicesLive), | 344 | Layer.provideMerge(PlatformServicesLive), |
| 314 | ); | 345 | ); |
| 315 | }), | 346 | }), |
apps/server/src/sourceControl/GitHubCli.test.ts created+240| ... | @@ -0,0 +1,240 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Layer } from "effect"; | ||
| 3 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 4 | import { VcsProcessExitError, type VcsError } from "@t3tools/contracts"; | ||
| 5 | import { afterEach, describe, expect, vi } from "vitest"; | ||
| 6 | |||
| 7 | import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; | ||
| 8 | import * as GitHubCli from "./GitHubCli.ts"; | ||
| 9 | |||
| 10 | const processOutput = (stdout: string): VcsProcessOutput => ({ | ||
| 11 | exitCode: ChildProcessSpawner.ExitCode(0), | ||
| 12 | stdout, | ||
| 13 | stderr: "", | ||
| 14 | stdoutTruncated: false, | ||
| 15 | stderrTruncated: false, | ||
| 16 | }); | ||
| 17 | |||
| 18 | const mockRun = vi.fn<(input: VcsProcessInput) => Effect.Effect<VcsProcessOutput, VcsError>>(); | ||
| 19 | |||
| 20 | const layer = GitHubCli.layer.pipe( | ||
| 21 | Layer.provide( | ||
| 22 | Layer.mock(VcsProcess)({ | ||
| 23 | run: mockRun, | ||
| 24 | }), | ||
| 25 | ), | ||
| 26 | ); | ||
| 27 | |||
| 28 | afterEach(() => { | ||
| 29 | mockRun.mockReset(); | ||
| 30 | }); | ||
| 31 | |||
| 32 | describe("GitHubCli.layer", () => { | ||
| 33 | it.effect("parses pull request view output", () => | ||
| 34 | Effect.gen(function* () { | ||
| 35 | mockRun.mockReturnValueOnce( | ||
| 36 | Effect.succeed( | ||
| 37 | processOutput( | ||
| 38 | JSON.stringify({ | ||
| 39 | number: 42, | ||
| 40 | title: "Add PR thread creation", | ||
| 41 | url: "https://github.com/pingdotgg/codething-mvp/pull/42", | ||
| 42 | baseRefName: "main", | ||
| 43 | headRefName: "feature/pr-threads", | ||
| 44 | state: "OPEN", | ||
| 45 | mergedAt: null, | ||
| 46 | isCrossRepository: true, | ||
| 47 | headRepository: { | ||
| 48 | nameWithOwner: "octocat/codething-mvp", | ||
| 49 | }, | ||
| 50 | headRepositoryOwner: { | ||
| 51 | login: "octocat", | ||
| 52 | }, | ||
| 53 | }), | ||
| 54 | ), | ||
| 55 | ), | ||
| 56 | ); | ||
| 57 | |||
| 58 | const gh = yield* GitHubCli.GitHubCli; | ||
| 59 | const result = yield* gh.getPullRequest({ | ||
| 60 | cwd: "/repo", | ||
| 61 | reference: "#42", | ||
| 62 | }); | ||
| 63 | |||
| 64 | assert.deepStrictEqual(result, { | ||
| 65 | number: 42, | ||
| 66 | title: "Add PR thread creation", | ||
| 67 | url: "https://github.com/pingdotgg/codething-mvp/pull/42", | ||
| 68 | baseRefName: "main", | ||
| 69 | headRefName: "feature/pr-threads", | ||
| 70 | state: "open", | ||
| 71 | isCrossRepository: true, | ||
| 72 | headRepositoryNameWithOwner: "octocat/codething-mvp", | ||
| 73 | headRepositoryOwnerLogin: "octocat", | ||
| 74 | }); | ||
| 75 | expect(mockRun).toHaveBeenCalledWith({ | ||
| 76 | operation: "GitHubCli.execute", | ||
| 77 | command: "gh", | ||
| 78 | args: [ | ||
| 79 | "pr", | ||
| 80 | "view", | ||
| 81 | "#42", | ||
| 82 | "--json", | ||
| 83 | "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", | ||
| 84 | ], | ||
| 85 | cwd: "/repo", | ||
| 86 | timeoutMs: 30_000, | ||
| 87 | }); | ||
| 88 | }).pipe(Effect.provide(layer)), | ||
| 89 | ); | ||
| 90 | |||
| 91 | it.effect("trims pull request fields decoded from gh json", () => | ||
| 92 | Effect.gen(function* () { | ||
| 93 | mockRun.mockReturnValueOnce( | ||
| 94 | Effect.succeed( | ||
| 95 | processOutput( | ||
| 96 | JSON.stringify({ | ||
| 97 | number: 42, | ||
| 98 | title: " Add PR thread creation \n", | ||
| 99 | url: " https://github.com/pingdotgg/codething-mvp/pull/42 ", | ||
| 100 | baseRefName: " main ", | ||
| 101 | headRefName: "\tfeature/pr-threads\t", | ||
| 102 | state: "OPEN", | ||
| 103 | mergedAt: null, | ||
| 104 | isCrossRepository: true, | ||
| 105 | headRepository: { | ||
| 106 | nameWithOwner: " octocat/codething-mvp ", | ||
| 107 | }, | ||
| 108 | headRepositoryOwner: { | ||
| 109 | login: " octocat ", | ||
| 110 | }, | ||
| 111 | }), | ||
| 112 | ), | ||
| 113 | ), | ||
| 114 | ); | ||
| 115 | |||
| 116 | const gh = yield* GitHubCli.GitHubCli; | ||
| 117 | const result = yield* gh.getPullRequest({ | ||
| 118 | cwd: "/repo", | ||
| 119 | reference: "#42", | ||
| 120 | }); | ||
| 121 | |||
| 122 | assert.deepStrictEqual(result, { | ||
| 123 | number: 42, | ||
| 124 | title: "Add PR thread creation", | ||
| 125 | url: "https://github.com/pingdotgg/codething-mvp/pull/42", | ||
| 126 | baseRefName: "main", | ||
| 127 | headRefName: "feature/pr-threads", | ||
| 128 | state: "open", | ||
| 129 | isCrossRepository: true, | ||
| 130 | headRepositoryNameWithOwner: "octocat/codething-mvp", | ||
| 131 | headRepositoryOwnerLogin: "octocat", | ||
| 132 | }); | ||
| 133 | }).pipe(Effect.provide(layer)), | ||
| 134 | ); | ||
| 135 | |||
| 136 | it.effect("skips invalid entries when parsing pr lists", () => | ||
| 137 | Effect.gen(function* () { | ||
| 138 | mockRun.mockReturnValueOnce( | ||
| 139 | Effect.succeed( | ||
| 140 | processOutput( | ||
| 141 | JSON.stringify([ | ||
| 142 | { | ||
| 143 | number: 0, | ||
| 144 | title: "invalid", | ||
| 145 | url: "https://github.com/pingdotgg/codething-mvp/pull/0", | ||
| 146 | baseRefName: "main", | ||
| 147 | headRefName: "feature/invalid", | ||
| 148 | }, | ||
| 149 | { | ||
| 150 | number: 43, | ||
| 151 | title: " Valid PR ", | ||
| 152 | url: " https://github.com/pingdotgg/codething-mvp/pull/43 ", | ||
| 153 | baseRefName: " main ", | ||
| 154 | headRefName: " feature/pr-list ", | ||
| 155 | headRepository: { | ||
| 156 | nameWithOwner: " ", | ||
| 157 | }, | ||
| 158 | headRepositoryOwner: { | ||
| 159 | login: " ", | ||
| 160 | }, | ||
| 161 | }, | ||
| 162 | ]), | ||
| 163 | ), | ||
| 164 | ), | ||
| 165 | ); | ||
| 166 | |||
| 167 | const gh = yield* GitHubCli.GitHubCli; | ||
| 168 | const result = yield* gh.listOpenPullRequests({ | ||
| 169 | cwd: "/repo", | ||
| 170 | headSelector: "feature/pr-list", | ||
| 171 | }); | ||
| 172 | |||
| 173 | assert.deepStrictEqual(result, [ | ||
| 174 | { | ||
| 175 | number: 43, | ||
| 176 | title: "Valid PR", | ||
| 177 | url: "https://github.com/pingdotgg/codething-mvp/pull/43", | ||
| 178 | baseRefName: "main", | ||
| 179 | headRefName: "feature/pr-list", | ||
| 180 | state: "open", | ||
| 181 | }, | ||
| 182 | ]); | ||
| 183 | }).pipe(Effect.provide(layer)), | ||
| 184 | ); | ||
| 185 | |||
| 186 | it.effect("reads repository clone URLs", () => | ||
| 187 | Effect.gen(function* () { | ||
| 188 | mockRun.mockReturnValueOnce( | ||
| 189 | Effect.succeed( | ||
| 190 | processOutput( | ||
| 191 | JSON.stringify({ | ||
| 192 | nameWithOwner: "octocat/codething-mvp", | ||
| 193 | url: "https://github.com/octocat/codething-mvp", | ||
| 194 | sshUrl: "git@github.com:octocat/codething-mvp.git", | ||
| 195 | }), | ||
| 196 | ), | ||
| 197 | ), | ||
| 198 | ); | ||
| 199 | |||
| 200 | const gh = yield* GitHubCli.GitHubCli; | ||
| 201 | const result = yield* gh.getRepositoryCloneUrls({ | ||
| 202 | cwd: "/repo", | ||
| 203 | repository: "octocat/codething-mvp", | ||
| 204 | }); | ||
| 205 | |||
| 206 | assert.deepStrictEqual(result, { | ||
| 207 | nameWithOwner: "octocat/codething-mvp", | ||
| 208 | url: "https://github.com/octocat/codething-mvp", | ||
| 209 | sshUrl: "git@github.com:octocat/codething-mvp.git", | ||
| 210 | }); | ||
| 211 | }).pipe(Effect.provide(layer)), | ||
| 212 | ); | ||
| 213 | |||
| 214 | it.effect("surfaces a friendly error when the pull request is not found", () => | ||
| 215 | Effect.gen(function* () { | ||
| 216 | mockRun.mockReturnValueOnce( | ||
| 217 | Effect.fail( | ||
| 218 | new VcsProcessExitError({ | ||
| 219 | operation: "GitHubCli.execute", | ||
| 220 | command: "gh pr view", | ||
| 221 | cwd: "/repo", | ||
| 222 | exitCode: 1, | ||
| 223 | detail: | ||
| 224 | "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)", | ||
| 225 | }), | ||
| 226 | ), | ||
| 227 | ); | ||
| 228 | |||
| 229 | const gh = yield* GitHubCli.GitHubCli; | ||
| 230 | const error = yield* gh | ||
| 231 | .getPullRequest({ | ||
| 232 | cwd: "/repo", | ||
| 233 | reference: "4888", | ||
| 234 | }) | ||
| 235 | .pipe(Effect.flip); | ||
| 236 | |||
| 237 | assert.equal(error.message.includes("Pull request not found"), true); | ||
| 238 | }).pipe(Effect.provide(layer)), | ||
| 239 | ); | ||
| 240 | }); | ||
apps/server/src/sourceControl/GitHubCli.ts created+308| ... | @@ -0,0 +1,308 @@ | ||
| 1 | import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect"; | ||
| 2 | |||
| 3 | import { GitHubCliError, TrimmedNonEmptyString, type VcsError } from "@t3tools/contracts"; | ||
| 4 | |||
| 5 | import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; | ||
| 6 | import { | ||
| 7 | decodeGitHubPullRequestJson, | ||
| 8 | decodeGitHubPullRequestListJson, | ||
| 9 | formatGitHubJsonDecodeError, | ||
| 10 | } from "../git/githubPullRequests.ts"; | ||
| 11 | |||
| 12 | const DEFAULT_TIMEOUT_MS = 30_000; | ||
| 13 | |||
| 14 | export interface GitHubPullRequestSummary { | ||
| 15 | readonly number: number; | ||
| 16 | readonly title: string; | ||
| 17 | readonly url: string; | ||
| 18 | readonly baseRefName: string; | ||
| 19 | readonly headRefName: string; | ||
| 20 | readonly state?: "open" | "closed" | "merged"; | ||
| 21 | readonly isCrossRepository?: boolean; | ||
| 22 | readonly headRepositoryNameWithOwner?: string | null; | ||
| 23 | readonly headRepositoryOwnerLogin?: string | null; | ||
| 24 | } | ||
| 25 | |||
| 26 | export interface GitHubRepositoryCloneUrls { | ||
| 27 | readonly nameWithOwner: string; | ||
| 28 | readonly url: string; | ||
| 29 | readonly sshUrl: string; | ||
| 30 | } | ||
| 31 | |||
| 32 | export interface GitHubCliShape { | ||
| 33 | readonly execute: (input: { | ||
| 34 | readonly cwd: string; | ||
| 35 | readonly args: ReadonlyArray<string>; | ||
| 36 | readonly timeoutMs?: number; | ||
| 37 | }) => Effect.Effect<VcsProcessOutput, GitHubCliError>; | ||
| 38 | |||
| 39 | readonly listOpenPullRequests: (input: { | ||
| 40 | readonly cwd: string; | ||
| 41 | readonly headSelector: string; | ||
| 42 | readonly limit?: number; | ||
| 43 | }) => Effect.Effect<ReadonlyArray<GitHubPullRequestSummary>, GitHubCliError>; | ||
| 44 | |||
| 45 | readonly getPullRequest: (input: { | ||
| 46 | readonly cwd: string; | ||
| 47 | readonly reference: string; | ||
| 48 | }) => Effect.Effect<GitHubPullRequestSummary, GitHubCliError>; | ||
| 49 | |||
| 50 | readonly getRepositoryCloneUrls: (input: { | ||
| 51 | readonly cwd: string; | ||
| 52 | readonly repository: string; | ||
| 53 | }) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubCliError>; | ||
| 54 | |||
| 55 | readonly createPullRequest: (input: { | ||
| 56 | readonly cwd: string; | ||
| 57 | readonly baseBranch: string; | ||
| 58 | readonly headSelector: string; | ||
| 59 | readonly title: string; | ||
| 60 | readonly bodyFile: string; | ||
| 61 | }) => Effect.Effect<void, GitHubCliError>; | ||
| 62 | |||
| 63 | readonly getDefaultBranch: (input: { | ||
| 64 | readonly cwd: string; | ||
| 65 | }) => Effect.Effect<string | null, GitHubCliError>; | ||
| 66 | |||
| 67 | readonly checkoutPullRequest: (input: { | ||
| 68 | readonly cwd: string; | ||
| 69 | readonly reference: string; | ||
| 70 | readonly force?: boolean; | ||
| 71 | }) => Effect.Effect<void, GitHubCliError>; | ||
| 72 | } | ||
| 73 | |||
| 74 | export class GitHubCli extends Context.Service<GitHubCli, GitHubCliShape>()( | ||
| 75 | "t3/source-control/GitHubCli", | ||
| 76 | ) {} | ||
| 77 | |||
| 78 | function errorText(error: VcsError | unknown): string { | ||
| 79 | if (typeof error === "object" && error !== null) { | ||
| 80 | const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : ""; | ||
| 81 | const detail = "detail" in error && typeof error.detail === "string" ? error.detail : ""; | ||
| 82 | const message = "message" in error && typeof error.message === "string" ? error.message : ""; | ||
| 83 | return [tag, detail, message].filter(Boolean).join("\n"); | ||
| 84 | } | ||
| 85 | |||
| 86 | return String(error); | ||
| 87 | } | ||
| 88 | |||
| 89 | function normalizeGitHubCliError( | ||
| 90 | operation: "execute" | "stdout", | ||
| 91 | error: VcsError | unknown, | ||
| 92 | ): GitHubCliError { | ||
| 93 | const text = errorText(error); | ||
| 94 | const lower = text.toLowerCase(); | ||
| 95 | |||
| 96 | if (lower.includes("command not found: gh") || lower.includes("enoent")) { | ||
| 97 | return new GitHubCliError({ | ||
| 98 | operation, | ||
| 99 | detail: "GitHub CLI (`gh`) is required but not available on PATH.", | ||
| 100 | cause: error, | ||
| 101 | }); | ||
| 102 | } | ||
| 103 | |||
| 104 | if ( | ||
| 105 | lower.includes("authentication failed") || | ||
| 106 | lower.includes("not logged in") || | ||
| 107 | lower.includes("gh auth login") || | ||
| 108 | lower.includes("no oauth token") | ||
| 109 | ) { | ||
| 110 | return new GitHubCliError({ | ||
| 111 | operation, | ||
| 112 | detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", | ||
| 113 | cause: error, | ||
| 114 | }); | ||
| 115 | } | ||
| 116 | |||
| 117 | if ( | ||
| 118 | lower.includes("could not resolve to a pullrequest") || | ||
| 119 | lower.includes("repository.pullrequest") || | ||
| 120 | lower.includes("no pull requests found for branch") || | ||
| 121 | lower.includes("pull request not found") | ||
| 122 | ) { | ||
| 123 | return new GitHubCliError({ | ||
| 124 | operation, | ||
| 125 | detail: "Pull request not found. Check the PR number or URL and try again.", | ||
| 126 | cause: error, | ||
| 127 | }); | ||
| 128 | } | ||
| 129 | |||
| 130 | return new GitHubCliError({ | ||
| 131 | operation, | ||
| 132 | detail: text, | ||
| 133 | cause: error, | ||
| 134 | }); | ||
| 135 | } | ||
| 136 | |||
| 137 | const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ | ||
| 138 | nameWithOwner: TrimmedNonEmptyString, | ||
| 139 | url: TrimmedNonEmptyString, | ||
| 140 | sshUrl: TrimmedNonEmptyString, | ||
| 141 | }); | ||
| 142 | |||
| 143 | function normalizeRepositoryCloneUrls( | ||
| 144 | raw: Schema.Schema.Type<typeof RawGitHubRepositoryCloneUrlsSchema>, | ||
| 145 | ): GitHubRepositoryCloneUrls { | ||
| 146 | return { | ||
| 147 | nameWithOwner: raw.nameWithOwner, | ||
| 148 | url: raw.url, | ||
| 149 | sshUrl: raw.sshUrl, | ||
| 150 | }; | ||
| 151 | } | ||
| 152 | |||
| 153 | function decodeGitHubJson<S extends Schema.Top>( | ||
| 154 | raw: string, | ||
| 155 | schema: S, | ||
| 156 | operation: "listOpenPullRequests" | "getPullRequest" | "getRepositoryCloneUrls", | ||
| 157 | invalidDetail: string, | ||
| 158 | ): Effect.Effect<S["Type"], GitHubCliError, S["DecodingServices"]> { | ||
| 159 | return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( | ||
| 160 | Effect.mapError( | ||
| 161 | (error) => | ||
| 162 | new GitHubCliError({ | ||
| 163 | operation, | ||
| 164 | detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, | ||
| 165 | cause: error, | ||
| 166 | }), | ||
| 167 | ), | ||
| 168 | ); | ||
| 169 | } | ||
| 170 | |||
| 171 | export const make = Effect.fn("makeGitHubCli")(function* () { | ||
| 172 | const process = yield* VcsProcess; | ||
| 173 | |||
| 174 | const execute: GitHubCliShape["execute"] = (input) => | ||
| 175 | process | ||
| 176 | .run({ | ||
| 177 | operation: "GitHubCli.execute", | ||
| 178 | command: "gh", | ||
| 179 | args: input.args, | ||
| 180 | cwd: input.cwd, | ||
| 181 | timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, | ||
| 182 | }) | ||
| 183 | .pipe(Effect.mapError((error) => normalizeGitHubCliError("execute", error))); | ||
| 184 | |||
| 185 | return GitHubCli.of({ | ||
| 186 | execute, | ||
| 187 | listOpenPullRequests: (input) => | ||
| 188 | execute({ | ||
| 189 | cwd: input.cwd, | ||
| 190 | args: [ | ||
| 191 | "pr", | ||
| 192 | "list", | ||
| 193 | "--head", | ||
| 194 | input.headSelector, | ||
| 195 | "--state", | ||
| 196 | "open", | ||
| 197 | "--limit", | ||
| 198 | String(input.limit ?? 1), | ||
| 199 | "--json", | ||
| 200 | "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", | ||
| 201 | ], | ||
| 202 | }).pipe( | ||
| 203 | Effect.map((result) => result.stdout.trim()), | ||
| 204 | Effect.flatMap((raw) => | ||
| 205 | raw.length === 0 | ||
| 206 | ? Effect.succeed([]) | ||
| 207 | : Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( | ||
| 208 | Effect.flatMap((decoded) => { | ||
| 209 | if (!Result.isSuccess(decoded)) { | ||
| 210 | return Effect.fail( | ||
| 211 | new GitHubCliError({ | ||
| 212 | operation: "listOpenPullRequests", | ||
| 213 | detail: `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, | ||
| 214 | cause: decoded.failure, | ||
| 215 | }), | ||
| 216 | ); | ||
| 217 | } | ||
| 218 | |||
| 219 | return Effect.succeed( | ||
| 220 | decoded.success.map(({ updatedAt: _updatedAt, ...summary }) => summary), | ||
| 221 | ); | ||
| 222 | }), | ||
| 223 | ), | ||
| 224 | ), | ||
| 225 | ), | ||
| 226 | getPullRequest: (input) => | ||
| 227 | execute({ | ||
| 228 | cwd: input.cwd, | ||
| 229 | args: [ | ||
| 230 | "pr", | ||
| 231 | "view", | ||
| 232 | input.reference, | ||
| 233 | "--json", | ||
| 234 | "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", | ||
| 235 | ], | ||
| 236 | }).pipe( | ||
| 237 | Effect.map((result) => result.stdout.trim()), | ||
| 238 | Effect.flatMap((raw) => | ||
| 239 | Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe( | ||
| 240 | Effect.flatMap((decoded) => { | ||
| 241 | if (!Result.isSuccess(decoded)) { | ||
| 242 | return Effect.fail( | ||
| 243 | new GitHubCliError({ | ||
| 244 | operation: "getPullRequest", | ||
| 245 | detail: `GitHub CLI returned invalid pull request JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, | ||
| 246 | cause: decoded.failure, | ||
| 247 | }), | ||
| 248 | ); | ||
| 249 | } | ||
| 250 | |||
| 251 | return Effect.succeed( | ||
| 252 | (({ updatedAt: _updatedAt, ...summary }) => summary)(decoded.success), | ||
| 253 | ); | ||
| 254 | }), | ||
| 255 | ), | ||
| 256 | ), | ||
| 257 | ), | ||
| 258 | getRepositoryCloneUrls: (input) => | ||
| 259 | execute({ | ||
| 260 | cwd: input.cwd, | ||
| 261 | args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], | ||
| 262 | }).pipe( | ||
| 263 | Effect.map((result) => result.stdout.trim()), | ||
| 264 | Effect.flatMap((raw) => | ||
| 265 | decodeGitHubJson( | ||
| 266 | raw, | ||
| 267 | RawGitHubRepositoryCloneUrlsSchema, | ||
| 268 | "getRepositoryCloneUrls", | ||
| 269 | "GitHub CLI returned invalid repository JSON.", | ||
| 270 | ), | ||
| 271 | ), | ||
| 272 | Effect.map(normalizeRepositoryCloneUrls), | ||
| 273 | ), | ||
| 274 | createPullRequest: (input) => | ||
| 275 | execute({ | ||
| 276 | cwd: input.cwd, | ||
| 277 | args: [ | ||
| 278 | "pr", | ||
| 279 | "create", | ||
| 280 | "--base", | ||
| 281 | input.baseBranch, | ||
| 282 | "--head", | ||
| 283 | input.headSelector, | ||
| 284 | "--title", | ||
| 285 | input.title, | ||
| 286 | "--body-file", | ||
| 287 | input.bodyFile, | ||
| 288 | ], | ||
| 289 | }).pipe(Effect.asVoid), | ||
| 290 | getDefaultBranch: (input) => | ||
| 291 | execute({ | ||
| 292 | cwd: input.cwd, | ||
| 293 | args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], | ||
| 294 | }).pipe( | ||
| 295 | Effect.map((value) => { | ||
| 296 | const trimmed = value.stdout.trim(); | ||
| 297 | return trimmed.length > 0 ? trimmed : null; | ||
| 298 | }), | ||
| 299 | ), | ||
| 300 | checkoutPullRequest: (input) => | ||
| 301 | execute({ | ||
| 302 | cwd: input.cwd, | ||
| 303 | args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], | ||
| 304 | }).pipe(Effect.asVoid), | ||
| 305 | }); | ||
| 306 | }); | ||
| 307 | |||
| 308 | export const layer = Layer.effect(GitHubCli, make()); | ||
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts created+154| ... | @@ -0,0 +1,154 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import { DateTime, Effect, Layer, Option } from "effect"; | ||
| 3 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 4 | |||
| 5 | import { GitHubCli, type GitHubCliShape } from "./GitHubCli.ts"; | ||
| 6 | import type { VcsProcessOutput } from "../vcs/VcsProcess.ts"; | ||
| 7 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; | ||
| 8 | |||
| 9 | const processResult = (stdout: string): VcsProcessOutput => ({ | ||
| 10 | exitCode: ChildProcessSpawner.ExitCode(0), | ||
| 11 | stdout, | ||
| 12 | stderr: "", | ||
| 13 | stdoutTruncated: false, | ||
| 14 | stderrTruncated: false, | ||
| 15 | }); | ||
| 16 | |||
| 17 | function makeProvider(github: Partial<GitHubCliShape>) { | ||
| 18 | return GitHubSourceControlProvider.make().pipe(Effect.provide(Layer.mock(GitHubCli)(github))); | ||
| 19 | } | ||
| 20 | |||
| 21 | it.effect("maps GitHub PR summaries into provider-neutral change requests", () => | ||
| 22 | Effect.gen(function* () { | ||
| 23 | const provider = yield* makeProvider({ | ||
| 24 | getPullRequest: () => | ||
| 25 | Effect.succeed({ | ||
| 26 | number: 42, | ||
| 27 | title: "Add GitHub provider", | ||
| 28 | url: "https://github.com/pingdotgg/t3code/pull/42", | ||
| 29 | baseRefName: "main", | ||
| 30 | headRefName: "feature/source-control", | ||
| 31 | state: "open", | ||
| 32 | isCrossRepository: true, | ||
| 33 | headRepositoryNameWithOwner: "fork/t3code", | ||
| 34 | headRepositoryOwnerLogin: "fork", | ||
| 35 | }), | ||
| 36 | }); | ||
| 37 | |||
| 38 | const changeRequest = yield* provider.getChangeRequest({ | ||
| 39 | cwd: "/repo", | ||
| 40 | reference: "42", | ||
| 41 | }); | ||
| 42 | |||
| 43 | assert.deepStrictEqual(changeRequest, { | ||
| 44 | provider: "github", | ||
| 45 | number: 42, | ||
| 46 | title: "Add GitHub provider", | ||
| 47 | url: "https://github.com/pingdotgg/t3code/pull/42", | ||
| 48 | baseRefName: "main", | ||
| 49 | headRefName: "feature/source-control", | ||
| 50 | state: "open", | ||
| 51 | updatedAt: Option.none(), | ||
| 52 | isCrossRepository: true, | ||
| 53 | headRepositoryNameWithOwner: "fork/t3code", | ||
| 54 | headRepositoryOwnerLogin: "fork", | ||
| 55 | }); | ||
| 56 | }), | ||
| 57 | ); | ||
| 58 | |||
| 59 | it.effect("uses gh json listing for non-open change request state queries", () => | ||
| 60 | Effect.gen(function* () { | ||
| 61 | let executeArgs: ReadonlyArray<string> = []; | ||
| 62 | const provider = yield* makeProvider({ | ||
| 63 | execute: (input) => { | ||
| 64 | executeArgs = input.args; | ||
| 65 | return Effect.succeed( | ||
| 66 | processResult( | ||
| 67 | JSON.stringify([ | ||
| 68 | { | ||
| 69 | number: 7, | ||
| 70 | title: "Merged work", | ||
| 71 | url: "https://github.com/pingdotgg/t3code/pull/7", | ||
| 72 | baseRefName: "main", | ||
| 73 | headRefName: "feature/merged", | ||
| 74 | state: "merged", | ||
| 75 | updatedAt: "2026-01-02T00:00:00.000Z", | ||
| 76 | }, | ||
| 77 | ]), | ||
| 78 | ), | ||
| 79 | ); | ||
| 80 | }, | ||
| 81 | }); | ||
| 82 | |||
| 83 | const changeRequests = yield* provider.listChangeRequests({ | ||
| 84 | cwd: "/repo", | ||
| 85 | headSelector: "feature/merged", | ||
| 86 | state: "all", | ||
| 87 | limit: 10, | ||
| 88 | }); | ||
| 89 | |||
| 90 | assert.deepStrictEqual(executeArgs, [ | ||
| 91 | "pr", | ||
| 92 | "list", | ||
| 93 | "--head", | ||
| 94 | "feature/merged", | ||
| 95 | "--state", | ||
| 96 | "all", | ||
| 97 | "--limit", | ||
| 98 | "10", | ||
| 99 | "--json", | ||
| 100 | "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | ||
| 101 | ]); | ||
| 102 | assert.strictEqual(changeRequests[0]?.provider, "github"); | ||
| 103 | assert.strictEqual(changeRequests[0]?.state, "merged"); | ||
| 104 | assert.deepStrictEqual( | ||
| 105 | changeRequests[0]?.updatedAt, | ||
| 106 | Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")), | ||
| 107 | ); | ||
| 108 | }), | ||
| 109 | ); | ||
| 110 | |||
| 111 | it.effect("treats empty non-open change request listing output as no results", () => | ||
| 112 | Effect.gen(function* () { | ||
| 113 | const provider = yield* makeProvider({ | ||
| 114 | execute: () => Effect.succeed(processResult("")), | ||
| 115 | }); | ||
| 116 | |||
| 117 | const changeRequests = yield* provider.listChangeRequests({ | ||
| 118 | cwd: "/repo", | ||
| 119 | headSelector: "feature/empty", | ||
| 120 | state: "all", | ||
| 121 | limit: 10, | ||
| 122 | }); | ||
| 123 | |||
| 124 | assert.deepStrictEqual(changeRequests, []); | ||
| 125 | }), | ||
| 126 | ); | ||
| 127 | |||
| 128 | it.effect("creates GitHub PRs through provider-neutral input names", () => | ||
| 129 | Effect.gen(function* () { | ||
| 130 | let createInput: Parameters<GitHubCliShape["createPullRequest"]>[0] | null = null; | ||
| 131 | const provider = yield* makeProvider({ | ||
| 132 | createPullRequest: (input) => { | ||
| 133 | createInput = input; | ||
| 134 | return Effect.void; | ||
| 135 | }, | ||
| 136 | }); | ||
| 137 | |||
| 138 | yield* provider.createChangeRequest({ | ||
| 139 | cwd: "/repo", | ||
| 140 | baseRefName: "main", | ||
| 141 | headSelector: "owner:feature/provider", | ||
| 142 | title: "Provider PR", | ||
| 143 | bodyFile: "/tmp/body.md", | ||
| 144 | }); | ||
| 145 | |||
| 146 | assert.deepStrictEqual(createInput, { | ||
| 147 | cwd: "/repo", | ||
| 148 | baseBranch: "main", | ||
| 149 | headSelector: "owner:feature/provider", | ||
| 150 | title: "Provider PR", | ||
| 151 | bodyFile: "/tmp/body.md", | ||
| 152 | }); | ||
| 153 | }), | ||
| 154 | ); | ||
apps/server/src/sourceControl/GitHubSourceControlProvider.ts created+145| ... | @@ -0,0 +1,145 @@ | ||
| 1 | import { Effect, Layer, Option, Result, Schema } from "effect"; | ||
| 2 | import { | ||
| 3 | SourceControlProviderError, | ||
| 4 | type ChangeRequest, | ||
| 5 | type ChangeRequestState, | ||
| 6 | type GitHubCliError, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | |||
| 9 | import { GitHubCli, type GitHubPullRequestSummary } from "./GitHubCli.ts"; | ||
| 10 | import { decodeGitHubPullRequestListJson } from "../git/githubPullRequests.ts"; | ||
| 11 | import { SourceControlProvider, type SourceControlProviderShape } from "./SourceControlProvider.ts"; | ||
| 12 | |||
| 13 | function providerError(operation: string, cause: GitHubCliError): SourceControlProviderError { | ||
| 14 | return new SourceControlProviderError({ | ||
| 15 | provider: "github", | ||
| 16 | operation, | ||
| 17 | detail: cause.detail, | ||
| 18 | cause, | ||
| 19 | }); | ||
| 20 | } | ||
| 21 | |||
| 22 | function toChangeRequest(summary: GitHubPullRequestSummary): ChangeRequest { | ||
| 23 | return { | ||
| 24 | provider: "github", | ||
| 25 | number: summary.number, | ||
| 26 | title: summary.title, | ||
| 27 | url: summary.url, | ||
| 28 | baseRefName: summary.baseRefName, | ||
| 29 | headRefName: summary.headRefName, | ||
| 30 | state: summary.state ?? "open", | ||
| 31 | updatedAt: Option.none(), | ||
| 32 | ...(summary.isCrossRepository !== undefined | ||
| 33 | ? { isCrossRepository: summary.isCrossRepository } | ||
| 34 | : {}), | ||
| 35 | ...(summary.headRepositoryNameWithOwner !== undefined | ||
| 36 | ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } | ||
| 37 | : {}), | ||
| 38 | ...(summary.headRepositoryOwnerLogin !== undefined | ||
| 39 | ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } | ||
| 40 | : {}), | ||
| 41 | }; | ||
| 42 | } | ||
| 43 | |||
| 44 | export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { | ||
| 45 | const github = yield* GitHubCli; | ||
| 46 | |||
| 47 | const listChangeRequests: SourceControlProviderShape["listChangeRequests"] = (input) => { | ||
| 48 | if (input.state === "open") { | ||
| 49 | return github | ||
| 50 | .listOpenPullRequests({ | ||
| 51 | cwd: input.cwd, | ||
| 52 | headSelector: input.headSelector, | ||
| 53 | ...(input.limit !== undefined ? { limit: input.limit } : {}), | ||
| 54 | }) | ||
| 55 | .pipe( | ||
| 56 | Effect.map((items) => items.map(toChangeRequest)), | ||
| 57 | Effect.mapError((error) => providerError("listChangeRequests", error)), | ||
| 58 | ); | ||
| 59 | } | ||
| 60 | |||
| 61 | const stateArg: ChangeRequestState | "all" = input.state; | ||
| 62 | return github | ||
| 63 | .execute({ | ||
| 64 | cwd: input.cwd, | ||
| 65 | args: [ | ||
| 66 | "pr", | ||
| 67 | "list", | ||
| 68 | "--head", | ||
| 69 | input.headSelector, | ||
| 70 | "--state", | ||
| 71 | stateArg, | ||
| 72 | "--limit", | ||
| 73 | String(input.limit ?? 20), | ||
| 74 | "--json", | ||
| 75 | "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | ||
| 76 | ], | ||
| 77 | }) | ||
| 78 | .pipe( | ||
| 79 | Effect.flatMap((result) => { | ||
| 80 | const raw = result.stdout.trim(); | ||
| 81 | if (raw.length === 0) { | ||
| 82 | return Effect.succeed([]); | ||
| 83 | } | ||
| 84 | return Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( | ||
| 85 | Effect.flatMap((decoded) => | ||
| 86 | Result.isSuccess(decoded) | ||
| 87 | ? Effect.succeed( | ||
| 88 | decoded.success.map((item) => ({ | ||
| 89 | ...toChangeRequest(item), | ||
| 90 | updatedAt: item.updatedAt, | ||
| 91 | })), | ||
| 92 | ) | ||
| 93 | : Effect.fail( | ||
| 94 | new SourceControlProviderError({ | ||
| 95 | provider: "github", | ||
| 96 | operation: "listChangeRequests", | ||
| 97 | detail: "GitHub CLI returned invalid change request JSON.", | ||
| 98 | cause: decoded.failure, | ||
| 99 | }), | ||
| 100 | ), | ||
| 101 | ), | ||
| 102 | ); | ||
| 103 | }), | ||
| 104 | Effect.mapError((error) => | ||
| 105 | Schema.is(SourceControlProviderError)(error) | ||
| 106 | ? error | ||
| 107 | : providerError("listChangeRequests", error), | ||
| 108 | ), | ||
| 109 | ); | ||
| 110 | }; | ||
| 111 | |||
| 112 | return SourceControlProvider.of({ | ||
| 113 | kind: "github", | ||
| 114 | listChangeRequests, | ||
| 115 | getChangeRequest: (input) => | ||
| 116 | github.getPullRequest(input).pipe( | ||
| 117 | Effect.map(toChangeRequest), | ||
| 118 | Effect.mapError((error) => providerError("getChangeRequest", error)), | ||
| 119 | ), | ||
| 120 | createChangeRequest: (input) => | ||
| 121 | github | ||
| 122 | .createPullRequest({ | ||
| 123 | cwd: input.cwd, | ||
| 124 | baseBranch: input.baseRefName, | ||
| 125 | headSelector: input.headSelector, | ||
| 126 | title: input.title, | ||
| 127 | bodyFile: input.bodyFile, | ||
| 128 | }) | ||
| 129 | .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))), | ||
| 130 | getRepositoryCloneUrls: (input) => | ||
| 131 | github | ||
| 132 | .getRepositoryCloneUrls(input) | ||
| 133 | .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), | ||
| 134 | getDefaultBranch: (input) => | ||
| 135 | github | ||
| 136 | .getDefaultBranch(input) | ||
| 137 | .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), | ||
| 138 | checkoutChangeRequest: (input) => | ||
| 139 | github | ||
| 140 | .checkoutPullRequest(input) | ||
| 141 | .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), | ||
| 142 | }); | ||
| 143 | }); | ||
| 144 | |||
| 145 | export const layer = Layer.effect(SourceControlProvider, make()); | ||
apps/server/src/sourceControl/SourceControlDiscovery.test.ts created+214| ... | @@ -0,0 +1,214 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 3 | import { Effect, Layer, Option } from "effect"; | ||
| 4 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 5 | import { VcsProcessSpawnError } from "@t3tools/contracts"; | ||
| 6 | |||
| 7 | import { ServerConfig } from "../config.ts"; | ||
| 8 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | ||
| 9 | import { SourceControlDiscovery, layer } from "./SourceControlDiscovery.ts"; | ||
| 10 | |||
| 11 | const processOutput = ( | ||
| 12 | stdout: string, | ||
| 13 | options?: { | ||
| 14 | readonly stderr?: string; | ||
| 15 | readonly exitCode?: ChildProcessSpawner.ExitCode; | ||
| 16 | }, | ||
| 17 | ): VcsProcess.VcsProcessOutput => ({ | ||
| 18 | exitCode: options?.exitCode ?? ChildProcessSpawner.ExitCode(0), | ||
| 19 | stdout, | ||
| 20 | stderr: options?.stderr ?? "", | ||
| 21 | stdoutTruncated: false, | ||
| 22 | stderrTruncated: false, | ||
| 23 | }); | ||
| 24 | |||
| 25 | it.effect("reports implemented tools separately from locally available CLIs", () => { | ||
| 26 | const testLayer = layer.pipe( | ||
| 27 | Layer.provide( | ||
| 28 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-discovery-" }), | ||
| 29 | ), | ||
| 30 | Layer.provide( | ||
| 31 | Layer.mock(VcsProcess.VcsProcess)({ | ||
| 32 | run: (input) => { | ||
| 33 | if (input.command === "git") { | ||
| 34 | return Effect.succeed(processOutput("git version 2.51.0\n")); | ||
| 35 | } | ||
| 36 | if (input.command === "gh" && input.args[0] === "--version") { | ||
| 37 | return Effect.succeed(processOutput("gh version 2.83.0\n")); | ||
| 38 | } | ||
| 39 | if (input.command === "gh" && input.args.join(" ") === "auth status") { | ||
| 40 | return Effect.succeed( | ||
| 41 | processOutput(`github.com | ||
| 42 | Logged in to github.com account juliusmarminge (keyring) | ||
| 43 | - Active account: true | ||
| 44 | - Git operations protocol: ssh | ||
| 45 | - Token: gho_************************************ | ||
| 46 | - Token scopes: 'admin:public_key', 'gist', 'read:org', 'repo' | ||
| 47 | `), | ||
| 48 | ); | ||
| 49 | } | ||
| 50 | return Effect.fail( | ||
| 51 | new VcsProcessSpawnError({ | ||
| 52 | operation: input.operation, | ||
| 53 | command: input.command, | ||
| 54 | cwd: input.cwd, | ||
| 55 | cause: new Error(`${input.command} not found`), | ||
| 56 | }), | ||
| 57 | ); | ||
| 58 | }, | ||
| 59 | }), | ||
| 60 | ), | ||
| 61 | Layer.provideMerge(NodeServices.layer), | ||
| 62 | ); | ||
| 63 | |||
| 64 | return Effect.gen(function* () { | ||
| 65 | const discovery = yield* SourceControlDiscovery; | ||
| 66 | const result = yield* discovery.discover; | ||
| 67 | |||
| 68 | assert.deepStrictEqual( | ||
| 69 | result.versionControlSystems.map((item) => ({ | ||
| 70 | kind: item.kind, | ||
| 71 | implemented: item.implemented, | ||
| 72 | status: item.status, | ||
| 73 | })), | ||
| 74 | [ | ||
| 75 | { kind: "git", implemented: true, status: "available" }, | ||
| 76 | { kind: "jj", implemented: false, status: "missing" }, | ||
| 77 | ], | ||
| 78 | ); | ||
| 79 | assert.deepStrictEqual( | ||
| 80 | result.sourceControlProviders.map((item) => ({ | ||
| 81 | kind: item.kind, | ||
| 82 | implemented: item.implemented, | ||
| 83 | status: item.status, | ||
| 84 | auth: item.auth.status, | ||
| 85 | account: item.auth.account, | ||
| 86 | })), | ||
| 87 | [ | ||
| 88 | { | ||
| 89 | kind: "github", | ||
| 90 | implemented: true, | ||
| 91 | status: "available", | ||
| 92 | auth: "authenticated", | ||
| 93 | account: Option.some("juliusmarminge"), | ||
| 94 | }, | ||
| 95 | { | ||
| 96 | kind: "gitlab", | ||
| 97 | implemented: false, | ||
| 98 | status: "missing", | ||
| 99 | auth: "unknown", | ||
| 100 | account: Option.none(), | ||
| 101 | }, | ||
| 102 | { | ||
| 103 | kind: "azure-devops", | ||
| 104 | implemented: false, | ||
| 105 | status: "missing", | ||
| 106 | auth: "unknown", | ||
| 107 | account: Option.none(), | ||
| 108 | }, | ||
| 109 | { | ||
| 110 | kind: "bitbucket", | ||
| 111 | implemented: false, | ||
| 112 | status: "missing", | ||
| 113 | auth: "unknown", | ||
| 114 | account: Option.none(), | ||
| 115 | }, | ||
| 116 | ], | ||
| 117 | ); | ||
| 118 | }).pipe(Effect.provide(testLayer)); | ||
| 119 | }); | ||
| 120 | |||
| 121 | it.effect("probes provider authentication without exposing token details", () => { | ||
| 122 | const testLayer = layer.pipe( | ||
| 123 | Layer.provide( | ||
| 124 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-auth-discovery-" }), | ||
| 125 | ), | ||
| 126 | Layer.provide( | ||
| 127 | Layer.mock(VcsProcess.VcsProcess)({ | ||
| 128 | run: (input) => { | ||
| 129 | if (input.args[0] === "--version") { | ||
| 130 | return Effect.succeed(processOutput(`${input.command} version test\n`)); | ||
| 131 | } | ||
| 132 | if (input.command === "gh" && input.args.join(" ") === "auth status") { | ||
| 133 | return Effect.succeed( | ||
| 134 | processOutput(`github.com | ||
| 135 | Logged in to github.com account octocat (keyring) | ||
| 136 | - Token: gho_************************************ | ||
| 137 | - Token scopes: 'repo' | ||
| 138 | `), | ||
| 139 | ); | ||
| 140 | } | ||
| 141 | if (input.command === "glab" && input.args.join(" ") === "auth status") { | ||
| 142 | return Effect.succeed( | ||
| 143 | processOutput(`gitlab.com | ||
| 144 | Logged in to gitlab.com as gitlab-user | ||
| 145 | `), | ||
| 146 | ); | ||
| 147 | } | ||
| 148 | if ( | ||
| 149 | input.command === "az" && | ||
| 150 | input.args.join(" ") === "account show --query user.name -o tsv" | ||
| 151 | ) { | ||
| 152 | return Effect.succeed(processOutput("azure-user@example.com\n")); | ||
| 153 | } | ||
| 154 | if (input.command === "bb" && input.args.join(" ") === "auth status") { | ||
| 155 | return Effect.succeed( | ||
| 156 | processOutput(`bitbucket.org | ||
| 157 | Logged in as bitbucket-user | ||
| 158 | `), | ||
| 159 | ); | ||
| 160 | } | ||
| 161 | return Effect.fail( | ||
| 162 | new VcsProcessSpawnError({ | ||
| 163 | operation: input.operation, | ||
| 164 | command: input.command, | ||
| 165 | cwd: input.cwd, | ||
| 166 | cause: new Error(`${input.command} not found`), | ||
| 167 | }), | ||
| 168 | ); | ||
| 169 | }, | ||
| 170 | }), | ||
| 171 | ), | ||
| 172 | Layer.provideMerge(NodeServices.layer), | ||
| 173 | ); | ||
| 174 | |||
| 175 | return Effect.gen(function* () { | ||
| 176 | const discovery = yield* SourceControlDiscovery; | ||
| 177 | const result = yield* discovery.discover; | ||
| 178 | |||
| 179 | assert.deepStrictEqual( | ||
| 180 | result.sourceControlProviders.map((item) => ({ | ||
| 181 | kind: item.kind, | ||
| 182 | auth: item.auth.status, | ||
| 183 | account: item.auth.account, | ||
| 184 | detail: item.auth.detail, | ||
| 185 | })), | ||
| 186 | [ | ||
| 187 | { | ||
| 188 | kind: "github", | ||
| 189 | auth: "authenticated", | ||
| 190 | account: Option.some("octocat"), | ||
| 191 | detail: Option.none(), | ||
| 192 | }, | ||
| 193 | { | ||
| 194 | kind: "gitlab", | ||
| 195 | auth: "authenticated", | ||
| 196 | account: Option.some("gitlab-user"), | ||
| 197 | detail: Option.none(), | ||
| 198 | }, | ||
| 199 | { | ||
| 200 | kind: "azure-devops", | ||
| 201 | auth: "authenticated", | ||
| 202 | account: Option.some("azure-user@example.com"), | ||
| 203 | detail: Option.none(), | ||
| 204 | }, | ||
| 205 | { | ||
| 206 | kind: "bitbucket", | ||
| 207 | auth: "authenticated", | ||
| 208 | account: Option.some("bitbucket-user"), | ||
| 209 | detail: Option.none(), | ||
| 210 | }, | ||
| 211 | ], | ||
| 212 | ); | ||
| 213 | }).pipe(Effect.provide(testLayer)); | ||
| 214 | }); | ||
apps/server/src/sourceControl/SourceControlDiscovery.ts created+412| ... | @@ -0,0 +1,412 @@ | ||
| 1 | import { | ||
| 2 | type SourceControlProviderAuth, | ||
| 3 | type SourceControlDiscoveryResult, | ||
| 4 | type SourceControlProviderDiscoveryItem, | ||
| 5 | type SourceControlProviderKind, | ||
| 6 | type VcsDiscoveryItem, | ||
| 7 | type VcsDriverKind, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import { Context, Effect, Layer, Option } from "effect"; | ||
| 10 | |||
| 11 | import { ServerConfig } from "../config.ts"; | ||
| 12 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | ||
| 13 | |||
| 14 | interface DiscoveryProbe { | ||
| 15 | readonly label: string; | ||
| 16 | readonly executable: string; | ||
| 17 | readonly versionArgs: ReadonlyArray<string>; | ||
| 18 | readonly implemented: boolean; | ||
| 19 | readonly installHint: string; | ||
| 20 | } | ||
| 21 | |||
| 22 | type VcsProbe = DiscoveryProbe & { | ||
| 23 | readonly kind: VcsDriverKind; | ||
| 24 | }; | ||
| 25 | |||
| 26 | type ProviderProbe = DiscoveryProbe & { | ||
| 27 | readonly kind: SourceControlProviderKind; | ||
| 28 | readonly authArgs: ReadonlyArray<string>; | ||
| 29 | readonly parseAuth: (input: AuthProbeInput) => SourceControlProviderAuth; | ||
| 30 | }; | ||
| 31 | |||
| 32 | interface AuthProbeInput { | ||
| 33 | readonly stdout: string; | ||
| 34 | readonly stderr: string; | ||
| 35 | readonly exitCode: VcsProcess.VcsProcessOutput["exitCode"]; | ||
| 36 | } | ||
| 37 | |||
| 38 | interface DiscoveryProbeResult<Kind extends string> { | ||
| 39 | readonly kind: Kind; | ||
| 40 | readonly label: string; | ||
| 41 | readonly executable: string; | ||
| 42 | readonly implemented: boolean; | ||
| 43 | readonly status: "available" | "missing"; | ||
| 44 | readonly version: Option.Option<string>; | ||
| 45 | readonly installHint: string; | ||
| 46 | readonly detail: Option.Option<string>; | ||
| 47 | } | ||
| 48 | |||
| 49 | const VCS_PROBES: ReadonlyArray<VcsProbe> = [ | ||
| 50 | { | ||
| 51 | kind: "git", | ||
| 52 | label: "Git", | ||
| 53 | executable: "git", | ||
| 54 | versionArgs: ["--version"], | ||
| 55 | implemented: true, | ||
| 56 | installHint: "Install Git from https://git-scm.com/downloads or with your package manager.", | ||
| 57 | }, | ||
| 58 | { | ||
| 59 | kind: "jj", | ||
| 60 | label: "Jujutsu", | ||
| 61 | executable: "jj", | ||
| 62 | versionArgs: ["--version"], | ||
| 63 | implemented: false, | ||
| 64 | installHint: "Install Jujutsu with `brew install jj` or from https://github.com/jj-vcs/jj.", | ||
| 65 | }, | ||
| 66 | ]; | ||
| 67 | |||
| 68 | const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray<ProviderProbe> = [ | ||
| 69 | { | ||
| 70 | kind: "github", | ||
| 71 | label: "GitHub", | ||
| 72 | executable: "gh", | ||
| 73 | versionArgs: ["--version"], | ||
| 74 | authArgs: ["auth", "status"], | ||
| 75 | parseAuth: parseGitHubAuth, | ||
| 76 | implemented: true, | ||
| 77 | installHint: "Install GitHub CLI with `brew install gh` or from https://cli.github.com/.", | ||
| 78 | }, | ||
| 79 | { | ||
| 80 | kind: "gitlab", | ||
| 81 | label: "GitLab", | ||
| 82 | executable: "glab", | ||
| 83 | versionArgs: ["--version"], | ||
| 84 | authArgs: ["auth", "status"], | ||
| 85 | parseAuth: parseGitLabAuth, | ||
| 86 | implemented: false, | ||
| 87 | installHint: | ||
| 88 | "Install GitLab CLI with `brew install glab` or from https://gitlab.com/gitlab-org/cli.", | ||
| 89 | }, | ||
| 90 | { | ||
| 91 | kind: "azure-devops", | ||
| 92 | label: "Azure DevOps", | ||
| 93 | executable: "az", | ||
| 94 | versionArgs: ["--version"], | ||
| 95 | authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"], | ||
| 96 | parseAuth: parseAzureAuth, | ||
| 97 | implemented: false, | ||
| 98 | installHint: | ||
| 99 | "Install Azure CLI with `brew install azure-cli`, then add Azure DevOps support with `az extension add --name azure-devops`.", | ||
| 100 | }, | ||
| 101 | { | ||
| 102 | kind: "bitbucket", | ||
| 103 | label: "Bitbucket", | ||
| 104 | executable: "bb", | ||
| 105 | versionArgs: ["--version"], | ||
| 106 | authArgs: ["auth", "status"], | ||
| 107 | parseAuth: parseBitbucketAuth, | ||
| 108 | implemented: false, | ||
| 109 | installHint: "Install a Bitbucket CLI (`bb`) and authenticate it for your Bitbucket workspace.", | ||
| 110 | }, | ||
| 111 | ]; | ||
| 112 | |||
| 113 | function firstNonEmptyLine(text: string): Option.Option<string> { | ||
| 114 | const line = text | ||
| 115 | .split(/\r?\n/) | ||
| 116 | .map((entry) => entry.trim()) | ||
| 117 | .find((entry) => entry.length > 0); | ||
| 118 | return line === undefined ? Option.none() : Option.some(line); | ||
| 119 | } | ||
| 120 | |||
| 121 | function detailFromCause(cause: unknown): Option.Option<string> { | ||
| 122 | if (cause instanceof Error && cause.message.trim().length > 0) { | ||
| 123 | return Option.some(cause.message.trim()); | ||
| 124 | } | ||
| 125 | return Option.none(); | ||
| 126 | } | ||
| 127 | |||
| 128 | function authAccount(account: string | undefined): Option.Option<string> { | ||
| 129 | const trimmed = account?.trim(); | ||
| 130 | return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); | ||
| 131 | } | ||
| 132 | |||
| 133 | function authHost(host: string | undefined): Option.Option<string> { | ||
| 134 | const trimmed = host?.trim(); | ||
| 135 | return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); | ||
| 136 | } | ||
| 137 | |||
| 138 | function authDetail(detail: string | undefined): Option.Option<string> { | ||
| 139 | const trimmed = detail?.trim(); | ||
| 140 | return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); | ||
| 141 | } | ||
| 142 | |||
| 143 | function providerAuth(input: { | ||
| 144 | readonly status: SourceControlProviderAuth["status"]; | ||
| 145 | readonly account?: string | undefined; | ||
| 146 | readonly host?: string | undefined; | ||
| 147 | readonly detail?: string | undefined; | ||
| 148 | }): SourceControlProviderAuth { | ||
| 149 | return { | ||
| 150 | status: input.status, | ||
| 151 | account: authAccount(input.account), | ||
| 152 | host: authHost(input.host), | ||
| 153 | detail: authDetail(input.detail), | ||
| 154 | }; | ||
| 155 | } | ||
| 156 | |||
| 157 | function unknownAuth(detail?: string): SourceControlProviderAuth { | ||
| 158 | return providerAuth({ status: "unknown", detail }); | ||
| 159 | } | ||
| 160 | |||
| 161 | function combinedAuthOutput(input: AuthProbeInput): string { | ||
| 162 | return [input.stdout, input.stderr].filter((entry) => entry.trim().length > 0).join("\n"); | ||
| 163 | } | ||
| 164 | |||
| 165 | function sanitizedAuthLines(text: string): ReadonlyArray<string> { | ||
| 166 | return text | ||
| 167 | .split(/\r?\n/) | ||
| 168 | .map((entry) => entry.trim()) | ||
| 169 | .filter((entry) => entry.length > 0) | ||
| 170 | .filter((entry) => !/^[-\s]*token(?:\s+scopes?)?:/iu.test(entry)); | ||
| 171 | } | ||
| 172 | |||
| 173 | function firstSafeAuthLine(text: string): string | undefined { | ||
| 174 | return sanitizedAuthLines(text)[0]; | ||
| 175 | } | ||
| 176 | |||
| 177 | function parseCliHost(text: string): string | undefined { | ||
| 178 | return sanitizedAuthLines(text) | ||
| 179 | .map((line) => line.replace(/^[^a-z0-9]+/iu, "")) | ||
| 180 | .find((line) => /^[a-z0-9][a-z0-9.-]*(?::\d+)?$/iu.test(line)); | ||
| 181 | } | ||
| 182 | |||
| 183 | function matchFirst(text: string, patterns: ReadonlyArray<RegExp>): string | undefined { | ||
| 184 | for (const pattern of patterns) { | ||
| 185 | const match = pattern.exec(text); | ||
| 186 | const value = match?.[1]?.trim(); | ||
| 187 | if (value && value.length > 0) return value; | ||
| 188 | } | ||
| 189 | return undefined; | ||
| 190 | } | ||
| 191 | |||
| 192 | function parseGitHubAuth(input: AuthProbeInput): SourceControlProviderAuth { | ||
| 193 | const output = combinedAuthOutput(input); | ||
| 194 | const account = matchFirst(output, [ | ||
| 195 | /Logged in to .* account\s+([^\s(]+)/iu, | ||
| 196 | /Logged in to .* as\s+([^\s(]+)/iu, | ||
| 197 | ]); | ||
| 198 | const host = parseCliHost(output); | ||
| 199 | |||
| 200 | if (input.exitCode !== 0) { | ||
| 201 | return providerAuth({ | ||
| 202 | status: "unauthenticated", | ||
| 203 | host, | ||
| 204 | detail: firstSafeAuthLine(output) ?? "Run `gh auth login` to authenticate GitHub CLI.", | ||
| 205 | }); | ||
| 206 | } | ||
| 207 | |||
| 208 | if (account) { | ||
| 209 | return providerAuth({ status: "authenticated", account, host }); | ||
| 210 | } | ||
| 211 | |||
| 212 | return providerAuth({ | ||
| 213 | status: "unknown", | ||
| 214 | host, | ||
| 215 | detail: firstSafeAuthLine(output) ?? "GitHub CLI auth status could not be parsed.", | ||
| 216 | }); | ||
| 217 | } | ||
| 218 | |||
| 219 | function parseGitLabAuth(input: AuthProbeInput): SourceControlProviderAuth { | ||
| 220 | const output = combinedAuthOutput(input); | ||
| 221 | const account = matchFirst(output, [ | ||
| 222 | /Logged in to .* as\s+([^\s(]+)/iu, | ||
| 223 | /Logged in to .* account\s+([^\s(]+)/iu, | ||
| 224 | /account:\s*([^\s(]+)/iu, | ||
| 225 | ]); | ||
| 226 | const host = parseCliHost(output); | ||
| 227 | |||
| 228 | if (input.exitCode !== 0) { | ||
| 229 | return providerAuth({ | ||
| 230 | status: "unauthenticated", | ||
| 231 | host, | ||
| 232 | detail: firstSafeAuthLine(output) ?? "Run `glab auth login` to authenticate GitLab CLI.", | ||
| 233 | }); | ||
| 234 | } | ||
| 235 | |||
| 236 | if (account) { | ||
| 237 | return providerAuth({ status: "authenticated", account, host }); | ||
| 238 | } | ||
| 239 | |||
| 240 | return providerAuth({ | ||
| 241 | status: "unknown", | ||
| 242 | host, | ||
| 243 | detail: firstSafeAuthLine(output) ?? "GitLab CLI auth status could not be parsed.", | ||
| 244 | }); | ||
| 245 | } | ||
| 246 | |||
| 247 | function parseAzureAuth(input: AuthProbeInput): SourceControlProviderAuth { | ||
| 248 | const account = input.stdout.trim().split(/\r?\n/)[0]?.trim(); | ||
| 249 | |||
| 250 | if (input.exitCode !== 0) { | ||
| 251 | return providerAuth({ | ||
| 252 | status: "unauthenticated", | ||
| 253 | detail: | ||
| 254 | firstSafeAuthLine(combinedAuthOutput(input)) ?? "Run `az login` to authenticate Azure CLI.", | ||
| 255 | }); | ||
| 256 | } | ||
| 257 | |||
| 258 | if (account && account.length > 0) { | ||
| 259 | return providerAuth({ status: "authenticated", account, host: "dev.azure.com" }); | ||
| 260 | } | ||
| 261 | |||
| 262 | return providerAuth({ | ||
| 263 | status: "unknown", | ||
| 264 | host: "dev.azure.com", | ||
| 265 | detail: "Azure CLI account status could not be parsed.", | ||
| 266 | }); | ||
| 267 | } | ||
| 268 | |||
| 269 | function parseBitbucketAuth(input: AuthProbeInput): SourceControlProviderAuth { | ||
| 270 | const output = combinedAuthOutput(input); | ||
| 271 | const account = matchFirst(output, [ | ||
| 272 | /Logged in to .* as\s+([^\s(]+)/iu, | ||
| 273 | /Logged in as\s+([^\s(]+)/iu, | ||
| 274 | /account:\s*([^\s(]+)/iu, | ||
| 275 | /user:\s*([^\s(]+)/iu, | ||
| 276 | /username:\s*([^\s(]+)/iu, | ||
| 277 | ]); | ||
| 278 | const host = parseCliHost(output) ?? "bitbucket.org"; | ||
| 279 | |||
| 280 | if (input.exitCode !== 0) { | ||
| 281 | return providerAuth({ | ||
| 282 | status: "unauthenticated", | ||
| 283 | host, | ||
| 284 | detail: | ||
| 285 | firstSafeAuthLine(output) ?? "Authenticate the Bitbucket CLI before enabling Bitbucket.", | ||
| 286 | }); | ||
| 287 | } | ||
| 288 | |||
| 289 | if (account) { | ||
| 290 | return providerAuth({ status: "authenticated", account, host }); | ||
| 291 | } | ||
| 292 | |||
| 293 | return providerAuth({ | ||
| 294 | status: "unknown", | ||
| 295 | host, | ||
| 296 | detail: firstSafeAuthLine(output) ?? "Bitbucket CLI auth status could not be parsed.", | ||
| 297 | }); | ||
| 298 | } | ||
| 299 | |||
| 300 | export interface SourceControlDiscoveryShape { | ||
| 301 | readonly discover: Effect.Effect<SourceControlDiscoveryResult>; | ||
| 302 | } | ||
| 303 | |||
| 304 | export class SourceControlDiscovery extends Context.Service< | ||
| 305 | SourceControlDiscovery, | ||
| 306 | SourceControlDiscoveryShape | ||
| 307 | >()("t3/source-control/SourceControlDiscovery") {} | ||
| 308 | |||
| 309 | export const layer = Layer.effect( | ||
| 310 | SourceControlDiscovery, | ||
| 311 | Effect.gen(function* () { | ||
| 312 | const config = yield* ServerConfig; | ||
| 313 | const process = yield* VcsProcess.VcsProcess; | ||
| 314 | |||
| 315 | const probe = <Kind extends VcsDriverKind | SourceControlProviderKind>( | ||
| 316 | input: DiscoveryProbe & { readonly kind: Kind }, | ||
| 317 | ): Effect.Effect<DiscoveryProbeResult<Kind>> => | ||
| 318 | process | ||
| 319 | .run({ | ||
| 320 | operation: "source-control.discovery.probe", | ||
| 321 | command: input.executable, | ||
| 322 | args: input.versionArgs, | ||
| 323 | cwd: config.cwd, | ||
| 324 | timeoutMs: 5_000, | ||
| 325 | maxOutputBytes: 8_000, | ||
| 326 | truncateOutputAtMaxBytes: true, | ||
| 327 | }) | ||
| 328 | .pipe( | ||
| 329 | Effect.map( | ||
| 330 | (result) => | ||
| 331 | ({ | ||
| 332 | kind: input.kind, | ||
| 333 | label: input.label, | ||
| 334 | executable: input.executable, | ||
| 335 | implemented: input.implemented, | ||
| 336 | status: "available" as const, | ||
| 337 | version: Option.orElse(firstNonEmptyLine(result.stdout), () => | ||
| 338 | firstNonEmptyLine(result.stderr), | ||
| 339 | ), | ||
| 340 | installHint: input.installHint, | ||
| 341 | detail: Option.none<string>(), | ||
| 342 | }) satisfies DiscoveryProbeResult<Kind>, | ||
| 343 | ), | ||
| 344 | Effect.catch((cause) => | ||
| 345 | Effect.succeed({ | ||
| 346 | kind: input.kind, | ||
| 347 | label: input.label, | ||
| 348 | executable: input.executable, | ||
| 349 | implemented: input.implemented, | ||
| 350 | status: "missing" as const, | ||
| 351 | version: Option.none<string>(), | ||
| 352 | installHint: input.installHint, | ||
| 353 | detail: detailFromCause(cause), | ||
| 354 | } satisfies DiscoveryProbeResult<Kind>), | ||
| 355 | ), | ||
| 356 | ); | ||
| 357 | |||
| 358 | const probeProvider = (input: ProviderProbe) => | ||
| 359 | probe(input).pipe( | ||
| 360 | Effect.flatMap((item) => { | ||
| 361 | if (item.status !== "available") { | ||
| 362 | return Effect.succeed({ | ||
| 363 | ...item, | ||
| 364 | auth: unknownAuth("CLI is not installed."), | ||
| 365 | } satisfies SourceControlProviderDiscoveryItem); | ||
| 366 | } | ||
| 367 | |||
| 368 | return process | ||
| 369 | .run({ | ||
| 370 | operation: "source-control.discovery.auth", | ||
| 371 | command: input.executable, | ||
| 372 | args: input.authArgs, | ||
| 373 | cwd: config.cwd, | ||
| 374 | allowNonZeroExit: true, | ||
| 375 | timeoutMs: 5_000, | ||
| 376 | maxOutputBytes: 8_000, | ||
| 377 | truncateOutputAtMaxBytes: true, | ||
| 378 | }) | ||
| 379 | .pipe( | ||
| 380 | Effect.map( | ||
| 381 | (result) => | ||
| 382 | ({ | ||
| 383 | ...item, | ||
| 384 | auth: input.parseAuth(result), | ||
| 385 | }) satisfies SourceControlProviderDiscoveryItem, | ||
| 386 | ), | ||
| 387 | Effect.catch((cause) => | ||
| 388 | Effect.succeed({ | ||
| 389 | ...item, | ||
| 390 | auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))), | ||
| 391 | } satisfies SourceControlProviderDiscoveryItem), | ||
| 392 | ), | ||
| 393 | ); | ||
| 394 | }), | ||
| 395 | ); | ||
| 396 | |||
| 397 | return SourceControlDiscovery.of({ | ||
| 398 | discover: Effect.all({ | ||
| 399 | versionControlSystems: Effect.all( | ||
| 400 | VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray<Effect.Effect<VcsDiscoveryItem>>, | ||
| 401 | { concurrency: "unbounded" }, | ||
| 402 | ), | ||
| 403 | sourceControlProviders: Effect.all( | ||
| 404 | SOURCE_CONTROL_PROVIDER_PROBES.map((entry) => probeProvider(entry)) as ReadonlyArray< | ||
| 405 | Effect.Effect<SourceControlProviderDiscoveryItem> | ||
| 406 | >, | ||
| 407 | { concurrency: "unbounded" }, | ||
| 408 | ), | ||
| 409 | }), | ||
| 410 | }); | ||
| 411 | }), | ||
| 412 | ); | ||
apps/server/src/sourceControl/SourceControlProvider.ts created+68| ... | @@ -0,0 +1,68 @@ | ||
| 1 | import { Context, Effect } from "effect"; | ||
| 2 | import type { | ||
| 3 | ChangeRequest, | ||
| 4 | ChangeRequestState, | ||
| 5 | SourceControlProviderError, | ||
| 6 | SourceControlProviderInfo, | ||
| 7 | SourceControlProviderKind, | ||
| 8 | SourceControlRepositoryCloneUrls, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | |||
| 11 | export interface SourceControlProviderContext { | ||
| 12 | readonly provider: SourceControlProviderInfo; | ||
| 13 | readonly remoteName: string; | ||
| 14 | readonly remoteUrl: string; | ||
| 15 | } | ||
| 16 | |||
| 17 | export interface SourceControlRefSelector { | ||
| 18 | readonly refName: string; | ||
| 19 | readonly owner?: string; | ||
| 20 | readonly repository?: string; | ||
| 21 | } | ||
| 22 | |||
| 23 | export interface SourceControlProviderShape { | ||
| 24 | readonly kind: SourceControlProviderKind; | ||
| 25 | readonly listChangeRequests: (input: { | ||
| 26 | readonly cwd: string; | ||
| 27 | readonly context?: SourceControlProviderContext; | ||
| 28 | readonly source?: SourceControlRefSelector; | ||
| 29 | readonly headSelector: string; | ||
| 30 | readonly state: ChangeRequestState | "all"; | ||
| 31 | readonly limit?: number; | ||
| 32 | }) => Effect.Effect<ReadonlyArray<ChangeRequest>, SourceControlProviderError>; | ||
| 33 | readonly getChangeRequest: (input: { | ||
| 34 | readonly cwd: string; | ||
| 35 | readonly context?: SourceControlProviderContext; | ||
| 36 | readonly reference: string; | ||
| 37 | }) => Effect.Effect<ChangeRequest, SourceControlProviderError>; | ||
| 38 | readonly createChangeRequest: (input: { | ||
| 39 | readonly cwd: string; | ||
| 40 | readonly context?: SourceControlProviderContext; | ||
| 41 | readonly source?: SourceControlRefSelector; | ||
| 42 | readonly target?: SourceControlRefSelector; | ||
| 43 | readonly baseRefName: string; | ||
| 44 | readonly headSelector: string; | ||
| 45 | readonly title: string; | ||
| 46 | readonly bodyFile: string; | ||
| 47 | }) => Effect.Effect<void, SourceControlProviderError>; | ||
| 48 | readonly getRepositoryCloneUrls: (input: { | ||
| 49 | readonly cwd: string; | ||
| 50 | readonly context?: SourceControlProviderContext; | ||
| 51 | readonly repository: string; | ||
| 52 | }) => Effect.Effect<SourceControlRepositoryCloneUrls, SourceControlProviderError>; | ||
| 53 | readonly getDefaultBranch: (input: { | ||
| 54 | readonly cwd: string; | ||
| 55 | readonly context?: SourceControlProviderContext; | ||
| 56 | }) => Effect.Effect<string | null, SourceControlProviderError>; | ||
| 57 | readonly checkoutChangeRequest: (input: { | ||
| 58 | readonly cwd: string; | ||
| 59 | readonly context?: SourceControlProviderContext; | ||
| 60 | readonly reference: string; | ||
| 61 | readonly force?: boolean; | ||
| 62 | }) => Effect.Effect<void, SourceControlProviderError>; | ||
| 63 | } | ||
| 64 | |||
| 65 | export class SourceControlProvider extends Context.Service< | ||
| 66 | SourceControlProvider, | ||
| 67 | SourceControlProviderShape | ||
| 68 | >()("t3/source-control/SourceControlProvider") {} | ||
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts created+114| ... | @@ -0,0 +1,114 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import { DateTime, Effect, Layer, Option } from "effect"; | ||
| 3 | |||
| 4 | import { GitHubCli } from "./GitHubCli.ts"; | ||
| 5 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; | ||
| 6 | import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; | ||
| 7 | import type { VcsDriverShape } from "../vcs/VcsDriver.ts"; | ||
| 8 | |||
| 9 | const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); | ||
| 10 | |||
| 11 | function makeRegistry(input: { | ||
| 12 | readonly remotes: ReadonlyArray<{ | ||
| 13 | readonly name: string; | ||
| 14 | readonly url: string; | ||
| 15 | }>; | ||
| 16 | }) { | ||
| 17 | const driver = { | ||
| 18 | listRemotes: () => | ||
| 19 | Effect.succeed({ | ||
| 20 | remotes: input.remotes.map((remote) => ({ | ||
| 21 | ...remote, | ||
| 22 | pushUrl: Option.none(), | ||
| 23 | isPrimary: remote.name === "origin", | ||
| 24 | })), | ||
| 25 | freshness: { | ||
| 26 | source: "live-local" as const, | ||
| 27 | observedAt: TEST_EPOCH, | ||
| 28 | expiresAt: Option.none(), | ||
| 29 | }, | ||
| 30 | }), | ||
| 31 | } satisfies Partial<VcsDriverShape>; | ||
| 32 | |||
| 33 | const registryLayer = Layer.mock(VcsDriverRegistry)({ | ||
| 34 | get: () => Effect.succeed(driver as unknown as VcsDriverShape), | ||
| 35 | resolve: () => | ||
| 36 | Effect.succeed({ | ||
| 37 | kind: "git", | ||
| 38 | repository: { | ||
| 39 | kind: "git", | ||
| 40 | rootPath: "/repo", | ||
| 41 | metadataPath: null, | ||
| 42 | freshness: { | ||
| 43 | source: "live-local" as const, | ||
| 44 | observedAt: TEST_EPOCH, | ||
| 45 | expiresAt: Option.none(), | ||
| 46 | }, | ||
| 47 | }, | ||
| 48 | driver: driver as unknown as VcsDriverShape, | ||
| 49 | }), | ||
| 50 | }); | ||
| 51 | |||
| 52 | return SourceControlProviderRegistry.make().pipe( | ||
| 53 | Effect.provide(Layer.mergeAll(registryLayer, Layer.mock(GitHubCli)({}))), | ||
| 54 | ); | ||
| 55 | } | ||
| 56 | |||
| 57 | it.effect("routes GitHub remotes to the GitHub provider", () => | ||
| 58 | Effect.gen(function* () { | ||
| 59 | const registry = yield* makeRegistry({ | ||
| 60 | remotes: [{ name: "origin", url: "git@github.com:pingdotgg/t3code.git" }], | ||
| 61 | }); | ||
| 62 | |||
| 63 | const provider = yield* registry.resolve({ cwd: "/repo" }); | ||
| 64 | |||
| 65 | assert.strictEqual(provider.kind, "github"); | ||
| 66 | }), | ||
| 67 | ); | ||
| 68 | |||
| 69 | it.effect("routes directly by provider kind for remote-first workflows", () => | ||
| 70 | Effect.gen(function* () { | ||
| 71 | const registry = yield* makeRegistry({ | ||
| 72 | remotes: [], | ||
| 73 | }); | ||
| 74 | |||
| 75 | const provider = yield* registry.get("github"); | ||
| 76 | |||
| 77 | assert.strictEqual(provider.kind, "github"); | ||
| 78 | }), | ||
| 79 | ); | ||
| 80 | |||
| 81 | it.effect( | ||
| 82 | "detects GitLab remotes and returns an unsupported provider until one is registered", | ||
| 83 | () => | ||
| 84 | Effect.gen(function* () { | ||
| 85 | const registry = yield* makeRegistry({ | ||
| 86 | remotes: [{ name: "origin", url: "git@gitlab.com:group/project.git" }], | ||
| 87 | }); | ||
| 88 | |||
| 89 | const provider = yield* registry.resolve({ cwd: "/repo" }); | ||
| 90 | |||
| 91 | assert.strictEqual(provider.kind, "gitlab"); | ||
| 92 | const error = yield* Effect.flip( | ||
| 93 | provider.listChangeRequests({ | ||
| 94 | cwd: "/repo", | ||
| 95 | headSelector: "feature/source-control", | ||
| 96 | state: "open", | ||
| 97 | }), | ||
| 98 | ); | ||
| 99 | |||
| 100 | assert.strictEqual(error.provider, "gitlab"); | ||
| 101 | }), | ||
| 102 | ); | ||
| 103 | |||
| 104 | it.effect("falls back to a non-origin remote when origin is not configured", () => | ||
| 105 | Effect.gen(function* () { | ||
| 106 | const registry = yield* makeRegistry({ | ||
| 107 | remotes: [{ name: "upstream", url: "https://dev.azure.com/acme/project/_git/repo" }], | ||
| 108 | }); | ||
| 109 | |||
| 110 | const provider = yield* registry.resolve({ cwd: "/repo" }); | ||
| 111 | |||
| 112 | assert.strictEqual(provider.kind, "azure-devops"); | ||
| 113 | }), | ||
| 114 | ); | ||
apps/server/src/sourceControl/SourceControlProviderRegistry.ts created+162| ... | @@ -0,0 +1,162 @@ | ||
| 1 | import { Cache, Context, Duration, Effect, Exit, Layer } from "effect"; | ||
| 2 | import { SourceControlProviderError } from "@t3tools/contracts"; | ||
| 3 | import type { SourceControlProviderKind } from "@t3tools/contracts"; | ||
| 4 | import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; | ||
| 5 | |||
| 6 | import { | ||
| 7 | SourceControlProvider, | ||
| 8 | type SourceControlProviderContext, | ||
| 9 | type SourceControlProviderShape, | ||
| 10 | } from "./SourceControlProvider.ts"; | ||
| 11 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; | ||
| 12 | import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; | ||
| 13 | |||
| 14 | const PROVIDER_DETECTION_CACHE_CAPACITY = 2_048; | ||
| 15 | const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5); | ||
| 16 | |||
| 17 | export interface SourceControlProviderRegistration { | ||
| 18 | readonly kind: SourceControlProviderKind; | ||
| 19 | readonly provider: SourceControlProviderShape; | ||
| 20 | } | ||
| 21 | |||
| 22 | export interface SourceControlProviderHandle { | ||
| 23 | readonly provider: SourceControlProviderShape; | ||
| 24 | readonly context: SourceControlProviderContext | null; | ||
| 25 | } | ||
| 26 | |||
| 27 | export interface SourceControlProviderRegistryShape { | ||
| 28 | readonly get: ( | ||
| 29 | kind: SourceControlProviderKind, | ||
| 30 | ) => Effect.Effect<SourceControlProviderShape, SourceControlProviderError>; | ||
| 31 | readonly resolveHandle: (input: { | ||
| 32 | readonly cwd: string; | ||
| 33 | }) => Effect.Effect<SourceControlProviderHandle, SourceControlProviderError>; | ||
| 34 | readonly resolve: (input: { | ||
| 35 | readonly cwd: string; | ||
| 36 | }) => Effect.Effect<SourceControlProviderShape, SourceControlProviderError>; | ||
| 37 | } | ||
| 38 | |||
| 39 | export class SourceControlProviderRegistry extends Context.Service< | ||
| 40 | SourceControlProviderRegistry, | ||
| 41 | SourceControlProviderRegistryShape | ||
| 42 | >()("t3/source-control/SourceControlProviderRegistry") {} | ||
| 43 | |||
| 44 | function unsupportedProvider(kind: SourceControlProviderKind): SourceControlProviderShape { | ||
| 45 | const unsupported = (operation: string) => | ||
| 46 | Effect.fail( | ||
| 47 | new SourceControlProviderError({ | ||
| 48 | provider: kind, | ||
| 49 | operation, | ||
| 50 | detail: `No ${kind} source control provider is registered.`, | ||
| 51 | }), | ||
| 52 | ); | ||
| 53 | |||
| 54 | return SourceControlProvider.of({ | ||
| 55 | kind, | ||
| 56 | listChangeRequests: () => unsupported("listChangeRequests"), | ||
| 57 | getChangeRequest: () => unsupported("getChangeRequest"), | ||
| 58 | createChangeRequest: () => unsupported("createChangeRequest"), | ||
| 59 | getRepositoryCloneUrls: () => unsupported("getRepositoryCloneUrls"), | ||
| 60 | getDefaultBranch: () => unsupported("getDefaultBranch"), | ||
| 61 | checkoutChangeRequest: () => unsupported("checkoutChangeRequest"), | ||
| 62 | }); | ||
| 63 | } | ||
| 64 | |||
| 65 | function providerDetectionError(operation: string, cwd: string, cause: unknown) { | ||
| 66 | return new SourceControlProviderError({ | ||
| 67 | provider: "unknown", | ||
| 68 | operation, | ||
| 69 | detail: `Failed to detect source control provider for ${cwd}.`, | ||
| 70 | cause, | ||
| 71 | }); | ||
| 72 | } | ||
| 73 | |||
| 74 | function selectProviderContext( | ||
| 75 | remotes: ReadonlyArray<{ | ||
| 76 | readonly name: string; | ||
| 77 | readonly url: string; | ||
| 78 | }>, | ||
| 79 | ): SourceControlProviderContext | null { | ||
| 80 | const candidates = remotes | ||
| 81 | .map((remote) => { | ||
| 82 | const provider = detectSourceControlProviderFromRemoteUrl(remote.url); | ||
| 83 | return provider | ||
| 84 | ? { | ||
| 85 | provider, | ||
| 86 | remoteName: remote.name, | ||
| 87 | remoteUrl: remote.url, | ||
| 88 | } | ||
| 89 | : null; | ||
| 90 | }) | ||
| 91 | .filter((value): value is SourceControlProviderContext => value !== null); | ||
| 92 | |||
| 93 | return ( | ||
| 94 | candidates.find((candidate) => candidate.remoteName === "origin") ?? | ||
| 95 | candidates.find((candidate) => candidate.provider.kind !== "unknown") ?? | ||
| 96 | candidates[0] ?? | ||
| 97 | null | ||
| 98 | ); | ||
| 99 | } | ||
| 100 | |||
| 101 | export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWithProviders")( | ||
| 102 | function* (registrations: ReadonlyArray<SourceControlProviderRegistration>) { | ||
| 103 | const vcsRegistry = yield* VcsDriverRegistry; | ||
| 104 | const providers = new Map<SourceControlProviderKind, SourceControlProviderShape>( | ||
| 105 | registrations.map((registration) => [registration.kind, registration.provider]), | ||
| 106 | ); | ||
| 107 | |||
| 108 | const get: SourceControlProviderRegistryShape["get"] = (kind) => | ||
| 109 | Effect.succeed(providers.get(kind) ?? unsupportedProvider(kind)); | ||
| 110 | |||
| 111 | const detectProviderContext = Effect.fn("SourceControlProviderRegistry.detectProviderContext")( | ||
| 112 | function* (cwd: string) { | ||
| 113 | const handle = yield* vcsRegistry | ||
| 114 | .resolve({ cwd }) | ||
| 115 | .pipe(Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error))); | ||
| 116 | const remotes = yield* handle.driver | ||
| 117 | .listRemotes(cwd) | ||
| 118 | .pipe(Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error))); | ||
| 119 | |||
| 120 | return selectProviderContext(remotes.remotes); | ||
| 121 | }, | ||
| 122 | ); | ||
| 123 | |||
| 124 | const providerContextCache = yield* Cache.makeWith< | ||
| 125 | string, | ||
| 126 | SourceControlProviderContext | null, | ||
| 127 | SourceControlProviderError | ||
| 128 | >(detectProviderContext, { | ||
| 129 | capacity: PROVIDER_DETECTION_CACHE_CAPACITY, | ||
| 130 | timeToLive: (exit) => (Exit.isSuccess(exit) ? PROVIDER_DETECTION_CACHE_TTL : Duration.zero), | ||
| 131 | }); | ||
| 132 | |||
| 133 | const resolveHandle: SourceControlProviderRegistryShape["resolveHandle"] = (input) => | ||
| 134 | Cache.get(providerContextCache, input.cwd).pipe( | ||
| 135 | Effect.map((context) => { | ||
| 136 | const kind = context?.provider.kind ?? "unknown"; | ||
| 137 | return { | ||
| 138 | provider: providers.get(kind) ?? unsupportedProvider(kind), | ||
| 139 | context, | ||
| 140 | } satisfies SourceControlProviderHandle; | ||
| 141 | }), | ||
| 142 | ); | ||
| 143 | |||
| 144 | return SourceControlProviderRegistry.of({ | ||
| 145 | get, | ||
| 146 | resolveHandle, | ||
| 147 | resolve: (input) => resolveHandle(input).pipe(Effect.map((handle) => handle.provider)), | ||
| 148 | }); | ||
| 149 | }, | ||
| 150 | ); | ||
| 151 | |||
| 152 | export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () { | ||
| 153 | const github = yield* GitHubSourceControlProvider.make(); | ||
| 154 | return yield* makeWithProviders([ | ||
| 155 | { | ||
| 156 | kind: "github", | ||
| 157 | provider: github, | ||
| 158 | }, | ||
| 159 | ]); | ||
| 160 | }); | ||
| 161 | |||
| 162 | export const layer = Layer.effect(SourceControlProviderRegistry, make()); | ||
apps/server/src/textGeneration/ClaudeTextGeneration.test.ts created+339| ... | @@ -0,0 +1,339 @@ | ||
| 1 | import { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts"; | ||
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 3 | import { it } from "@effect/vitest"; | ||
| 4 | import { Effect, FileSystem, Layer, Path, Schema } from "effect"; | ||
| 5 | import { createModelSelection } from "@t3tools/shared/model"; | ||
| 6 | import { expect } from "vitest"; | ||
| 7 | |||
| 8 | import { ServerConfig } from "../config.ts"; | ||
| 9 | import { type TextGenerationShape } from "./TextGeneration.ts"; | ||
| 10 | import { sanitizeThreadTitle } from "./TextGenerationUtils.ts"; | ||
| 11 | import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts"; | ||
| 12 | |||
| 13 | const ClaudeTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { | ||
| 14 | prefix: "t3code-claude-text-generation-test-", | ||
| 15 | }).pipe(Layer.provideMerge(NodeServices.layer)); | ||
| 16 | |||
| 17 | function makeFakeClaudeBinary(dir: string) { | ||
| 18 | return Effect.gen(function* () { | ||
| 19 | const fs = yield* FileSystem.FileSystem; | ||
| 20 | const path = yield* Path.Path; | ||
| 21 | const binDir = path.join(dir, "bin"); | ||
| 22 | const claudePath = path.join(binDir, "claude"); | ||
| 23 | yield* fs.makeDirectory(binDir, { recursive: true }); | ||
| 24 | |||
| 25 | yield* fs.writeFileString( | ||
| 26 | claudePath, | ||
| 27 | [ | ||
| 28 | "#!/bin/sh", | ||
| 29 | 'args="$*"', | ||
| 30 | 'stdin_content="$(cat)"', | ||
| 31 | 'if [ -n "$T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN" ]; then', | ||
| 32 | ' printf "%s" "$args" | grep -F -- "$T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN" >/dev/null || {', | ||
| 33 | ' printf "%s\\n" "args missing expected content" >&2', | ||
| 34 | " exit 2", | ||
| 35 | " }", | ||
| 36 | "fi", | ||
| 37 | 'if [ -n "$T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN" ]; then', | ||
| 38 | ' if printf "%s" "$args" | grep -F -- "$T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN" >/dev/null; then', | ||
| 39 | ' printf "%s\\n" "args contained forbidden content" >&2', | ||
| 40 | " exit 3", | ||
| 41 | " fi", | ||
| 42 | "fi", | ||
| 43 | 'if [ -n "$T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN" ]; then', | ||
| 44 | ' printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN" >/dev/null || {', | ||
| 45 | ' printf "%s\\n" "stdin missing expected content" >&2', | ||
| 46 | " exit 4", | ||
| 47 | " }", | ||
| 48 | "fi", | ||
| 49 | 'if [ -n "$T3_FAKE_CLAUDE_HOME_MUST_BE" ] && [ "$HOME" != "$T3_FAKE_CLAUDE_HOME_MUST_BE" ]; then', | ||
| 50 | ' printf "%s\\n" "HOME was $HOME" >&2', | ||
| 51 | " exit 5", | ||
| 52 | "fi", | ||
| 53 | 'if [ -n "$T3_FAKE_CLAUDE_STDERR" ]; then', | ||
| 54 | ' printf "%s\\n" "$T3_FAKE_CLAUDE_STDERR" >&2', | ||
| 55 | "fi", | ||
| 56 | 'printf "%s" "$T3_FAKE_CLAUDE_OUTPUT"', | ||
| 57 | 'exit "${T3_FAKE_CLAUDE_EXIT_CODE:-0}"', | ||
| 58 | "", | ||
| 59 | ].join("\n"), | ||
| 60 | ); | ||
| 61 | yield* fs.chmod(claudePath, 0o755); | ||
| 62 | return binDir; | ||
| 63 | }); | ||
| 64 | } | ||
| 65 | |||
| 66 | function withFakeClaudeEnv<A, E, R>( | ||
| 67 | input: { | ||
| 68 | output: string; | ||
| 69 | exitCode?: number; | ||
| 70 | stderr?: string; | ||
| 71 | argsMustContain?: string; | ||
| 72 | argsMustNotContain?: string; | ||
| 73 | stdinMustContain?: string; | ||
| 74 | homeMustBe?: string; | ||
| 75 | claudeConfig?: Partial<ClaudeSettings>; | ||
| 76 | }, | ||
| 77 | effectFn: (textGeneration: TextGenerationShape) => Effect.Effect<A, E, R>, | ||
| 78 | ) { | ||
| 79 | return Effect.gen(function* () { | ||
| 80 | const fs = yield* FileSystem.FileSystem; | ||
| 81 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-claude-text-" }); | ||
| 82 | const binDir = yield* makeFakeClaudeBinary(tempDir); | ||
| 83 | const previousPath = process.env.PATH; | ||
| 84 | const previousOutput = process.env.T3_FAKE_CLAUDE_OUTPUT; | ||
| 85 | const previousExitCode = process.env.T3_FAKE_CLAUDE_EXIT_CODE; | ||
| 86 | const previousStderr = process.env.T3_FAKE_CLAUDE_STDERR; | ||
| 87 | const previousArgsMustContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; | ||
| 88 | const previousArgsMustNotContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; | ||
| 89 | const previousStdinMustContain = process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; | ||
| 90 | const previousHomeMustBe = process.env.T3_FAKE_CLAUDE_HOME_MUST_BE; | ||
| 91 | |||
| 92 | yield* Effect.acquireRelease( | ||
| 93 | Effect.sync(() => { | ||
| 94 | process.env.PATH = `${binDir}:${previousPath ?? ""}`; | ||
| 95 | process.env.T3_FAKE_CLAUDE_OUTPUT = input.output; | ||
| 96 | |||
| 97 | if (input.exitCode !== undefined) { | ||
| 98 | process.env.T3_FAKE_CLAUDE_EXIT_CODE = String(input.exitCode); | ||
| 99 | } else { | ||
| 100 | delete process.env.T3_FAKE_CLAUDE_EXIT_CODE; | ||
| 101 | } | ||
| 102 | |||
| 103 | if (input.stderr !== undefined) { | ||
| 104 | process.env.T3_FAKE_CLAUDE_STDERR = input.stderr; | ||
| 105 | } else { | ||
| 106 | delete process.env.T3_FAKE_CLAUDE_STDERR; | ||
| 107 | } | ||
| 108 | |||
| 109 | if (input.argsMustContain !== undefined) { | ||
| 110 | process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = input.argsMustContain; | ||
| 111 | } else { | ||
| 112 | delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; | ||
| 113 | } | ||
| 114 | |||
| 115 | if (input.argsMustNotContain !== undefined) { | ||
| 116 | process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = input.argsMustNotContain; | ||
| 117 | } else { | ||
| 118 | delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; | ||
| 119 | } | ||
| 120 | |||
| 121 | if (input.stdinMustContain !== undefined) { | ||
| 122 | process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = input.stdinMustContain; | ||
| 123 | } else { | ||
| 124 | delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; | ||
| 125 | } | ||
| 126 | |||
| 127 | if (input.homeMustBe !== undefined) { | ||
| 128 | process.env.T3_FAKE_CLAUDE_HOME_MUST_BE = input.homeMustBe; | ||
| 129 | } else { | ||
| 130 | delete process.env.T3_FAKE_CLAUDE_HOME_MUST_BE; | ||
| 131 | } | ||
| 132 | }), | ||
| 133 | () => | ||
| 134 | Effect.sync(() => { | ||
| 135 | process.env.PATH = previousPath; | ||
| 136 | |||
| 137 | if (previousOutput === undefined) { | ||
| 138 | delete process.env.T3_FAKE_CLAUDE_OUTPUT; | ||
| 139 | } else { | ||
| 140 | process.env.T3_FAKE_CLAUDE_OUTPUT = previousOutput; | ||
| 141 | } | ||
| 142 | |||
| 143 | if (previousExitCode === undefined) { | ||
| 144 | delete process.env.T3_FAKE_CLAUDE_EXIT_CODE; | ||
| 145 | } else { | ||
| 146 | process.env.T3_FAKE_CLAUDE_EXIT_CODE = previousExitCode; | ||
| 147 | } | ||
| 148 | |||
| 149 | if (previousStderr === undefined) { | ||
| 150 | delete process.env.T3_FAKE_CLAUDE_STDERR; | ||
| 151 | } else { | ||
| 152 | process.env.T3_FAKE_CLAUDE_STDERR = previousStderr; | ||
| 153 | } | ||
| 154 | |||
| 155 | if (previousArgsMustContain === undefined) { | ||
| 156 | delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; | ||
| 157 | } else { | ||
| 158 | process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = previousArgsMustContain; | ||
| 159 | } | ||
| 160 | |||
| 161 | if (previousArgsMustNotContain === undefined) { | ||
| 162 | delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; | ||
| 163 | } else { | ||
| 164 | process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = previousArgsMustNotContain; | ||
| 165 | } | ||
| 166 | |||
| 167 | if (previousStdinMustContain === undefined) { | ||
| 168 | delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; | ||
| 169 | } else { | ||
| 170 | process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = previousStdinMustContain; | ||
| 171 | } | ||
| 172 | |||
| 173 | if (previousHomeMustBe === undefined) { | ||
| 174 | delete process.env.T3_FAKE_CLAUDE_HOME_MUST_BE; | ||
| 175 | } else { | ||
| 176 | process.env.T3_FAKE_CLAUDE_HOME_MUST_BE = previousHomeMustBe; | ||
| 177 | } | ||
| 178 | }), | ||
| 179 | ); | ||
| 180 | |||
| 181 | const config = Schema.decodeSync(ClaudeSettings)(input.claudeConfig ?? {}); | ||
| 182 | const textGeneration = yield* makeClaudeTextGeneration(config); | ||
| 183 | return yield* effectFn(textGeneration); | ||
| 184 | }).pipe(Effect.scoped); | ||
| 185 | } | ||
| 186 | |||
| 187 | it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { | ||
| 188 | it.effect("forwards Claude thinking settings for Haiku without passing effort", () => | ||
| 189 | withFakeClaudeEnv( | ||
| 190 | { | ||
| 191 | output: JSON.stringify({ | ||
| 192 | structured_output: { | ||
| 193 | subject: "Add important change", | ||
| 194 | body: "", | ||
| 195 | }, | ||
| 196 | }), | ||
| 197 | argsMustContain: '--settings {"alwaysThinkingEnabled":false}', | ||
| 198 | argsMustNotContain: "--effort", | ||
| 199 | }, | ||
| 200 | (textGeneration) => | ||
| 201 | Effect.gen(function* () { | ||
| 202 | const generated = yield* textGeneration.generateCommitMessage({ | ||
| 203 | cwd: process.cwd(), | ||
| 204 | branch: "feature/claude-effect", | ||
| 205 | stagedSummary: "M README.md", | ||
| 206 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 207 | modelSelection: { | ||
| 208 | ...createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-haiku-4-5", [ | ||
| 209 | { id: "thinking", value: false }, | ||
| 210 | { id: "effort", value: "high" }, | ||
| 211 | ]), | ||
| 212 | }, | ||
| 213 | }); | ||
| 214 | |||
| 215 | expect(generated.subject).toBe("Add important change"); | ||
| 216 | }), | ||
| 217 | ), | ||
| 218 | ); | ||
| 219 | |||
| 220 | it.effect("forwards Claude fast mode and supported effort", () => | ||
| 221 | withFakeClaudeEnv( | ||
| 222 | { | ||
| 223 | output: JSON.stringify({ | ||
| 224 | structured_output: { | ||
| 225 | title: "Improve orchestration flow", | ||
| 226 | body: "Body", | ||
| 227 | }, | ||
| 228 | }), | ||
| 229 | argsMustContain: '--effort max --settings {"fastMode":true}', | ||
| 230 | }, | ||
| 231 | (textGeneration) => | ||
| 232 | Effect.gen(function* () { | ||
| 233 | const generated = yield* textGeneration.generatePrContent({ | ||
| 234 | cwd: process.cwd(), | ||
| 235 | baseBranch: "main", | ||
| 236 | headBranch: "feature/claude-effect", | ||
| 237 | commitSummary: "Improve orchestration", | ||
| 238 | diffSummary: "1 file changed", | ||
| 239 | diffPatch: "diff --git a/README.md b/README.md", | ||
| 240 | modelSelection: { | ||
| 241 | ...createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-opus-4-6", [ | ||
| 242 | { id: "effort", value: "max" }, | ||
| 243 | { id: "fastMode", value: true }, | ||
| 244 | ]), | ||
| 245 | }, | ||
| 246 | }); | ||
| 247 | |||
| 248 | expect(generated.title).toBe("Improve orchestration flow"); | ||
| 249 | }), | ||
| 250 | ), | ||
| 251 | ); | ||
| 252 | |||
| 253 | it.effect("generates thread titles through the Claude provider", () => | ||
| 254 | withFakeClaudeEnv( | ||
| 255 | { | ||
| 256 | output: JSON.stringify({ | ||
| 257 | structured_output: { | ||
| 258 | title: | ||
| 259 | ' "Reconnect failures after restart because the session state does not recover" ', | ||
| 260 | }, | ||
| 261 | }), | ||
| 262 | stdinMustContain: "You write concise thread titles for coding conversations.", | ||
| 263 | }, | ||
| 264 | (textGeneration) => | ||
| 265 | Effect.gen(function* () { | ||
| 266 | const generated = yield* textGeneration.generateThreadTitle({ | ||
| 267 | cwd: process.cwd(), | ||
| 268 | message: "Please investigate reconnect failures after restarting the session.", | ||
| 269 | modelSelection: { | ||
| 270 | instanceId: ProviderInstanceId.make("claudeAgent"), | ||
| 271 | model: "claude-sonnet-4-6", | ||
| 272 | }, | ||
| 273 | }); | ||
| 274 | |||
| 275 | expect(generated.title).toBe( | ||
| 276 | sanitizeThreadTitle( | ||
| 277 | '"Reconnect failures after restart because the session state does not recover"', | ||
| 278 | ), | ||
| 279 | ); | ||
| 280 | }), | ||
| 281 | ), | ||
| 282 | ); | ||
| 283 | |||
| 284 | it.effect("runs Claude text generation with the configured Claude HOME", () => | ||
| 285 | Effect.gen(function* () { | ||
| 286 | const path = yield* Path.Path; | ||
| 287 | const claudeHome = path.join(process.cwd(), ".claude-work-test"); | ||
| 288 | return yield* withFakeClaudeEnv( | ||
| 289 | { | ||
| 290 | output: JSON.stringify({ | ||
| 291 | structured_output: { | ||
| 292 | title: "Use Claude home", | ||
| 293 | }, | ||
| 294 | }), | ||
| 295 | homeMustBe: claudeHome, | ||
| 296 | claudeConfig: { homePath: claudeHome }, | ||
| 297 | }, | ||
| 298 | (textGeneration) => | ||
| 299 | Effect.gen(function* () { | ||
| 300 | const generated = yield* textGeneration.generateThreadTitle({ | ||
| 301 | cwd: process.cwd(), | ||
| 302 | message: "thread title", | ||
| 303 | modelSelection: { | ||
| 304 | instanceId: ProviderInstanceId.make("claudeAgent"), | ||
| 305 | model: "claude-sonnet-4-6", | ||
| 306 | }, | ||
| 307 | }); | ||
| 308 | |||
| 309 | expect(generated.title).toBe(sanitizeThreadTitle("Use Claude home")); | ||
| 310 | }), | ||
| 311 | ); | ||
| 312 | }), | ||
| 313 | ); | ||
| 314 | |||
| 315 | it.effect("falls back when Claude thread title normalization becomes whitespace-only", () => | ||
| 316 | withFakeClaudeEnv( | ||
| 317 | { | ||
| 318 | output: JSON.stringify({ | ||
| 319 | structured_output: { | ||
| 320 | title: ' """ """ ', | ||
| 321 | }, | ||
| 322 | }), | ||
| 323 | }, | ||
| 324 | (textGeneration) => | ||
| 325 | Effect.gen(function* () { | ||
| 326 | const generated = yield* textGeneration.generateThreadTitle({ | ||
| 327 | cwd: process.cwd(), | ||
| 328 | message: "Name this thread.", | ||
| 329 | modelSelection: { | ||
| 330 | instanceId: ProviderInstanceId.make("claudeAgent"), | ||
| 331 | model: "claude-sonnet-4-6", | ||
| 332 | }, | ||
| 333 | }); | ||
| 334 | |||
| 335 | expect(generated.title).toBe("New thread"); | ||
| 336 | }), | ||
| 337 | ), | ||
| 338 | ); | ||
| 339 | }); | ||
apps/server/src/textGeneration/ClaudeTextGeneration.ts created+324| ... | @@ -0,0 +1,324 @@ | ||
| 1 | /** | ||
| 2 | * ClaudeTextGeneration – Text generation layer using the Claude CLI. | ||
| 3 | * | ||
| 4 | * Implements the same TextGenerationShape contract as CodexTextGeneration but | ||
| 5 | * delegates to the `claude` CLI (`claude -p`) with structured JSON output | ||
| 6 | * instead of the `codex exec` CLI. | ||
| 7 | * | ||
| 8 | * @module ClaudeTextGeneration | ||
| 9 | */ | ||
| 10 | import { Effect, Option, Schema, Stream } from "effect"; | ||
| 11 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 12 | |||
| 13 | import { type ClaudeSettings, type ModelSelection } from "@t3tools/contracts"; | ||
| 14 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | ||
| 15 | |||
| 16 | import { TextGenerationError } from "@t3tools/contracts"; | ||
| 17 | import { type TextGenerationShape } from "./TextGeneration.ts"; | ||
| 18 | import { | ||
| 19 | buildBranchNamePrompt, | ||
| 20 | buildCommitMessagePrompt, | ||
| 21 | buildPrContentPrompt, | ||
| 22 | buildThreadTitlePrompt, | ||
| 23 | } from "./TextGenerationPrompts.ts"; | ||
| 24 | import { | ||
| 25 | normalizeCliError, | ||
| 26 | sanitizeCommitSubject, | ||
| 27 | sanitizePrTitle, | ||
| 28 | sanitizeThreadTitle, | ||
| 29 | toJsonSchemaObject, | ||
| 30 | } from "./TextGenerationUtils.ts"; | ||
| 31 | import { | ||
| 32 | getModelSelectionStringOptionValue, | ||
| 33 | getProviderOptionDescriptors, | ||
| 34 | } from "@t3tools/shared/model"; | ||
| 35 | import { | ||
| 36 | getClaudeModelCapabilities, | ||
| 37 | normalizeClaudeCliEffort, | ||
| 38 | resolveClaudeApiModelId, | ||
| 39 | resolveClaudeEffort, | ||
| 40 | } from "../provider/Layers/ClaudeProvider.ts"; | ||
| 41 | import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts"; | ||
| 42 | |||
| 43 | const CLAUDE_TIMEOUT_MS = 180_000; | ||
| 44 | |||
| 45 | /** | ||
| 46 | * Schema for the wrapper JSON returned by `claude -p --output-format json`. | ||
| 47 | * We only care about `structured_output`. | ||
| 48 | */ | ||
| 49 | const ClaudeOutputEnvelope = Schema.Struct({ | ||
| 50 | structured_output: Schema.Unknown, | ||
| 51 | }); | ||
| 52 | |||
| 53 | export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* ( | ||
| 54 | claudeSettings: ClaudeSettings, | ||
| 55 | environment: NodeJS.ProcessEnv = process.env, | ||
| 56 | ) { | ||
| 57 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 58 | const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); | ||
| 59 | |||
| 60 | const readStreamAsString = <E>( | ||
| 61 | operation: string, | ||
| 62 | stream: Stream.Stream<Uint8Array, E>, | ||
| 63 | ): Effect.Effect<string, TextGenerationError> => | ||
| 64 | stream.pipe( | ||
| 65 | Stream.decodeText(), | ||
| 66 | Stream.runFold( | ||
| 67 | () => "", | ||
| 68 | (acc, chunk) => acc + chunk, | ||
| 69 | ), | ||
| 70 | Effect.mapError((cause) => | ||
| 71 | normalizeCliError("claude", operation, cause, "Failed to collect process output"), | ||
| 72 | ), | ||
| 73 | ); | ||
| 74 | |||
| 75 | /** | ||
| 76 | * Spawn the Claude CLI with structured JSON output and return the parsed, | ||
| 77 | * schema-validated result. | ||
| 78 | */ | ||
| 79 | const runClaudeJson = Effect.fn("runClaudeJson")(function* <S extends Schema.Top>({ | ||
| 80 | operation, | ||
| 81 | cwd, | ||
| 82 | prompt, | ||
| 83 | outputSchemaJson, | ||
| 84 | modelSelection, | ||
| 85 | }: { | ||
| 86 | operation: | ||
| 87 | | "generateCommitMessage" | ||
| 88 | | "generatePrContent" | ||
| 89 | | "generateBranchName" | ||
| 90 | | "generateThreadTitle"; | ||
| 91 | cwd: string; | ||
| 92 | prompt: string; | ||
| 93 | outputSchemaJson: S; | ||
| 94 | modelSelection: ModelSelection; | ||
| 95 | }): Effect.fn.Return<S["Type"], TextGenerationError, S["DecodingServices"]> { | ||
| 96 | const jsonSchemaStr = JSON.stringify(toJsonSchemaObject(outputSchemaJson)); | ||
| 97 | const caps = getClaudeModelCapabilities(modelSelection.model); | ||
| 98 | const descriptors = getProviderOptionDescriptors({ | ||
| 99 | caps, | ||
| 100 | selections: modelSelection.options, | ||
| 101 | }); | ||
| 102 | const findDescriptor = (id: string) => descriptors.find((descriptor) => descriptor.id === id); | ||
| 103 | const rawEffortSelection = getModelSelectionStringOptionValue(modelSelection, "effort"); | ||
| 104 | const resolvedEffort = resolveClaudeEffort(caps, rawEffortSelection); | ||
| 105 | const cliEffort = normalizeClaudeCliEffort(resolvedEffort); | ||
| 106 | const thinkingDescriptor = findDescriptor("thinking"); | ||
| 107 | const fastModeDescriptor = findDescriptor("fastMode"); | ||
| 108 | const thinking = | ||
| 109 | thinkingDescriptor?.type === "boolean" ? thinkingDescriptor.currentValue : undefined; | ||
| 110 | const fastMode = | ||
| 111 | fastModeDescriptor?.type === "boolean" ? fastModeDescriptor.currentValue : undefined; | ||
| 112 | const settings = { | ||
| 113 | ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), | ||
| 114 | ...(fastMode ? { fastMode: true } : {}), | ||
| 115 | }; | ||
| 116 | |||
| 117 | const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () { | ||
| 118 | const command = ChildProcess.make( | ||
| 119 | claudeSettings.binaryPath || "claude", | ||
| 120 | [ | ||
| 121 | "-p", | ||
| 122 | "--output-format", | ||
| 123 | "json", | ||
| 124 | "--json-schema", | ||
| 125 | jsonSchemaStr, | ||
| 126 | "--model", | ||
| 127 | resolveClaudeApiModelId(modelSelection), | ||
| 128 | ...(cliEffort ? ["--effort", cliEffort] : []), | ||
| 129 | ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []), | ||
| 130 | "--dangerously-skip-permissions", | ||
| 131 | ], | ||
| 132 | { | ||
| 133 | env: claudeEnvironment, | ||
| 134 | cwd, | ||
| 135 | shell: process.platform === "win32", | ||
| 136 | stdin: { | ||
| 137 | stream: Stream.encodeText(Stream.make(prompt)), | ||
| 138 | }, | ||
| 139 | }, | ||
| 140 | ); | ||
| 141 | |||
| 142 | const child = yield* commandSpawner | ||
| 143 | .spawn(command) | ||
| 144 | .pipe( | ||
| 145 | Effect.mapError((cause) => | ||
| 146 | normalizeCliError("claude", operation, cause, "Failed to spawn Claude CLI process"), | ||
| 147 | ), | ||
| 148 | ); | ||
| 149 | |||
| 150 | const [stdout, stderr, exitCode] = yield* Effect.all( | ||
| 151 | [ | ||
| 152 | readStreamAsString(operation, child.stdout), | ||
| 153 | readStreamAsString(operation, child.stderr), | ||
| 154 | child.exitCode.pipe( | ||
| 155 | Effect.mapError((cause) => | ||
| 156 | normalizeCliError("claude", operation, cause, "Failed to read Claude CLI exit code"), | ||
| 157 | ), | ||
| 158 | ), | ||
| 159 | ], | ||
| 160 | { concurrency: "unbounded" }, | ||
| 161 | ); | ||
| 162 | |||
| 163 | if (exitCode !== 0) { | ||
| 164 | const stderrDetail = stderr.trim(); | ||
| 165 | const stdoutDetail = stdout.trim(); | ||
| 166 | const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail; | ||
| 167 | return yield* new TextGenerationError({ | ||
| 168 | operation, | ||
| 169 | detail: | ||
| 170 | detail.length > 0 | ||
| 171 | ? `Claude CLI command failed: ${detail}` | ||
| 172 | : `Claude CLI command failed with code ${exitCode}.`, | ||
| 173 | }); | ||
| 174 | } | ||
| 175 | |||
| 176 | return stdout; | ||
| 177 | }); | ||
| 178 | |||
| 179 | const rawStdout = yield* runClaudeCommand().pipe( | ||
| 180 | Effect.scoped, | ||
| 181 | Effect.timeoutOption(CLAUDE_TIMEOUT_MS), | ||
| 182 | Effect.flatMap( | ||
| 183 | Option.match({ | ||
| 184 | onNone: () => | ||
| 185 | Effect.fail( | ||
| 186 | new TextGenerationError({ operation, detail: "Claude CLI request timed out." }), | ||
| 187 | ), | ||
| 188 | onSome: (value) => Effect.succeed(value), | ||
| 189 | }), | ||
| 190 | ), | ||
| 191 | ); | ||
| 192 | |||
| 193 | const envelope = yield* Schema.decodeEffect(Schema.fromJsonString(ClaudeOutputEnvelope))( | ||
| 194 | rawStdout, | ||
| 195 | ).pipe( | ||
| 196 | Effect.catchTag("SchemaError", (cause) => | ||
| 197 | Effect.fail( | ||
| 198 | new TextGenerationError({ | ||
| 199 | operation, | ||
| 200 | detail: "Claude CLI returned unexpected output format.", | ||
| 201 | cause, | ||
| 202 | }), | ||
| 203 | ), | ||
| 204 | ), | ||
| 205 | ); | ||
| 206 | |||
| 207 | return yield* Schema.decodeEffect(outputSchemaJson)(envelope.structured_output).pipe( | ||
| 208 | Effect.catchTag("SchemaError", (cause) => | ||
| 209 | Effect.fail( | ||
| 210 | new TextGenerationError({ | ||
| 211 | operation, | ||
| 212 | detail: "Claude returned invalid structured output.", | ||
| 213 | cause, | ||
| 214 | }), | ||
| 215 | ), | ||
| 216 | ), | ||
| 217 | ); | ||
| 218 | }); | ||
| 219 | |||
| 220 | // --------------------------------------------------------------------------- | ||
| 221 | // TextGenerationShape methods | ||
| 222 | // --------------------------------------------------------------------------- | ||
| 223 | |||
| 224 | const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( | ||
| 225 | "ClaudeTextGeneration.generateCommitMessage", | ||
| 226 | )(function* (input) { | ||
| 227 | const { prompt, outputSchema } = buildCommitMessagePrompt({ | ||
| 228 | branch: input.branch, | ||
| 229 | stagedSummary: input.stagedSummary, | ||
| 230 | stagedPatch: input.stagedPatch, | ||
| 231 | includeBranch: input.includeBranch === true, | ||
| 232 | }); | ||
| 233 | |||
| 234 | const generated = yield* runClaudeJson({ | ||
| 235 | operation: "generateCommitMessage", | ||
| 236 | cwd: input.cwd, | ||
| 237 | prompt, | ||
| 238 | outputSchemaJson: outputSchema, | ||
| 239 | modelSelection: input.modelSelection, | ||
| 240 | }); | ||
| 241 | |||
| 242 | return { | ||
| 243 | subject: sanitizeCommitSubject(generated.subject), | ||
| 244 | body: generated.body.trim(), | ||
| 245 | ...("branch" in generated && typeof generated.branch === "string" | ||
| 246 | ? { branch: sanitizeFeatureBranchName(generated.branch) } | ||
| 247 | : {}), | ||
| 248 | }; | ||
| 249 | }); | ||
| 250 | |||
| 251 | const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( | ||
| 252 | "ClaudeTextGeneration.generatePrContent", | ||
| 253 | )(function* (input) { | ||
| 254 | const { prompt, outputSchema } = buildPrContentPrompt({ | ||
| 255 | baseBranch: input.baseBranch, | ||
| 256 | headBranch: input.headBranch, | ||
| 257 | commitSummary: input.commitSummary, | ||
| 258 | diffSummary: input.diffSummary, | ||
| 259 | diffPatch: input.diffPatch, | ||
| 260 | }); | ||
| 261 | |||
| 262 | const generated = yield* runClaudeJson({ | ||
| 263 | operation: "generatePrContent", | ||
| 264 | cwd: input.cwd, | ||
| 265 | prompt, | ||
| 266 | outputSchemaJson: outputSchema, | ||
| 267 | modelSelection: input.modelSelection, | ||
| 268 | }); | ||
| 269 | |||
| 270 | return { | ||
| 271 | title: sanitizePrTitle(generated.title), | ||
| 272 | body: generated.body.trim(), | ||
| 273 | }; | ||
| 274 | }); | ||
| 275 | |||
| 276 | const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( | ||
| 277 | "ClaudeTextGeneration.generateBranchName", | ||
| 278 | )(function* (input) { | ||
| 279 | const { prompt, outputSchema } = buildBranchNamePrompt({ | ||
| 280 | message: input.message, | ||
| 281 | attachments: input.attachments, | ||
| 282 | }); | ||
| 283 | |||
| 284 | const generated = yield* runClaudeJson({ | ||
| 285 | operation: "generateBranchName", | ||
| 286 | cwd: input.cwd, | ||
| 287 | prompt, | ||
| 288 | outputSchemaJson: outputSchema, | ||
| 289 | modelSelection: input.modelSelection, | ||
| 290 | }); | ||
| 291 | |||
| 292 | return { | ||
| 293 | branch: sanitizeBranchFragment(generated.branch), | ||
| 294 | }; | ||
| 295 | }); | ||
| 296 | |||
| 297 | const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( | ||
| 298 | "ClaudeTextGeneration.generateThreadTitle", | ||
| 299 | )(function* (input) { | ||
| 300 | const { prompt, outputSchema } = buildThreadTitlePrompt({ | ||
| 301 | message: input.message, | ||
| 302 | attachments: input.attachments, | ||
| 303 | }); | ||
| 304 | |||
| 305 | const generated = yield* runClaudeJson({ | ||
| 306 | operation: "generateThreadTitle", | ||
| 307 | cwd: input.cwd, | ||
| 308 | prompt, | ||
| 309 | outputSchemaJson: outputSchema, | ||
| 310 | modelSelection: input.modelSelection, | ||
| 311 | }); | ||
| 312 | |||
| 313 | return { | ||
| 314 | title: sanitizeThreadTitle(generated.title), | ||
| 315 | }; | ||
| 316 | }); | ||
| 317 | |||
| 318 | return { | ||
| 319 | generateCommitMessage, | ||
| 320 | generatePrContent, | ||
| 321 | generateBranchName, | ||
| 322 | generateThreadTitle, | ||
| 323 | } satisfies TextGenerationShape; | ||
| 324 | }); | ||
apps/server/src/textGeneration/CodexTextGeneration.test.ts created+592| ... | @@ -0,0 +1,592 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { it } from "@effect/vitest"; | ||
| 3 | import { Effect, FileSystem, Layer, Path, Result, Schema } from "effect"; | ||
| 4 | import { createModelSelection } from "@t3tools/shared/model"; | ||
| 5 | import { expect } from "vitest"; | ||
| 6 | |||
| 7 | import { CodexSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; | ||
| 8 | |||
| 9 | import { ServerConfig } from "../config.ts"; | ||
| 10 | import { type TextGenerationShape } from "./TextGeneration.ts"; | ||
| 11 | import { makeCodexTextGeneration } from "./CodexTextGeneration.ts"; | ||
| 12 | |||
| 13 | const DEFAULT_TEST_MODEL_SELECTION = createModelSelection( | ||
| 14 | ProviderInstanceId.make("codex"), | ||
| 15 | "gpt-5.4-mini", | ||
| 16 | ); | ||
| 17 | |||
| 18 | const CodexTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { | ||
| 19 | prefix: "t3code-codex-text-generation-test-", | ||
| 20 | }).pipe(Layer.provideMerge(NodeServices.layer)); | ||
| 21 | |||
| 22 | function makeFakeCodexBinary( | ||
| 23 | dir: string, | ||
| 24 | input: { | ||
| 25 | output: string; | ||
| 26 | exitCode?: number; | ||
| 27 | stderr?: string; | ||
| 28 | requireImage?: boolean; | ||
| 29 | requireFastServiceTier?: boolean; | ||
| 30 | requireReasoningEffort?: string; | ||
| 31 | forbidReasoningEffort?: boolean; | ||
| 32 | stdinMustContain?: string; | ||
| 33 | stdinMustNotContain?: string; | ||
| 34 | }, | ||
| 35 | ) { | ||
| 36 | return Effect.gen(function* () { | ||
| 37 | const fs = yield* FileSystem.FileSystem; | ||
| 38 | const path = yield* Path.Path; | ||
| 39 | const binDir = path.join(dir, "bin"); | ||
| 40 | const codexPath = path.join(binDir, "codex"); | ||
| 41 | yield* fs.makeDirectory(binDir, { recursive: true }); | ||
| 42 | |||
| 43 | yield* fs.writeFileString( | ||
| 44 | codexPath, | ||
| 45 | [ | ||
| 46 | "#!/bin/sh", | ||
| 47 | 'output_path=""', | ||
| 48 | 'seen_image="0"', | ||
| 49 | 'seen_fast_service_tier="0"', | ||
| 50 | 'seen_reasoning_effort=""', | ||
| 51 | "while [ $# -gt 0 ]; do", | ||
| 52 | ' if [ "$1" = "--image" ]; then', | ||
| 53 | " shift", | ||
| 54 | ' if [ -n "$1" ]; then', | ||
| 55 | ' seen_image="1"', | ||
| 56 | " fi", | ||
| 57 | " shift", | ||
| 58 | " continue", | ||
| 59 | " fi", | ||
| 60 | ' if [ "$1" = "--config" ]; then', | ||
| 61 | " shift", | ||
| 62 | ' if [ "$1" = "service_tier=\\"fast\\"" ]; then', | ||
| 63 | ' seen_fast_service_tier="1"', | ||
| 64 | " fi", | ||
| 65 | ' case "$1" in', | ||
| 66 | " model_reasoning_effort=*)", | ||
| 67 | ' seen_reasoning_effort="$1"', | ||
| 68 | " ;;", | ||
| 69 | " esac", | ||
| 70 | " shift", | ||
| 71 | " continue", | ||
| 72 | " fi", | ||
| 73 | ' if [ "$1" = "--output-last-message" ]; then', | ||
| 74 | " shift", | ||
| 75 | ' output_path="$1"', | ||
| 76 | " shift", | ||
| 77 | " continue", | ||
| 78 | " fi", | ||
| 79 | " shift", | ||
| 80 | "done", | ||
| 81 | 'stdin_content="$(cat)"', | ||
| 82 | ...(input.requireImage | ||
| 83 | ? [ | ||
| 84 | 'if [ "$seen_image" != "1" ]; then', | ||
| 85 | ' printf "%s\\n" "missing --image input" >&2', | ||
| 86 | ` exit 2`, | ||
| 87 | "fi", | ||
| 88 | ] | ||
| 89 | : []), | ||
| 90 | ...(input.requireFastServiceTier | ||
| 91 | ? [ | ||
| 92 | 'if [ "$seen_fast_service_tier" != "1" ]; then', | ||
| 93 | ' printf "%s\\n" "missing fast service tier config" >&2', | ||
| 94 | ` exit 5`, | ||
| 95 | "fi", | ||
| 96 | ] | ||
| 97 | : []), | ||
| 98 | ...(input.requireReasoningEffort !== undefined | ||
| 99 | ? [ | ||
| 100 | `if [ "$seen_reasoning_effort" != "model_reasoning_effort=\\"${input.requireReasoningEffort}\\"" ]; then`, | ||
| 101 | ' printf "%s\\n" "unexpected reasoning effort config: $seen_reasoning_effort" >&2', | ||
| 102 | ` exit 6`, | ||
| 103 | "fi", | ||
| 104 | ] | ||
| 105 | : []), | ||
| 106 | ...(input.forbidReasoningEffort | ||
| 107 | ? [ | ||
| 108 | 'if [ -n "$seen_reasoning_effort" ]; then', | ||
| 109 | ' printf "%s\\n" "reasoning effort config should be omitted: $seen_reasoning_effort" >&2', | ||
| 110 | ` exit 7`, | ||
| 111 | "fi", | ||
| 112 | ] | ||
| 113 | : []), | ||
| 114 | ...(input.stdinMustContain !== undefined | ||
| 115 | ? [ | ||
| 116 | `if ! printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustContain)} >/dev/null; then`, | ||
| 117 | ' printf "%s\\n" "stdin missing expected content" >&2', | ||
| 118 | ` exit 3`, | ||
| 119 | "fi", | ||
| 120 | ] | ||
| 121 | : []), | ||
| 122 | ...(input.stdinMustNotContain !== undefined | ||
| 123 | ? [ | ||
| 124 | `if printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustNotContain)} >/dev/null; then`, | ||
| 125 | ' printf "%s\\n" "stdin contained forbidden content" >&2', | ||
| 126 | ` exit 4`, | ||
| 127 | "fi", | ||
| 128 | ] | ||
| 129 | : []), | ||
| 130 | ...(input.stderr !== undefined | ||
| 131 | ? [`printf "%s\\n" ${JSON.stringify(input.stderr)} >&2`] | ||
| 132 | : []), | ||
| 133 | 'if [ -n "$output_path" ]; then', | ||
| 134 | " cat > \"$output_path\" <<'__T3CODE_FAKE_CODEX_OUTPUT__'", | ||
| 135 | input.output, | ||
| 136 | "__T3CODE_FAKE_CODEX_OUTPUT__", | ||
| 137 | "fi", | ||
| 138 | `exit ${input.exitCode ?? 0}`, | ||
| 139 | "", | ||
| 140 | ].join("\n"), | ||
| 141 | ); | ||
| 142 | yield* fs.chmod(codexPath, 0o755); | ||
| 143 | return codexPath; | ||
| 144 | }); | ||
| 145 | } | ||
| 146 | |||
| 147 | function withFakeCodexEnv<A, E, R>( | ||
| 148 | input: { | ||
| 149 | output: string; | ||
| 150 | exitCode?: number; | ||
| 151 | stderr?: string; | ||
| 152 | requireImage?: boolean; | ||
| 153 | requireFastServiceTier?: boolean; | ||
| 154 | requireReasoningEffort?: string; | ||
| 155 | forbidReasoningEffort?: boolean; | ||
| 156 | stdinMustContain?: string; | ||
| 157 | stdinMustNotContain?: string; | ||
| 158 | }, | ||
| 159 | effectFn: (textGeneration: TextGenerationShape) => Effect.Effect<A, E, R>, | ||
| 160 | ) { | ||
| 161 | return Effect.gen(function* () { | ||
| 162 | const fs = yield* FileSystem.FileSystem; | ||
| 163 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" }); | ||
| 164 | const codexPath = yield* makeFakeCodexBinary(tempDir, input); | ||
| 165 | const config = Schema.decodeSync(CodexSettings)({ binaryPath: codexPath }); | ||
| 166 | const textGeneration = yield* makeCodexTextGeneration(config); | ||
| 167 | return yield* effectFn(textGeneration); | ||
| 168 | }).pipe(Effect.scoped); | ||
| 169 | } | ||
| 170 | |||
| 171 | it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { | ||
| 172 | it.effect("generates and sanitizes commit messages without branch by default", () => | ||
| 173 | withFakeCodexEnv( | ||
| 174 | { | ||
| 175 | output: JSON.stringify({ | ||
| 176 | subject: | ||
| 177 | " Add important change to the system with too much detail and a trailing period.\nsecondary line", | ||
| 178 | body: "\n- added migration\n- updated tests\n", | ||
| 179 | }), | ||
| 180 | stdinMustNotContain: "branch must be a short semantic git branch fragment", | ||
| 181 | }, | ||
| 182 | (textGeneration) => | ||
| 183 | Effect.gen(function* () { | ||
| 184 | const generated = yield* textGeneration.generateCommitMessage({ | ||
| 185 | cwd: process.cwd(), | ||
| 186 | branch: "feature/codex-effect", | ||
| 187 | stagedSummary: "M README.md", | ||
| 188 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 189 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 190 | }); | ||
| 191 | |||
| 192 | expect(generated.subject.length).toBeLessThanOrEqual(72); | ||
| 193 | expect(generated.subject.endsWith(".")).toBe(false); | ||
| 194 | expect(generated.body).toBe("- added migration\n- updated tests"); | ||
| 195 | expect(generated.branch).toBeUndefined(); | ||
| 196 | }), | ||
| 197 | ), | ||
| 198 | ); | ||
| 199 | |||
| 200 | it.effect( | ||
| 201 | "forwards codex fast mode and non-default reasoning effort into codex exec config", | ||
| 202 | () => | ||
| 203 | withFakeCodexEnv( | ||
| 204 | { | ||
| 205 | output: JSON.stringify({ | ||
| 206 | subject: "Add important change", | ||
| 207 | body: "", | ||
| 208 | }), | ||
| 209 | requireFastServiceTier: true, | ||
| 210 | requireReasoningEffort: "xhigh", | ||
| 211 | stdinMustNotContain: "branch must be a short semantic git branch fragment", | ||
| 212 | }, | ||
| 213 | (textGeneration) => | ||
| 214 | textGeneration.generateCommitMessage({ | ||
| 215 | cwd: process.cwd(), | ||
| 216 | branch: "feature/codex-effect", | ||
| 217 | stagedSummary: "M README.md", | ||
| 218 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 219 | modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ | ||
| 220 | { id: "reasoningEffort", value: "xhigh" }, | ||
| 221 | { id: "fastMode", value: true }, | ||
| 222 | ]), | ||
| 223 | }), | ||
| 224 | ), | ||
| 225 | ); | ||
| 226 | |||
| 227 | it.effect("defaults git text generation codex effort to low", () => | ||
| 228 | withFakeCodexEnv( | ||
| 229 | { | ||
| 230 | output: JSON.stringify({ | ||
| 231 | subject: "Add important change", | ||
| 232 | body: "", | ||
| 233 | }), | ||
| 234 | requireReasoningEffort: "low", | ||
| 235 | }, | ||
| 236 | (textGeneration) => | ||
| 237 | textGeneration.generateCommitMessage({ | ||
| 238 | cwd: process.cwd(), | ||
| 239 | branch: "feature/codex-effect", | ||
| 240 | stagedSummary: "M README.md", | ||
| 241 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 242 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 243 | }), | ||
| 244 | ), | ||
| 245 | ); | ||
| 246 | |||
| 247 | it.effect("generates commit message with branch when includeBranch is true", () => | ||
| 248 | withFakeCodexEnv( | ||
| 249 | { | ||
| 250 | output: JSON.stringify({ | ||
| 251 | subject: "Add important change", | ||
| 252 | body: "", | ||
| 253 | branch: "fix/important-system-change", | ||
| 254 | }), | ||
| 255 | stdinMustContain: "branch must be a short semantic git branch fragment", | ||
| 256 | }, | ||
| 257 | (textGeneration) => | ||
| 258 | Effect.gen(function* () { | ||
| 259 | const generated = yield* textGeneration.generateCommitMessage({ | ||
| 260 | cwd: process.cwd(), | ||
| 261 | branch: "feature/codex-effect", | ||
| 262 | stagedSummary: "M README.md", | ||
| 263 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 264 | includeBranch: true, | ||
| 265 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 266 | }); | ||
| 267 | |||
| 268 | expect(generated.subject).toBe("Add important change"); | ||
| 269 | expect(generated.branch).toBe("feature/fix/important-system-change"); | ||
| 270 | }), | ||
| 271 | ), | ||
| 272 | ); | ||
| 273 | |||
| 274 | it.effect("generates PR content and trims markdown body", () => | ||
| 275 | withFakeCodexEnv( | ||
| 276 | { | ||
| 277 | output: JSON.stringify({ | ||
| 278 | title: " Improve orchestration flow\nwith ignored suffix", | ||
| 279 | body: "\n## Summary\n- improve flow\n\n## Testing\n- bun test\n\n", | ||
| 280 | }), | ||
| 281 | }, | ||
| 282 | (textGeneration) => | ||
| 283 | Effect.gen(function* () { | ||
| 284 | const generated = yield* textGeneration.generatePrContent({ | ||
| 285 | cwd: process.cwd(), | ||
| 286 | baseBranch: "main", | ||
| 287 | headBranch: "feature/codex-effect", | ||
| 288 | commitSummary: "feat: improve orchestration flow", | ||
| 289 | diffSummary: "2 files changed", | ||
| 290 | diffPatch: "diff --git a/a.ts b/a.ts", | ||
| 291 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 292 | }); | ||
| 293 | |||
| 294 | expect(generated.title).toBe("Improve orchestration flow"); | ||
| 295 | expect(generated.body.startsWith("## Summary")).toBe(true); | ||
| 296 | expect(generated.body.endsWith("\n\n")).toBe(false); | ||
| 297 | }), | ||
| 298 | ), | ||
| 299 | ); | ||
| 300 | |||
| 301 | it.effect("generates branch names and normalizes branch fragments", () => | ||
| 302 | withFakeCodexEnv( | ||
| 303 | { | ||
| 304 | output: JSON.stringify({ | ||
| 305 | branch: " Feat/Session ", | ||
| 306 | }), | ||
| 307 | stdinMustNotContain: "Image attachments supplied to the model", | ||
| 308 | }, | ||
| 309 | (textGeneration) => | ||
| 310 | Effect.gen(function* () { | ||
| 311 | const generated = yield* textGeneration.generateBranchName({ | ||
| 312 | cwd: process.cwd(), | ||
| 313 | message: "Please update session handling.", | ||
| 314 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 315 | }); | ||
| 316 | |||
| 317 | expect(generated.branch).toBe("feat/session"); | ||
| 318 | }), | ||
| 319 | ), | ||
| 320 | ); | ||
| 321 | |||
| 322 | it.effect("generates thread titles and trims them for sidebar use", () => | ||
| 323 | withFakeCodexEnv( | ||
| 324 | { | ||
| 325 | output: JSON.stringify({ | ||
| 326 | title: | ||
| 327 | ' "Investigate websocket reconnect regressions after worktree restore" \nignored line', | ||
| 328 | }), | ||
| 329 | }, | ||
| 330 | (textGeneration) => | ||
| 331 | Effect.gen(function* () { | ||
| 332 | const generated = yield* textGeneration.generateThreadTitle({ | ||
| 333 | cwd: process.cwd(), | ||
| 334 | message: "Please investigate websocket reconnect regressions after a worktree restore.", | ||
| 335 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 336 | }); | ||
| 337 | |||
| 338 | expect(generated.title).toBe("Investigate websocket reconnect regressions aft..."); | ||
| 339 | }), | ||
| 340 | ), | ||
| 341 | ); | ||
| 342 | |||
| 343 | it.effect("falls back when thread title normalization becomes whitespace-only", () => | ||
| 344 | withFakeCodexEnv( | ||
| 345 | { | ||
| 346 | output: JSON.stringify({ | ||
| 347 | title: ' """ """ ', | ||
| 348 | }), | ||
| 349 | }, | ||
| 350 | (textGeneration) => | ||
| 351 | Effect.gen(function* () { | ||
| 352 | const generated = yield* textGeneration.generateThreadTitle({ | ||
| 353 | cwd: process.cwd(), | ||
| 354 | message: "Name this thread.", | ||
| 355 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 356 | }); | ||
| 357 | |||
| 358 | expect(generated.title).toBe("New thread"); | ||
| 359 | }), | ||
| 360 | ), | ||
| 361 | ); | ||
| 362 | |||
| 363 | it.effect("trims whitespace exposed after quote removal in thread titles", () => | ||
| 364 | withFakeCodexEnv( | ||
| 365 | { | ||
| 366 | output: JSON.stringify({ | ||
| 367 | title: ` "' hello world '" `, | ||
| 368 | }), | ||
| 369 | }, | ||
| 370 | (textGeneration) => | ||
| 371 | Effect.gen(function* () { | ||
| 372 | const generated = yield* textGeneration.generateThreadTitle({ | ||
| 373 | cwd: process.cwd(), | ||
| 374 | message: "Name this thread.", | ||
| 375 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 376 | }); | ||
| 377 | |||
| 378 | expect(generated.title).toBe("hello world"); | ||
| 379 | }), | ||
| 380 | ), | ||
| 381 | ); | ||
| 382 | |||
| 383 | it.effect("omits attachment metadata section when no attachments are provided", () => | ||
| 384 | withFakeCodexEnv( | ||
| 385 | { | ||
| 386 | output: JSON.stringify({ | ||
| 387 | branch: "fix/session-timeout", | ||
| 388 | }), | ||
| 389 | stdinMustNotContain: "Attachment metadata:", | ||
| 390 | }, | ||
| 391 | (textGeneration) => | ||
| 392 | Effect.gen(function* () { | ||
| 393 | const generated = yield* textGeneration.generateBranchName({ | ||
| 394 | cwd: process.cwd(), | ||
| 395 | message: "Fix timeout behavior.", | ||
| 396 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 397 | }); | ||
| 398 | |||
| 399 | expect(generated.branch).toBe("fix/session-timeout"); | ||
| 400 | }), | ||
| 401 | ), | ||
| 402 | ); | ||
| 403 | |||
| 404 | it.effect("passes image attachments through as codex image inputs", () => | ||
| 405 | withFakeCodexEnv( | ||
| 406 | { | ||
| 407 | output: JSON.stringify({ | ||
| 408 | branch: "fix/ui-regression", | ||
| 409 | }), | ||
| 410 | requireImage: true, | ||
| 411 | stdinMustContain: "Attachment metadata:", | ||
| 412 | }, | ||
| 413 | (textGeneration) => | ||
| 414 | Effect.gen(function* () { | ||
| 415 | const fs = yield* FileSystem.FileSystem; | ||
| 416 | const path = yield* Path.Path; | ||
| 417 | const { attachmentsDir } = yield* ServerConfig; | ||
| 418 | const attachmentId = `thread-branch-image-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; | ||
| 419 | const attachmentPath = path.join(attachmentsDir, `${attachmentId}.png`); | ||
| 420 | yield* fs.makeDirectory(attachmentsDir, { recursive: true }); | ||
| 421 | yield* fs.writeFile(attachmentPath, Buffer.from("hello")); | ||
| 422 | |||
| 423 | const generated = yield* textGeneration.generateBranchName({ | ||
| 424 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 425 | cwd: process.cwd(), | ||
| 426 | message: "Fix layout bug from screenshot.", | ||
| 427 | attachments: [ | ||
| 428 | { | ||
| 429 | type: "image", | ||
| 430 | id: attachmentId, | ||
| 431 | name: "bug.png", | ||
| 432 | mimeType: "image/png", | ||
| 433 | sizeBytes: 5, | ||
| 434 | }, | ||
| 435 | ], | ||
| 436 | }); | ||
| 437 | |||
| 438 | expect(generated.branch).toBe("fix/ui-regression"); | ||
| 439 | }), | ||
| 440 | ), | ||
| 441 | ); | ||
| 442 | |||
| 443 | it.effect("resolves persisted attachment ids to files for codex image inputs", () => | ||
| 444 | withFakeCodexEnv( | ||
| 445 | { | ||
| 446 | output: JSON.stringify({ | ||
| 447 | branch: "fix/ui-regression", | ||
| 448 | }), | ||
| 449 | requireImage: true, | ||
| 450 | }, | ||
| 451 | (textGeneration) => | ||
| 452 | Effect.gen(function* () { | ||
| 453 | const fs = yield* FileSystem.FileSystem; | ||
| 454 | const path = yield* Path.Path; | ||
| 455 | const { attachmentsDir } = yield* ServerConfig; | ||
| 456 | const attachmentId = `thread-1-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; | ||
| 457 | const imagePath = path.join(attachmentsDir, `${attachmentId}.png`); | ||
| 458 | yield* fs.makeDirectory(attachmentsDir, { recursive: true }); | ||
| 459 | yield* fs.writeFile(imagePath, Buffer.from("hello")); | ||
| 460 | |||
| 461 | const generated = yield* textGeneration | ||
| 462 | .generateBranchName({ | ||
| 463 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 464 | cwd: process.cwd(), | ||
| 465 | message: "Fix layout bug from screenshot.", | ||
| 466 | attachments: [ | ||
| 467 | { | ||
| 468 | type: "image", | ||
| 469 | id: attachmentId, | ||
| 470 | name: "bug.png", | ||
| 471 | mimeType: "image/png", | ||
| 472 | sizeBytes: 5, | ||
| 473 | }, | ||
| 474 | ], | ||
| 475 | }) | ||
| 476 | .pipe( | ||
| 477 | Effect.tap(() => | ||
| 478 | fs.stat(imagePath).pipe( | ||
| 479 | Effect.map((fileInfo) => { | ||
| 480 | expect(fileInfo.type).toBe("File"); | ||
| 481 | }), | ||
| 482 | ), | ||
| 483 | ), | ||
| 484 | Effect.ensuring(fs.remove(imagePath).pipe(Effect.catch(() => Effect.void))), | ||
| 485 | ); | ||
| 486 | |||
| 487 | expect(generated.branch).toBe("fix/ui-regression"); | ||
| 488 | }), | ||
| 489 | ), | ||
| 490 | ); | ||
| 491 | |||
| 492 | it.effect("ignores missing attachment ids for codex image inputs", () => | ||
| 493 | withFakeCodexEnv( | ||
| 494 | { | ||
| 495 | output: JSON.stringify({ | ||
| 496 | branch: "fix/ui-regression", | ||
| 497 | }), | ||
| 498 | requireImage: true, | ||
| 499 | }, | ||
| 500 | (textGeneration) => | ||
| 501 | Effect.gen(function* () { | ||
| 502 | const fs = yield* FileSystem.FileSystem; | ||
| 503 | const path = yield* Path.Path; | ||
| 504 | const { attachmentsDir } = yield* ServerConfig; | ||
| 505 | const missingAttachmentId = `thread-missing-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; | ||
| 506 | const missingPath = path.join(attachmentsDir, `${missingAttachmentId}.png`); | ||
| 507 | yield* fs.remove(missingPath).pipe(Effect.catch(() => Effect.void)); | ||
| 508 | |||
| 509 | const result = yield* textGeneration | ||
| 510 | .generateBranchName({ | ||
| 511 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 512 | cwd: process.cwd(), | ||
| 513 | message: "Fix layout bug from screenshot.", | ||
| 514 | attachments: [ | ||
| 515 | { | ||
| 516 | type: "image", | ||
| 517 | id: missingAttachmentId, | ||
| 518 | name: "outside.png", | ||
| 519 | mimeType: "image/png", | ||
| 520 | sizeBytes: 5, | ||
| 521 | }, | ||
| 522 | ], | ||
| 523 | }) | ||
| 524 | .pipe(Effect.result); | ||
| 525 | |||
| 526 | expect(Result.isFailure(result)).toBe(true); | ||
| 527 | if (Result.isFailure(result)) { | ||
| 528 | expect(result.failure).toBeInstanceOf(TextGenerationError); | ||
| 529 | expect(result.failure.message).toContain("missing --image input"); | ||
| 530 | } | ||
| 531 | }), | ||
| 532 | ), | ||
| 533 | ); | ||
| 534 | |||
| 535 | it.effect( | ||
| 536 | "fails with typed TextGenerationError when codex returns wrong branch payload shape", | ||
| 537 | () => | ||
| 538 | withFakeCodexEnv( | ||
| 539 | { | ||
| 540 | output: JSON.stringify({ | ||
| 541 | title: "This is not a branch payload", | ||
| 542 | }), | ||
| 543 | }, | ||
| 544 | (textGeneration) => | ||
| 545 | Effect.gen(function* () { | ||
| 546 | const result = yield* textGeneration | ||
| 547 | .generateBranchName({ | ||
| 548 | cwd: process.cwd(), | ||
| 549 | message: "Fix websocket reconnect flake", | ||
| 550 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 551 | }) | ||
| 552 | .pipe(Effect.result); | ||
| 553 | |||
| 554 | expect(Result.isFailure(result)).toBe(true); | ||
| 555 | if (Result.isFailure(result)) { | ||
| 556 | expect(result.failure).toBeInstanceOf(TextGenerationError); | ||
| 557 | expect(result.failure.message).toContain("Codex returned invalid structured output"); | ||
| 558 | } | ||
| 559 | }), | ||
| 560 | ), | ||
| 561 | ); | ||
| 562 | |||
| 563 | it.effect("returns typed TextGenerationError when codex exits non-zero", () => | ||
| 564 | withFakeCodexEnv( | ||
| 565 | { | ||
| 566 | output: JSON.stringify({ subject: "ignored", body: "" }), | ||
| 567 | exitCode: 1, | ||
| 568 | stderr: "codex execution failed", | ||
| 569 | }, | ||
| 570 | (textGeneration) => | ||
| 571 | Effect.gen(function* () { | ||
| 572 | const result = yield* textGeneration | ||
| 573 | .generateCommitMessage({ | ||
| 574 | cwd: process.cwd(), | ||
| 575 | branch: "feature/codex-error", | ||
| 576 | stagedSummary: "M README.md", | ||
| 577 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 578 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 579 | }) | ||
| 580 | .pipe(Effect.result); | ||
| 581 | |||
| 582 | expect(Result.isFailure(result)).toBe(true); | ||
| 583 | if (Result.isFailure(result)) { | ||
| 584 | expect(result.failure).toBeInstanceOf(TextGenerationError); | ||
| 585 | expect(result.failure.message).toContain( | ||
| 586 | "Codex CLI command failed: codex execution failed", | ||
| 587 | ); | ||
| 588 | } | ||
| 589 | }), | ||
| 590 | ), | ||
| 591 | ); | ||
| 592 | }); | ||
apps/server/src/textGeneration/CodexTextGeneration.ts created+388| ... | @@ -0,0 +1,388 @@ | ||
| 1 | import { Effect, FileSystem, Option, Path, Random, Schema, Scope, Stream } from "effect"; | ||
| 2 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 3 | |||
| 4 | import { type CodexSettings, type ModelSelection } from "@t3tools/contracts"; | ||
| 5 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | ||
| 6 | |||
| 7 | import { resolveAttachmentPath } from "../attachmentStore.ts"; | ||
| 8 | import { ServerConfig } from "../config.ts"; | ||
| 9 | import { expandHomePath } from "../pathExpansion.ts"; | ||
| 10 | import { TextGenerationError } from "@t3tools/contracts"; | ||
| 11 | import { | ||
| 12 | type BranchNameGenerationInput, | ||
| 13 | type ThreadTitleGenerationResult, | ||
| 14 | type TextGenerationShape, | ||
| 15 | } from "./TextGeneration.ts"; | ||
| 16 | import { | ||
| 17 | buildBranchNamePrompt, | ||
| 18 | buildCommitMessagePrompt, | ||
| 19 | buildPrContentPrompt, | ||
| 20 | buildThreadTitlePrompt, | ||
| 21 | } from "./TextGenerationPrompts.ts"; | ||
| 22 | import { | ||
| 23 | normalizeCliError, | ||
| 24 | sanitizeCommitSubject, | ||
| 25 | sanitizePrTitle, | ||
| 26 | sanitizeThreadTitle, | ||
| 27 | toJsonSchemaObject, | ||
| 28 | } from "./TextGenerationUtils.ts"; | ||
| 29 | import { | ||
| 30 | getModelSelectionBooleanOptionValue, | ||
| 31 | getModelSelectionStringOptionValue, | ||
| 32 | } from "@t3tools/shared/model"; | ||
| 33 | |||
| 34 | const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low"; | ||
| 35 | const CODEX_TIMEOUT_MS = 180_000; | ||
| 36 | /** | ||
| 37 | * Build a Codex text-generation closure bound to a specific `CodexSettings` | ||
| 38 | * payload. See `makeCodexAdapter` for the overall per-instance rationale. | ||
| 39 | */ | ||
| 40 | export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(function* ( | ||
| 41 | codexConfig: CodexSettings, | ||
| 42 | environment: NodeJS.ProcessEnv = process.env, | ||
| 43 | ) { | ||
| 44 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 45 | const path = yield* Path.Path; | ||
| 46 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 47 | const serverConfig = yield* Effect.service(ServerConfig); | ||
| 48 | |||
| 49 | type MaterializedImageAttachments = { | ||
| 50 | readonly imagePaths: ReadonlyArray<string>; | ||
| 51 | }; | ||
| 52 | |||
| 53 | const readStreamAsString = <E>( | ||
| 54 | operation: string, | ||
| 55 | stream: Stream.Stream<Uint8Array, E>, | ||
| 56 | ): Effect.Effect<string, TextGenerationError> => | ||
| 57 | stream.pipe( | ||
| 58 | Stream.decodeText(), | ||
| 59 | Stream.runFold( | ||
| 60 | () => "", | ||
| 61 | (acc, chunk) => acc + chunk, | ||
| 62 | ), | ||
| 63 | Effect.mapError((cause) => | ||
| 64 | normalizeCliError("codex", operation, cause, "Failed to collect process output"), | ||
| 65 | ), | ||
| 66 | ); | ||
| 67 | |||
| 68 | const writeTempFile = ( | ||
| 69 | operation: string, | ||
| 70 | prefix: string, | ||
| 71 | content: string, | ||
| 72 | ): Effect.Effect<string, TextGenerationError, Scope.Scope> => { | ||
| 73 | return Effect.gen(function* () { | ||
| 74 | const tempFileId = yield* Random.nextUUIDv4; | ||
| 75 | return yield* fileSystem | ||
| 76 | .makeTempFileScoped({ | ||
| 77 | prefix: `t3code-${prefix}-${process.pid}-${tempFileId}.tmp`, | ||
| 78 | }) | ||
| 79 | .pipe(Effect.tap((filePath) => fileSystem.writeFileString(filePath, content))); | ||
| 80 | }).pipe( | ||
| 81 | Effect.mapError( | ||
| 82 | (cause) => | ||
| 83 | new TextGenerationError({ | ||
| 84 | operation, | ||
| 85 | detail: `Failed to write temp file`, | ||
| 86 | cause, | ||
| 87 | }), | ||
| 88 | ), | ||
| 89 | ); | ||
| 90 | }; | ||
| 91 | |||
| 92 | const safeUnlink = (filePath: string): Effect.Effect<void, never> => | ||
| 93 | fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void)); | ||
| 94 | |||
| 95 | const materializeImageAttachments = Effect.fn("materializeImageAttachments")(function* ( | ||
| 96 | _operation: | ||
| 97 | | "generateCommitMessage" | ||
| 98 | | "generatePrContent" | ||
| 99 | | "generateBranchName" | ||
| 100 | | "generateThreadTitle", | ||
| 101 | attachments: BranchNameGenerationInput["attachments"], | ||
| 102 | ): Effect.fn.Return<MaterializedImageAttachments, TextGenerationError> { | ||
| 103 | if (!attachments || attachments.length === 0) { | ||
| 104 | return { imagePaths: [] }; | ||
| 105 | } | ||
| 106 | |||
| 107 | const imagePaths: string[] = []; | ||
| 108 | for (const attachment of attachments) { | ||
| 109 | if (attachment.type !== "image") { | ||
| 110 | continue; | ||
| 111 | } | ||
| 112 | |||
| 113 | const resolvedPath = resolveAttachmentPath({ | ||
| 114 | attachmentsDir: serverConfig.attachmentsDir, | ||
| 115 | attachment, | ||
| 116 | }); | ||
| 117 | if (!resolvedPath || !path.isAbsolute(resolvedPath)) { | ||
| 118 | continue; | ||
| 119 | } | ||
| 120 | const fileInfo = yield* fileSystem | ||
| 121 | .stat(resolvedPath) | ||
| 122 | .pipe(Effect.catch(() => Effect.succeed(null))); | ||
| 123 | if (!fileInfo || fileInfo.type !== "File") { | ||
| 124 | continue; | ||
| 125 | } | ||
| 126 | imagePaths.push(resolvedPath); | ||
| 127 | } | ||
| 128 | return { imagePaths }; | ||
| 129 | }); | ||
| 130 | |||
| 131 | const runCodexJson = Effect.fn("runCodexJson")(function* <S extends Schema.Top>({ | ||
| 132 | operation, | ||
| 133 | cwd, | ||
| 134 | prompt, | ||
| 135 | outputSchemaJson, | ||
| 136 | imagePaths = [], | ||
| 137 | cleanupPaths = [], | ||
| 138 | modelSelection, | ||
| 139 | }: { | ||
| 140 | operation: | ||
| 141 | | "generateCommitMessage" | ||
| 142 | | "generatePrContent" | ||
| 143 | | "generateBranchName" | ||
| 144 | | "generateThreadTitle"; | ||
| 145 | cwd: string; | ||
| 146 | prompt: string; | ||
| 147 | outputSchemaJson: S; | ||
| 148 | imagePaths?: ReadonlyArray<string>; | ||
| 149 | cleanupPaths?: ReadonlyArray<string>; | ||
| 150 | modelSelection: ModelSelection; | ||
| 151 | }): Effect.fn.Return<S["Type"], TextGenerationError, S["DecodingServices"]> { | ||
| 152 | const schemaPath = yield* writeTempFile( | ||
| 153 | operation, | ||
| 154 | "codex-schema", | ||
| 155 | JSON.stringify(toJsonSchemaObject(outputSchemaJson)), | ||
| 156 | ); | ||
| 157 | const outputPath = yield* writeTempFile(operation, "codex-output", ""); | ||
| 158 | |||
| 159 | const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () { | ||
| 160 | const reasoningEffort = | ||
| 161 | getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? | ||
| 162 | CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; | ||
| 163 | const command = ChildProcess.make( | ||
| 164 | codexConfig.binaryPath || "codex", | ||
| 165 | [ | ||
| 166 | "exec", | ||
| 167 | "--ephemeral", | ||
| 168 | "--skip-git-repo-check", | ||
| 169 | "-s", | ||
| 170 | "read-only", | ||
| 171 | "--model", | ||
| 172 | modelSelection.model, | ||
| 173 | "--config", | ||
| 174 | `model_reasoning_effort="${reasoningEffort}"`, | ||
| 175 | ...(getModelSelectionBooleanOptionValue(modelSelection, "fastMode") === true | ||
| 176 | ? ["--config", `service_tier="fast"`] | ||
| 177 | : []), | ||
| 178 | "--output-schema", | ||
| 179 | schemaPath, | ||
| 180 | "--output-last-message", | ||
| 181 | outputPath, | ||
| 182 | ...imagePaths.flatMap((imagePath) => ["--image", imagePath]), | ||
| 183 | "-", | ||
| 184 | ], | ||
| 185 | { | ||
| 186 | env: { | ||
| 187 | ...environment, | ||
| 188 | ...(codexConfig.homePath ? { CODEX_HOME: expandHomePath(codexConfig.homePath) } : {}), | ||
| 189 | }, | ||
| 190 | cwd, | ||
| 191 | shell: process.platform === "win32", | ||
| 192 | stdin: { | ||
| 193 | stream: Stream.encodeText(Stream.make(prompt)), | ||
| 194 | }, | ||
| 195 | }, | ||
| 196 | ); | ||
| 197 | |||
| 198 | const child = yield* commandSpawner | ||
| 199 | .spawn(command) | ||
| 200 | .pipe( | ||
| 201 | Effect.mapError((cause) => | ||
| 202 | normalizeCliError("codex", operation, cause, "Failed to spawn Codex CLI process"), | ||
| 203 | ), | ||
| 204 | ); | ||
| 205 | |||
| 206 | const [stdout, stderr, exitCode] = yield* Effect.all( | ||
| 207 | [ | ||
| 208 | readStreamAsString(operation, child.stdout), | ||
| 209 | readStreamAsString(operation, child.stderr), | ||
| 210 | child.exitCode.pipe( | ||
| 211 | Effect.mapError((cause) => | ||
| 212 | normalizeCliError("codex", operation, cause, "Failed to read Codex CLI exit code"), | ||
| 213 | ), | ||
| 214 | ), | ||
| 215 | ], | ||
| 216 | { concurrency: "unbounded" }, | ||
| 217 | ); | ||
| 218 | |||
| 219 | if (exitCode !== 0) { | ||
| 220 | const stderrDetail = stderr.trim(); | ||
| 221 | const stdoutDetail = stdout.trim(); | ||
| 222 | const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail; | ||
| 223 | return yield* new TextGenerationError({ | ||
| 224 | operation, | ||
| 225 | detail: | ||
| 226 | detail.length > 0 | ||
| 227 | ? `Codex CLI command failed: ${detail}` | ||
| 228 | : `Codex CLI command failed with code ${exitCode}.`, | ||
| 229 | }); | ||
| 230 | } | ||
| 231 | }); | ||
| 232 | |||
| 233 | const cleanup = Effect.all( | ||
| 234 | [schemaPath, outputPath, ...cleanupPaths].map((filePath) => safeUnlink(filePath)), | ||
| 235 | { | ||
| 236 | concurrency: "unbounded", | ||
| 237 | }, | ||
| 238 | ).pipe(Effect.asVoid); | ||
| 239 | |||
| 240 | return yield* Effect.gen(function* () { | ||
| 241 | yield* runCodexCommand().pipe( | ||
| 242 | Effect.scoped, | ||
| 243 | Effect.timeoutOption(CODEX_TIMEOUT_MS), | ||
| 244 | Effect.flatMap( | ||
| 245 | Option.match({ | ||
| 246 | onNone: () => | ||
| 247 | Effect.fail( | ||
| 248 | new TextGenerationError({ operation, detail: "Codex CLI request timed out." }), | ||
| 249 | ), | ||
| 250 | onSome: () => Effect.void, | ||
| 251 | }), | ||
| 252 | ), | ||
| 253 | ); | ||
| 254 | |||
| 255 | return yield* fileSystem.readFileString(outputPath).pipe( | ||
| 256 | Effect.mapError( | ||
| 257 | (cause) => | ||
| 258 | new TextGenerationError({ | ||
| 259 | operation, | ||
| 260 | detail: "Failed to read Codex output file.", | ||
| 261 | cause, | ||
| 262 | }), | ||
| 263 | ), | ||
| 264 | Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson))), | ||
| 265 | Effect.catchTag("SchemaError", (cause) => | ||
| 266 | Effect.fail( | ||
| 267 | new TextGenerationError({ | ||
| 268 | operation, | ||
| 269 | detail: "Codex returned invalid structured output.", | ||
| 270 | cause, | ||
| 271 | }), | ||
| 272 | ), | ||
| 273 | ), | ||
| 274 | ); | ||
| 275 | }).pipe(Effect.ensuring(cleanup)); | ||
| 276 | }); | ||
| 277 | |||
| 278 | const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( | ||
| 279 | "CodexTextGeneration.generateCommitMessage", | ||
| 280 | )(function* (input) { | ||
| 281 | const { prompt, outputSchema } = buildCommitMessagePrompt({ | ||
| 282 | branch: input.branch, | ||
| 283 | stagedSummary: input.stagedSummary, | ||
| 284 | stagedPatch: input.stagedPatch, | ||
| 285 | includeBranch: input.includeBranch === true, | ||
| 286 | }); | ||
| 287 | |||
| 288 | const generated = yield* runCodexJson({ | ||
| 289 | operation: "generateCommitMessage", | ||
| 290 | cwd: input.cwd, | ||
| 291 | prompt, | ||
| 292 | outputSchemaJson: outputSchema, | ||
| 293 | modelSelection: input.modelSelection, | ||
| 294 | }); | ||
| 295 | |||
| 296 | return { | ||
| 297 | subject: sanitizeCommitSubject(generated.subject), | ||
| 298 | body: generated.body.trim(), | ||
| 299 | ...("branch" in generated && typeof generated.branch === "string" | ||
| 300 | ? { branch: sanitizeFeatureBranchName(generated.branch) } | ||
| 301 | : {}), | ||
| 302 | }; | ||
| 303 | }); | ||
| 304 | |||
| 305 | const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( | ||
| 306 | "CodexTextGeneration.generatePrContent", | ||
| 307 | )(function* (input) { | ||
| 308 | const { prompt, outputSchema } = buildPrContentPrompt({ | ||
| 309 | baseBranch: input.baseBranch, | ||
| 310 | headBranch: input.headBranch, | ||
| 311 | commitSummary: input.commitSummary, | ||
| 312 | diffSummary: input.diffSummary, | ||
| 313 | diffPatch: input.diffPatch, | ||
| 314 | }); | ||
| 315 | |||
| 316 | const generated = yield* runCodexJson({ | ||
| 317 | operation: "generatePrContent", | ||
| 318 | cwd: input.cwd, | ||
| 319 | prompt, | ||
| 320 | outputSchemaJson: outputSchema, | ||
| 321 | modelSelection: input.modelSelection, | ||
| 322 | }); | ||
| 323 | |||
| 324 | return { | ||
| 325 | title: sanitizePrTitle(generated.title), | ||
| 326 | body: generated.body.trim(), | ||
| 327 | }; | ||
| 328 | }); | ||
| 329 | |||
| 330 | const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( | ||
| 331 | "CodexTextGeneration.generateBranchName", | ||
| 332 | )(function* (input) { | ||
| 333 | const { imagePaths } = yield* materializeImageAttachments( | ||
| 334 | "generateBranchName", | ||
| 335 | input.attachments, | ||
| 336 | ); | ||
| 337 | const { prompt, outputSchema } = buildBranchNamePrompt({ | ||
| 338 | message: input.message, | ||
| 339 | attachments: input.attachments, | ||
| 340 | }); | ||
| 341 | |||
| 342 | const generated = yield* runCodexJson({ | ||
| 343 | operation: "generateBranchName", | ||
| 344 | cwd: input.cwd, | ||
| 345 | prompt, | ||
| 346 | outputSchemaJson: outputSchema, | ||
| 347 | imagePaths, | ||
| 348 | modelSelection: input.modelSelection, | ||
| 349 | }); | ||
| 350 | |||
| 351 | return { | ||
| 352 | branch: sanitizeBranchFragment(generated.branch), | ||
| 353 | }; | ||
| 354 | }); | ||
| 355 | |||
| 356 | const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( | ||
| 357 | "CodexTextGeneration.generateThreadTitle", | ||
| 358 | )(function* (input) { | ||
| 359 | const { imagePaths } = yield* materializeImageAttachments( | ||
| 360 | "generateThreadTitle", | ||
| 361 | input.attachments, | ||
| 362 | ); | ||
| 363 | const { prompt, outputSchema } = buildThreadTitlePrompt({ | ||
| 364 | message: input.message, | ||
| 365 | attachments: input.attachments, | ||
| 366 | }); | ||
| 367 | |||
| 368 | const generated = yield* runCodexJson({ | ||
| 369 | operation: "generateThreadTitle", | ||
| 370 | cwd: input.cwd, | ||
| 371 | prompt, | ||
| 372 | outputSchemaJson: outputSchema, | ||
| 373 | imagePaths, | ||
| 374 | modelSelection: input.modelSelection, | ||
| 375 | }); | ||
| 376 | |||
| 377 | return { | ||
| 378 | title: sanitizeThreadTitle(generated.title), | ||
| 379 | } satisfies ThreadTitleGenerationResult; | ||
| 380 | }); | ||
| 381 | |||
| 382 | return { | ||
| 383 | generateCommitMessage, | ||
| 384 | generatePrContent, | ||
| 385 | generateBranchName, | ||
| 386 | generateThreadTitle, | ||
| 387 | } satisfies TextGenerationShape; | ||
| 388 | }); | ||
apps/server/src/textGeneration/CursorTextGeneration.test.ts created+264| ... | @@ -0,0 +1,264 @@ | ||
| 1 | import * as path from "node:path"; | ||
| 2 | import * as os from "node:os"; | ||
| 3 | import { fileURLToPath } from "node:url"; | ||
| 4 | import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | ||
| 5 | |||
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 7 | import { it } from "@effect/vitest"; | ||
| 8 | import { Effect, Layer, Schema } from "effect"; | ||
| 9 | import { createModelSelection } from "@t3tools/shared/model"; | ||
| 10 | import { expect } from "vitest"; | ||
| 11 | |||
| 12 | import { CursorSettings, ProviderInstanceId } from "@t3tools/contracts"; | ||
| 13 | |||
| 14 | import { ServerConfig } from "../config.ts"; | ||
| 15 | import { type TextGenerationShape } from "./TextGeneration.ts"; | ||
| 16 | import { makeCursorTextGeneration } from "./CursorTextGeneration.ts"; | ||
| 17 | |||
| 18 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); | ||
| 19 | const mockAgentPath = path.join(__dirname, "../../scripts/acp-mock-agent.ts"); | ||
| 20 | |||
| 21 | function shellSingleQuote(value: string): string { | ||
| 22 | return `'${value.replaceAll("'", `'"'"'`)}'`; | ||
| 23 | } | ||
| 24 | |||
| 25 | const CursorTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { | ||
| 26 | prefix: "t3code-cursor-text-generation-test-", | ||
| 27 | }).pipe(Layer.provideMerge(NodeServices.layer)); | ||
| 28 | |||
| 29 | function makeAcpAgentWrapper(dir: string, env: Record<string, string>): string { | ||
| 30 | const binDir = path.join(dir, "bin"); | ||
| 31 | const agentPath = path.join(binDir, "agent"); | ||
| 32 | mkdirSync(binDir, { recursive: true }); | ||
| 33 | writeFileSync( | ||
| 34 | agentPath, | ||
| 35 | [ | ||
| 36 | "#!/bin/sh", | ||
| 37 | ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`), | ||
| 38 | 'if [ "$1" != "acp" ]; then', | ||
| 39 | ' printf "%s\\n" "unexpected args: $*" >&2', | ||
| 40 | " exit 11", | ||
| 41 | "fi", | ||
| 42 | `exec bun ${JSON.stringify(mockAgentPath)}`, | ||
| 43 | "", | ||
| 44 | ].join("\n"), | ||
| 45 | "utf8", | ||
| 46 | ); | ||
| 47 | chmodSync(agentPath, 0o755); | ||
| 48 | return agentPath; | ||
| 49 | } | ||
| 50 | |||
| 51 | function withFakeAcpAgent<A, E, R>( | ||
| 52 | env: Record<string, string>, | ||
| 53 | effectFn: (textGeneration: TextGenerationShape) => Effect.Effect<A, E, R>, | ||
| 54 | ) { | ||
| 55 | return Effect.gen(function* () { | ||
| 56 | const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-acp-")); | ||
| 57 | yield* Effect.addFinalizer(() => | ||
| 58 | Effect.sync(() => { | ||
| 59 | rmSync(tempDir, { recursive: true, force: true }); | ||
| 60 | }), | ||
| 61 | ); | ||
| 62 | const agentPath = makeAcpAgentWrapper(tempDir, env); | ||
| 63 | const config = Schema.decodeSync(CursorSettings)({ binaryPath: agentPath }); | ||
| 64 | const textGeneration = yield* makeCursorTextGeneration(config); | ||
| 65 | return yield* effectFn(textGeneration); | ||
| 66 | }).pipe(Effect.scoped); | ||
| 67 | } | ||
| 68 | |||
| 69 | function waitForFileContent(path: string): Effect.Effect<string> { | ||
| 70 | return Effect.promise(async () => { | ||
| 71 | const deadline = Date.now() + 5_000; | ||
| 72 | for (;;) { | ||
| 73 | try { | ||
| 74 | return readFileSync(path, "utf8"); | ||
| 75 | } catch (error) { | ||
| 76 | if (Date.now() >= deadline) { | ||
| 77 | throw error instanceof Error ? error : new Error(String(error)); | ||
| 78 | } | ||
| 79 | } | ||
| 80 | await new Promise((resolve) => setTimeout(resolve, 25)); | ||
| 81 | } | ||
| 82 | }); | ||
| 83 | } | ||
| 84 | |||
| 85 | it.layer(CursorTextGenerationTestLayer)("CursorTextGeneration", (it) => { | ||
| 86 | it.effect("uses ACP model config options instead of raw CLI model ids", () => { | ||
| 87 | const requestLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-log-")); | ||
| 88 | const requestLogPath = path.join(requestLogDir, "requests.ndjson"); | ||
| 89 | |||
| 90 | return withFakeAcpAgent( | ||
| 91 | { | ||
| 92 | T3_ACP_REQUEST_LOG_PATH: requestLogPath, | ||
| 93 | T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ | ||
| 94 | subject: "Add generated commit message", | ||
| 95 | body: "- verify cursor acp model config path", | ||
| 96 | }), | ||
| 97 | }, | ||
| 98 | (textGeneration) => | ||
| 99 | Effect.gen(function* () { | ||
| 100 | const generated = yield* textGeneration.generateCommitMessage({ | ||
| 101 | cwd: process.cwd(), | ||
| 102 | branch: "feature/cursor-text-generation", | ||
| 103 | stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts", | ||
| 104 | stagedPatch: | ||
| 105 | "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts", | ||
| 106 | modelSelection: { | ||
| 107 | ...createModelSelection(ProviderInstanceId.make("cursor"), "gpt-5.4", [ | ||
| 108 | { id: "reasoning", value: "xhigh" }, | ||
| 109 | { id: "fastMode", value: true }, | ||
| 110 | { id: "contextWindow", value: "1m" }, | ||
| 111 | ]), | ||
| 112 | }, | ||
| 113 | }); | ||
| 114 | |||
| 115 | expect(generated.subject).toBe("Add generated commit message"); | ||
| 116 | expect(generated.body).toBe("- verify cursor acp model config path"); | ||
| 117 | |||
| 118 | const requests = readFileSync(requestLogPath, "utf8") | ||
| 119 | .trim() | ||
| 120 | .split("\n") | ||
| 121 | .filter((line) => line.length > 0) | ||
| 122 | .map( | ||
| 123 | (line) => JSON.parse(line) as { method?: string; params?: Record<string, unknown> }, | ||
| 124 | ); | ||
| 125 | |||
| 126 | expect( | ||
| 127 | requests.find((request) => request.method === "initialize")?.params?.clientCapabilities, | ||
| 128 | ).toMatchObject({ | ||
| 129 | _meta: { | ||
| 130 | parameterizedModelPicker: true, | ||
| 131 | }, | ||
| 132 | }); | ||
| 133 | expect( | ||
| 134 | requests.some( | ||
| 135 | (request) => | ||
| 136 | request.method === "session/set_config_option" && | ||
| 137 | request.params?.configId === "model" && | ||
| 138 | request.params?.value === "gpt-5.4", | ||
| 139 | ), | ||
| 140 | ).toBe(true); | ||
| 141 | expect( | ||
| 142 | requests.some( | ||
| 143 | (request) => | ||
| 144 | request.method === "session/set_config_option" && | ||
| 145 | request.params?.configId === "reasoning" && | ||
| 146 | request.params?.value === "extra-high", | ||
| 147 | ), | ||
| 148 | ).toBe(true); | ||
| 149 | expect( | ||
| 150 | requests.some( | ||
| 151 | (request) => | ||
| 152 | request.method === "session/set_config_option" && | ||
| 153 | request.params?.configId === "context" && | ||
| 154 | request.params?.value === "1m", | ||
| 155 | ), | ||
| 156 | ).toBe(true); | ||
| 157 | expect( | ||
| 158 | requests.some( | ||
| 159 | (request) => | ||
| 160 | request.method === "session/set_config_option" && | ||
| 161 | request.params?.configId === "fast" && | ||
| 162 | request.params?.value === "true", | ||
| 163 | ), | ||
| 164 | ).toBe(true); | ||
| 165 | expect( | ||
| 166 | requests.find((request) => request.method === "session/prompt")?.params?.prompt, | ||
| 167 | ).toEqual( | ||
| 168 | expect.arrayContaining([ | ||
| 169 | expect.objectContaining({ | ||
| 170 | type: "text", | ||
| 171 | text: expect.stringContaining("Staged patch:"), | ||
| 172 | }), | ||
| 173 | ]), | ||
| 174 | ); | ||
| 175 | |||
| 176 | rmSync(requestLogDir, { recursive: true, force: true }); | ||
| 177 | }), | ||
| 178 | ); | ||
| 179 | }); | ||
| 180 | |||
| 181 | it.effect("accepts json objects with extra assistant text around them", () => | ||
| 182 | withFakeAcpAgent( | ||
| 183 | { | ||
| 184 | T3_ACP_PROMPT_RESPONSE_TEXT: | ||
| 185 | 'Sure, here is the JSON:\n```json\n{\n "subject": "Update README dummy comment with attribution and date",\n "body": ""\n}\n```\nDone.', | ||
| 186 | }, | ||
| 187 | (textGeneration) => | ||
| 188 | Effect.gen(function* () { | ||
| 189 | const generated = yield* textGeneration.generateCommitMessage({ | ||
| 190 | cwd: process.cwd(), | ||
| 191 | branch: "feature/cursor-noisy-json", | ||
| 192 | stagedSummary: "M README.md", | ||
| 193 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 194 | modelSelection: { | ||
| 195 | instanceId: ProviderInstanceId.make("cursor"), | ||
| 196 | model: "composer-2", | ||
| 197 | }, | ||
| 198 | }); | ||
| 199 | |||
| 200 | expect(generated.subject).toBe("Update README dummy comment with attribution and date"); | ||
| 201 | expect(generated.body).toBe(""); | ||
| 202 | }), | ||
| 203 | ), | ||
| 204 | ); | ||
| 205 | |||
| 206 | it.effect("generates thread titles through Cursor ACP text generation", () => | ||
| 207 | withFakeAcpAgent( | ||
| 208 | { | ||
| 209 | T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ | ||
| 210 | title: '"Trim reconnect spinner status after resume."', | ||
| 211 | }), | ||
| 212 | }, | ||
| 213 | (textGeneration) => | ||
| 214 | Effect.gen(function* () { | ||
| 215 | const generated = yield* textGeneration.generateThreadTitle({ | ||
| 216 | cwd: process.cwd(), | ||
| 217 | message: "Fix the reconnect spinner after a resumed session.", | ||
| 218 | modelSelection: { | ||
| 219 | instanceId: ProviderInstanceId.make("cursor"), | ||
| 220 | model: "composer-2", | ||
| 221 | }, | ||
| 222 | }); | ||
| 223 | |||
| 224 | expect(generated.title).toBe("Trim reconnect spinner status after resume."); | ||
| 225 | }), | ||
| 226 | ), | ||
| 227 | ); | ||
| 228 | |||
| 229 | it.effect("closes the ACP child process after text generation completes", () => { | ||
| 230 | const exitLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-exit-log-")); | ||
| 231 | const exitLogPath = path.join(exitLogDir, "exit.log"); | ||
| 232 | |||
| 233 | return withFakeAcpAgent( | ||
| 234 | { | ||
| 235 | T3_ACP_EXIT_LOG_PATH: exitLogPath, | ||
| 236 | T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ | ||
| 237 | subject: "Close runtime after generation", | ||
| 238 | body: "", | ||
| 239 | }), | ||
| 240 | }, | ||
| 241 | (textGeneration) => | ||
| 242 | Effect.gen(function* () { | ||
| 243 | const generated = yield* textGeneration.generateCommitMessage({ | ||
| 244 | cwd: process.cwd(), | ||
| 245 | branch: "feature/cursor-runtime-close", | ||
| 246 | stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts", | ||
| 247 | stagedPatch: | ||
| 248 | "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts", | ||
| 249 | modelSelection: { | ||
| 250 | instanceId: ProviderInstanceId.make("cursor"), | ||
| 251 | model: "composer-2", | ||
| 252 | }, | ||
| 253 | }); | ||
| 254 | |||
| 255 | expect(generated.subject).toBe("Close runtime after generation"); | ||
| 256 | |||
| 257 | const exitLog = yield* waitForFileContent(exitLogPath); | ||
| 258 | expect(exitLog).toContain("exit:0"); | ||
| 259 | |||
| 260 | rmSync(exitLogDir, { recursive: true, force: true }); | ||
| 261 | }), | ||
| 262 | ); | ||
| 263 | }); | ||
| 264 | }); | ||
apps/server/src/textGeneration/CursorTextGeneration.ts created+276| ... | @@ -0,0 +1,276 @@ | ||
| 1 | import { Effect, Option, Ref, Schema } from "effect"; | ||
| 2 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 3 | |||
| 4 | import { type CursorSettings, type ModelSelection } from "@t3tools/contracts"; | ||
| 5 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | ||
| 6 | |||
| 7 | import { TextGenerationError } from "@t3tools/contracts"; | ||
| 8 | import { type ThreadTitleGenerationResult, type TextGenerationShape } from "./TextGeneration.ts"; | ||
| 9 | import { | ||
| 10 | buildBranchNamePrompt, | ||
| 11 | buildCommitMessagePrompt, | ||
| 12 | buildPrContentPrompt, | ||
| 13 | buildThreadTitlePrompt, | ||
| 14 | } from "./TextGenerationPrompts.ts"; | ||
| 15 | import { | ||
| 16 | extractJsonObject, | ||
| 17 | sanitizeCommitSubject, | ||
| 18 | sanitizePrTitle, | ||
| 19 | sanitizeThreadTitle, | ||
| 20 | } from "./TextGenerationUtils.ts"; | ||
| 21 | import { | ||
| 22 | applyCursorAcpModelSelection, | ||
| 23 | makeCursorAcpRuntime, | ||
| 24 | } from "../provider/acp/CursorAcpSupport.ts"; | ||
| 25 | |||
| 26 | const CURSOR_TIMEOUT_MS = 180_000; | ||
| 27 | |||
| 28 | function mapCursorAcpError( | ||
| 29 | operation: | ||
| 30 | | "generateCommitMessage" | ||
| 31 | | "generatePrContent" | ||
| 32 | | "generateBranchName" | ||
| 33 | | "generateThreadTitle", | ||
| 34 | detail: string, | ||
| 35 | cause: unknown, | ||
| 36 | ): TextGenerationError { | ||
| 37 | return new TextGenerationError({ | ||
| 38 | operation, | ||
| 39 | detail, | ||
| 40 | ...(cause !== undefined ? { cause } : {}), | ||
| 41 | }); | ||
| 42 | } | ||
| 43 | |||
| 44 | function isTextGenerationError(error: unknown): error is TextGenerationError { | ||
| 45 | return ( | ||
| 46 | typeof error === "object" && | ||
| 47 | error !== null && | ||
| 48 | "_tag" in error && | ||
| 49 | error._tag === "TextGenerationError" | ||
| 50 | ); | ||
| 51 | } | ||
| 52 | |||
| 53 | /** | ||
| 54 | * Build a Cursor text-generation closure bound to a specific `CursorSettings` | ||
| 55 | * payload. See `makeCodexAdapter` for the overall per-instance rationale. | ||
| 56 | */ | ||
| 57 | export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(function* ( | ||
| 58 | cursorSettings: CursorSettings, | ||
| 59 | environment: NodeJS.ProcessEnv = process.env, | ||
| 60 | ) { | ||
| 61 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 62 | |||
| 63 | const runCursorJson = <S extends Schema.Top>({ | ||
| 64 | operation, | ||
| 65 | cwd, | ||
| 66 | prompt, | ||
| 67 | outputSchemaJson, | ||
| 68 | modelSelection, | ||
| 69 | }: { | ||
| 70 | operation: | ||
| 71 | | "generateCommitMessage" | ||
| 72 | | "generatePrContent" | ||
| 73 | | "generateBranchName" | ||
| 74 | | "generateThreadTitle"; | ||
| 75 | cwd: string; | ||
| 76 | prompt: string; | ||
| 77 | outputSchemaJson: S; | ||
| 78 | modelSelection: ModelSelection; | ||
| 79 | }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> => | ||
| 80 | Effect.gen(function* () { | ||
| 81 | const outputRef = yield* Ref.make(""); | ||
| 82 | const runtime = yield* makeCursorAcpRuntime({ | ||
| 83 | cursorSettings, | ||
| 84 | environment, | ||
| 85 | childProcessSpawner: commandSpawner, | ||
| 86 | cwd, | ||
| 87 | clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, | ||
| 88 | }); | ||
| 89 | |||
| 90 | yield* runtime.handleSessionUpdate((notification) => { | ||
| 91 | const update = notification.update; | ||
| 92 | if (update.sessionUpdate !== "agent_message_chunk") { | ||
| 93 | return Effect.void; | ||
| 94 | } | ||
| 95 | const content = update.content; | ||
| 96 | if (content.type !== "text") { | ||
| 97 | return Effect.void; | ||
| 98 | } | ||
| 99 | return Ref.update(outputRef, (current) => current + content.text); | ||
| 100 | }); | ||
| 101 | |||
| 102 | const promptResult = yield* Effect.gen(function* () { | ||
| 103 | yield* runtime.start(); | ||
| 104 | yield* Effect.ignore(runtime.setMode("ask")); | ||
| 105 | yield* applyCursorAcpModelSelection({ | ||
| 106 | runtime, | ||
| 107 | model: modelSelection.model, | ||
| 108 | selections: modelSelection.options, | ||
| 109 | mapError: ({ cause, configId, step }) => | ||
| 110 | mapCursorAcpError( | ||
| 111 | operation, | ||
| 112 | step === "set-config-option" | ||
| 113 | ? `Failed to set Cursor ACP config option "${configId}" for text generation.` | ||
| 114 | : "Failed to set Cursor ACP base model for text generation.", | ||
| 115 | cause, | ||
| 116 | ), | ||
| 117 | }); | ||
| 118 | |||
| 119 | return yield* runtime.prompt({ | ||
| 120 | prompt: [{ type: "text", text: prompt }], | ||
| 121 | }); | ||
| 122 | }).pipe( | ||
| 123 | Effect.timeoutOption(CURSOR_TIMEOUT_MS), | ||
| 124 | Effect.flatMap( | ||
| 125 | Option.match({ | ||
| 126 | onNone: () => | ||
| 127 | Effect.fail( | ||
| 128 | new TextGenerationError({ | ||
| 129 | operation, | ||
| 130 | detail: "Cursor Agent request timed out.", | ||
| 131 | }), | ||
| 132 | ), | ||
| 133 | onSome: (value) => Effect.succeed(value), | ||
| 134 | }), | ||
| 135 | ), | ||
| 136 | Effect.mapError((cause) => | ||
| 137 | isTextGenerationError(cause) | ||
| 138 | ? cause | ||
| 139 | : mapCursorAcpError(operation, "Cursor ACP request failed.", cause), | ||
| 140 | ), | ||
| 141 | ); | ||
| 142 | |||
| 143 | const rawResult = (yield* Ref.get(outputRef)).trim(); | ||
| 144 | if (!rawResult) { | ||
| 145 | return yield* new TextGenerationError({ | ||
| 146 | operation, | ||
| 147 | detail: | ||
| 148 | promptResult.stopReason === "cancelled" | ||
| 149 | ? "Cursor ACP request was cancelled." | ||
| 150 | : "Cursor Agent returned empty output.", | ||
| 151 | }); | ||
| 152 | } | ||
| 153 | |||
| 154 | return yield* Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson))( | ||
| 155 | extractJsonObject(rawResult), | ||
| 156 | ).pipe( | ||
| 157 | Effect.catchTag("SchemaError", (cause) => | ||
| 158 | Effect.fail( | ||
| 159 | new TextGenerationError({ | ||
| 160 | operation, | ||
| 161 | detail: "Cursor Agent returned invalid structured output.", | ||
| 162 | cause, | ||
| 163 | }), | ||
| 164 | ), | ||
| 165 | ), | ||
| 166 | ); | ||
| 167 | }).pipe( | ||
| 168 | Effect.mapError((cause) => | ||
| 169 | isTextGenerationError(cause) | ||
| 170 | ? cause | ||
| 171 | : mapCursorAcpError(operation, "Cursor ACP text generation failed.", cause), | ||
| 172 | ), | ||
| 173 | Effect.scoped, | ||
| 174 | ); | ||
| 175 | |||
| 176 | const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( | ||
| 177 | "CursorTextGeneration.generateCommitMessage", | ||
| 178 | )(function* (input) { | ||
| 179 | const { prompt, outputSchema } = buildCommitMessagePrompt({ | ||
| 180 | branch: input.branch, | ||
| 181 | stagedSummary: input.stagedSummary, | ||
| 182 | stagedPatch: input.stagedPatch, | ||
| 183 | includeBranch: input.includeBranch === true, | ||
| 184 | }); | ||
| 185 | |||
| 186 | const generated = yield* runCursorJson({ | ||
| 187 | operation: "generateCommitMessage", | ||
| 188 | cwd: input.cwd, | ||
| 189 | prompt, | ||
| 190 | outputSchemaJson: outputSchema, | ||
| 191 | modelSelection: input.modelSelection, | ||
| 192 | }); | ||
| 193 | |||
| 194 | return { | ||
| 195 | subject: sanitizeCommitSubject(generated.subject), | ||
| 196 | body: generated.body.trim(), | ||
| 197 | ...("branch" in generated && typeof generated.branch === "string" | ||
| 198 | ? { branch: sanitizeFeatureBranchName(generated.branch) } | ||
| 199 | : {}), | ||
| 200 | }; | ||
| 201 | }); | ||
| 202 | |||
| 203 | const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( | ||
| 204 | "CursorTextGeneration.generatePrContent", | ||
| 205 | )(function* (input) { | ||
| 206 | const { prompt, outputSchema } = buildPrContentPrompt({ | ||
| 207 | baseBranch: input.baseBranch, | ||
| 208 | headBranch: input.headBranch, | ||
| 209 | commitSummary: input.commitSummary, | ||
| 210 | diffSummary: input.diffSummary, | ||
| 211 | diffPatch: input.diffPatch, | ||
| 212 | }); | ||
| 213 | |||
| 214 | const generated = yield* runCursorJson({ | ||
| 215 | operation: "generatePrContent", | ||
| 216 | cwd: input.cwd, | ||
| 217 | prompt, | ||
| 218 | outputSchemaJson: outputSchema, | ||
| 219 | modelSelection: input.modelSelection, | ||
| 220 | }); | ||
| 221 | |||
| 222 | return { | ||
| 223 | title: sanitizePrTitle(generated.title), | ||
| 224 | body: generated.body.trim(), | ||
| 225 | }; | ||
| 226 | }); | ||
| 227 | |||
| 228 | const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( | ||
| 229 | "CursorTextGeneration.generateBranchName", | ||
| 230 | )(function* (input) { | ||
| 231 | const { prompt, outputSchema } = buildBranchNamePrompt({ | ||
| 232 | message: input.message, | ||
| 233 | attachments: input.attachments, | ||
| 234 | }); | ||
| 235 | |||
| 236 | const generated = yield* runCursorJson({ | ||
| 237 | operation: "generateBranchName", | ||
| 238 | cwd: input.cwd, | ||
| 239 | prompt, | ||
| 240 | outputSchemaJson: outputSchema, | ||
| 241 | modelSelection: input.modelSelection, | ||
| 242 | }); | ||
| 243 | |||
| 244 | return { | ||
| 245 | branch: sanitizeBranchFragment(generated.branch), | ||
| 246 | }; | ||
| 247 | }); | ||
| 248 | |||
| 249 | const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( | ||
| 250 | "CursorTextGeneration.generateThreadTitle", | ||
| 251 | )(function* (input) { | ||
| 252 | const { prompt, outputSchema } = buildThreadTitlePrompt({ | ||
| 253 | message: input.message, | ||
| 254 | attachments: input.attachments, | ||
| 255 | }); | ||
| 256 | |||
| 257 | const generated = yield* runCursorJson({ | ||
| 258 | operation: "generateThreadTitle", | ||
| 259 | cwd: input.cwd, | ||
| 260 | prompt, | ||
| 261 | outputSchemaJson: outputSchema, | ||
| 262 | modelSelection: input.modelSelection, | ||
| 263 | }); | ||
| 264 | |||
| 265 | return { | ||
| 266 | title: sanitizeThreadTitle(generated.title), | ||
| 267 | } satisfies ThreadTitleGenerationResult; | ||
| 268 | }); | ||
| 269 | |||
| 270 | return { | ||
| 271 | generateCommitMessage, | ||
| 272 | generatePrContent, | ||
| 273 | generateBranchName, | ||
| 274 | generateThreadTitle, | ||
| 275 | } satisfies TextGenerationShape; | ||
| 276 | }); | ||
apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts created+346| ... | @@ -0,0 +1,346 @@ | ||
| 1 | import { OpenCodeSettings, ProviderInstanceId } from "@t3tools/contracts"; | ||
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 3 | import { it } from "@effect/vitest"; | ||
| 4 | import { Duration, Effect, Layer, Schema } from "effect"; | ||
| 5 | import { TestClock } from "effect/testing"; | ||
| 6 | import { NetService } from "@t3tools/shared/Net"; | ||
| 7 | import { beforeEach, expect } from "vitest"; | ||
| 8 | |||
| 9 | import { ServerConfig } from "../config.ts"; | ||
| 10 | import { | ||
| 11 | OpenCodeRuntime, | ||
| 12 | OpenCodeRuntimeError, | ||
| 13 | type OpenCodeRuntimeShape, | ||
| 14 | } from "../provider/opencodeRuntime.ts"; | ||
| 15 | import { type TextGenerationShape } from "./TextGeneration.ts"; | ||
| 16 | import { makeOpenCodeTextGeneration } from "./OpenCodeTextGeneration.ts"; | ||
| 17 | |||
| 18 | const runtimeMock = { | ||
| 19 | state: { | ||
| 20 | startCalls: [] as string[], | ||
| 21 | promptUrls: [] as string[], | ||
| 22 | authHeaders: [] as Array<string | null>, | ||
| 23 | closeCalls: [] as string[], | ||
| 24 | promptResult: undefined as | ||
| 25 | | { data?: { info?: { error?: unknown }; parts?: Array<{ type: string; text?: string }> } } | ||
| 26 | | undefined, | ||
| 27 | }, | ||
| 28 | reset() { | ||
| 29 | this.state.startCalls.length = 0; | ||
| 30 | this.state.promptUrls.length = 0; | ||
| 31 | this.state.authHeaders.length = 0; | ||
| 32 | this.state.closeCalls.length = 0; | ||
| 33 | this.state.promptResult = undefined; | ||
| 34 | }, | ||
| 35 | }; | ||
| 36 | |||
| 37 | const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { | ||
| 38 | startOpenCodeServerProcess: ({ binaryPath }) => | ||
| 39 | Effect.gen(function* () { | ||
| 40 | const index = runtimeMock.state.startCalls.length + 1; | ||
| 41 | const url = `http://127.0.0.1:${4_300 + index}`; | ||
| 42 | runtimeMock.state.startCalls.push(binaryPath); | ||
| 43 | // The production runtime binds server lifetime to the caller's scope. | ||
| 44 | // Mirror that here so the closeCalls probe observes scope close. | ||
| 45 | yield* Effect.addFinalizer(() => | ||
| 46 | Effect.sync(() => { | ||
| 47 | runtimeMock.state.closeCalls.push(url); | ||
| 48 | }), | ||
| 49 | ); | ||
| 50 | return { | ||
| 51 | url, | ||
| 52 | exitCode: Effect.never, | ||
| 53 | }; | ||
| 54 | }), | ||
| 55 | connectToOpenCodeServer: ({ serverUrl }) => | ||
| 56 | Effect.succeed({ | ||
| 57 | url: serverUrl ?? "http://127.0.0.1:4301", | ||
| 58 | exitCode: null, | ||
| 59 | external: Boolean(serverUrl), | ||
| 60 | }), | ||
| 61 | runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), | ||
| 62 | createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => | ||
| 63 | ({ | ||
| 64 | session: { | ||
| 65 | create: async () => ({ data: { id: `${baseUrl}/session` } }), | ||
| 66 | prompt: async () => { | ||
| 67 | runtimeMock.state.promptUrls.push(baseUrl); | ||
| 68 | runtimeMock.state.authHeaders.push( | ||
| 69 | serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, | ||
| 70 | ); | ||
| 71 | return ( | ||
| 72 | runtimeMock.state.promptResult ?? { | ||
| 73 | data: { | ||
| 74 | parts: [ | ||
| 75 | { | ||
| 76 | type: "text", | ||
| 77 | text: JSON.stringify({ | ||
| 78 | subject: "Improve OpenCode reuse", | ||
| 79 | body: "Reuse one server for the full action.", | ||
| 80 | }), | ||
| 81 | }, | ||
| 82 | ], | ||
| 83 | }, | ||
| 84 | } | ||
| 85 | ); | ||
| 86 | }, | ||
| 87 | }, | ||
| 88 | }) as unknown as ReturnType<OpenCodeRuntimeShape["createOpenCodeSdkClient"]>, | ||
| 89 | loadOpenCodeInventory: () => | ||
| 90 | Effect.fail( | ||
| 91 | new OpenCodeRuntimeError({ | ||
| 92 | operation: "loadOpenCodeInventory", | ||
| 93 | detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventory not used in this test", | ||
| 94 | cause: null, | ||
| 95 | }), | ||
| 96 | ), | ||
| 97 | }; | ||
| 98 | |||
| 99 | const DEFAULT_TEST_MODEL_SELECTION = { | ||
| 100 | instanceId: ProviderInstanceId.make("opencode"), | ||
| 101 | model: "openai/gpt-5", | ||
| 102 | }; | ||
| 103 | |||
| 104 | const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; | ||
| 105 | |||
| 106 | const OpenCodeTextGenerationTestLayer = Layer.succeed( | ||
| 107 | OpenCodeRuntime, | ||
| 108 | OpenCodeRuntimeTestDouble, | ||
| 109 | ).pipe( | ||
| 110 | Layer.provideMerge( | ||
| 111 | ServerConfig.layerTest(process.cwd(), { | ||
| 112 | prefix: "t3code-opencode-text-generation-test-", | ||
| 113 | }), | ||
| 114 | ), | ||
| 115 | Layer.provideMerge(NetService.layer), | ||
| 116 | Layer.provideMerge(NodeServices.layer), | ||
| 117 | ); | ||
| 118 | |||
| 119 | const OpenCodeTextGenerationExistingServerTestLayer = Layer.succeed( | ||
| 120 | OpenCodeRuntime, | ||
| 121 | OpenCodeRuntimeTestDouble, | ||
| 122 | ).pipe( | ||
| 123 | Layer.provideMerge( | ||
| 124 | ServerConfig.layerTest(process.cwd(), { | ||
| 125 | prefix: "t3code-opencode-text-generation-existing-server-test-", | ||
| 126 | }), | ||
| 127 | ), | ||
| 128 | Layer.provideMerge(NetService.layer), | ||
| 129 | Layer.provideMerge(NodeServices.layer), | ||
| 130 | ); | ||
| 131 | |||
| 132 | const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ | ||
| 133 | binaryPath: "fake-opencode", | ||
| 134 | }); | ||
| 135 | const EXISTING_SERVER_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ | ||
| 136 | binaryPath: "fake-opencode", | ||
| 137 | serverUrl: "http://127.0.0.1:9999", | ||
| 138 | serverPassword: "secret-password", | ||
| 139 | }); | ||
| 140 | |||
| 141 | function withOpenCodeTextGeneration<A, E, R>( | ||
| 142 | settings: OpenCodeSettings, | ||
| 143 | effectFn: (textGeneration: TextGenerationShape) => Effect.Effect<A, E, R>, | ||
| 144 | ) { | ||
| 145 | return Effect.gen(function* () { | ||
| 146 | const textGeneration = yield* makeOpenCodeTextGeneration(settings); | ||
| 147 | return yield* effectFn(textGeneration); | ||
| 148 | }).pipe(Effect.scoped); | ||
| 149 | } | ||
| 150 | |||
| 151 | beforeEach(() => { | ||
| 152 | runtimeMock.reset(); | ||
| 153 | }); | ||
| 154 | |||
| 155 | const advanceIdleClock = Effect.gen(function* () { | ||
| 156 | yield* Effect.yieldNow; | ||
| 157 | yield* TestClock.adjust(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS + 1)); | ||
| 158 | yield* Effect.yieldNow; | ||
| 159 | }); | ||
| 160 | |||
| 161 | it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { | ||
| 162 | it.effect("reuses a warm server across back-to-back requests and closes it after idling", () => | ||
| 163 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => | ||
| 164 | Effect.gen(function* () { | ||
| 165 | yield* textGeneration.generateCommitMessage({ | ||
| 166 | cwd: process.cwd(), | ||
| 167 | branch: "feature/opencode-reuse", | ||
| 168 | stagedSummary: "M README.md", | ||
| 169 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 170 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 171 | }); | ||
| 172 | yield* textGeneration.generateCommitMessage({ | ||
| 173 | cwd: process.cwd(), | ||
| 174 | branch: "feature/opencode-reuse", | ||
| 175 | stagedSummary: "M README.md", | ||
| 176 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 177 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 178 | }); | ||
| 179 | |||
| 180 | expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]); | ||
| 181 | expect(runtimeMock.state.promptUrls).toEqual([ | ||
| 182 | "http://127.0.0.1:4301", | ||
| 183 | "http://127.0.0.1:4301", | ||
| 184 | ]); | ||
| 185 | expect(runtimeMock.state.closeCalls).toEqual([]); | ||
| 186 | |||
| 187 | yield* advanceIdleClock; | ||
| 188 | |||
| 189 | expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); | ||
| 190 | }), | ||
| 191 | ).pipe(Effect.provide(TestClock.layer())), | ||
| 192 | ); | ||
| 193 | |||
| 194 | it.effect("starts a new server after the warm server idles out", () => | ||
| 195 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => | ||
| 196 | Effect.gen(function* () { | ||
| 197 | yield* textGeneration.generateCommitMessage({ | ||
| 198 | cwd: process.cwd(), | ||
| 199 | branch: "feature/opencode-reuse", | ||
| 200 | stagedSummary: "M README.md", | ||
| 201 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 202 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 203 | }); | ||
| 204 | |||
| 205 | yield* advanceIdleClock; | ||
| 206 | |||
| 207 | yield* textGeneration.generateCommitMessage({ | ||
| 208 | cwd: process.cwd(), | ||
| 209 | branch: "feature/opencode-reuse", | ||
| 210 | stagedSummary: "M README.md", | ||
| 211 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 212 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 213 | }); | ||
| 214 | |||
| 215 | expect(runtimeMock.state.startCalls).toEqual(["fake-opencode", "fake-opencode"]); | ||
| 216 | expect(runtimeMock.state.promptUrls).toEqual([ | ||
| 217 | "http://127.0.0.1:4301", | ||
| 218 | "http://127.0.0.1:4302", | ||
| 219 | ]); | ||
| 220 | expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); | ||
| 221 | }), | ||
| 222 | ).pipe(Effect.provide(TestClock.layer())), | ||
| 223 | ); | ||
| 224 | |||
| 225 | it.effect("returns a typed empty-output error when OpenCode returns no text parts", () => | ||
| 226 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => | ||
| 227 | Effect.gen(function* () { | ||
| 228 | runtimeMock.state.promptResult = { data: {} }; | ||
| 229 | |||
| 230 | const error = yield* textGeneration | ||
| 231 | .generateCommitMessage({ | ||
| 232 | cwd: process.cwd(), | ||
| 233 | branch: "feature/opencode-reuse", | ||
| 234 | stagedSummary: "M README.md", | ||
| 235 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 236 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 237 | }) | ||
| 238 | .pipe(Effect.flip); | ||
| 239 | |||
| 240 | expect(error.message).toContain("OpenCode returned empty output."); | ||
| 241 | }), | ||
| 242 | ), | ||
| 243 | ); | ||
| 244 | |||
| 245 | it.effect("parses JSON returned as plain text output", () => | ||
| 246 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => | ||
| 247 | Effect.gen(function* () { | ||
| 248 | runtimeMock.state.promptResult = { | ||
| 249 | data: { | ||
| 250 | parts: [ | ||
| 251 | { | ||
| 252 | type: "text", | ||
| 253 | text: 'Here is the result:\n{"subject":"Tighten OpenCode parsing","body":"Handle JSON text output locally."}', | ||
| 254 | }, | ||
| 255 | ], | ||
| 256 | }, | ||
| 257 | }; | ||
| 258 | |||
| 259 | const result = yield* textGeneration.generateCommitMessage({ | ||
| 260 | cwd: process.cwd(), | ||
| 261 | branch: "feature/opencode-reuse", | ||
| 262 | stagedSummary: "M README.md", | ||
| 263 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 264 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 265 | }); | ||
| 266 | |||
| 267 | expect(result).toEqual({ | ||
| 268 | subject: "Tighten OpenCode parsing", | ||
| 269 | body: "Handle JSON text output locally.", | ||
| 270 | }); | ||
| 271 | }), | ||
| 272 | ), | ||
| 273 | ); | ||
| 274 | |||
| 275 | it.effect("surfaces the upstream OpenCode structured-output error message", () => | ||
| 276 | withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => | ||
| 277 | Effect.gen(function* () { | ||
| 278 | runtimeMock.state.promptResult = { | ||
| 279 | data: { | ||
| 280 | info: { | ||
| 281 | error: { | ||
| 282 | name: "StructuredOutputError", | ||
| 283 | data: { | ||
| 284 | message: "Model did not produce structured output", | ||
| 285 | retries: 2, | ||
| 286 | }, | ||
| 287 | }, | ||
| 288 | }, | ||
| 289 | }, | ||
| 290 | }; | ||
| 291 | |||
| 292 | const error = yield* textGeneration | ||
| 293 | .generateCommitMessage({ | ||
| 294 | cwd: process.cwd(), | ||
| 295 | branch: "feature/opencode-reuse", | ||
| 296 | stagedSummary: "M README.md", | ||
| 297 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 298 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 299 | }) | ||
| 300 | .pipe(Effect.flip); | ||
| 301 | |||
| 302 | expect(error.message).toContain("Model did not produce structured output"); | ||
| 303 | }), | ||
| 304 | ), | ||
| 305 | ); | ||
| 306 | }); | ||
| 307 | |||
| 308 | it.layer(OpenCodeTextGenerationExistingServerTestLayer)( | ||
| 309 | "OpenCodeTextGeneration with configured server URL", | ||
| 310 | (it) => { | ||
| 311 | it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () => | ||
| 312 | withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => | ||
| 313 | Effect.gen(function* () { | ||
| 314 | yield* textGeneration.generateCommitMessage({ | ||
| 315 | cwd: process.cwd(), | ||
| 316 | branch: "feature/opencode-reuse", | ||
| 317 | stagedSummary: "M README.md", | ||
| 318 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 319 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 320 | }); | ||
| 321 | yield* textGeneration.generateCommitMessage({ | ||
| 322 | cwd: process.cwd(), | ||
| 323 | branch: "feature/opencode-reuse", | ||
| 324 | stagedSummary: "M README.md", | ||
| 325 | stagedPatch: "diff --git a/README.md b/README.md", | ||
| 326 | modelSelection: DEFAULT_TEST_MODEL_SELECTION, | ||
| 327 | }); | ||
| 328 | |||
| 329 | expect(runtimeMock.state.startCalls).toEqual([]); | ||
| 330 | expect(runtimeMock.state.promptUrls).toEqual([ | ||
| 331 | "http://127.0.0.1:9999", | ||
| 332 | "http://127.0.0.1:9999", | ||
| 333 | ]); | ||
| 334 | expect(runtimeMock.state.authHeaders).toEqual([ | ||
| 335 | `Basic ${btoa("opencode:secret-password")}`, | ||
| 336 | `Basic ${btoa("opencode:secret-password")}`, | ||
| 337 | ]); | ||
| 338 | |||
| 339 | yield* advanceIdleClock; | ||
| 340 | |||
| 341 | expect(runtimeMock.state.closeCalls).toEqual([]); | ||
| 342 | }), | ||
| 343 | ).pipe(Effect.provide(TestClock.layer())), | ||
| 344 | ); | ||
| 345 | }, | ||
| 346 | ); | ||
apps/server/src/textGeneration/OpenCodeTextGeneration.ts created+464| ... | @@ -0,0 +1,464 @@ | ||
| 1 | import { Effect, Exit, Fiber, Schema, Scope } from "effect"; | ||
| 2 | import * as Semaphore from "effect/Semaphore"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | TextGenerationError, | ||
| 6 | type ChatAttachment, | ||
| 7 | type ModelSelection, | ||
| 8 | type OpenCodeSettings, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; | ||
| 11 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | ||
| 12 | |||
| 13 | import { ServerConfig } from "../config.ts"; | ||
| 14 | import { resolveAttachmentPath } from "../attachmentStore.ts"; | ||
| 15 | import { | ||
| 16 | buildBranchNamePrompt, | ||
| 17 | buildCommitMessagePrompt, | ||
| 18 | buildPrContentPrompt, | ||
| 19 | buildThreadTitlePrompt, | ||
| 20 | } from "./TextGenerationPrompts.ts"; | ||
| 21 | import { type TextGenerationShape } from "./TextGeneration.ts"; | ||
| 22 | import { | ||
| 23 | extractJsonObject, | ||
| 24 | sanitizeCommitSubject, | ||
| 25 | sanitizePrTitle, | ||
| 26 | sanitizeThreadTitle, | ||
| 27 | } from "./TextGenerationUtils.ts"; | ||
| 28 | import { | ||
| 29 | OpenCodeRuntime, | ||
| 30 | type OpenCodeServerConnection, | ||
| 31 | type OpenCodeServerProcess, | ||
| 32 | openCodeRuntimeErrorDetail, | ||
| 33 | parseOpenCodeModelSlug, | ||
| 34 | toOpenCodeFileParts, | ||
| 35 | } from "../provider/opencodeRuntime.ts"; | ||
| 36 | |||
| 37 | const OPENCODE_TEXT_GENERATION_IDLE_TTL = "30 seconds"; | ||
| 38 | |||
| 39 | function getOpenCodePromptErrorMessage(error: unknown): string | null { | ||
| 40 | if (!error || typeof error !== "object") { | ||
| 41 | return null; | ||
| 42 | } | ||
| 43 | |||
| 44 | const message = | ||
| 45 | "data" in error && | ||
| 46 | error.data && | ||
| 47 | typeof error.data === "object" && | ||
| 48 | "message" in error.data && | ||
| 49 | typeof error.data.message === "string" | ||
| 50 | ? error.data.message.trim() | ||
| 51 | : ""; | ||
| 52 | if (message.length > 0) { | ||
| 53 | return message; | ||
| 54 | } | ||
| 55 | |||
| 56 | if ("name" in error && typeof error.name === "string") { | ||
| 57 | const name = error.name.trim(); | ||
| 58 | return name.length > 0 ? name : null; | ||
| 59 | } | ||
| 60 | |||
| 61 | return null; | ||
| 62 | } | ||
| 63 | |||
| 64 | function getOpenCodeTextResponse(parts: ReadonlyArray<unknown> | undefined): string { | ||
| 65 | return (parts ?? []) | ||
| 66 | .flatMap((part) => { | ||
| 67 | if (!part || typeof part !== "object") { | ||
| 68 | return []; | ||
| 69 | } | ||
| 70 | if (!("type" in part) || part.type !== "text") { | ||
| 71 | return []; | ||
| 72 | } | ||
| 73 | if (!("text" in part) || typeof part.text !== "string") { | ||
| 74 | return []; | ||
| 75 | } | ||
| 76 | return [part.text]; | ||
| 77 | }) | ||
| 78 | .join("") | ||
| 79 | .trim(); | ||
| 80 | } | ||
| 81 | |||
| 82 | interface SharedOpenCodeTextGenerationServerState { | ||
| 83 | server: OpenCodeServerProcess | null; | ||
| 84 | /** | ||
| 85 | * The scope that owns the shared server's lifetime. Closing this scope | ||
| 86 | * terminates the OpenCode child process and interrupts any fibers the | ||
| 87 | * runtime forked during startup. We don't hold a `close()` function on | ||
| 88 | * the server handle anymore — the scope is the only lifecycle handle. | ||
| 89 | */ | ||
| 90 | serverScope: Scope.Closeable | null; | ||
| 91 | binaryPath: string | null; | ||
| 92 | activeRequests: number; | ||
| 93 | idleCloseFiber: Fiber.Fiber<void, never> | null; | ||
| 94 | } | ||
| 95 | |||
| 96 | export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* ( | ||
| 97 | openCodeSettings: OpenCodeSettings, | ||
| 98 | environment: NodeJS.ProcessEnv = process.env, | ||
| 99 | ) { | ||
| 100 | const serverConfig = yield* ServerConfig; | ||
| 101 | const openCodeRuntime = yield* OpenCodeRuntime; | ||
| 102 | const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => | ||
| 103 | Scope.close(scope, Exit.void), | ||
| 104 | ); | ||
| 105 | const sharedServerMutex = yield* Semaphore.make(1); | ||
| 106 | const sharedServerState: SharedOpenCodeTextGenerationServerState = { | ||
| 107 | server: null, | ||
| 108 | serverScope: null, | ||
| 109 | binaryPath: null, | ||
| 110 | activeRequests: 0, | ||
| 111 | idleCloseFiber: null, | ||
| 112 | }; | ||
| 113 | |||
| 114 | const closeSharedServer = Effect.fn("closeSharedServer")(function* () { | ||
| 115 | const scope = sharedServerState.serverScope; | ||
| 116 | sharedServerState.server = null; | ||
| 117 | sharedServerState.serverScope = null; | ||
| 118 | sharedServerState.binaryPath = null; | ||
| 119 | if (scope !== null) { | ||
| 120 | yield* Scope.close(scope, Exit.void).pipe(Effect.ignore); | ||
| 121 | } | ||
| 122 | }); | ||
| 123 | |||
| 124 | const cancelIdleCloseFiber = Effect.fn("cancelIdleCloseFiber")(function* () { | ||
| 125 | const idleCloseFiber = sharedServerState.idleCloseFiber; | ||
| 126 | sharedServerState.idleCloseFiber = null; | ||
| 127 | if (idleCloseFiber !== null) { | ||
| 128 | yield* Fiber.interrupt(idleCloseFiber).pipe(Effect.ignore); | ||
| 129 | } | ||
| 130 | }); | ||
| 131 | |||
| 132 | const scheduleIdleClose = Effect.fn("scheduleIdleClose")(function* ( | ||
| 133 | server: OpenCodeServerProcess, | ||
| 134 | ) { | ||
| 135 | yield* cancelIdleCloseFiber(); | ||
| 136 | const fiber = yield* Effect.sleep(OPENCODE_TEXT_GENERATION_IDLE_TTL).pipe( | ||
| 137 | Effect.andThen( | ||
| 138 | sharedServerMutex.withPermit( | ||
| 139 | Effect.gen(function* () { | ||
| 140 | if (sharedServerState.server !== server || sharedServerState.activeRequests > 0) { | ||
| 141 | return; | ||
| 142 | } | ||
| 143 | sharedServerState.idleCloseFiber = null; | ||
| 144 | yield* closeSharedServer(); | ||
| 145 | }), | ||
| 146 | ), | ||
| 147 | ), | ||
| 148 | Effect.forkIn(idleFiberScope), | ||
| 149 | ); | ||
| 150 | sharedServerState.idleCloseFiber = fiber; | ||
| 151 | }); | ||
| 152 | |||
| 153 | const acquireSharedServer = (input: { | ||
| 154 | readonly binaryPath: string; | ||
| 155 | readonly operation: | ||
| 156 | | "generateCommitMessage" | ||
| 157 | | "generatePrContent" | ||
| 158 | | "generateBranchName" | ||
| 159 | | "generateThreadTitle"; | ||
| 160 | }) => | ||
| 161 | sharedServerMutex.withPermit( | ||
| 162 | Effect.gen(function* () { | ||
| 163 | yield* cancelIdleCloseFiber(); | ||
| 164 | |||
| 165 | const existingServer = sharedServerState.server; | ||
| 166 | if (existingServer !== null) { | ||
| 167 | if ( | ||
| 168 | sharedServerState.binaryPath !== input.binaryPath && | ||
| 169 | sharedServerState.activeRequests === 0 | ||
| 170 | ) { | ||
| 171 | yield* closeSharedServer(); | ||
| 172 | } else { | ||
| 173 | if (sharedServerState.binaryPath !== input.binaryPath) { | ||
| 174 | yield* Effect.logWarning( | ||
| 175 | "OpenCode shared server binary path mismatch: requested " + | ||
| 176 | input.binaryPath + | ||
| 177 | " but active server uses " + | ||
| 178 | sharedServerState.binaryPath + | ||
| 179 | "; reusing existing server because there are active requests", | ||
| 180 | ); | ||
| 181 | } | ||
| 182 | sharedServerState.activeRequests += 1; | ||
| 183 | return existingServer; | ||
| 184 | } | ||
| 185 | } | ||
| 186 | |||
| 187 | // Create a fresh scope that owns this shared server. The runtime | ||
| 188 | // will attach its child-process and fiber finalizers to this scope; | ||
| 189 | // closing it kills the server and interrupts those fibers. | ||
| 190 | // | ||
| 191 | // The `Scope.make` / spawn / record-or-close transitions run inside | ||
| 192 | // `uninterruptibleMask` so an interrupt arriving between any two | ||
| 193 | // steps can't orphan the scope (and the child process attached to | ||
| 194 | // it) before we either close it on failure or hand ownership to | ||
| 195 | // `sharedServerState`. `restore` keeps the actual spawn | ||
| 196 | // interruptible; an interrupt during the spawn is captured by | ||
| 197 | // `Effect.exit` and drives us through the failure branch that | ||
| 198 | // closes the fresh scope. | ||
| 199 | return yield* Effect.uninterruptibleMask((restore) => | ||
| 200 | Effect.gen(function* () { | ||
| 201 | const serverScope = yield* Scope.make(); | ||
| 202 | const startedExit = yield* Effect.exit( | ||
| 203 | restore( | ||
| 204 | openCodeRuntime | ||
| 205 | .startOpenCodeServerProcess({ | ||
| 206 | binaryPath: input.binaryPath, | ||
| 207 | environment, | ||
| 208 | }) | ||
| 209 | .pipe( | ||
| 210 | Effect.provideService(Scope.Scope, serverScope), | ||
| 211 | Effect.mapError( | ||
| 212 | (cause) => | ||
| 213 | new TextGenerationError({ | ||
| 214 | operation: input.operation, | ||
| 215 | detail: openCodeRuntimeErrorDetail(cause), | ||
| 216 | cause, | ||
| 217 | }), | ||
| 218 | ), | ||
| 219 | ), | ||
| 220 | ), | ||
| 221 | ); | ||
| 222 | if (startedExit._tag === "Failure") { | ||
| 223 | yield* Scope.close(serverScope, Exit.void).pipe(Effect.ignore); | ||
| 224 | return yield* Effect.failCause(startedExit.cause); | ||
| 225 | } | ||
| 226 | |||
| 227 | const server = startedExit.value; | ||
| 228 | sharedServerState.server = server; | ||
| 229 | sharedServerState.serverScope = serverScope; | ||
| 230 | sharedServerState.binaryPath = input.binaryPath; | ||
| 231 | sharedServerState.activeRequests = 1; | ||
| 232 | return server; | ||
| 233 | }), | ||
| 234 | ); | ||
| 235 | }), | ||
| 236 | ); | ||
| 237 | |||
| 238 | const releaseSharedServer = (server: OpenCodeServerProcess) => | ||
| 239 | sharedServerMutex.withPermit( | ||
| 240 | Effect.gen(function* () { | ||
| 241 | if (sharedServerState.server !== server) { | ||
| 242 | return; | ||
| 243 | } | ||
| 244 | sharedServerState.activeRequests = Math.max(0, sharedServerState.activeRequests - 1); | ||
| 245 | if (sharedServerState.activeRequests === 0) { | ||
| 246 | yield* scheduleIdleClose(server); | ||
| 247 | } | ||
| 248 | }), | ||
| 249 | ); | ||
| 250 | |||
| 251 | // Module-level finalizer: on layer shutdown, cancel the idle close fiber | ||
| 252 | // and close the shared server scope. Consumers therefore cannot leak | ||
| 253 | // the shared OpenCode server by forgetting to call anything. | ||
| 254 | yield* Effect.addFinalizer(() => | ||
| 255 | sharedServerMutex.withPermit( | ||
| 256 | Effect.gen(function* () { | ||
| 257 | yield* cancelIdleCloseFiber(); | ||
| 258 | sharedServerState.activeRequests = 0; | ||
| 259 | yield* closeSharedServer(); | ||
| 260 | }), | ||
| 261 | ), | ||
| 262 | ); | ||
| 263 | |||
| 264 | const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* <S extends Schema.Top>(input: { | ||
| 265 | readonly operation: | ||
| 266 | | "generateCommitMessage" | ||
| 267 | | "generatePrContent" | ||
| 268 | | "generateBranchName" | ||
| 269 | | "generateThreadTitle"; | ||
| 270 | readonly cwd: string; | ||
| 271 | readonly prompt: string; | ||
| 272 | readonly outputSchemaJson: S; | ||
| 273 | readonly modelSelection: ModelSelection; | ||
| 274 | readonly attachments?: ReadonlyArray<ChatAttachment> | undefined; | ||
| 275 | }) { | ||
| 276 | const parsedModel = parseOpenCodeModelSlug(input.modelSelection.model); | ||
| 277 | if (!parsedModel) { | ||
| 278 | return yield* new TextGenerationError({ | ||
| 279 | operation: input.operation, | ||
| 280 | detail: "OpenCode model selection must use the 'provider/model' format.", | ||
| 281 | }); | ||
| 282 | } | ||
| 283 | |||
| 284 | const fileParts = toOpenCodeFileParts({ | ||
| 285 | attachments: input.attachments, | ||
| 286 | resolveAttachmentPath: (attachment) => | ||
| 287 | resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), | ||
| 288 | }); | ||
| 289 | |||
| 290 | const runAgainstServer = (server: Pick<OpenCodeServerConnection, "url">) => | ||
| 291 | Effect.tryPromise({ | ||
| 292 | try: async () => { | ||
| 293 | const client = openCodeRuntime.createOpenCodeSdkClient({ | ||
| 294 | baseUrl: server.url, | ||
| 295 | directory: input.cwd, | ||
| 296 | ...(openCodeSettings.serverUrl.length > 0 && openCodeSettings.serverPassword | ||
| 297 | ? { serverPassword: openCodeSettings.serverPassword } | ||
| 298 | : {}), | ||
| 299 | }); | ||
| 300 | const session = await client.session.create({ | ||
| 301 | title: `T3 Code ${input.operation}`, | ||
| 302 | permission: [{ permission: "*", pattern: "*", action: "deny" }], | ||
| 303 | }); | ||
| 304 | if (!session.data) { | ||
| 305 | throw new Error("OpenCode session.create returned no session payload."); | ||
| 306 | } | ||
| 307 | const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); | ||
| 308 | const selectedVariant = getModelSelectionStringOptionValue( | ||
| 309 | input.modelSelection, | ||
| 310 | "variant", | ||
| 311 | ); | ||
| 312 | |||
| 313 | const result = await client.session.prompt({ | ||
| 314 | sessionID: session.data.id, | ||
| 315 | model: parsedModel, | ||
| 316 | ...(selectedAgent ? { agent: selectedAgent } : {}), | ||
| 317 | ...(selectedVariant ? { variant: selectedVariant } : {}), | ||
| 318 | parts: [{ type: "text", text: input.prompt }, ...fileParts], | ||
| 319 | }); | ||
| 320 | const info = result.data?.info; | ||
| 321 | const errorMessage = getOpenCodePromptErrorMessage(info?.error); | ||
| 322 | if (errorMessage) { | ||
| 323 | throw new Error(errorMessage); | ||
| 324 | } | ||
| 325 | const rawText = getOpenCodeTextResponse(result.data?.parts); | ||
| 326 | if (rawText.length === 0) { | ||
| 327 | throw new Error("OpenCode returned empty output."); | ||
| 328 | } | ||
| 329 | return rawText; | ||
| 330 | }, | ||
| 331 | catch: (cause) => | ||
| 332 | new TextGenerationError({ | ||
| 333 | operation: input.operation, | ||
| 334 | detail: openCodeRuntimeErrorDetail(cause), | ||
| 335 | cause, | ||
| 336 | }), | ||
| 337 | }); | ||
| 338 | |||
| 339 | const rawOutput = | ||
| 340 | openCodeSettings.serverUrl.length > 0 | ||
| 341 | ? yield* runAgainstServer({ url: openCodeSettings.serverUrl }) | ||
| 342 | : yield* Effect.acquireUseRelease( | ||
| 343 | acquireSharedServer({ | ||
| 344 | binaryPath: openCodeSettings.binaryPath, | ||
| 345 | operation: input.operation, | ||
| 346 | }), | ||
| 347 | runAgainstServer, | ||
| 348 | releaseSharedServer, | ||
| 349 | ); | ||
| 350 | |||
| 351 | return yield* Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson))( | ||
| 352 | extractJsonObject(rawOutput), | ||
| 353 | ).pipe( | ||
| 354 | Effect.catchTag("SchemaError", (cause) => | ||
| 355 | Effect.fail( | ||
| 356 | new TextGenerationError({ | ||
| 357 | operation: input.operation, | ||
| 358 | detail: "OpenCode returned invalid structured output.", | ||
| 359 | cause, | ||
| 360 | }), | ||
| 361 | ), | ||
| 362 | ), | ||
| 363 | ); | ||
| 364 | }); | ||
| 365 | |||
| 366 | const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( | ||
| 367 | "OpenCodeTextGeneration.generateCommitMessage", | ||
| 368 | )(function* (input) { | ||
| 369 | const { prompt, outputSchema } = buildCommitMessagePrompt({ | ||
| 370 | branch: input.branch, | ||
| 371 | stagedSummary: input.stagedSummary, | ||
| 372 | stagedPatch: input.stagedPatch, | ||
| 373 | includeBranch: input.includeBranch === true, | ||
| 374 | }); | ||
| 375 | const generated = yield* runOpenCodeJson({ | ||
| 376 | operation: "generateCommitMessage", | ||
| 377 | cwd: input.cwd, | ||
| 378 | prompt, | ||
| 379 | outputSchemaJson: outputSchema, | ||
| 380 | modelSelection: input.modelSelection, | ||
| 381 | }); | ||
| 382 | |||
| 383 | return { | ||
| 384 | subject: sanitizeCommitSubject(generated.subject), | ||
| 385 | body: generated.body.trim(), | ||
| 386 | ...("branch" in generated && typeof generated.branch === "string" | ||
| 387 | ? { branch: sanitizeFeatureBranchName(generated.branch) } | ||
| 388 | : {}), | ||
| 389 | }; | ||
| 390 | }); | ||
| 391 | |||
| 392 | const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( | ||
| 393 | "OpenCodeTextGeneration.generatePrContent", | ||
| 394 | )(function* (input) { | ||
| 395 | const { prompt, outputSchema } = buildPrContentPrompt({ | ||
| 396 | baseBranch: input.baseBranch, | ||
| 397 | headBranch: input.headBranch, | ||
| 398 | commitSummary: input.commitSummary, | ||
| 399 | diffSummary: input.diffSummary, | ||
| 400 | diffPatch: input.diffPatch, | ||
| 401 | }); | ||
| 402 | const generated = yield* runOpenCodeJson({ | ||
| 403 | operation: "generatePrContent", | ||
| 404 | cwd: input.cwd, | ||
| 405 | prompt, | ||
| 406 | outputSchemaJson: outputSchema, | ||
| 407 | modelSelection: input.modelSelection, | ||
| 408 | }); | ||
| 409 | |||
| 410 | return { | ||
| 411 | title: sanitizePrTitle(generated.title), | ||
| 412 | body: generated.body.trim(), | ||
| 413 | }; | ||
| 414 | }); | ||
| 415 | |||
| 416 | const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( | ||
| 417 | "OpenCodeTextGeneration.generateBranchName", | ||
| 418 | )(function* (input) { | ||
| 419 | const { prompt, outputSchema } = buildBranchNamePrompt({ | ||
| 420 | message: input.message, | ||
| 421 | attachments: input.attachments, | ||
| 422 | }); | ||
| 423 | const generated = yield* runOpenCodeJson({ | ||
| 424 | operation: "generateBranchName", | ||
| 425 | cwd: input.cwd, | ||
| 426 | prompt, | ||
| 427 | outputSchemaJson: outputSchema, | ||
| 428 | modelSelection: input.modelSelection, | ||
| 429 | attachments: input.attachments, | ||
| 430 | }); | ||
| 431 | |||
| 432 | return { | ||
| 433 | branch: sanitizeBranchFragment(generated.branch), | ||
| 434 | }; | ||
| 435 | }); | ||
| 436 | |||
| 437 | const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( | ||
| 438 | "OpenCodeTextGeneration.generateThreadTitle", | ||
| 439 | )(function* (input) { | ||
| 440 | const { prompt, outputSchema } = buildThreadTitlePrompt({ | ||
| 441 | message: input.message, | ||
| 442 | attachments: input.attachments, | ||
| 443 | }); | ||
| 444 | const generated = yield* runOpenCodeJson({ | ||
| 445 | operation: "generateThreadTitle", | ||
| 446 | cwd: input.cwd, | ||
| 447 | prompt, | ||
| 448 | outputSchemaJson: outputSchema, | ||
| 449 | modelSelection: input.modelSelection, | ||
| 450 | attachments: input.attachments, | ||
| 451 | }); | ||
| 452 | |||
| 453 | return { | ||
| 454 | title: sanitizeThreadTitle(generated.title), | ||
| 455 | }; | ||
| 456 | }); | ||
| 457 | |||
| 458 | return { | ||
| 459 | generateCommitMessage, | ||
| 460 | generatePrContent, | ||
| 461 | generateBranchName, | ||
| 462 | generateThreadTitle, | ||
| 463 | } satisfies TextGenerationShape; | ||
| 464 | }); | ||
apps/server/src/textGeneration/TextGeneration.test.ts created+117| ... | @@ -0,0 +1,117 @@ | ||
| 1 | import { it } from "@effect/vitest"; | ||
| 2 | import { Effect, PubSub, Result, Stream } from "effect"; | ||
| 3 | import { describe, expect } from "vitest"; | ||
| 4 | |||
| 5 | import { ProviderInstanceId } from "@t3tools/contracts"; | ||
| 6 | import { createModelSelection } from "@t3tools/shared/model"; | ||
| 7 | |||
| 8 | import type { ProviderInstance } from "../provider/ProviderDriver.ts"; | ||
| 9 | import type { ProviderInstanceRegistryShape } from "../provider/Services/ProviderInstanceRegistry.ts"; | ||
| 10 | import type { TextGenerationShape } from "./TextGeneration.ts"; | ||
| 11 | |||
| 12 | import { makeTextGenerationFromRegistry } from "./TextGeneration.ts"; | ||
| 13 | |||
| 14 | const makeStubTextGeneration = (overrides: Partial<TextGenerationShape>): TextGenerationShape => ({ | ||
| 15 | generateCommitMessage: () => | ||
| 16 | Effect.die("generateCommitMessage stub not configured for this test"), | ||
| 17 | generatePrContent: () => Effect.die("generatePrContent stub not configured for this test"), | ||
| 18 | generateBranchName: () => Effect.die("generateBranchName stub not configured for this test"), | ||
| 19 | generateThreadTitle: () => Effect.die("generateThreadTitle stub not configured for this test"), | ||
| 20 | ...overrides, | ||
| 21 | }); | ||
| 22 | |||
| 23 | const makeStubInstance = ( | ||
| 24 | instanceId: ProviderInstanceId, | ||
| 25 | textGeneration: TextGenerationShape, | ||
| 26 | ): ProviderInstance => | ||
| 27 | ({ | ||
| 28 | instanceId, | ||
| 29 | driverKind: instanceId as unknown as ProviderInstance["driverKind"], | ||
| 30 | continuationIdentity: { | ||
| 31 | driverKind: instanceId as unknown as ProviderInstance["driverKind"], | ||
| 32 | continuationKey: `${instanceId}:test`, | ||
| 33 | }, | ||
| 34 | displayName: undefined, | ||
| 35 | enabled: true, | ||
| 36 | snapshot: {} as ProviderInstance["snapshot"], | ||
| 37 | adapter: {} as ProviderInstance["adapter"], | ||
| 38 | textGeneration, | ||
| 39 | }) satisfies ProviderInstance; | ||
| 40 | |||
| 41 | const makeStubRegistry = ( | ||
| 42 | instances: ReadonlyArray<ProviderInstance>, | ||
| 43 | ): ProviderInstanceRegistryShape => { | ||
| 44 | const byId = new Map(instances.map((instance) => [instance.instanceId, instance] as const)); | ||
| 45 | return { | ||
| 46 | getInstance: (id) => Effect.succeed(byId.get(id)), | ||
| 47 | listInstances: Effect.succeed(instances), | ||
| 48 | listUnavailable: Effect.succeed([]), | ||
| 49 | streamChanges: Stream.empty, | ||
| 50 | // Tests never drive changes through this stub; acquire a throwaway | ||
| 51 | // subscription on an unused PubSub so the shape is satisfied. | ||
| 52 | subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), (pubsub) => | ||
| 53 | PubSub.subscribe(pubsub), | ||
| 54 | ), | ||
| 55 | }; | ||
| 56 | }; | ||
| 57 | |||
| 58 | describe("makeTextGenerationFromRegistry", () => { | ||
| 59 | it.effect("delegates to the matching instance's textGeneration closure", () => | ||
| 60 | Effect.gen(function* () { | ||
| 61 | const personalId = ProviderInstanceId.make("codex_personal"); | ||
| 62 | const personalCalls: string[] = []; | ||
| 63 | const personal = makeStubInstance( | ||
| 64 | personalId, | ||
| 65 | makeStubTextGeneration({ | ||
| 66 | generateBranchName: (input) => { | ||
| 67 | personalCalls.push(input.message); | ||
| 68 | return Effect.succeed({ branch: "personal-branch" }); | ||
| 69 | }, | ||
| 70 | }), | ||
| 71 | ); | ||
| 72 | |||
| 73 | const workId = ProviderInstanceId.make("codex_work"); | ||
| 74 | const work = makeStubInstance( | ||
| 75 | workId, | ||
| 76 | makeStubTextGeneration({ | ||
| 77 | generateBranchName: () => Effect.succeed({ branch: "work-branch" }), | ||
| 78 | }), | ||
| 79 | ); | ||
| 80 | |||
| 81 | const tg = makeTextGenerationFromRegistry(makeStubRegistry([personal, work])); | ||
| 82 | |||
| 83 | const result = yield* tg.generateBranchName({ | ||
| 84 | cwd: process.cwd(), | ||
| 85 | message: "Refactor the routing layer", | ||
| 86 | modelSelection: createModelSelection(ProviderInstanceId.make("codex_personal"), "gpt-5"), | ||
| 87 | }); | ||
| 88 | |||
| 89 | expect(result.branch).toBe("personal-branch"); | ||
| 90 | expect(personalCalls).toEqual(["Refactor the routing layer"]); | ||
| 91 | }), | ||
| 92 | ); | ||
| 93 | |||
| 94 | it.effect("fails with TextGenerationError when the instance is unknown", () => | ||
| 95 | Effect.gen(function* () { | ||
| 96 | const tg = makeTextGenerationFromRegistry(makeStubRegistry([])); | ||
| 97 | |||
| 98 | const result = yield* tg | ||
| 99 | .generateBranchName({ | ||
| 100 | cwd: process.cwd(), | ||
| 101 | message: "anything", | ||
| 102 | modelSelection: createModelSelection( | ||
| 103 | ProviderInstanceId.make("missing_instance"), | ||
| 104 | "gpt-5", | ||
| 105 | ), | ||
| 106 | }) | ||
| 107 | .pipe(Effect.result); | ||
| 108 | |||
| 109 | expect(Result.isFailure(result)).toBe(true); | ||
| 110 | if (Result.isFailure(result)) { | ||
| 111 | expect(result.failure._tag).toBe("TextGenerationError"); | ||
| 112 | expect(result.failure.operation).toBe("generateBranchName"); | ||
| 113 | expect(result.failure.detail).toContain("missing_instance"); | ||
| 114 | } | ||
| 115 | }), | ||
| 116 | ); | ||
| 117 | }); | ||
apps/server/src/textGeneration/TextGeneration.ts created+171| ... | @@ -0,0 +1,171 @@ | ||
| 1 | import { Context, Effect, Layer } from "effect"; | ||
| 2 | import type { ChatAttachment, ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; | ||
| 3 | import { TextGenerationError } from "@t3tools/contracts"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | ProviderInstanceRegistry, | ||
| 7 | type ProviderInstanceRegistryShape, | ||
| 8 | } from "../provider/Services/ProviderInstanceRegistry.ts"; | ||
| 9 | import type { ProviderInstance } from "../provider/ProviderDriver.ts"; | ||
| 10 | |||
| 11 | export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "opencode"; | ||
| 12 | |||
| 13 | export interface CommitMessageGenerationInput { | ||
| 14 | cwd: string; | ||
| 15 | branch: string | null; | ||
| 16 | stagedSummary: string; | ||
| 17 | stagedPatch: string; | ||
| 18 | /** When true, the model also returns a semantic branch name for the change. */ | ||
| 19 | includeBranch?: boolean; | ||
| 20 | /** What model and provider to use for generation. */ | ||
| 21 | modelSelection: ModelSelection; | ||
| 22 | } | ||
| 23 | |||
| 24 | export interface CommitMessageGenerationResult { | ||
| 25 | subject: string; | ||
| 26 | body: string; | ||
| 27 | /** Only present when `includeBranch` was set on the input. */ | ||
| 28 | branch?: string | undefined; | ||
| 29 | } | ||
| 30 | |||
| 31 | export interface PrContentGenerationInput { | ||
| 32 | cwd: string; | ||
| 33 | baseBranch: string; | ||
| 34 | headBranch: string; | ||
| 35 | commitSummary: string; | ||
| 36 | diffSummary: string; | ||
| 37 | diffPatch: string; | ||
| 38 | /** What model and provider to use for generation. */ | ||
| 39 | modelSelection: ModelSelection; | ||
| 40 | } | ||
| 41 | |||
| 42 | export interface PrContentGenerationResult { | ||
| 43 | title: string; | ||
| 44 | body: string; | ||
| 45 | } | ||
| 46 | |||
| 47 | export interface BranchNameGenerationInput { | ||
| 48 | cwd: string; | ||
| 49 | message: string; | ||
| 50 | attachments?: ReadonlyArray<ChatAttachment> | undefined; | ||
| 51 | /** What model and provider to use for generation. */ | ||
| 52 | modelSelection: ModelSelection; | ||
| 53 | } | ||
| 54 | |||
| 55 | export interface BranchNameGenerationResult { | ||
| 56 | branch: string; | ||
| 57 | } | ||
| 58 | |||
| 59 | export interface ThreadTitleGenerationInput { | ||
| 60 | cwd: string; | ||
| 61 | message: string; | ||
| 62 | attachments?: ReadonlyArray<ChatAttachment> | undefined; | ||
| 63 | /** What model and provider to use for generation. */ | ||
| 64 | modelSelection: ModelSelection; | ||
| 65 | } | ||
| 66 | |||
| 67 | export interface ThreadTitleGenerationResult { | ||
| 68 | title: string; | ||
| 69 | } | ||
| 70 | |||
| 71 | export interface TextGenerationService { | ||
| 72 | generateCommitMessage( | ||
| 73 | input: CommitMessageGenerationInput, | ||
| 74 | ): Promise<CommitMessageGenerationResult>; | ||
| 75 | generatePrContent(input: PrContentGenerationInput): Promise<PrContentGenerationResult>; | ||
| 76 | generateBranchName(input: BranchNameGenerationInput): Promise<BranchNameGenerationResult>; | ||
| 77 | generateThreadTitle(input: ThreadTitleGenerationInput): Promise<ThreadTitleGenerationResult>; | ||
| 78 | } | ||
| 79 | |||
| 80 | /** | ||
| 81 | * TextGenerationShape - Service API for commit/PR text generation. | ||
| 82 | */ | ||
| 83 | export interface TextGenerationShape { | ||
| 84 | /** | ||
| 85 | * Generate a commit message from staged change context. | ||
| 86 | */ | ||
| 87 | readonly generateCommitMessage: ( | ||
| 88 | input: CommitMessageGenerationInput, | ||
| 89 | ) => Effect.Effect<CommitMessageGenerationResult, TextGenerationError>; | ||
| 90 | |||
| 91 | /** | ||
| 92 | * Generate pull request title/body from branch and diff context. | ||
| 93 | */ | ||
| 94 | readonly generatePrContent: ( | ||
| 95 | input: PrContentGenerationInput, | ||
| 96 | ) => Effect.Effect<PrContentGenerationResult, TextGenerationError>; | ||
| 97 | |||
| 98 | /** | ||
| 99 | * Generate a concise branch name from a user message. | ||
| 100 | */ | ||
| 101 | readonly generateBranchName: ( | ||
| 102 | input: BranchNameGenerationInput, | ||
| 103 | ) => Effect.Effect<BranchNameGenerationResult, TextGenerationError>; | ||
| 104 | |||
| 105 | /** | ||
| 106 | * Generate a concise thread title from a user's first message. | ||
| 107 | */ | ||
| 108 | readonly generateThreadTitle: ( | ||
| 109 | input: ThreadTitleGenerationInput, | ||
| 110 | ) => Effect.Effect<ThreadTitleGenerationResult, TextGenerationError>; | ||
| 111 | } | ||
| 112 | |||
| 113 | /** | ||
| 114 | * TextGeneration - Service tag for commit and PR text generation. | ||
| 115 | */ | ||
| 116 | export class TextGeneration extends Context.Service<TextGeneration, TextGenerationShape>()( | ||
| 117 | "t3/text-generation/TextGeneration", | ||
| 118 | ) {} | ||
| 119 | |||
| 120 | type TextGenerationOp = | ||
| 121 | | "generateCommitMessage" | ||
| 122 | | "generatePrContent" | ||
| 123 | | "generateBranchName" | ||
| 124 | | "generateThreadTitle"; | ||
| 125 | |||
| 126 | const resolveInstance = ( | ||
| 127 | registry: ProviderInstanceRegistryShape, | ||
| 128 | operation: TextGenerationOp, | ||
| 129 | instanceId: ProviderInstanceId, | ||
| 130 | ): Effect.Effect<ProviderInstance["textGeneration"], TextGenerationError> => | ||
| 131 | registry.getInstance(instanceId).pipe( | ||
| 132 | Effect.flatMap((instance) => | ||
| 133 | instance | ||
| 134 | ? Effect.succeed(instance.textGeneration) | ||
| 135 | : Effect.fail( | ||
| 136 | new TextGenerationError({ | ||
| 137 | operation, | ||
| 138 | detail: `No provider instance registered for id '${instanceId}'.`, | ||
| 139 | }), | ||
| 140 | ), | ||
| 141 | ), | ||
| 142 | ); | ||
| 143 | |||
| 144 | export const makeTextGenerationFromRegistry = ( | ||
| 145 | registry: ProviderInstanceRegistryShape, | ||
| 146 | ): TextGenerationShape => ({ | ||
| 147 | generateCommitMessage: (input) => | ||
| 148 | resolveInstance(registry, "generateCommitMessage", input.modelSelection.instanceId).pipe( | ||
| 149 | Effect.flatMap((textGeneration) => textGeneration.generateCommitMessage(input)), | ||
| 150 | ), | ||
| 151 | generatePrContent: (input) => | ||
| 152 | resolveInstance(registry, "generatePrContent", input.modelSelection.instanceId).pipe( | ||
| 153 | Effect.flatMap((textGeneration) => textGeneration.generatePrContent(input)), | ||
| 154 | ), | ||
| 155 | generateBranchName: (input) => | ||
| 156 | resolveInstance(registry, "generateBranchName", input.modelSelection.instanceId).pipe( | ||
| 157 | Effect.flatMap((textGeneration) => textGeneration.generateBranchName(input)), | ||
| 158 | ), | ||
| 159 | generateThreadTitle: (input) => | ||
| 160 | resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe( | ||
| 161 | Effect.flatMap((textGeneration) => textGeneration.generateThreadTitle(input)), | ||
| 162 | ), | ||
| 163 | }); | ||
| 164 | |||
| 165 | export const layer = Layer.effect( | ||
| 166 | TextGeneration, | ||
| 167 | Effect.gen(function* () { | ||
| 168 | const registry = yield* ProviderInstanceRegistry; | ||
| 169 | return makeTextGenerationFromRegistry(registry); | ||
| 170 | }), | ||
| 171 | ); | ||
apps/server/src/textGeneration/TextGenerationPolicy.ts created+19| ... | @@ -0,0 +1,19 @@ | ||
| 1 | import { Schema } from "effect"; | ||
| 2 | |||
| 3 | export const TextGenerationPolicyKind = Schema.Literals([ | ||
| 4 | "default", | ||
| 5 | "conventional_commits", | ||
| 6 | "repo_conventions", | ||
| 7 | "custom", | ||
| 8 | ]); | ||
| 9 | export type TextGenerationPolicyKind = typeof TextGenerationPolicyKind.Type; | ||
| 10 | |||
| 11 | export const TextGenerationPolicy = Schema.Struct({ | ||
| 12 | kind: TextGenerationPolicyKind, | ||
| 13 | commitInstructions: Schema.optional(Schema.String), | ||
| 14 | changeRequestInstructions: Schema.optional(Schema.String), | ||
| 15 | branchInstructions: Schema.optional(Schema.String), | ||
| 16 | threadTitleInstructions: Schema.optional(Schema.String), | ||
| 17 | inferRepositoryConventions: Schema.Boolean, | ||
| 18 | }); | ||
| 19 | export type TextGenerationPolicy = typeof TextGenerationPolicy.Type; | ||
apps/server/src/textGeneration/TextGenerationPresets.ts created+41| ... | @@ -0,0 +1,41 @@ | ||
| 1 | import type { TextGenerationPolicy, TextGenerationPolicyKind } from "./TextGenerationPolicy.ts"; | ||
| 2 | |||
| 3 | export const defaultTextGenerationPolicy: TextGenerationPolicy = { | ||
| 4 | kind: "default", | ||
| 5 | inferRepositoryConventions: false, | ||
| 6 | }; | ||
| 7 | |||
| 8 | export const conventionalCommitsTextGenerationPolicy: TextGenerationPolicy = { | ||
| 9 | kind: "conventional_commits", | ||
| 10 | commitInstructions: | ||
| 11 | "Use Conventional Commits when generating commit subjects. Prefer the narrowest accurate type and include a scope only when it is obvious from the diff.", | ||
| 12 | changeRequestInstructions: | ||
| 13 | "Keep the change request title concise. Do not force Conventional Commit syntax into the title unless the repository already uses it.", | ||
| 14 | inferRepositoryConventions: false, | ||
| 15 | }; | ||
| 16 | |||
| 17 | export const repositoryConventionsTextGenerationPolicy: TextGenerationPolicy = { | ||
| 18 | kind: "repo_conventions", | ||
| 19 | commitInstructions: | ||
| 20 | "Follow the repository's established commit message style when examples are available.", | ||
| 21 | changeRequestInstructions: | ||
| 22 | "Follow the repository's established change request title and body style when examples are available.", | ||
| 23 | inferRepositoryConventions: true, | ||
| 24 | }; | ||
| 25 | |||
| 26 | export const customTextGenerationPolicy = ( | ||
| 27 | overrides: Omit<Partial<TextGenerationPolicy>, "kind">, | ||
| 28 | ): TextGenerationPolicy => ({ | ||
| 29 | kind: "custom", | ||
| 30 | inferRepositoryConventions: false, | ||
| 31 | ...overrides, | ||
| 32 | }); | ||
| 33 | |||
| 34 | export const textGenerationPresets: Record< | ||
| 35 | Exclude<TextGenerationPolicyKind, "custom">, | ||
| 36 | TextGenerationPolicy | ||
| 37 | > = { | ||
| 38 | default: defaultTextGenerationPolicy, | ||
| 39 | conventional_commits: conventionalCommitsTextGenerationPolicy, | ||
| 40 | repo_conventions: repositoryConventionsTextGenerationPolicy, | ||
| 41 | }; | ||
apps/server/src/textGeneration/TextGenerationPrompts.test.ts created+193| ... | @@ -0,0 +1,193 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | buildBranchNamePrompt, | ||
| 5 | buildCommitMessagePrompt, | ||
| 6 | buildPrContentPrompt, | ||
| 7 | buildThreadTitlePrompt, | ||
| 8 | } from "./TextGenerationPrompts.ts"; | ||
| 9 | import { normalizeCliError, sanitizeThreadTitle } from "./TextGenerationUtils.ts"; | ||
| 10 | import { TextGenerationError } from "@t3tools/contracts"; | ||
| 11 | |||
| 12 | describe("buildCommitMessagePrompt", () => { | ||
| 13 | it("includes staged patch and summary in the prompt", () => { | ||
| 14 | const result = buildCommitMessagePrompt({ | ||
| 15 | branch: "main", | ||
| 16 | stagedSummary: "M README.md", | ||
| 17 | stagedPatch: "diff --git a/README.md b/README.md\n+hello", | ||
| 18 | includeBranch: false, | ||
| 19 | }); | ||
| 20 | |||
| 21 | expect(result.prompt).toContain("Staged files:"); | ||
| 22 | expect(result.prompt).toContain("M README.md"); | ||
| 23 | expect(result.prompt).toContain("Staged patch:"); | ||
| 24 | expect(result.prompt).toContain("diff --git a/README.md b/README.md"); | ||
| 25 | expect(result.prompt).toContain("Branch: main"); | ||
| 26 | // Should NOT include the branch generation instruction | ||
| 27 | expect(result.prompt).not.toContain("branch must be a short semantic git branch fragment"); | ||
| 28 | }); | ||
| 29 | |||
| 30 | it("includes branch generation instruction when includeBranch is true", () => { | ||
| 31 | const result = buildCommitMessagePrompt({ | ||
| 32 | branch: "feature/foo", | ||
| 33 | stagedSummary: "M README.md", | ||
| 34 | stagedPatch: "diff", | ||
| 35 | includeBranch: true, | ||
| 36 | }); | ||
| 37 | |||
| 38 | expect(result.prompt).toContain("branch must be a short semantic git branch fragment"); | ||
| 39 | expect(result.prompt).toContain("Return a JSON object with keys: subject, body, branch."); | ||
| 40 | }); | ||
| 41 | |||
| 42 | it("shows (detached) when branch is null", () => { | ||
| 43 | const result = buildCommitMessagePrompt({ | ||
| 44 | branch: null, | ||
| 45 | stagedSummary: "M a.ts", | ||
| 46 | stagedPatch: "diff", | ||
| 47 | includeBranch: false, | ||
| 48 | }); | ||
| 49 | |||
| 50 | expect(result.prompt).toContain("Branch: (detached)"); | ||
| 51 | }); | ||
| 52 | }); | ||
| 53 | |||
| 54 | describe("buildPrContentPrompt", () => { | ||
| 55 | it("includes branch names, commits, and diff in the prompt", () => { | ||
| 56 | const result = buildPrContentPrompt({ | ||
| 57 | baseBranch: "main", | ||
| 58 | headBranch: "feature/auth", | ||
| 59 | commitSummary: "feat: add login page", | ||
| 60 | diffSummary: "3 files changed", | ||
| 61 | diffPatch: "diff --git a/auth.ts b/auth.ts\n+export function login()", | ||
| 62 | }); | ||
| 63 | |||
| 64 | expect(result.prompt).toContain("Base branch: main"); | ||
| 65 | expect(result.prompt).toContain("Head branch: feature/auth"); | ||
| 66 | expect(result.prompt).toContain("Commits:"); | ||
| 67 | expect(result.prompt).toContain("feat: add login page"); | ||
| 68 | expect(result.prompt).toContain("Diff stat:"); | ||
| 69 | expect(result.prompt).toContain("3 files changed"); | ||
| 70 | expect(result.prompt).toContain("Diff patch:"); | ||
| 71 | expect(result.prompt).toContain("export function login()"); | ||
| 72 | }); | ||
| 73 | }); | ||
| 74 | |||
| 75 | describe("buildBranchNamePrompt", () => { | ||
| 76 | it("includes the user message in the prompt", () => { | ||
| 77 | const result = buildBranchNamePrompt({ | ||
| 78 | message: "Fix the login timeout bug", | ||
| 79 | }); | ||
| 80 | |||
| 81 | expect(result.prompt).toContain("User message:"); | ||
| 82 | expect(result.prompt).toContain("Fix the login timeout bug"); | ||
| 83 | expect(result.prompt).not.toContain("Attachment metadata:"); | ||
| 84 | }); | ||
| 85 | |||
| 86 | it("includes attachment metadata when attachments are provided", () => { | ||
| 87 | const result = buildBranchNamePrompt({ | ||
| 88 | message: "Fix the layout from screenshot", | ||
| 89 | attachments: [ | ||
| 90 | { | ||
| 91 | type: "image" as const, | ||
| 92 | id: "att-123", | ||
| 93 | name: "screenshot.png", | ||
| 94 | mimeType: "image/png", | ||
| 95 | sizeBytes: 12345, | ||
| 96 | }, | ||
| 97 | ], | ||
| 98 | }); | ||
| 99 | |||
| 100 | expect(result.prompt).toContain("Attachment metadata:"); | ||
| 101 | expect(result.prompt).toContain("screenshot.png"); | ||
| 102 | expect(result.prompt).toContain("image/png"); | ||
| 103 | expect(result.prompt).toContain("12345 bytes"); | ||
| 104 | }); | ||
| 105 | }); | ||
| 106 | |||
| 107 | describe("buildThreadTitlePrompt", () => { | ||
| 108 | it("includes the user message in the prompt", () => { | ||
| 109 | const result = buildThreadTitlePrompt({ | ||
| 110 | message: "Investigate reconnect regressions after session restore", | ||
| 111 | }); | ||
| 112 | |||
| 113 | expect(result.prompt).toContain("User message:"); | ||
| 114 | expect(result.prompt).toContain("Investigate reconnect regressions after session restore"); | ||
| 115 | expect(result.prompt).not.toContain("Attachment metadata:"); | ||
| 116 | }); | ||
| 117 | |||
| 118 | it("includes attachment metadata when attachments are provided", () => { | ||
| 119 | const result = buildThreadTitlePrompt({ | ||
| 120 | message: "Name this thread from the screenshot", | ||
| 121 | attachments: [ | ||
| 122 | { | ||
| 123 | type: "image" as const, | ||
| 124 | id: "att-456", | ||
| 125 | name: "thread.png", | ||
| 126 | mimeType: "image/png", | ||
| 127 | sizeBytes: 67890, | ||
| 128 | }, | ||
| 129 | ], | ||
| 130 | }); | ||
| 131 | |||
| 132 | expect(result.prompt).toContain("Attachment metadata:"); | ||
| 133 | expect(result.prompt).toContain("thread.png"); | ||
| 134 | expect(result.prompt).toContain("image/png"); | ||
| 135 | expect(result.prompt).toContain("67890 bytes"); | ||
| 136 | }); | ||
| 137 | }); | ||
| 138 | |||
| 139 | describe("sanitizeThreadTitle", () => { | ||
| 140 | it("truncates long titles with the shared sidebar-safe limit", () => { | ||
| 141 | expect( | ||
| 142 | sanitizeThreadTitle( | ||
| 143 | ' "Reconnect failures after restart because the session state does not recover" ', | ||
| 144 | ), | ||
| 145 | ).toBe("Reconnect failures after restart because the se..."); | ||
| 146 | }); | ||
| 147 | }); | ||
| 148 | |||
| 149 | describe("normalizeCliError", () => { | ||
| 150 | it("detects 'Command not found' and includes CLI name in the message", () => { | ||
| 151 | const error = normalizeCliError( | ||
| 152 | "claude", | ||
| 153 | "generateCommitMessage", | ||
| 154 | new Error("Command not found: claude"), | ||
| 155 | "Something went wrong", | ||
| 156 | ); | ||
| 157 | |||
| 158 | expect(error).toBeInstanceOf(TextGenerationError); | ||
| 159 | expect(error.detail).toContain("Claude CLI"); | ||
| 160 | expect(error.detail).toContain("not available on PATH"); | ||
| 161 | }); | ||
| 162 | |||
| 163 | it("uses the CLI name from the first argument for codex", () => { | ||
| 164 | const error = normalizeCliError( | ||
| 165 | "codex", | ||
| 166 | "generateBranchName", | ||
| 167 | new Error("Command not found: codex"), | ||
| 168 | "Something went wrong", | ||
| 169 | ); | ||
| 170 | |||
| 171 | expect(error).toBeInstanceOf(TextGenerationError); | ||
| 172 | expect(error.detail).toContain("Codex CLI"); | ||
| 173 | expect(error.detail).toContain("not available on PATH"); | ||
| 174 | }); | ||
| 175 | |||
| 176 | it("returns the error as-is if it is already a TextGenerationError", () => { | ||
| 177 | const existing = new TextGenerationError({ | ||
| 178 | operation: "generatePrContent", | ||
| 179 | detail: "Already wrapped", | ||
| 180 | }); | ||
| 181 | |||
| 182 | const result = normalizeCliError("claude", "generatePrContent", existing, "fallback"); | ||
| 183 | |||
| 184 | expect(result).toBe(existing); | ||
| 185 | }); | ||
| 186 | |||
| 187 | it("wraps unknown non-Error values with the fallback message", () => { | ||
| 188 | const result = normalizeCliError("codex", "generateCommitMessage", "string error", "fallback"); | ||
| 189 | |||
| 190 | expect(result).toBeInstanceOf(TextGenerationError); | ||
| 191 | expect(result.detail).toBe("fallback"); | ||
| 192 | }); | ||
| 193 | }); | ||
apps/server/src/textGeneration/TextGenerationPrompts.ts created+218| ... | @@ -0,0 +1,218 @@ | ||
| 1 | /** | ||
| 2 | * Shared prompt builders for text generation providers. | ||
| 3 | * | ||
| 4 | * Extracts the prompt construction logic that is identical across | ||
| 5 | * Codex, Claude, and any future CLI-based text generation backends. | ||
| 6 | * | ||
| 7 | * @module textGenerationPrompts | ||
| 8 | */ | ||
| 9 | import { Schema } from "effect"; | ||
| 10 | import type { ChatAttachment } from "@t3tools/contracts"; | ||
| 11 | |||
| 12 | import { limitSection } from "./TextGenerationUtils.ts"; | ||
| 13 | import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; | ||
| 14 | |||
| 15 | function policyInstruction(instruction: string | undefined): ReadonlyArray<string> { | ||
| 16 | const trimmed = instruction?.trim(); | ||
| 17 | return trimmed ? ["", "Additional instructions:", limitSection(trimmed, 4_000)] : []; | ||
| 18 | } | ||
| 19 | |||
| 20 | // --------------------------------------------------------------------------- | ||
| 21 | // Commit message | ||
| 22 | // --------------------------------------------------------------------------- | ||
| 23 | |||
| 24 | export interface CommitMessagePromptInput { | ||
| 25 | branch: string | null; | ||
| 26 | stagedSummary: string; | ||
| 27 | stagedPatch: string; | ||
| 28 | includeBranch: boolean; | ||
| 29 | policy?: TextGenerationPolicy | undefined; | ||
| 30 | } | ||
| 31 | |||
| 32 | export function buildCommitMessagePrompt(input: CommitMessagePromptInput) { | ||
| 33 | const wantsBranch = input.includeBranch; | ||
| 34 | |||
| 35 | const prompt = [ | ||
| 36 | "You write concise git commit messages.", | ||
| 37 | wantsBranch | ||
| 38 | ? "Return a JSON object with keys: subject, body, branch." | ||
| 39 | : "Return a JSON object with keys: subject, body.", | ||
| 40 | "Rules:", | ||
| 41 | "- subject must be imperative, <= 72 chars, and no trailing period", | ||
| 42 | "- body can be empty string or short bullet points", | ||
| 43 | ...(wantsBranch | ||
| 44 | ? ["- branch must be a short semantic git branch fragment for this change"] | ||
| 45 | : []), | ||
| 46 | "- capture the primary user-visible or developer-visible change", | ||
| 47 | ...policyInstruction(input.policy?.commitInstructions), | ||
| 48 | "", | ||
| 49 | `Branch: ${input.branch ?? "(detached)"}`, | ||
| 50 | "", | ||
| 51 | "Staged files:", | ||
| 52 | limitSection(input.stagedSummary, 6_000), | ||
| 53 | "", | ||
| 54 | "Staged patch:", | ||
| 55 | limitSection(input.stagedPatch, 40_000), | ||
| 56 | ].join("\n"); | ||
| 57 | |||
| 58 | if (wantsBranch) { | ||
| 59 | return { | ||
| 60 | prompt, | ||
| 61 | outputSchema: Schema.Struct({ | ||
| 62 | subject: Schema.String, | ||
| 63 | body: Schema.String, | ||
| 64 | branch: Schema.String, | ||
| 65 | }), | ||
| 66 | }; | ||
| 67 | } | ||
| 68 | |||
| 69 | return { | ||
| 70 | prompt, | ||
| 71 | outputSchema: Schema.Struct({ | ||
| 72 | subject: Schema.String, | ||
| 73 | body: Schema.String, | ||
| 74 | }), | ||
| 75 | }; | ||
| 76 | } | ||
| 77 | |||
| 78 | // --------------------------------------------------------------------------- | ||
| 79 | // PR content | ||
| 80 | // --------------------------------------------------------------------------- | ||
| 81 | |||
| 82 | export interface PrContentPromptInput { | ||
| 83 | baseBranch: string; | ||
| 84 | headBranch: string; | ||
| 85 | commitSummary: string; | ||
| 86 | diffSummary: string; | ||
| 87 | diffPatch: string; | ||
| 88 | policy?: TextGenerationPolicy | undefined; | ||
| 89 | } | ||
| 90 | |||
| 91 | export function buildPrContentPrompt(input: PrContentPromptInput) { | ||
| 92 | const prompt = [ | ||
| 93 | "You write GitHub pull request content.", | ||
| 94 | "Return a JSON object with keys: title, body.", | ||
| 95 | "Rules:", | ||
| 96 | "- title should be concise and specific", | ||
| 97 | "- body must be markdown and include headings '## Summary' and '## Testing'", | ||
| 98 | "- under Summary, provide short bullet points", | ||
| 99 | "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate", | ||
| 100 | ...policyInstruction(input.policy?.changeRequestInstructions), | ||
| 101 | "", | ||
| 102 | `Base branch: ${input.baseBranch}`, | ||
| 103 | `Head branch: ${input.headBranch}`, | ||
| 104 | "", | ||
| 105 | "Commits:", | ||
| 106 | limitSection(input.commitSummary, 12_000), | ||
| 107 | "", | ||
| 108 | "Diff stat:", | ||
| 109 | limitSection(input.diffSummary, 12_000), | ||
| 110 | "", | ||
| 111 | "Diff patch:", | ||
| 112 | limitSection(input.diffPatch, 40_000), | ||
| 113 | ].join("\n"); | ||
| 114 | |||
| 115 | const outputSchema = Schema.Struct({ | ||
| 116 | title: Schema.String, | ||
| 117 | body: Schema.String, | ||
| 118 | }); | ||
| 119 | |||
| 120 | return { prompt, outputSchema }; | ||
| 121 | } | ||
| 122 | |||
| 123 | // --------------------------------------------------------------------------- | ||
| 124 | // Branch name | ||
| 125 | // --------------------------------------------------------------------------- | ||
| 126 | |||
| 127 | export interface BranchNamePromptInput { | ||
| 128 | message: string; | ||
| 129 | attachments?: ReadonlyArray<ChatAttachment> | undefined; | ||
| 130 | policy?: TextGenerationPolicy | undefined; | ||
| 131 | } | ||
| 132 | |||
| 133 | interface PromptFromMessageInput { | ||
| 134 | instruction: string; | ||
| 135 | responseShape: string; | ||
| 136 | rules: ReadonlyArray<string>; | ||
| 137 | message: string; | ||
| 138 | attachments?: ReadonlyArray<ChatAttachment> | undefined; | ||
| 139 | additionalInstructions?: string | undefined; | ||
| 140 | } | ||
| 141 | |||
| 142 | function buildPromptFromMessage(input: PromptFromMessageInput): string { | ||
| 143 | const attachmentLines = (input.attachments ?? []).map( | ||
| 144 | (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, | ||
| 145 | ); | ||
| 146 | |||
| 147 | const promptSections = [ | ||
| 148 | input.instruction, | ||
| 149 | input.responseShape, | ||
| 150 | "Rules:", | ||
| 151 | ...input.rules.map((rule) => `- ${rule}`), | ||
| 152 | "", | ||
| 153 | "User message:", | ||
| 154 | limitSection(input.message, 8_000), | ||
| 155 | ...policyInstruction(input.additionalInstructions), | ||
| 156 | ]; | ||
| 157 | if (attachmentLines.length > 0) { | ||
| 158 | promptSections.push( | ||
| 159 | "", | ||
| 160 | "Attachment metadata:", | ||
| 161 | limitSection(attachmentLines.join("\n"), 4_000), | ||
| 162 | ); | ||
| 163 | } | ||
| 164 | |||
| 165 | return promptSections.join("\n"); | ||
| 166 | } | ||
| 167 | |||
| 168 | export function buildBranchNamePrompt(input: BranchNamePromptInput) { | ||
| 169 | const prompt = buildPromptFromMessage({ | ||
| 170 | instruction: "You generate concise git branch names.", | ||
| 171 | responseShape: "Return a JSON object with key: branch.", | ||
| 172 | rules: [ | ||
| 173 | "Branch should describe the requested work from the user message.", | ||
| 174 | "Keep it short and specific (2-6 words).", | ||
| 175 | "Use plain words only, no issue prefixes and no punctuation-heavy text.", | ||
| 176 | "If images are attached, use them as primary context for visual/UI issues.", | ||
| 177 | ], | ||
| 178 | message: input.message, | ||
| 179 | attachments: input.attachments, | ||
| 180 | additionalInstructions: input.policy?.branchInstructions, | ||
| 181 | }); | ||
| 182 | const outputSchema = Schema.Struct({ | ||
| 183 | branch: Schema.String, | ||
| 184 | }); | ||
| 185 | |||
| 186 | return { prompt, outputSchema }; | ||
| 187 | } | ||
| 188 | |||
| 189 | // --------------------------------------------------------------------------- | ||
| 190 | // Thread title | ||
| 191 | // --------------------------------------------------------------------------- | ||
| 192 | |||
| 193 | export interface ThreadTitlePromptInput { | ||
| 194 | message: string; | ||
| 195 | attachments?: ReadonlyArray<ChatAttachment> | undefined; | ||
| 196 | policy?: TextGenerationPolicy | undefined; | ||
| 197 | } | ||
| 198 | |||
| 199 | export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) { | ||
| 200 | const prompt = buildPromptFromMessage({ | ||
| 201 | instruction: "You write concise thread titles for coding conversations.", | ||
| 202 | responseShape: "Return a JSON object with key: title.", | ||
| 203 | rules: [ | ||
| 204 | "Title should summarize the user's request, not restate it verbatim.", | ||
| 205 | "Keep it short and specific (3-8 words).", | ||
| 206 | "Avoid quotes, filler, prefixes, and trailing punctuation.", | ||
| 207 | "If images are attached, use them as primary context for visual/UI issues.", | ||
| 208 | ], | ||
| 209 | message: input.message, | ||
| 210 | attachments: input.attachments, | ||
| 211 | additionalInstructions: input.policy?.threadTitleInstructions, | ||
| 212 | }); | ||
| 213 | const outputSchema = Schema.Struct({ | ||
| 214 | title: Schema.String, | ||
| 215 | }); | ||
| 216 | |||
| 217 | return { prompt, outputSchema }; | ||
| 218 | } | ||
apps/server/src/textGeneration/TextGenerationUtils.ts created+159| ... | @@ -0,0 +1,159 @@ | ||
| 1 | import { Schema } from "effect"; | ||
| 2 | |||
| 3 | import { TextGenerationError } from "@t3tools/contracts"; | ||
| 4 | |||
| 5 | /** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */ | ||
| 6 | export function toJsonSchemaObject(schema: Schema.Top): unknown { | ||
| 7 | const document = Schema.toJsonSchemaDocument(schema); | ||
| 8 | if (document.definitions && Object.keys(document.definitions).length > 0) { | ||
| 9 | return { ...document.schema, $defs: document.definitions }; | ||
| 10 | } | ||
| 11 | return document.schema; | ||
| 12 | } | ||
| 13 | |||
| 14 | /** Truncate a text section to `maxChars`, appending a `[truncated]` marker when needed. */ | ||
| 15 | export function limitSection(value: string, maxChars: number): string { | ||
| 16 | if (value.length <= maxChars) return value; | ||
| 17 | const truncated = value.slice(0, maxChars); | ||
| 18 | return `${truncated}\n\n[truncated]`; | ||
| 19 | } | ||
| 20 | |||
| 21 | export function extractJsonObject(raw: string): string { | ||
| 22 | const trimmed = raw.trim(); | ||
| 23 | if (trimmed.length === 0) { | ||
| 24 | return trimmed; | ||
| 25 | } | ||
| 26 | |||
| 27 | const start = trimmed.indexOf("{"); | ||
| 28 | if (start < 0) { | ||
| 29 | return trimmed; | ||
| 30 | } | ||
| 31 | |||
| 32 | let depth = 0; | ||
| 33 | let inString = false; | ||
| 34 | let escaping = false; | ||
| 35 | for (let index = start; index < trimmed.length; index += 1) { | ||
| 36 | const char = trimmed[index]; | ||
| 37 | if (inString) { | ||
| 38 | if (escaping) { | ||
| 39 | escaping = false; | ||
| 40 | } else if (char === "\\") { | ||
| 41 | escaping = true; | ||
| 42 | } else if (char === '"') { | ||
| 43 | inString = false; | ||
| 44 | } | ||
| 45 | continue; | ||
| 46 | } | ||
| 47 | |||
| 48 | if (char === '"') { | ||
| 49 | inString = true; | ||
| 50 | continue; | ||
| 51 | } | ||
| 52 | |||
| 53 | if (char === "{") { | ||
| 54 | depth += 1; | ||
| 55 | continue; | ||
| 56 | } | ||
| 57 | |||
| 58 | if (char === "}") { | ||
| 59 | depth -= 1; | ||
| 60 | if (depth === 0) { | ||
| 61 | return trimmed.slice(start, index + 1); | ||
| 62 | } | ||
| 63 | } | ||
| 64 | } | ||
| 65 | |||
| 66 | return trimmed.slice(start); | ||
| 67 | } | ||
| 68 | |||
| 69 | /** Normalise a raw commit subject to imperative-mood, ≤72 chars, no trailing period. */ | ||
| 70 | export function sanitizeCommitSubject(raw: string): string { | ||
| 71 | const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; | ||
| 72 | const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim(); | ||
| 73 | if (withoutTrailingPeriod.length === 0) { | ||
| 74 | return "Update project files"; | ||
| 75 | } | ||
| 76 | |||
| 77 | if (withoutTrailingPeriod.length <= 72) { | ||
| 78 | return withoutTrailingPeriod; | ||
| 79 | } | ||
| 80 | return withoutTrailingPeriod.slice(0, 72).trimEnd(); | ||
| 81 | } | ||
| 82 | |||
| 83 | /** Normalise a raw PR title to a single line with a sensible fallback. */ | ||
| 84 | export function sanitizePrTitle(raw: string): string { | ||
| 85 | const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; | ||
| 86 | if (singleLine.length > 0) { | ||
| 87 | return singleLine; | ||
| 88 | } | ||
| 89 | return "Update project changes"; | ||
| 90 | } | ||
| 91 | |||
| 92 | /** Normalise a raw thread title to a compact single-line sidebar-safe label. */ | ||
| 93 | export function sanitizeThreadTitle(raw: string): string { | ||
| 94 | const normalized = raw | ||
| 95 | .trim() | ||
| 96 | .split(/\r?\n/g)[0] | ||
| 97 | ?.trim() | ||
| 98 | .replace(/^['"`]+|['"`]+$/g, "") | ||
| 99 | .trim() | ||
| 100 | .replace(/\s+/g, " "); | ||
| 101 | |||
| 102 | if (!normalized || normalized.trim().length === 0) { | ||
| 103 | return "New thread"; | ||
| 104 | } | ||
| 105 | |||
| 106 | if (normalized.length <= 50) { | ||
| 107 | return normalized; | ||
| 108 | } | ||
| 109 | |||
| 110 | return `${normalized.slice(0, 47).trimEnd()}...`; | ||
| 111 | } | ||
| 112 | |||
| 113 | /** CLI name to human-readable label, e.g. "codex" → "Codex CLI (`codex`)" */ | ||
| 114 | function cliLabel(cliName: string): string { | ||
| 115 | const capitalized = cliName.charAt(0).toUpperCase() + cliName.slice(1); | ||
| 116 | return `${capitalized} CLI (\`${cliName}\`)`; | ||
| 117 | } | ||
| 118 | |||
| 119 | /** | ||
| 120 | * Normalize an unknown error from a CLI text generation process into a | ||
| 121 | * typed `TextGenerationError`. Parameterized by CLI name so both Codex | ||
| 122 | * and Claude (and future providers) can share the same logic. | ||
| 123 | */ | ||
| 124 | export function normalizeCliError( | ||
| 125 | cliName: string, | ||
| 126 | operation: string, | ||
| 127 | error: unknown, | ||
| 128 | fallback: string, | ||
| 129 | ): TextGenerationError { | ||
| 130 | if (Schema.is(TextGenerationError)(error)) { | ||
| 131 | return error; | ||
| 132 | } | ||
| 133 | |||
| 134 | if (error instanceof Error) { | ||
| 135 | const lower = error.message.toLowerCase(); | ||
| 136 | if ( | ||
| 137 | error.message.includes(`Command not found: ${cliName}`) || | ||
| 138 | lower.includes(`spawn ${cliName}`) || | ||
| 139 | lower.includes("enoent") | ||
| 140 | ) { | ||
| 141 | return new TextGenerationError({ | ||
| 142 | operation, | ||
| 143 | detail: `${cliLabel(cliName)} is required but not available on PATH.`, | ||
| 144 | cause: error, | ||
| 145 | }); | ||
| 146 | } | ||
| 147 | return new TextGenerationError({ | ||
| 148 | operation, | ||
| 149 | detail: `${fallback}: ${error.message}`, | ||
| 150 | cause: error, | ||
| 151 | }); | ||
| 152 | } | ||
| 153 | |||
| 154 | return new TextGenerationError({ | ||
| 155 | operation, | ||
| 156 | detail: fallback, | ||
| 157 | cause: error, | ||
| 158 | }); | ||
| 159 | } | ||
apps/server/src/vcs/GitVcsDriver.test.ts created+99| ... | @@ -0,0 +1,99 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { Effect, FileSystem, Layer, Path, PlatformError } from "effect"; | ||
| 3 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 4 | import { assert, it } from "@effect/vitest"; | ||
| 5 | |||
| 6 | import { GitCommandError } from "@t3tools/contracts"; | ||
| 7 | import { ServerConfig } from "../config.ts"; | ||
| 8 | import * as GitVcsDriver from "./GitVcsDriver.ts"; | ||
| 9 | import * as VcsProcess from "./VcsProcess.ts"; | ||
| 10 | import { runVcsDriverContractSuite } from "./testing/VcsDriverContractHarness.ts"; | ||
| 11 | |||
| 12 | const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { | ||
| 13 | prefix: "t3-git-vcs-contract-", | ||
| 14 | }); | ||
| 15 | const GitContractLayer = Layer.mergeAll(GitVcsDriver.vcsLayer, GitVcsDriver.layer).pipe( | ||
| 16 | Layer.provide(ServerConfigLayer), | ||
| 17 | Layer.provideMerge(VcsProcess.layer), | ||
| 18 | Layer.provideMerge(NodeServices.layer), | ||
| 19 | ); | ||
| 20 | |||
| 21 | const runGit = (cwd: string, args: ReadonlyArray<string>) => | ||
| 22 | Effect.gen(function* () { | ||
| 23 | const driver = yield* GitVcsDriver.GitVcsDriver; | ||
| 24 | yield* driver.execute({ | ||
| 25 | operation: "GitVcsDriver.contract.git", | ||
| 26 | cwd, | ||
| 27 | args, | ||
| 28 | timeoutMs: 10_000, | ||
| 29 | }); | ||
| 30 | }); | ||
| 31 | |||
| 32 | type GitContractError = GitCommandError | PlatformError.PlatformError; | ||
| 33 | |||
| 34 | runVcsDriverContractSuite<GitVcsDriver.GitVcsDriver, GitContractError>({ | ||
| 35 | name: "Git", | ||
| 36 | kind: "git", | ||
| 37 | layer: GitContractLayer, | ||
| 38 | fixture: { | ||
| 39 | createRepo: (cwd) => | ||
| 40 | Effect.gen(function* () { | ||
| 41 | yield* runGit(cwd, ["init"]); | ||
| 42 | yield* runGit(cwd, ["config", "user.email", "test@test.com"]); | ||
| 43 | yield* runGit(cwd, ["config", "user.name", "Test"]); | ||
| 44 | }), | ||
| 45 | writeFile: (cwd, relativePath, contents) => | ||
| 46 | Effect.gen(function* () { | ||
| 47 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 48 | const path = yield* Path.Path; | ||
| 49 | const absolutePath = path.join(cwd, relativePath); | ||
| 50 | yield* fileSystem.makeDirectory(path.dirname(absolutePath), { recursive: true }); | ||
| 51 | yield* fileSystem.writeFileString(absolutePath, contents); | ||
| 52 | }), | ||
| 53 | trackFile: (cwd, relativePath) => runGit(cwd, ["add", relativePath]), | ||
| 54 | commit: (cwd, message) => runGit(cwd, ["commit", "-m", message]), | ||
| 55 | ignorePath: (cwd, pattern) => | ||
| 56 | Effect.gen(function* () { | ||
| 57 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 58 | const path = yield* Path.Path; | ||
| 59 | yield* fileSystem.writeFileString(path.join(cwd, ".gitignore"), `${pattern}\n`); | ||
| 60 | }), | ||
| 61 | }, | ||
| 62 | }); | ||
| 63 | |||
| 64 | it.effect("GitVcsDriver forwards execute env to the VCS process", () => { | ||
| 65 | let observedEnv: NodeJS.ProcessEnv | undefined; | ||
| 66 | |||
| 67 | return Effect.gen(function* () { | ||
| 68 | const driver = yield* GitVcsDriver.makeVcsDriverShape(); | ||
| 69 | |||
| 70 | yield* driver.execute({ | ||
| 71 | operation: "GitVcsDriver.test.env", | ||
| 72 | cwd: "/repo", | ||
| 73 | args: ["status"], | ||
| 74 | env: { | ||
| 75 | GIT_INDEX_FILE: "/tmp/t3-index", | ||
| 76 | }, | ||
| 77 | }); | ||
| 78 | |||
| 79 | assert.deepStrictEqual(observedEnv, { | ||
| 80 | GIT_INDEX_FILE: "/tmp/t3-index", | ||
| 81 | }); | ||
| 82 | }).pipe( | ||
| 83 | Effect.provide( | ||
| 84 | Layer.mock(VcsProcess.VcsProcess)({ | ||
| 85 | run: (input) => | ||
| 86 | Effect.sync(() => { | ||
| 87 | observedEnv = input.env; | ||
| 88 | return { | ||
| 89 | exitCode: ChildProcessSpawner.ExitCode(0), | ||
| 90 | stdout: "", | ||
| 91 | stderr: "", | ||
| 92 | stdoutTruncated: false, | ||
| 93 | stderrTruncated: false, | ||
| 94 | }; | ||
| 95 | }), | ||
| 96 | }), | ||
| 97 | ), | ||
| 98 | ); | ||
| 99 | }); | ||
apps/server/src/vcs/GitVcsDriver.ts created+543| ... | @@ -0,0 +1,543 @@ | ||
| 1 | import { Context, DateTime, Effect, Layer, Option } from "effect"; | ||
| 2 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | GitCommandError, | ||
| 6 | VcsProcessExitError, | ||
| 7 | type VcsSwitchRefInput, | ||
| 8 | type VcsSwitchRefResult, | ||
| 9 | type VcsCreateRefInput, | ||
| 10 | type VcsCreateRefResult, | ||
| 11 | type VcsCreateWorktreeInput, | ||
| 12 | type VcsCreateWorktreeResult, | ||
| 13 | type VcsInitInput, | ||
| 14 | type VcsListRefsInput, | ||
| 15 | type VcsListRefsResult, | ||
| 16 | type VcsPullResult, | ||
| 17 | type VcsRemoveWorktreeInput, | ||
| 18 | type VcsStatusInput, | ||
| 19 | type VcsStatusResult, | ||
| 20 | } from "@t3tools/contracts"; | ||
| 21 | import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts"; | ||
| 22 | import { VcsDriver, type VcsDriverShape } from "./VcsDriver.ts"; | ||
| 23 | import { VcsProcess, type VcsProcessShape } from "./VcsProcess.ts"; | ||
| 24 | |||
| 25 | export interface ExecuteGitInput { | ||
| 26 | readonly operation: string; | ||
| 27 | readonly cwd: string; | ||
| 28 | readonly args: ReadonlyArray<string>; | ||
| 29 | readonly stdin?: string; | ||
| 30 | readonly env?: NodeJS.ProcessEnv; | ||
| 31 | readonly allowNonZeroExit?: boolean; | ||
| 32 | readonly timeoutMs?: number; | ||
| 33 | readonly maxOutputBytes?: number; | ||
| 34 | readonly truncateOutputAtMaxBytes?: boolean; | ||
| 35 | readonly progress?: ExecuteGitProgress; | ||
| 36 | } | ||
| 37 | |||
| 38 | export interface ExecuteGitResult { | ||
| 39 | readonly exitCode: ChildProcessSpawner.ExitCode; | ||
| 40 | readonly stdout: string; | ||
| 41 | readonly stderr: string; | ||
| 42 | readonly stdoutTruncated: boolean; | ||
| 43 | readonly stderrTruncated: boolean; | ||
| 44 | } | ||
| 45 | |||
| 46 | export interface GitStatusDetails { | ||
| 47 | isRepo: boolean; | ||
| 48 | sourceControlProvider?: VcsStatusResult["sourceControlProvider"]; | ||
| 49 | hasOriginRemote: boolean; | ||
| 50 | isDefaultBranch: boolean; | ||
| 51 | branch: string | null; | ||
| 52 | upstreamRef: string | null; | ||
| 53 | hasWorkingTreeChanges: boolean; | ||
| 54 | workingTree: VcsStatusResult["workingTree"]; | ||
| 55 | hasUpstream: boolean; | ||
| 56 | aheadCount: number; | ||
| 57 | behindCount: number; | ||
| 58 | } | ||
| 59 | |||
| 60 | export interface GitPreparedCommitContext { | ||
| 61 | stagedSummary: string; | ||
| 62 | stagedPatch: string; | ||
| 63 | } | ||
| 64 | |||
| 65 | export interface ExecuteGitProgress { | ||
| 66 | readonly onStdoutLine?: (line: string) => Effect.Effect<void, never>; | ||
| 67 | readonly onStderrLine?: (line: string) => Effect.Effect<void, never>; | ||
| 68 | readonly onHookStarted?: (hookName: string) => Effect.Effect<void, never>; | ||
| 69 | readonly onHookFinished?: (input: { | ||
| 70 | hookName: string; | ||
| 71 | exitCode: number | null; | ||
| 72 | durationMs: number | null; | ||
| 73 | }) => Effect.Effect<void, never>; | ||
| 74 | } | ||
| 75 | |||
| 76 | export interface GitCommitProgress { | ||
| 77 | readonly onOutputLine?: (input: { | ||
| 78 | stream: "stdout" | "stderr"; | ||
| 79 | text: string; | ||
| 80 | }) => Effect.Effect<void, never>; | ||
| 81 | readonly onHookStarted?: (hookName: string) => Effect.Effect<void, never>; | ||
| 82 | readonly onHookFinished?: (input: { | ||
| 83 | hookName: string; | ||
| 84 | exitCode: number | null; | ||
| 85 | durationMs: number | null; | ||
| 86 | }) => Effect.Effect<void, never>; | ||
| 87 | } | ||
| 88 | |||
| 89 | export interface GitCommitOptions { | ||
| 90 | readonly timeoutMs?: number; | ||
| 91 | readonly progress?: GitCommitProgress; | ||
| 92 | } | ||
| 93 | |||
| 94 | export interface GitPushResult { | ||
| 95 | status: "pushed" | "skipped_up_to_date"; | ||
| 96 | branch: string; | ||
| 97 | upstreamBranch?: string | undefined; | ||
| 98 | setUpstream?: boolean | undefined; | ||
| 99 | } | ||
| 100 | |||
| 101 | export interface GitRangeContext { | ||
| 102 | commitSummary: string; | ||
| 103 | diffSummary: string; | ||
| 104 | diffPatch: string; | ||
| 105 | } | ||
| 106 | |||
| 107 | export interface GitRenameBranchInput { | ||
| 108 | cwd: string; | ||
| 109 | oldBranch: string; | ||
| 110 | newBranch: string; | ||
| 111 | } | ||
| 112 | |||
| 113 | export interface GitRenameBranchResult { | ||
| 114 | branch: string; | ||
| 115 | } | ||
| 116 | |||
| 117 | export interface GitFetchPullRequestBranchInput { | ||
| 118 | cwd: string; | ||
| 119 | prNumber: number; | ||
| 120 | branch: string; | ||
| 121 | } | ||
| 122 | |||
| 123 | export interface GitEnsureRemoteInput { | ||
| 124 | cwd: string; | ||
| 125 | preferredName: string; | ||
| 126 | url: string; | ||
| 127 | } | ||
| 128 | |||
| 129 | export interface GitFetchRemoteBranchInput { | ||
| 130 | cwd: string; | ||
| 131 | remoteName: string; | ||
| 132 | remoteBranch: string; | ||
| 133 | localBranch: string; | ||
| 134 | } | ||
| 135 | |||
| 136 | export interface GitSetBranchUpstreamInput { | ||
| 137 | cwd: string; | ||
| 138 | branch: string; | ||
| 139 | remoteName: string; | ||
| 140 | remoteBranch: string; | ||
| 141 | } | ||
| 142 | |||
| 143 | export interface GitVcsDriverShape { | ||
| 144 | readonly execute: (input: ExecuteGitInput) => Effect.Effect<ExecuteGitResult, GitCommandError>; | ||
| 145 | readonly status: (input: VcsStatusInput) => Effect.Effect<VcsStatusResult, GitCommandError>; | ||
| 146 | readonly statusDetails: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>; | ||
| 147 | readonly statusDetailsLocal: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>; | ||
| 148 | readonly prepareCommitContext: ( | ||
| 149 | cwd: string, | ||
| 150 | filePaths?: readonly string[], | ||
| 151 | ) => Effect.Effect<GitPreparedCommitContext | null, GitCommandError>; | ||
| 152 | readonly commit: ( | ||
| 153 | cwd: string, | ||
| 154 | subject: string, | ||
| 155 | body: string, | ||
| 156 | options?: GitCommitOptions, | ||
| 157 | ) => Effect.Effect<{ commitSha: string }, GitCommandError>; | ||
| 158 | readonly pushCurrentBranch: ( | ||
| 159 | cwd: string, | ||
| 160 | fallbackBranch: string | null, | ||
| 161 | ) => Effect.Effect<GitPushResult, GitCommandError>; | ||
| 162 | readonly readRangeContext: ( | ||
| 163 | cwd: string, | ||
| 164 | baseRef: string, | ||
| 165 | ) => Effect.Effect<GitRangeContext, GitCommandError>; | ||
| 166 | readonly readConfigValue: ( | ||
| 167 | cwd: string, | ||
| 168 | key: string, | ||
| 169 | ) => Effect.Effect<string | null, GitCommandError>; | ||
| 170 | readonly listRefs: (input: VcsListRefsInput) => Effect.Effect<VcsListRefsResult, GitCommandError>; | ||
| 171 | readonly pullCurrentBranch: (cwd: string) => Effect.Effect<VcsPullResult, GitCommandError>; | ||
| 172 | readonly createWorktree: ( | ||
| 173 | input: VcsCreateWorktreeInput, | ||
| 174 | ) => Effect.Effect<VcsCreateWorktreeResult, GitCommandError>; | ||
| 175 | readonly fetchPullRequestBranch: ( | ||
| 176 | input: GitFetchPullRequestBranchInput, | ||
| 177 | ) => Effect.Effect<void, GitCommandError>; | ||
| 178 | readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect<string, GitCommandError>; | ||
| 179 | readonly fetchRemoteBranch: ( | ||
| 180 | input: GitFetchRemoteBranchInput, | ||
| 181 | ) => Effect.Effect<void, GitCommandError>; | ||
| 182 | readonly setBranchUpstream: ( | ||
| 183 | input: GitSetBranchUpstreamInput, | ||
| 184 | ) => Effect.Effect<void, GitCommandError>; | ||
| 185 | readonly removeWorktree: (input: VcsRemoveWorktreeInput) => Effect.Effect<void, GitCommandError>; | ||
| 186 | readonly renameBranch: ( | ||
| 187 | input: GitRenameBranchInput, | ||
| 188 | ) => Effect.Effect<GitRenameBranchResult, GitCommandError>; | ||
| 189 | readonly createRef: ( | ||
| 190 | input: VcsCreateRefInput, | ||
| 191 | ) => Effect.Effect<VcsCreateRefResult, GitCommandError>; | ||
| 192 | readonly switchRef: ( | ||
| 193 | input: VcsSwitchRefInput, | ||
| 194 | ) => Effect.Effect<VcsSwitchRefResult, GitCommandError>; | ||
| 195 | readonly initRepo: (input: VcsInitInput) => Effect.Effect<void, GitCommandError>; | ||
| 196 | readonly listLocalBranchNames: (cwd: string) => Effect.Effect<string[], GitCommandError>; | ||
| 197 | } | ||
| 198 | |||
| 199 | export class GitVcsDriver extends Context.Service<GitVcsDriver, GitVcsDriverShape>()( | ||
| 200 | "t3/vcs/GitVcsDriver", | ||
| 201 | ) {} | ||
| 202 | |||
| 203 | const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; | ||
| 204 | const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; | ||
| 205 | const WORKSPACE_GIT_HARDENED_CONFIG_ARGS = [ | ||
| 206 | "-c", | ||
| 207 | "core.fsmonitor=false", | ||
| 208 | "-c", | ||
| 209 | "core.untrackedCache=false", | ||
| 210 | ] as const; | ||
| 211 | |||
| 212 | const nowFreshness = Effect.fn("GitVcsDriver.nowFreshness")(function* () { | ||
| 213 | const now = yield* DateTime.now; | ||
| 214 | return { | ||
| 215 | source: "live-local" as const, | ||
| 216 | observedAt: now, | ||
| 217 | expiresAt: Option.none(), | ||
| 218 | }; | ||
| 219 | }); | ||
| 220 | |||
| 221 | function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { | ||
| 222 | const parts = input.split("\0"); | ||
| 223 | if (parts.length === 0) return []; | ||
| 224 | |||
| 225 | if (truncated && parts[parts.length - 1]?.length) { | ||
| 226 | parts.pop(); | ||
| 227 | } | ||
| 228 | |||
| 229 | return parts.filter((value) => value.length > 0); | ||
| 230 | } | ||
| 231 | |||
| 232 | function chunkPathsForGitCheckIgnore(relativePaths: ReadonlyArray<string>): string[][] { | ||
| 233 | const chunks: string[][] = []; | ||
| 234 | let chunk: string[] = []; | ||
| 235 | let chunkBytes = 0; | ||
| 236 | |||
| 237 | for (const relativePath of relativePaths) { | ||
| 238 | const relativePathBytes = Buffer.byteLength(relativePath) + 1; | ||
| 239 | if (chunk.length > 0 && chunkBytes + relativePathBytes > GIT_CHECK_IGNORE_MAX_STDIN_BYTES) { | ||
| 240 | chunks.push(chunk); | ||
| 241 | chunk = []; | ||
| 242 | chunkBytes = 0; | ||
| 243 | } | ||
| 244 | |||
| 245 | chunk.push(relativePath); | ||
| 246 | chunkBytes += relativePathBytes; | ||
| 247 | |||
| 248 | if (chunkBytes >= GIT_CHECK_IGNORE_MAX_STDIN_BYTES) { | ||
| 249 | chunks.push(chunk); | ||
| 250 | chunk = []; | ||
| 251 | chunkBytes = 0; | ||
| 252 | } | ||
| 253 | } | ||
| 254 | |||
| 255 | if (chunk.length > 0) { | ||
| 256 | chunks.push(chunk); | ||
| 257 | } | ||
| 258 | |||
| 259 | return chunks; | ||
| 260 | } | ||
| 261 | |||
| 262 | function parseGitRemoteVerboseOutput( | ||
| 263 | output: string, | ||
| 264 | ): Map<string, { url?: string; pushUrl?: string }> { | ||
| 265 | const remotes = new Map<string, { url?: string; pushUrl?: string }>(); | ||
| 266 | for (const line of output.split("\n")) { | ||
| 267 | const trimmed = line.trim(); | ||
| 268 | if (trimmed.length === 0) { | ||
| 269 | continue; | ||
| 270 | } | ||
| 271 | |||
| 272 | const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); | ||
| 273 | if (!match) { | ||
| 274 | continue; | ||
| 275 | } | ||
| 276 | |||
| 277 | const name = match[1]; | ||
| 278 | const url = match[2]; | ||
| 279 | const direction = match[3]; | ||
| 280 | if (!name || !url || !direction) { | ||
| 281 | continue; | ||
| 282 | } | ||
| 283 | const remote = remotes.get(name) ?? {}; | ||
| 284 | if (direction === "fetch") { | ||
| 285 | remote.url = url; | ||
| 286 | } else { | ||
| 287 | remote.pushUrl = url; | ||
| 288 | } | ||
| 289 | remotes.set(name, remote); | ||
| 290 | } | ||
| 291 | return remotes; | ||
| 292 | } | ||
| 293 | |||
| 294 | const gitCommand = ( | ||
| 295 | process: VcsProcessShape, | ||
| 296 | operation: string, | ||
| 297 | cwd: string, | ||
| 298 | args: ReadonlyArray<string>, | ||
| 299 | options?: { | ||
| 300 | readonly stdin?: string; | ||
| 301 | readonly env?: NodeJS.ProcessEnv; | ||
| 302 | readonly allowNonZeroExit?: boolean; | ||
| 303 | readonly timeoutMs?: number; | ||
| 304 | readonly maxOutputBytes?: number; | ||
| 305 | readonly truncateOutputAtMaxBytes?: boolean; | ||
| 306 | }, | ||
| 307 | ) => | ||
| 308 | process.run({ | ||
| 309 | operation, | ||
| 310 | command: "git", | ||
| 311 | args, | ||
| 312 | cwd, | ||
| 313 | ...(options?.stdin !== undefined ? { stdin: options.stdin } : {}), | ||
| 314 | ...(options?.env !== undefined ? { env: options.env } : {}), | ||
| 315 | ...(options?.allowNonZeroExit !== undefined | ||
| 316 | ? { allowNonZeroExit: options.allowNonZeroExit } | ||
| 317 | : {}), | ||
| 318 | ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), | ||
| 319 | ...(options?.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), | ||
| 320 | ...(options?.truncateOutputAtMaxBytes !== undefined | ||
| 321 | ? { truncateOutputAtMaxBytes: options.truncateOutputAtMaxBytes } | ||
| 322 | : {}), | ||
| 323 | }); | ||
| 324 | |||
| 325 | export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* () { | ||
| 326 | const process = yield* VcsProcess; | ||
| 327 | const capabilities = { | ||
| 328 | kind: "git" as const, | ||
| 329 | supportsWorktrees: true, | ||
| 330 | supportsBookmarks: false, | ||
| 331 | supportsAtomicSnapshot: false, | ||
| 332 | supportsPushDefaultRemote: true, | ||
| 333 | ignoreClassifier: "native" as const, | ||
| 334 | }; | ||
| 335 | |||
| 336 | const isInsideWorkTree: VcsDriverShape["isInsideWorkTree"] = (cwd) => | ||
| 337 | gitCommand( | ||
| 338 | process, | ||
| 339 | "GitVcsDriver.isInsideWorkTree", | ||
| 340 | cwd, | ||
| 341 | ["rev-parse", "--is-inside-work-tree"], | ||
| 342 | { | ||
| 343 | allowNonZeroExit: true, | ||
| 344 | timeoutMs: 5_000, | ||
| 345 | maxOutputBytes: 4_096, | ||
| 346 | }, | ||
| 347 | ).pipe(Effect.map((result) => result.exitCode === 0 && result.stdout.trim() === "true")); | ||
| 348 | |||
| 349 | const execute: VcsDriverShape["execute"] = (input) => | ||
| 350 | gitCommand(process, input.operation, input.cwd, input.args, { | ||
| 351 | ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), | ||
| 352 | ...(input.env !== undefined ? { env: input.env } : {}), | ||
| 353 | ...(input.allowNonZeroExit !== undefined ? { allowNonZeroExit: input.allowNonZeroExit } : {}), | ||
| 354 | ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), | ||
| 355 | ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}), | ||
| 356 | ...(input.truncateOutputAtMaxBytes !== undefined | ||
| 357 | ? { truncateOutputAtMaxBytes: input.truncateOutputAtMaxBytes } | ||
| 358 | : {}), | ||
| 359 | }); | ||
| 360 | |||
| 361 | const detectRepository: VcsDriverShape["detectRepository"] = Effect.fn("detectRepository")( | ||
| 362 | function* (cwd) { | ||
| 363 | if (!(yield* isInsideWorkTree(cwd))) { | ||
| 364 | return null; | ||
| 365 | } | ||
| 366 | |||
| 367 | const root = yield* gitCommand(process, "GitVcsDriver.detectRepository.root", cwd, [ | ||
| 368 | "rev-parse", | ||
| 369 | "--show-toplevel", | ||
| 370 | ]); | ||
| 371 | const gitCommonDir = yield* gitCommand( | ||
| 372 | process, | ||
| 373 | "GitVcsDriver.detectRepository.commonDir", | ||
| 374 | cwd, | ||
| 375 | ["rev-parse", "--git-common-dir"], | ||
| 376 | ).pipe(Effect.catch(() => Effect.succeed(null))); | ||
| 377 | |||
| 378 | return { | ||
| 379 | kind: "git" as const, | ||
| 380 | rootPath: root.stdout.trim(), | ||
| 381 | metadataPath: gitCommonDir?.stdout.trim() || null, | ||
| 382 | freshness: yield* nowFreshness(), | ||
| 383 | }; | ||
| 384 | }, | ||
| 385 | ); | ||
| 386 | |||
| 387 | const listWorkspaceFiles: VcsDriverShape["listWorkspaceFiles"] = (cwd) => | ||
| 388 | gitCommand( | ||
| 389 | process, | ||
| 390 | "GitVcsDriver.listWorkspaceFiles", | ||
| 391 | cwd, | ||
| 392 | [ | ||
| 393 | ...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, | ||
| 394 | "ls-files", | ||
| 395 | "--cached", | ||
| 396 | "--others", | ||
| 397 | "--exclude-standard", | ||
| 398 | "-z", | ||
| 399 | ], | ||
| 400 | { | ||
| 401 | allowNonZeroExit: true, | ||
| 402 | timeoutMs: 20_000, | ||
| 403 | maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, | ||
| 404 | truncateOutputAtMaxBytes: true, | ||
| 405 | }, | ||
| 406 | ).pipe( | ||
| 407 | Effect.flatMap((result) => | ||
| 408 | result.exitCode === 0 | ||
| 409 | ? Effect.gen(function* () { | ||
| 410 | const freshness = yield* nowFreshness(); | ||
| 411 | return { | ||
| 412 | paths: splitNullSeparatedPaths(result.stdout, result.stdoutTruncated), | ||
| 413 | truncated: result.stdoutTruncated, | ||
| 414 | freshness, | ||
| 415 | }; | ||
| 416 | }) | ||
| 417 | : Effect.fail( | ||
| 418 | new VcsProcessExitError({ | ||
| 419 | operation: "GitVcsDriver.listWorkspaceFiles", | ||
| 420 | command: "git ls-files", | ||
| 421 | cwd, | ||
| 422 | exitCode: result.exitCode, | ||
| 423 | detail: result.stderr.trim() || "git ls-files failed", | ||
| 424 | }), | ||
| 425 | ), | ||
| 426 | ), | ||
| 427 | ); | ||
| 428 | |||
| 429 | const listRemotes: VcsDriverShape["listRemotes"] = Effect.fn("listRemotes")(function* (cwd) { | ||
| 430 | const result = yield* gitCommand(process, "GitVcsDriver.listRemotes", cwd, ["remote", "-v"], { | ||
| 431 | allowNonZeroExit: true, | ||
| 432 | timeoutMs: 5_000, | ||
| 433 | maxOutputBytes: 64 * 1024, | ||
| 434 | }); | ||
| 435 | |||
| 436 | if (result.exitCode !== 0) { | ||
| 437 | return yield* new VcsProcessExitError({ | ||
| 438 | operation: "GitVcsDriver.listRemotes", | ||
| 439 | command: "git remote -v", | ||
| 440 | cwd, | ||
| 441 | exitCode: result.exitCode, | ||
| 442 | detail: result.stderr.trim() || "git remote -v failed", | ||
| 443 | }); | ||
| 444 | } | ||
| 445 | |||
| 446 | const parsed = parseGitRemoteVerboseOutput(result.stdout); | ||
| 447 | const remotes = Array.from(parsed.entries()).flatMap(([name, remote]) => { | ||
| 448 | if (!remote.url) { | ||
| 449 | return []; | ||
| 450 | } | ||
| 451 | return [ | ||
| 452 | { | ||
| 453 | name, | ||
| 454 | url: remote.url, | ||
| 455 | pushUrl: remote.pushUrl ? Option.some(remote.pushUrl) : Option.none(), | ||
| 456 | isPrimary: name === "origin", | ||
| 457 | }, | ||
| 458 | ]; | ||
| 459 | }); | ||
| 460 | |||
| 461 | return { | ||
| 462 | remotes, | ||
| 463 | freshness: yield* nowFreshness(), | ||
| 464 | }; | ||
| 465 | }); | ||
| 466 | |||
| 467 | const filterIgnoredPaths: VcsDriverShape["filterIgnoredPaths"] = Effect.fn("filterIgnoredPaths")( | ||
| 468 | function* (cwd, relativePaths) { | ||
| 469 | if (relativePaths.length === 0) { | ||
| 470 | return relativePaths; | ||
| 471 | } | ||
| 472 | |||
| 473 | const ignoredPaths = new Set<string>(); | ||
| 474 | const chunks = chunkPathsForGitCheckIgnore(relativePaths); | ||
| 475 | |||
| 476 | for (const chunk of chunks) { | ||
| 477 | const result = yield* gitCommand( | ||
| 478 | process, | ||
| 479 | "GitVcsDriver.filterIgnoredPaths", | ||
| 480 | cwd, | ||
| 481 | [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"], | ||
| 482 | { | ||
| 483 | stdin: `${chunk.join("\0")}\0`, | ||
| 484 | allowNonZeroExit: true, | ||
| 485 | timeoutMs: 20_000, | ||
| 486 | maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, | ||
| 487 | truncateOutputAtMaxBytes: true, | ||
| 488 | }, | ||
| 489 | ); | ||
| 490 | |||
| 491 | if (result.exitCode !== 0 && result.exitCode !== 1) { | ||
| 492 | return yield* new VcsProcessExitError({ | ||
| 493 | operation: "GitVcsDriver.filterIgnoredPaths", | ||
| 494 | command: "git check-ignore", | ||
| 495 | cwd, | ||
| 496 | exitCode: result.exitCode, | ||
| 497 | detail: result.stderr.trim() || "git check-ignore failed", | ||
| 498 | }); | ||
| 499 | } | ||
| 500 | |||
| 501 | for (const ignoredPath of splitNullSeparatedPaths(result.stdout, result.stdoutTruncated)) { | ||
| 502 | ignoredPaths.add(ignoredPath); | ||
| 503 | } | ||
| 504 | } | ||
| 505 | |||
| 506 | if (ignoredPaths.size === 0) { | ||
| 507 | return relativePaths; | ||
| 508 | } | ||
| 509 | |||
| 510 | return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath)); | ||
| 511 | }, | ||
| 512 | ); | ||
| 513 | |||
| 514 | const initRepository: VcsDriverShape["initRepository"] = (input) => | ||
| 515 | gitCommand(process, "GitVcsDriver.initRepository", input.cwd, ["init"], { | ||
| 516 | timeoutMs: 10_000, | ||
| 517 | maxOutputBytes: 64 * 1024, | ||
| 518 | }).pipe(Effect.asVoid); | ||
| 519 | |||
| 520 | return { | ||
| 521 | capabilities, | ||
| 522 | execute, | ||
| 523 | detectRepository, | ||
| 524 | isInsideWorkTree, | ||
| 525 | listWorkspaceFiles, | ||
| 526 | listRemotes, | ||
| 527 | filterIgnoredPaths, | ||
| 528 | initRepository, | ||
| 529 | } satisfies VcsDriverShape; | ||
| 530 | }); | ||
| 531 | |||
| 532 | export const makeVcsDriver = Effect.fn("makeGitVcsDriver")(function* () { | ||
| 533 | const driver = yield* makeVcsDriverShape(); | ||
| 534 | return VcsDriver.of(driver); | ||
| 535 | }); | ||
| 536 | |||
| 537 | export const make = Effect.fn("makeGitVcsDriverService")(function* () { | ||
| 538 | const git = yield* makeGitVcsDriverCore(); | ||
| 539 | return GitVcsDriver.of(git); | ||
| 540 | }); | ||
| 541 | |||
| 542 | export const vcsLayer = Layer.effect(VcsDriver, makeVcsDriver()); | ||
| 543 | export const layer = Layer.effect(GitVcsDriver, make()); | ||
apps/server/src/vcs/GitVcsDriverCore.test.ts created+246| ... | @@ -0,0 +1,246 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { assert, it } from "@effect/vitest"; | ||
| 3 | import { Effect, FileSystem, Layer, Path, PlatformError, Scope } from "effect"; | ||
| 4 | import { describe } from "vitest"; | ||
| 5 | |||
| 6 | import { GitCommandError } from "@t3tools/contracts"; | ||
| 7 | import { ServerConfig } from "../config.ts"; | ||
| 8 | import * as GitVcsDriver from "./GitVcsDriver.ts"; | ||
| 9 | |||
| 10 | const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { | ||
| 11 | prefix: "t3-git-vcs-driver-test-", | ||
| 12 | }); | ||
| 13 | const TestLayer = GitVcsDriver.layer.pipe( | ||
| 14 | Layer.provide(ServerConfigLayer), | ||
| 15 | Layer.provideMerge(NodeServices.layer), | ||
| 16 | ); | ||
| 17 | |||
| 18 | const makeTmpDir = ( | ||
| 19 | prefix = "git-vcs-driver-test-", | ||
| 20 | ): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> => | ||
| 21 | Effect.gen(function* () { | ||
| 22 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 23 | return yield* fileSystem.makeTempDirectoryScoped({ prefix }); | ||
| 24 | }); | ||
| 25 | |||
| 26 | const writeTextFile = ( | ||
| 27 | cwd: string, | ||
| 28 | relativePath: string, | ||
| 29 | contents: string, | ||
| 30 | ): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> => | ||
| 31 | Effect.gen(function* () { | ||
| 32 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 33 | const pathService = yield* Path.Path; | ||
| 34 | const filePath = pathService.join(cwd, relativePath); | ||
| 35 | yield* fileSystem.makeDirectory(pathService.dirname(filePath), { recursive: true }); | ||
| 36 | yield* fileSystem.writeFileString(filePath, contents); | ||
| 37 | }); | ||
| 38 | |||
| 39 | const git = ( | ||
| 40 | cwd: string, | ||
| 41 | args: ReadonlyArray<string>, | ||
| 42 | env?: NodeJS.ProcessEnv, | ||
| 43 | ): Effect.Effect<string, GitCommandError, GitVcsDriver.GitVcsDriver> => | ||
| 44 | Effect.gen(function* () { | ||
| 45 | const driver = yield* GitVcsDriver.GitVcsDriver; | ||
| 46 | const result = yield* driver.execute({ | ||
| 47 | operation: "GitVcsDriver.test.git", | ||
| 48 | cwd, | ||
| 49 | args, | ||
| 50 | ...(env ? { env } : {}), | ||
| 51 | timeoutMs: 10_000, | ||
| 52 | }); | ||
| 53 | return result.stdout.trim(); | ||
| 54 | }); | ||
| 55 | |||
| 56 | const initRepoWithCommit = ( | ||
| 57 | cwd: string, | ||
| 58 | ): Effect.Effect< | ||
| 59 | { readonly initialBranch: string }, | ||
| 60 | GitCommandError | PlatformError.PlatformError, | ||
| 61 | GitVcsDriver.GitVcsDriver | FileSystem.FileSystem | Path.Path | ||
| 62 | > => | ||
| 63 | Effect.gen(function* () { | ||
| 64 | const driver = yield* GitVcsDriver.GitVcsDriver; | ||
| 65 | yield* driver.initRepo({ cwd }); | ||
| 66 | yield* git(cwd, ["config", "user.email", "test@test.com"]); | ||
| 67 | yield* git(cwd, ["config", "user.name", "Test"]); | ||
| 68 | yield* writeTextFile(cwd, "README.md", "# test\n"); | ||
| 69 | yield* git(cwd, ["add", "."]); | ||
| 70 | yield* git(cwd, ["commit", "-m", "initial commit"]); | ||
| 71 | const initialBranch = yield* git(cwd, ["branch", "--show-current"]); | ||
| 72 | return { initialBranch }; | ||
| 73 | }); | ||
| 74 | |||
| 75 | it.layer(TestLayer)("GitVcsDriver core integration", (it) => { | ||
| 76 | describe("repository status", () => { | ||
| 77 | it.effect("reports non-repository directories without failing", () => | ||
| 78 | Effect.gen(function* () { | ||
| 79 | const cwd = yield* makeTmpDir(); | ||
| 80 | const driver = yield* GitVcsDriver.GitVcsDriver; | ||
| 81 | |||
| 82 | const refs = yield* driver.listRefs({ cwd }); | ||
| 83 | assert.equal(refs.isRepo, false); | ||
| 84 | assert.deepStrictEqual(refs.refs, []); | ||
| 85 | }), | ||
| 86 | ); | ||
| 87 | |||
| 88 | it.effect("reports refName and dirty state for a repository", () => | ||
| 89 | Effect.gen(function* () { | ||
| 90 | const cwd = yield* makeTmpDir(); | ||
| 91 | const { initialBranch } = yield* initRepoWithCommit(cwd); | ||
| 92 | yield* writeTextFile(cwd, "feature.ts", "export const value = 1;\n"); | ||
| 93 | |||
| 94 | const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); | ||
| 95 | |||
| 96 | assert.equal(status.isRepo, true); | ||
| 97 | assert.equal(status.branch, initialBranch); | ||
| 98 | assert.equal(status.hasWorkingTreeChanges, true); | ||
| 99 | assert.include( | ||
| 100 | status.workingTree.files.map((file) => file.path), | ||
| 101 | "feature.ts", | ||
| 102 | ); | ||
| 103 | }), | ||
| 104 | ); | ||
| 105 | }); | ||
| 106 | |||
| 107 | describe("refName operations", () => { | ||
| 108 | it.effect("creates, checks out, renames, and lists refs", () => | ||
| 109 | Effect.gen(function* () { | ||
| 110 | const cwd = yield* makeTmpDir(); | ||
| 111 | yield* initRepoWithCommit(cwd); | ||
| 112 | const driver = yield* GitVcsDriver.GitVcsDriver; | ||
| 113 | |||
| 114 | yield* driver.createRef({ cwd, refName: "feature/original" }); | ||
| 115 | const switchRef = yield* driver.switchRef({ cwd, refName: "feature/original" }); | ||
| 116 | assert.equal(switchRef.refName, "feature/original"); | ||
| 117 | |||
| 118 | const renamed = yield* driver.renameBranch({ | ||
| 119 | cwd, | ||
| 120 | oldBranch: "feature/original", | ||
| 121 | newBranch: "feature/renamed", | ||
| 122 | }); | ||
| 123 | assert.equal(renamed.branch, "feature/renamed"); | ||
| 124 | assert.equal(yield* git(cwd, ["branch", "--show-current"]), "feature/renamed"); | ||
| 125 | |||
| 126 | const refs = yield* driver.listRefs({ cwd }); | ||
| 127 | assert.equal( | ||
| 128 | refs.refs.find((refName) => refName.name === "feature/renamed")?.current, | ||
| 129 | true, | ||
| 130 | ); | ||
| 131 | }), | ||
| 132 | ); | ||
| 133 | |||
| 134 | it.effect("returns the existing refName when rename source and target match", () => | ||
| 135 | Effect.gen(function* () { | ||
| 136 | const cwd = yield* makeTmpDir(); | ||
| 137 | yield* initRepoWithCommit(cwd); | ||
| 138 | const driver = yield* GitVcsDriver.GitVcsDriver; | ||
| 139 | |||
| 140 | const current = yield* git(cwd, ["branch", "--show-current"]); | ||
| 141 | const result = yield* driver.renameBranch({ | ||
| 142 | cwd, | ||
| 143 | oldBranch: current, | ||
| 144 | newBranch: current, | ||
| 145 | }); | ||
| 146 | |||
| 147 | assert.equal(result.branch, current); | ||
| 148 | }), | ||
| 149 | ); | ||
| 150 | }); | ||
| 151 | |||
| 152 | describe("worktree operations", () => { | ||
| 153 | it.effect("creates and removes a worktree for a new refName", () => | ||
| 154 | Effect.gen(function* () { | ||
| 155 | const cwd = yield* makeTmpDir(); | ||
| 156 | const { initialBranch } = yield* initRepoWithCommit(cwd); | ||
| 157 | const pathService = yield* Path.Path; | ||
| 158 | const worktreePath = pathService.join( | ||
| 159 | yield* makeTmpDir("git-worktrees-"), | ||
| 160 | "feature-worktree", | ||
| 161 | ); | ||
| 162 | const driver = yield* GitVcsDriver.GitVcsDriver; | ||
| 163 | |||
| 164 | const created = yield* driver.createWorktree({ | ||
| 165 | cwd, | ||
| 166 | path: worktreePath, | ||
| 167 | refName: initialBranch, | ||
| 168 | newRefName: "feature/worktree", | ||
| 169 | }); | ||
| 170 | |||
| 171 | assert.equal(created.worktree.path, worktreePath); | ||
| 172 | assert.equal(created.worktree.refName, "feature/worktree"); | ||
| 173 | assert.equal(yield* git(worktreePath, ["branch", "--show-current"]), "feature/worktree"); | ||
| 174 | |||
| 175 | yield* driver.removeWorktree({ cwd, path: worktreePath }); | ||
| 176 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 177 | assert.equal(yield* fileSystem.exists(worktreePath), false); | ||
| 178 | }), | ||
| 179 | ); | ||
| 180 | }); | ||
| 181 | |||
| 182 | describe("commit context", () => { | ||
| 183 | it.effect("stages selected files and commits only those files", () => | ||
| 184 | Effect.gen(function* () { | ||
| 185 | const cwd = yield* makeTmpDir(); | ||
| 186 | yield* initRepoWithCommit(cwd); | ||
| 187 | const driver = yield* GitVcsDriver.GitVcsDriver; | ||
| 188 | |||
| 189 | yield* writeTextFile(cwd, "a.txt", "a\n"); | ||
| 190 | yield* writeTextFile(cwd, "b.txt", "b\n"); | ||
| 191 | |||
| 192 | const context = yield* driver.prepareCommitContext(cwd, ["a.txt"]); | ||
| 193 | assert.include(context?.stagedSummary ?? "", "a.txt"); | ||
| 194 | assert.notInclude(context?.stagedSummary ?? "", "b.txt"); | ||
| 195 | |||
| 196 | const commit = yield* driver.commit(cwd, "Add a", ""); | ||
| 197 | assert.match(commit.commitSha, /^[a-f0-9]{40}$/); | ||
| 198 | assert.equal(yield* git(cwd, ["log", "-1", "--pretty=%s"]), "Add a"); | ||
| 199 | |||
| 200 | const status = yield* git(cwd, ["status", "--porcelain"]); | ||
| 201 | assert.include(status, "?? b.txt"); | ||
| 202 | assert.notInclude(status, "a.txt"); | ||
| 203 | }), | ||
| 204 | ); | ||
| 205 | }); | ||
| 206 | |||
| 207 | describe("remote operations", () => { | ||
| 208 | it.effect("pushes with upstream setup and skips when already up to date", () => | ||
| 209 | Effect.gen(function* () { | ||
| 210 | const cwd = yield* makeTmpDir(); | ||
| 211 | const remote = yield* makeTmpDir("git-remote-"); | ||
| 212 | yield* initRepoWithCommit(cwd); | ||
| 213 | yield* git(remote, ["init", "--bare"]); | ||
| 214 | yield* git(cwd, ["remote", "add", "origin", remote]); | ||
| 215 | yield* (yield* GitVcsDriver.GitVcsDriver).createRef({ | ||
| 216 | cwd, | ||
| 217 | refName: "feature/push", | ||
| 218 | }); | ||
| 219 | yield* (yield* GitVcsDriver.GitVcsDriver).switchRef({ | ||
| 220 | cwd, | ||
| 221 | refName: "feature/push", | ||
| 222 | }); | ||
| 223 | yield* writeTextFile(cwd, "feature.txt", "feature\n"); | ||
| 224 | yield* (yield* GitVcsDriver.GitVcsDriver).prepareCommitContext(cwd); | ||
| 225 | yield* (yield* GitVcsDriver.GitVcsDriver).commit(cwd, "Add feature", ""); | ||
| 226 | |||
| 227 | const pushed = yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch(cwd, null); | ||
| 228 | assert.deepInclude(pushed, { | ||
| 229 | status: "pushed", | ||
| 230 | branch: "feature/push", | ||
| 231 | setUpstream: true, | ||
| 232 | }); | ||
| 233 | assert.equal( | ||
| 234 | yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), | ||
| 235 | "origin/feature/push", | ||
| 236 | ); | ||
| 237 | |||
| 238 | const skipped = yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch(cwd, null); | ||
| 239 | assert.deepInclude(skipped, { | ||
| 240 | status: "skipped_up_to_date", | ||
| 241 | branch: "feature/push", | ||
| 242 | }); | ||
| 243 | }), | ||
| 244 | ); | ||
| 245 | }); | ||
| 246 | }); | ||
apps/server/src/vcs/GitVcsDriverCore.ts created+2087| ... | @@ -0,0 +1,2087 @@ | ||
| 1 | import { | ||
| 2 | Cache, | ||
| 3 | Data, | ||
| 4 | DateTime, | ||
| 5 | Duration, | ||
| 6 | Effect, | ||
| 7 | Exit, | ||
| 8 | FileSystem, | ||
| 9 | Option, | ||
| 10 | Path, | ||
| 11 | PlatformError, | ||
| 12 | Ref, | ||
| 13 | Result, | ||
| 14 | Schema, | ||
| 15 | Scope, | ||
| 16 | Semaphore, | ||
| 17 | Stream, | ||
| 18 | } from "effect"; | ||
| 19 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 20 | |||
| 21 | import { GitCommandError, type VcsRef } from "@t3tools/contracts"; | ||
| 22 | import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; | ||
| 23 | import { compactTraceAttributes } from "../observability/Attributes.ts"; | ||
| 24 | import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; | ||
| 25 | import type { | ||
| 26 | ExecuteGitProgress, | ||
| 27 | GitCommitOptions, | ||
| 28 | GitVcsDriverShape, | ||
| 29 | GitStatusDetails, | ||
| 30 | ExecuteGitInput, | ||
| 31 | ExecuteGitResult, | ||
| 32 | } from "./GitVcsDriver.ts"; | ||
| 33 | import { | ||
| 34 | parseRemoteNames, | ||
| 35 | parseRemoteNamesInGitOrder, | ||
| 36 | parseRemoteRefWithRemoteNames, | ||
| 37 | } from "../git/remoteRefs.ts"; | ||
| 38 | import { ServerConfig } from "../config.ts"; | ||
| 39 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | ||
| 40 | |||
| 41 | const DEFAULT_TIMEOUT_MS = 30_000; | ||
| 42 | const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; | ||
| 43 | const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; | ||
| 44 | const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000; | ||
| 45 | const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000; | ||
| 46 | const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; | ||
| 47 | const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000; | ||
| 48 | const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); | ||
| 49 | const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); | ||
| 50 | const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); | ||
| 51 | const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; | ||
| 52 | const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; | ||
| 53 | const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; | ||
| 54 | const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitStatusDetails>({ | ||
| 55 | isRepo: false, | ||
| 56 | hasOriginRemote: false, | ||
| 57 | isDefaultBranch: false, | ||
| 58 | branch: null, | ||
| 59 | upstreamRef: null, | ||
| 60 | hasWorkingTreeChanges: false, | ||
| 61 | workingTree: { files: [], insertions: 0, deletions: 0 }, | ||
| 62 | hasUpstream: false, | ||
| 63 | aheadCount: 0, | ||
| 64 | behindCount: 0, | ||
| 65 | }); | ||
| 66 | |||
| 67 | type TraceTailState = { | ||
| 68 | processedChars: number; | ||
| 69 | remainder: string; | ||
| 70 | }; | ||
| 71 | |||
| 72 | class StatusRemoteRefreshCacheKey extends Data.Class<{ | ||
| 73 | gitCommonDir: string; | ||
| 74 | remoteName: string; | ||
| 75 | }> {} | ||
| 76 | |||
| 77 | interface ExecuteGitOptions { | ||
| 78 | stdin?: string | undefined; | ||
| 79 | timeoutMs?: number | undefined; | ||
| 80 | allowNonZeroExit?: boolean | undefined; | ||
| 81 | fallbackErrorMessage?: string | undefined; | ||
| 82 | maxOutputBytes?: number | undefined; | ||
| 83 | truncateOutputAtMaxBytes?: boolean | undefined; | ||
| 84 | progress?: ExecuteGitProgress | undefined; | ||
| 85 | } | ||
| 86 | |||
| 87 | function parseBranchAb(value: string): { ahead: number; behind: number } { | ||
| 88 | const match = value.match(/^\+(\d+)\s+-(\d+)$/); | ||
| 89 | if (!match) return { ahead: 0, behind: 0 }; | ||
| 90 | return { | ||
| 91 | ahead: Number(match[1] ?? "0"), | ||
| 92 | behind: Number(match[2] ?? "0"), | ||
| 93 | }; | ||
| 94 | } | ||
| 95 | |||
| 96 | function parseNumstatEntries( | ||
| 97 | stdout: string, | ||
| 98 | ): Array<{ path: string; insertions: number; deletions: number }> { | ||
| 99 | const entries: Array<{ path: string; insertions: number; deletions: number }> = []; | ||
| 100 | for (const line of stdout.split(/\r?\n/g)) { | ||
| 101 | if (line.trim().length === 0) continue; | ||
| 102 | const [addedRaw, deletedRaw, ...pathParts] = line.split("\t"); | ||
| 103 | const rawPath = | ||
| 104 | pathParts.length > 1 ? (pathParts.at(-1) ?? "").trim() : pathParts.join("\t").trim(); | ||
| 105 | if (rawPath.length === 0) continue; | ||
| 106 | const added = Number.parseInt(addedRaw ?? "0", 10); | ||
| 107 | const deleted = Number.parseInt(deletedRaw ?? "0", 10); | ||
| 108 | const renameArrowIndex = rawPath.indexOf(" => "); | ||
| 109 | const normalizedPath = | ||
| 110 | renameArrowIndex >= 0 ? rawPath.slice(renameArrowIndex + " => ".length).trim() : rawPath; | ||
| 111 | entries.push({ | ||
| 112 | path: normalizedPath.length > 0 ? normalizedPath : rawPath, | ||
| 113 | insertions: Number.isFinite(added) ? added : 0, | ||
| 114 | deletions: Number.isFinite(deleted) ? deleted : 0, | ||
| 115 | }); | ||
| 116 | } | ||
| 117 | return entries; | ||
| 118 | } | ||
| 119 | |||
| 120 | function parsePorcelainPath(line: string): string | null { | ||
| 121 | if (line.startsWith("? ") || line.startsWith("! ")) { | ||
| 122 | const simple = line.slice(2).trim(); | ||
| 123 | return simple.length > 0 ? simple : null; | ||
| 124 | } | ||
| 125 | |||
| 126 | if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) { | ||
| 127 | return null; | ||
| 128 | } | ||
| 129 | |||
| 130 | const tabIndex = line.indexOf("\t"); | ||
| 131 | if (tabIndex >= 0) { | ||
| 132 | const fromTab = line.slice(tabIndex + 1); | ||
| 133 | const [filePath] = fromTab.split("\t"); | ||
| 134 | return filePath?.trim().length ? filePath.trim() : null; | ||
| 135 | } | ||
| 136 | |||
| 137 | const parts = line.trim().split(/\s+/g); | ||
| 138 | const filePath = parts.at(-1) ?? ""; | ||
| 139 | return filePath.length > 0 ? filePath : null; | ||
| 140 | } | ||
| 141 | |||
| 142 | function parseBranchLine(line: string): { name: string; current: boolean } | null { | ||
| 143 | const trimmed = line.trim(); | ||
| 144 | if (trimmed.length === 0) return null; | ||
| 145 | |||
| 146 | const name = trimmed.replace(/^[*+]\s+/, ""); | ||
| 147 | // Exclude symbolic refs like: "origin/HEAD -> origin/main". | ||
| 148 | // Exclude detached HEAD pseudo-refs like: "(HEAD detached at origin/main)". | ||
| 149 | if (name.includes(" -> ") || name.startsWith("(")) return null; | ||
| 150 | |||
| 151 | return { | ||
| 152 | name, | ||
| 153 | current: trimmed.startsWith("* "), | ||
| 154 | }; | ||
| 155 | } | ||
| 156 | |||
| 157 | function filterBranchesForListQuery( | ||
| 158 | refs: ReadonlyArray<VcsRef>, | ||
| 159 | query?: string, | ||
| 160 | ): ReadonlyArray<VcsRef> { | ||
| 161 | if (!query) { | ||
| 162 | return refs; | ||
| 163 | } | ||
| 164 | |||
| 165 | const normalizedQuery = query.toLowerCase(); | ||
| 166 | return refs.filter((refName) => refName.name.toLowerCase().includes(normalizedQuery)); | ||
| 167 | } | ||
| 168 | |||
| 169 | function paginateBranches(input: { | ||
| 170 | refs: ReadonlyArray<VcsRef>; | ||
| 171 | cursor?: number | undefined; | ||
| 172 | limit?: number | undefined; | ||
| 173 | }): { | ||
| 174 | refs: ReadonlyArray<VcsRef>; | ||
| 175 | nextCursor: number | null; | ||
| 176 | totalCount: number; | ||
| 177 | } { | ||
| 178 | const cursor = input.cursor ?? 0; | ||
| 179 | const limit = input.limit ?? GIT_LIST_BRANCHES_DEFAULT_LIMIT; | ||
| 180 | const totalCount = input.refs.length; | ||
| 181 | const refs = input.refs.slice(cursor, cursor + limit); | ||
| 182 | const nextCursor = cursor + refs.length < totalCount ? cursor + refs.length : null; | ||
| 183 | |||
| 184 | return { | ||
| 185 | refs, | ||
| 186 | nextCursor, | ||
| 187 | totalCount, | ||
| 188 | }; | ||
| 189 | } | ||
| 190 | |||
| 191 | function sanitizeRemoteName(value: string): string { | ||
| 192 | const sanitized = value | ||
| 193 | .trim() | ||
| 194 | .replace(/[^A-Za-z0-9._-]+/g, "-") | ||
| 195 | .replace(/^-+|-+$/g, ""); | ||
| 196 | return sanitized.length > 0 ? sanitized : "fork"; | ||
| 197 | } | ||
| 198 | |||
| 199 | function normalizeRemoteUrl(value: string): string { | ||
| 200 | return value | ||
| 201 | .trim() | ||
| 202 | .replace(/\/+$/g, "") | ||
| 203 | .replace(/\.git$/i, "") | ||
| 204 | .toLowerCase(); | ||
| 205 | } | ||
| 206 | |||
| 207 | function parseRemoteFetchUrls(stdout: string): Map<string, string> { | ||
| 208 | const remotes = new Map<string, string>(); | ||
| 209 | for (const line of stdout.split("\n")) { | ||
| 210 | const trimmed = line.trim(); | ||
| 211 | if (trimmed.length === 0) continue; | ||
| 212 | const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); | ||
| 213 | if (!match) continue; | ||
| 214 | const [, remoteName = "", remoteUrl = "", direction = ""] = match; | ||
| 215 | if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) { | ||
| 216 | continue; | ||
| 217 | } | ||
| 218 | remotes.set(remoteName, remoteUrl); | ||
| 219 | } | ||
| 220 | return remotes; | ||
| 221 | } | ||
| 222 | |||
| 223 | function parseUpstreamRefWithRemoteNames( | ||
| 224 | upstreamRef: string, | ||
| 225 | remoteNames: ReadonlyArray<string>, | ||
| 226 | ): { upstreamRef: string; remoteName: string; branchName: string } | null { | ||
| 227 | const parsed = parseRemoteRefWithRemoteNames(upstreamRef, remoteNames); | ||
| 228 | if (!parsed) { | ||
| 229 | return null; | ||
| 230 | } | ||
| 231 | |||
| 232 | return { | ||
| 233 | upstreamRef, | ||
| 234 | remoteName: parsed.remoteName, | ||
| 235 | branchName: parsed.branchName, | ||
| 236 | }; | ||
| 237 | } | ||
| 238 | |||
| 239 | function parseUpstreamRefByFirstSeparator( | ||
| 240 | upstreamRef: string, | ||
| 241 | ): { upstreamRef: string; remoteName: string; branchName: string } | null { | ||
| 242 | const separatorIndex = upstreamRef.indexOf("/"); | ||
| 243 | if (separatorIndex <= 0 || separatorIndex === upstreamRef.length - 1) { | ||
| 244 | return null; | ||
| 245 | } | ||
| 246 | |||
| 247 | const remoteName = upstreamRef.slice(0, separatorIndex).trim(); | ||
| 248 | const branchName = upstreamRef.slice(separatorIndex + 1).trim(); | ||
| 249 | if (remoteName.length === 0 || branchName.length === 0) { | ||
| 250 | return null; | ||
| 251 | } | ||
| 252 | |||
| 253 | return { | ||
| 254 | upstreamRef, | ||
| 255 | remoteName, | ||
| 256 | branchName, | ||
| 257 | }; | ||
| 258 | } | ||
| 259 | |||
| 260 | function parseTrackingBranchByUpstreamRef(stdout: string, upstreamRef: string): string | null { | ||
| 261 | for (const line of stdout.split("\n")) { | ||
| 262 | const trimmedLine = line.trim(); | ||
| 263 | if (trimmedLine.length === 0) { | ||
| 264 | continue; | ||
| 265 | } | ||
| 266 | const [branchNameRaw, upstreamBranchRaw = ""] = trimmedLine.split("\t"); | ||
| 267 | const branchName = branchNameRaw?.trim() ?? ""; | ||
| 268 | const candidateUpstreamRef = upstreamBranchRaw.trim(); | ||
| 269 | if (branchName.length === 0 || candidateUpstreamRef.length === 0) { | ||
| 270 | continue; | ||
| 271 | } | ||
| 272 | if (candidateUpstreamRef === upstreamRef) { | ||
| 273 | return branchName; | ||
| 274 | } | ||
| 275 | } | ||
| 276 | |||
| 277 | return null; | ||
| 278 | } | ||
| 279 | |||
| 280 | function deriveLocalBranchNameFromRemoteRef(branchName: string): string | null { | ||
| 281 | const separatorIndex = branchName.indexOf("/"); | ||
| 282 | if (separatorIndex <= 0 || separatorIndex === branchName.length - 1) { | ||
| 283 | return null; | ||
| 284 | } | ||
| 285 | const localBranch = branchName.slice(separatorIndex + 1).trim(); | ||
| 286 | return localBranch.length > 0 ? localBranch : null; | ||
| 287 | } | ||
| 288 | |||
| 289 | function commandLabel(args: readonly string[]): string { | ||
| 290 | return `git ${args.join(" ")}`; | ||
| 291 | } | ||
| 292 | |||
| 293 | function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): string | null { | ||
| 294 | const trimmed = value.trim(); | ||
| 295 | const prefix = `refs/remotes/${remoteName}/`; | ||
| 296 | if (!trimmed.startsWith(prefix)) { | ||
| 297 | return null; | ||
| 298 | } | ||
| 299 | const refName = trimmed.slice(prefix.length).trim(); | ||
| 300 | return refName.length > 0 ? refName : null; | ||
| 301 | } | ||
| 302 | |||
| 303 | function createGitCommandError( | ||
| 304 | operation: string, | ||
| 305 | cwd: string, | ||
| 306 | args: readonly string[], | ||
| 307 | detail: string, | ||
| 308 | cause?: unknown, | ||
| 309 | ): GitCommandError { | ||
| 310 | return new GitCommandError({ | ||
| 311 | operation, | ||
| 312 | command: commandLabel(args), | ||
| 313 | cwd, | ||
| 314 | detail, | ||
| 315 | ...(cause !== undefined ? { cause } : {}), | ||
| 316 | }); | ||
| 317 | } | ||
| 318 | |||
| 319 | function quoteGitCommand(args: ReadonlyArray<string>): string { | ||
| 320 | return `git ${args.join(" ")}`; | ||
| 321 | } | ||
| 322 | |||
| 323 | function isMissingGitCwdError(error: GitCommandError): boolean { | ||
| 324 | const normalized = `${error.detail}\n${error.message}`.toLowerCase(); | ||
| 325 | return ( | ||
| 326 | normalized.includes("no such file or directory") || | ||
| 327 | normalized.includes("notfound: filesystem.access") || | ||
| 328 | normalized.includes("enoent") || | ||
| 329 | normalized.includes("not a directory") | ||
| 330 | ); | ||
| 331 | } | ||
| 332 | |||
| 333 | function toGitCommandError( | ||
| 334 | input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">, | ||
| 335 | detail: string, | ||
| 336 | ) { | ||
| 337 | return (cause: unknown) => | ||
| 338 | Schema.is(GitCommandError)(cause) | ||
| 339 | ? cause | ||
| 340 | : new GitCommandError({ | ||
| 341 | operation: input.operation, | ||
| 342 | command: quoteGitCommand(input.args), | ||
| 343 | cwd: input.cwd, | ||
| 344 | detail: `${cause instanceof Error && cause.message.length > 0 ? cause.message : "Unknown error"} - ${detail}`, | ||
| 345 | ...(cause !== undefined ? { cause } : {}), | ||
| 346 | }); | ||
| 347 | } | ||
| 348 | |||
| 349 | interface Trace2Monitor { | ||
| 350 | readonly env: NodeJS.ProcessEnv; | ||
| 351 | readonly flush: Effect.Effect<void, never>; | ||
| 352 | } | ||
| 353 | |||
| 354 | const nowUnixNano = DateTime.now.pipe( | ||
| 355 | Effect.map((now) => BigInt(DateTime.toEpochMillis(now)) * 1_000_000n), | ||
| 356 | ); | ||
| 357 | |||
| 358 | const addCurrentSpanEvent = (name: string, attributes: Record<string, unknown>) => | ||
| 359 | Effect.gen(function* () { | ||
| 360 | const span = yield* Effect.currentSpan; | ||
| 361 | const timestamp = yield* nowUnixNano; | ||
| 362 | yield* Effect.sync(() => { | ||
| 363 | span.event(name, timestamp, compactTraceAttributes(attributes)); | ||
| 364 | }); | ||
| 365 | }).pipe(Effect.catch(() => Effect.void)); | ||
| 366 | |||
| 367 | function trace2ChildKey(record: Record<string, unknown>): string | null { | ||
| 368 | const childId = record.child_id; | ||
| 369 | if (typeof childId === "number" || typeof childId === "string") { | ||
| 370 | return String(childId); | ||
| 371 | } | ||
| 372 | const hookName = record.hook_name; | ||
| 373 | return typeof hookName === "string" && hookName.trim().length > 0 ? hookName.trim() : null; | ||
| 374 | } | ||
| 375 | |||
| 376 | const Trace2Record = Schema.Record(Schema.String, Schema.Unknown); | ||
| 377 | |||
| 378 | const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( | ||
| 379 | input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">, | ||
| 380 | progress: ExecuteGitProgress | undefined, | ||
| 381 | ): Effect.fn.Return< | ||
| 382 | Trace2Monitor, | ||
| 383 | PlatformError.PlatformError, | ||
| 384 | Scope.Scope | FileSystem.FileSystem | Path.Path | ||
| 385 | > { | ||
| 386 | if (!progress?.onHookStarted && !progress?.onHookFinished) { | ||
| 387 | return { | ||
| 388 | env: {}, | ||
| 389 | flush: Effect.void, | ||
| 390 | }; | ||
| 391 | } | ||
| 392 | |||
| 393 | const fs = yield* FileSystem.FileSystem; | ||
| 394 | const path = yield* Path.Path; | ||
| 395 | const traceFilePath = yield* fs.makeTempFileScoped({ | ||
| 396 | prefix: `t3code-git-trace2-${process.pid}-`, | ||
| 397 | suffix: ".json", | ||
| 398 | }); | ||
| 399 | const hookStartByChildKey = new Map<string, { hookName: string; startedAtMs: number }>(); | ||
| 400 | const traceTailState = yield* Ref.make<TraceTailState>({ | ||
| 401 | processedChars: 0, | ||
| 402 | remainder: "", | ||
| 403 | }); | ||
| 404 | |||
| 405 | const handleTraceLine = Effect.fn("handleTraceLine")(function* (line: string) { | ||
| 406 | const trimmedLine = line.trim(); | ||
| 407 | if (trimmedLine.length === 0) { | ||
| 408 | return; | ||
| 409 | } | ||
| 410 | |||
| 411 | const traceRecord = decodeJsonResult(Trace2Record)(trimmedLine); | ||
| 412 | if (Result.isFailure(traceRecord)) { | ||
| 413 | yield* Effect.logDebug( | ||
| 414 | `GitVcsDriver.trace2: failed to parse trace line for ${quoteGitCommand(input.args)} in ${input.cwd}`, | ||
| 415 | traceRecord.failure, | ||
| 416 | ); | ||
| 417 | return; | ||
| 418 | } | ||
| 419 | |||
| 420 | if (traceRecord.success.child_class !== "hook") { | ||
| 421 | return; | ||
| 422 | } | ||
| 423 | |||
| 424 | const event = traceRecord.success.event; | ||
| 425 | const childKey = trace2ChildKey(traceRecord.success); | ||
| 426 | if (childKey === null) { | ||
| 427 | return; | ||
| 428 | } | ||
| 429 | const started = hookStartByChildKey.get(childKey); | ||
| 430 | const hookNameFromEvent = | ||
| 431 | typeof traceRecord.success.hook_name === "string" ? traceRecord.success.hook_name.trim() : ""; | ||
| 432 | const hookName = hookNameFromEvent.length > 0 ? hookNameFromEvent : (started?.hookName ?? ""); | ||
| 433 | if (hookName.length === 0) { | ||
| 434 | return; | ||
| 435 | } | ||
| 436 | |||
| 437 | if (event === "child_start") { | ||
| 438 | const now = yield* DateTime.now; | ||
| 439 | hookStartByChildKey.set(childKey, { hookName, startedAtMs: DateTime.toEpochMillis(now) }); | ||
| 440 | yield* addCurrentSpanEvent("git.hook.started", { | ||
| 441 | hookName, | ||
| 442 | }); | ||
| 443 | if (progress.onHookStarted) { | ||
| 444 | yield* progress.onHookStarted(hookName); | ||
| 445 | } | ||
| 446 | return; | ||
| 447 | } | ||
| 448 | |||
| 449 | if (event === "child_exit") { | ||
| 450 | hookStartByChildKey.delete(childKey); | ||
| 451 | const code = traceRecord.success.exitCode; | ||
| 452 | const exitCode = typeof code === "number" && Number.isInteger(code) ? code : null; | ||
| 453 | const now = yield* DateTime.now; | ||
| 454 | const durationMs = started | ||
| 455 | ? Math.max(0, DateTime.toEpochMillis(now) - started.startedAtMs) | ||
| 456 | : null; | ||
| 457 | yield* addCurrentSpanEvent("git.hook.finished", { | ||
| 458 | hookName: started?.hookName ?? hookName, | ||
| 459 | exitCode, | ||
| 460 | durationMs, | ||
| 461 | }); | ||
| 462 | if (progress.onHookFinished) { | ||
| 463 | yield* progress.onHookFinished({ | ||
| 464 | hookName: started?.hookName ?? hookName, | ||
| 465 | exitCode, | ||
| 466 | durationMs, | ||
| 467 | }); | ||
| 468 | } | ||
| 469 | } | ||
| 470 | }); | ||
| 471 | |||
| 472 | const deltaMutex = yield* Semaphore.make(1); | ||
| 473 | const readTraceDelta = deltaMutex.withPermit( | ||
| 474 | fs.readFileString(traceFilePath).pipe( | ||
| 475 | Effect.flatMap((contents) => | ||
| 476 | Effect.uninterruptible( | ||
| 477 | Ref.modify(traceTailState, ({ processedChars, remainder }) => { | ||
| 478 | if (contents.length <= processedChars) { | ||
| 479 | return [[], { processedChars, remainder }]; | ||
| 480 | } | ||
| 481 | |||
| 482 | const appended = contents.slice(processedChars); | ||
| 483 | const combined = remainder + appended; | ||
| 484 | const lines = combined.split("\n"); | ||
| 485 | const nextRemainder = lines.pop() ?? ""; | ||
| 486 | |||
| 487 | return [ | ||
| 488 | lines.map((line) => line.replace(/\r$/, "")), | ||
| 489 | { | ||
| 490 | processedChars: contents.length, | ||
| 491 | remainder: nextRemainder, | ||
| 492 | }, | ||
| 493 | ]; | ||
| 494 | }).pipe( | ||
| 495 | Effect.flatMap((lines) => Effect.forEach(lines, handleTraceLine, { discard: true })), | ||
| 496 | ), | ||
| 497 | ), | ||
| 498 | ), | ||
| 499 | Effect.ignore({ log: true }), | ||
| 500 | ), | ||
| 501 | ); | ||
| 502 | const traceFileName = path.basename(traceFilePath); | ||
| 503 | yield* Stream.runForEach(fs.watch(traceFilePath), (event) => { | ||
| 504 | const eventPath = event.path; | ||
| 505 | const isTargetTraceEvent = | ||
| 506 | eventPath === traceFilePath || | ||
| 507 | eventPath === traceFileName || | ||
| 508 | path.basename(eventPath) === traceFileName; | ||
| 509 | if (!isTargetTraceEvent) return Effect.void; | ||
| 510 | return readTraceDelta; | ||
| 511 | }).pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped); | ||
| 512 | |||
| 513 | const finalizeTrace2Monitor = Effect.fn("finalizeTrace2Monitor")(function* () { | ||
| 514 | yield* readTraceDelta; | ||
| 515 | const finalLine = yield* Ref.modify(traceTailState, ({ processedChars, remainder }) => [ | ||
| 516 | remainder.trim(), | ||
| 517 | { | ||
| 518 | processedChars, | ||
| 519 | remainder: "", | ||
| 520 | }, | ||
| 521 | ]); | ||
| 522 | if (finalLine.length > 0) { | ||
| 523 | yield* handleTraceLine(finalLine); | ||
| 524 | } | ||
| 525 | }); | ||
| 526 | |||
| 527 | yield* Effect.addFinalizer(finalizeTrace2Monitor); | ||
| 528 | |||
| 529 | return { | ||
| 530 | env: { | ||
| 531 | GIT_TRACE2_EVENT: traceFilePath, | ||
| 532 | }, | ||
| 533 | flush: readTraceDelta, | ||
| 534 | }; | ||
| 535 | }); | ||
| 536 | |||
| 537 | const collectOutput = Effect.fn("collectOutput")(function* <E>( | ||
| 538 | input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">, | ||
| 539 | stream: Stream.Stream<Uint8Array, E>, | ||
| 540 | maxOutputBytes: number, | ||
| 541 | truncateOutputAtMaxBytes: boolean, | ||
| 542 | onLine: ((line: string) => Effect.Effect<void, never>) | undefined, | ||
| 543 | ): Effect.fn.Return<{ readonly text: string; readonly truncated: boolean }, GitCommandError> { | ||
| 544 | const decoder = new TextDecoder(); | ||
| 545 | let bytes = 0; | ||
| 546 | let text = ""; | ||
| 547 | let lineBuffer = ""; | ||
| 548 | let truncated = false; | ||
| 549 | |||
| 550 | const emitCompleteLines = Effect.fn("emitCompleteLines")(function* (flush: boolean) { | ||
| 551 | let newlineIndex = lineBuffer.indexOf("\n"); | ||
| 552 | while (newlineIndex >= 0) { | ||
| 553 | const line = lineBuffer.slice(0, newlineIndex).replace(/\r$/, ""); | ||
| 554 | lineBuffer = lineBuffer.slice(newlineIndex + 1); | ||
| 555 | if (line.length > 0 && onLine) { | ||
| 556 | yield* onLine(line); | ||
| 557 | } | ||
| 558 | newlineIndex = lineBuffer.indexOf("\n"); | ||
| 559 | } | ||
| 560 | |||
| 561 | if (flush) { | ||
| 562 | const trailing = lineBuffer.replace(/\r$/, ""); | ||
| 563 | lineBuffer = ""; | ||
| 564 | if (trailing.length > 0 && onLine) { | ||
| 565 | yield* onLine(trailing); | ||
| 566 | } | ||
| 567 | } | ||
| 568 | }); | ||
| 569 | |||
| 570 | const processChunk = Effect.fn("processChunk")(function* (chunk: Uint8Array) { | ||
| 571 | if (truncateOutputAtMaxBytes && truncated) { | ||
| 572 | return; | ||
| 573 | } | ||
| 574 | const nextBytes = bytes + chunk.byteLength; | ||
| 575 | if (!truncateOutputAtMaxBytes && nextBytes > maxOutputBytes) { | ||
| 576 | return yield* new GitCommandError({ | ||
| 577 | operation: input.operation, | ||
| 578 | command: quoteGitCommand(input.args), | ||
| 579 | cwd: input.cwd, | ||
| 580 | detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`, | ||
| 581 | }); | ||
| 582 | } | ||
| 583 | |||
| 584 | const chunkToDecode = | ||
| 585 | truncateOutputAtMaxBytes && nextBytes > maxOutputBytes | ||
| 586 | ? chunk.subarray(0, Math.max(0, maxOutputBytes - bytes)) | ||
| 587 | : chunk; | ||
| 588 | bytes += chunkToDecode.byteLength; | ||
| 589 | truncated = truncateOutputAtMaxBytes && nextBytes > maxOutputBytes; | ||
| 590 | |||
| 591 | const decoded = decoder.decode(chunkToDecode, { stream: !truncated }); | ||
| 592 | text += decoded; | ||
| 593 | lineBuffer += decoded; | ||
| 594 | yield* emitCompleteLines(false); | ||
| 595 | }); | ||
| 596 | |||
| 597 | yield* Stream.runForEach(stream, processChunk).pipe( | ||
| 598 | Effect.mapError(toGitCommandError(input, "output stream failed.")), | ||
| 599 | ); | ||
| 600 | |||
| 601 | const remainder = truncated ? "" : decoder.decode(); | ||
| 602 | text += remainder; | ||
| 603 | lineBuffer += remainder; | ||
| 604 | yield* emitCompleteLines(true); | ||
| 605 | return { | ||
| 606 | text, | ||
| 607 | truncated, | ||
| 608 | }; | ||
| 609 | }); | ||
| 610 | |||
| 611 | export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* (options?: { | ||
| 612 | executeOverride?: GitVcsDriverShape["execute"]; | ||
| 613 | }) { | ||
| 614 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 615 | const path = yield* Path.Path; | ||
| 616 | const { worktreesDir } = yield* ServerConfig; | ||
| 617 | |||
| 618 | let executeRaw: GitVcsDriverShape["execute"]; | ||
| 619 | |||
| 620 | if (options?.executeOverride) { | ||
| 621 | executeRaw = options.executeOverride; | ||
| 622 | } else { | ||
| 623 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 624 | executeRaw = Effect.fnUntraced(function* (input) { | ||
| 625 | const commandInput = { | ||
| 626 | ...input, | ||
| 627 | args: [...input.args], | ||
| 628 | } as const; | ||
| 629 | const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; | ||
| 630 | const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; | ||
| 631 | const truncateOutputAtMaxBytes = input.truncateOutputAtMaxBytes ?? false; | ||
| 632 | |||
| 633 | const runGitCommand = Effect.fn("runGitCommand")(function* () { | ||
| 634 | const trace2Monitor = yield* createTrace2Monitor(commandInput, input.progress).pipe( | ||
| 635 | Effect.provideService(Path.Path, path), | ||
| 636 | Effect.provideService(FileSystem.FileSystem, fileSystem), | ||
| 637 | Effect.mapError(toGitCommandError(commandInput, "failed to create trace2 monitor.")), | ||
| 638 | ); | ||
| 639 | const child = yield* commandSpawner | ||
| 640 | .spawn( | ||
| 641 | ChildProcess.make("git", commandInput.args, { | ||
| 642 | cwd: commandInput.cwd, | ||
| 643 | env: { | ||
| 644 | ...process.env, | ||
| 645 | ...input.env, | ||
| 646 | ...trace2Monitor.env, | ||
| 647 | }, | ||
| 648 | }), | ||
| 649 | ) | ||
| 650 | .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn."))); | ||
| 651 | |||
| 652 | const [stdout, stderr, exitCode] = yield* Effect.all( | ||
| 653 | [ | ||
| 654 | collectOutput( | ||
| 655 | commandInput, | ||
| 656 | child.stdout, | ||
| 657 | maxOutputBytes, | ||
| 658 | truncateOutputAtMaxBytes, | ||
| 659 | input.progress?.onStdoutLine, | ||
| 660 | ), | ||
| 661 | collectOutput( | ||
| 662 | commandInput, | ||
| 663 | child.stderr, | ||
| 664 | maxOutputBytes, | ||
| 665 | truncateOutputAtMaxBytes, | ||
| 666 | input.progress?.onStderrLine, | ||
| 667 | ), | ||
| 668 | child.exitCode.pipe( | ||
| 669 | Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")), | ||
| 670 | ), | ||
| 671 | input.stdin === undefined | ||
| 672 | ? Effect.void | ||
| 673 | : Stream.run(Stream.encodeText(Stream.make(input.stdin)), child.stdin).pipe( | ||
| 674 | Effect.mapError(toGitCommandError(commandInput, "failed to write stdin.")), | ||
| 675 | ), | ||
| 676 | ], | ||
| 677 | { concurrency: "unbounded" }, | ||
| 678 | ).pipe(Effect.map(([stdout, stderr, exitCode]) => [stdout, stderr, exitCode] as const)); | ||
| 679 | yield* trace2Monitor.flush; | ||
| 680 | |||
| 681 | if (!input.allowNonZeroExit && exitCode !== 0) { | ||
| 682 | const trimmedStderr = stderr.text.trim(); | ||
| 683 | return yield* new GitCommandError({ | ||
| 684 | operation: commandInput.operation, | ||
| 685 | command: quoteGitCommand(commandInput.args), | ||
| 686 | cwd: commandInput.cwd, | ||
| 687 | detail: | ||
| 688 | trimmedStderr.length > 0 | ||
| 689 | ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}` | ||
| 690 | : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`, | ||
| 691 | }); | ||
| 692 | } | ||
| 693 | |||
| 694 | return { | ||
| 695 | exitCode, | ||
| 696 | stdout: stdout.text, | ||
| 697 | stderr: stderr.text, | ||
| 698 | stdoutTruncated: stdout.truncated, | ||
| 699 | stderrTruncated: stderr.truncated, | ||
| 700 | } satisfies ExecuteGitResult; | ||
| 701 | }); | ||
| 702 | |||
| 703 | return yield* runGitCommand().pipe( | ||
| 704 | Effect.scoped, | ||
| 705 | Effect.timeoutOption(timeoutMs), | ||
| 706 | Effect.flatMap((result) => | ||
| 707 | Option.match(result, { | ||
| 708 | onNone: () => | ||
| 709 | Effect.fail( | ||
| 710 | new GitCommandError({ | ||
| 711 | operation: commandInput.operation, | ||
| 712 | command: quoteGitCommand(commandInput.args), | ||
| 713 | cwd: commandInput.cwd, | ||
| 714 | detail: `${quoteGitCommand(commandInput.args)} timed out.`, | ||
| 715 | }), | ||
| 716 | ), | ||
| 717 | onSome: Effect.succeed, | ||
| 718 | }), | ||
| 719 | ), | ||
| 720 | ); | ||
| 721 | }); | ||
| 722 | } | ||
| 723 | |||
| 724 | const execute: GitVcsDriverShape["execute"] = (input) => | ||
| 725 | executeRaw(input).pipe( | ||
| 726 | withMetrics({ | ||
| 727 | counter: gitCommandsTotal, | ||
| 728 | timer: gitCommandDuration, | ||
| 729 | attributes: { | ||
| 730 | operation: input.operation, | ||
| 731 | }, | ||
| 732 | }), | ||
| 733 | Effect.withSpan(input.operation, { | ||
| 734 | kind: "client", | ||
| 735 | attributes: { | ||
| 736 | "git.operation": input.operation, | ||
| 737 | "git.cwd": input.cwd, | ||
| 738 | "git.args_count": input.args.length, | ||
| 739 | }, | ||
| 740 | }), | ||
| 741 | ); | ||
| 742 | |||
| 743 | const executeGit = ( | ||
| 744 | operation: string, | ||
| 745 | cwd: string, | ||
| 746 | args: readonly string[], | ||
| 747 | options: ExecuteGitOptions = {}, | ||
| 748 | ): Effect.Effect<ExecuteGitResult, GitCommandError> => | ||
| 749 | execute({ | ||
| 750 | operation, | ||
| 751 | cwd, | ||
| 752 | args, | ||
| 753 | ...(options.stdin !== undefined ? { stdin: options.stdin } : {}), | ||
| 754 | allowNonZeroExit: true, | ||
| 755 | ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), | ||
| 756 | ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), | ||
| 757 | ...(options.truncateOutputAtMaxBytes !== undefined | ||
| 758 | ? { truncateOutputAtMaxBytes: options.truncateOutputAtMaxBytes } | ||
| 759 | : {}), | ||
| 760 | ...(options.progress ? { progress: options.progress } : {}), | ||
| 761 | }).pipe( | ||
| 762 | Effect.flatMap((result) => { | ||
| 763 | if (options.allowNonZeroExit || result.exitCode === 0) { | ||
| 764 | return Effect.succeed(result); | ||
| 765 | } | ||
| 766 | const stderr = result.stderr.trim(); | ||
| 767 | if (stderr.length > 0) { | ||
| 768 | return Effect.fail(createGitCommandError(operation, cwd, args, stderr)); | ||
| 769 | } | ||
| 770 | if (options.fallbackErrorMessage) { | ||
| 771 | return Effect.fail( | ||
| 772 | createGitCommandError(operation, cwd, args, options.fallbackErrorMessage), | ||
| 773 | ); | ||
| 774 | } | ||
| 775 | return Effect.fail( | ||
| 776 | createGitCommandError( | ||
| 777 | operation, | ||
| 778 | cwd, | ||
| 779 | args, | ||
| 780 | `${commandLabel(args)} failed: code=${result.exitCode ?? "null"}`, | ||
| 781 | ), | ||
| 782 | ); | ||
| 783 | }), | ||
| 784 | ); | ||
| 785 | |||
| 786 | const runGit = ( | ||
| 787 | operation: string, | ||
| 788 | cwd: string, | ||
| 789 | args: readonly string[], | ||
| 790 | allowNonZeroExit = false, | ||
| 791 | ): Effect.Effect<void, GitCommandError> => | ||
| 792 | executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(Effect.asVoid); | ||
| 793 | |||
| 794 | const runGitStdout = ( | ||
| 795 | operation: string, | ||
| 796 | cwd: string, | ||
| 797 | args: readonly string[], | ||
| 798 | allowNonZeroExit = false, | ||
| 799 | ): Effect.Effect<string, GitCommandError> => | ||
| 800 | executeGit(operation, cwd, args, { allowNonZeroExit }).pipe( | ||
| 801 | Effect.map((result) => result.stdout), | ||
| 802 | ); | ||
| 803 | |||
| 804 | const runGitStdoutWithOptions = ( | ||
| 805 | operation: string, | ||
| 806 | cwd: string, | ||
| 807 | args: readonly string[], | ||
| 808 | options: ExecuteGitOptions = {}, | ||
| 809 | ): Effect.Effect<string, GitCommandError> => | ||
| 810 | executeGit(operation, cwd, args, options).pipe( | ||
| 811 | Effect.map((result) => | ||
| 812 | result.stdoutTruncated ? `${result.stdout}${OUTPUT_TRUNCATED_MARKER}` : result.stdout, | ||
| 813 | ), | ||
| 814 | ); | ||
| 815 | |||
| 816 | const branchExists = (cwd: string, refName: string): Effect.Effect<boolean, GitCommandError> => | ||
| 817 | executeGit( | ||
| 818 | "GitVcsDriver.branchExists", | ||
| 819 | cwd, | ||
| 820 | ["show-ref", "--verify", "--quiet", `refs/heads/${refName}`], | ||
| 821 | { | ||
| 822 | allowNonZeroExit: true, | ||
| 823 | timeoutMs: 5_000, | ||
| 824 | }, | ||
| 825 | ).pipe(Effect.map((result) => result.exitCode === 0)); | ||
| 826 | |||
| 827 | const resolveAvailableBranchName = Effect.fn("resolveAvailableBranchName")(function* ( | ||
| 828 | cwd: string, | ||
| 829 | desiredBranch: string, | ||
| 830 | ) { | ||
| 831 | const isDesiredTaken = yield* branchExists(cwd, desiredBranch); | ||
| 832 | if (!isDesiredTaken) { | ||
| 833 | return desiredBranch; | ||
| 834 | } | ||
| 835 | |||
| 836 | for (let suffix = 1; suffix <= 100; suffix += 1) { | ||
| 837 | const candidate = `${desiredBranch}-${suffix}`; | ||
| 838 | const isCandidateTaken = yield* branchExists(cwd, candidate); | ||
| 839 | if (!isCandidateTaken) { | ||
| 840 | return candidate; | ||
| 841 | } | ||
| 842 | } | ||
| 843 | |||
| 844 | return yield* createGitCommandError( | ||
| 845 | "GitVcsDriver.renameBranch", | ||
| 846 | cwd, | ||
| 847 | ["branch", "-m", "--", desiredBranch], | ||
| 848 | `Could not find an available branch name for '${desiredBranch}'.`, | ||
| 849 | ); | ||
| 850 | }); | ||
| 851 | |||
| 852 | const resolveCurrentUpstream = Effect.fn("resolveCurrentUpstream")(function* (cwd: string) { | ||
| 853 | const upstreamRef = yield* runGitStdout( | ||
| 854 | "GitVcsDriver.resolveCurrentUpstream", | ||
| 855 | cwd, | ||
| 856 | ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], | ||
| 857 | true, | ||
| 858 | ).pipe(Effect.map((stdout) => stdout.trim())); | ||
| 859 | |||
| 860 | if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") { | ||
| 861 | return null; | ||
| 862 | } | ||
| 863 | |||
| 864 | const remoteNames = yield* runGitStdout("GitVcsDriver.listRemoteNames", cwd, ["remote"]).pipe( | ||
| 865 | Effect.map(parseRemoteNames), | ||
| 866 | Effect.catch(() => Effect.succeed<ReadonlyArray<string>>([])), | ||
| 867 | ); | ||
| 868 | return ( | ||
| 869 | parseUpstreamRefWithRemoteNames(upstreamRef, remoteNames) ?? | ||
| 870 | parseUpstreamRefByFirstSeparator(upstreamRef) | ||
| 871 | ); | ||
| 872 | }); | ||
| 873 | |||
| 874 | const fetchRemoteForStatus = ( | ||
| 875 | gitCommonDir: string, | ||
| 876 | remoteName: string, | ||
| 877 | ): Effect.Effect<void, GitCommandError> => { | ||
| 878 | const fetchCwd = | ||
| 879 | path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; | ||
| 880 | return executeGit( | ||
| 881 | "GitVcsDriver.fetchRemoteForStatus", | ||
| 882 | fetchCwd, | ||
| 883 | ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName], | ||
| 884 | { | ||
| 885 | allowNonZeroExit: true, | ||
| 886 | timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), | ||
| 887 | }, | ||
| 888 | ).pipe(Effect.asVoid); | ||
| 889 | }; | ||
| 890 | |||
| 891 | const resolveGitCommonDir = Effect.fn("resolveGitCommonDir")(function* (cwd: string) { | ||
| 892 | const gitCommonDir = yield* runGitStdout("GitVcsDriver.resolveGitCommonDir", cwd, [ | ||
| 893 | "rev-parse", | ||
| 894 | "--git-common-dir", | ||
| 895 | ]).pipe(Effect.map((stdout) => stdout.trim())); | ||
| 896 | return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir); | ||
| 897 | }); | ||
| 898 | |||
| 899 | const refreshStatusRemoteCacheEntry = Effect.fn("refreshStatusRemoteCacheEntry")(function* ( | ||
| 900 | cacheKey: StatusRemoteRefreshCacheKey, | ||
| 901 | ) { | ||
| 902 | yield* fetchRemoteForStatus(cacheKey.gitCommonDir, cacheKey.remoteName); | ||
| 903 | return true as const; | ||
| 904 | }); | ||
| 905 | |||
| 906 | const statusRemoteRefreshCache = yield* Cache.makeWith(refreshStatusRemoteCacheEntry, { | ||
| 907 | capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY, | ||
| 908 | // Keep successful refreshes warm and briefly back off failed refreshes to avoid retry storms. | ||
| 909 | timeToLive: (exit) => | ||
| 910 | Exit.isSuccess(exit) | ||
| 911 | ? STATUS_UPSTREAM_REFRESH_INTERVAL | ||
| 912 | : STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN, | ||
| 913 | }); | ||
| 914 | |||
| 915 | const refreshStatusUpstreamIfStale = Effect.fn("refreshStatusUpstreamIfStale")(function* ( | ||
| 916 | cwd: string, | ||
| 917 | ) { | ||
| 918 | const upstream = yield* resolveCurrentUpstream(cwd); | ||
| 919 | if (!upstream) return; | ||
| 920 | const gitCommonDir = yield* resolveGitCommonDir(cwd); | ||
| 921 | yield* Cache.get( | ||
| 922 | statusRemoteRefreshCache, | ||
| 923 | new StatusRemoteRefreshCacheKey({ | ||
| 924 | gitCommonDir, | ||
| 925 | remoteName: upstream.remoteName, | ||
| 926 | }), | ||
| 927 | ); | ||
| 928 | }); | ||
| 929 | |||
| 930 | const resolveDefaultBranchName = ( | ||
| 931 | cwd: string, | ||
| 932 | remoteName: string, | ||
| 933 | ): Effect.Effect<string | null, GitCommandError> => | ||
| 934 | executeGit( | ||
| 935 | "GitVcsDriver.resolveDefaultBranchName", | ||
| 936 | cwd, | ||
| 937 | ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`], | ||
| 938 | { allowNonZeroExit: true }, | ||
| 939 | ).pipe( | ||
| 940 | Effect.map((result) => { | ||
| 941 | if (result.exitCode !== 0) { | ||
| 942 | return null; | ||
| 943 | } | ||
| 944 | return parseDefaultBranchFromRemoteHeadRef(result.stdout, remoteName); | ||
| 945 | }), | ||
| 946 | ); | ||
| 947 | |||
| 948 | const remoteBranchExists = ( | ||
| 949 | cwd: string, | ||
| 950 | remoteName: string, | ||
| 951 | refName: string, | ||
| 952 | ): Effect.Effect<boolean, GitCommandError> => | ||
| 953 | executeGit( | ||
| 954 | "GitVcsDriver.remoteBranchExists", | ||
| 955 | cwd, | ||
| 956 | ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${refName}`], | ||
| 957 | { | ||
| 958 | allowNonZeroExit: true, | ||
| 959 | }, | ||
| 960 | ).pipe(Effect.map((result) => result.exitCode === 0)); | ||
| 961 | |||
| 962 | const originRemoteExists = (cwd: string): Effect.Effect<boolean, GitCommandError> => | ||
| 963 | executeGit("GitVcsDriver.originRemoteExists", cwd, ["remote", "get-url", "origin"], { | ||
| 964 | allowNonZeroExit: true, | ||
| 965 | }).pipe(Effect.map((result) => result.exitCode === 0)); | ||
| 966 | |||
| 967 | const listRemoteNames = (cwd: string): Effect.Effect<ReadonlyArray<string>, GitCommandError> => | ||
| 968 | runGitStdout("GitVcsDriver.listRemoteNames", cwd, ["remote"]).pipe( | ||
| 969 | Effect.map(parseRemoteNamesInGitOrder), | ||
| 970 | ); | ||
| 971 | |||
| 972 | const resolvePrimaryRemoteName = Effect.fn("resolvePrimaryRemoteName")(function* (cwd: string) { | ||
| 973 | if (yield* originRemoteExists(cwd)) { | ||
| 974 | return "origin"; | ||
| 975 | } | ||
| 976 | const remotes = yield* listRemoteNames(cwd); | ||
| 977 | const [firstRemote] = remotes; | ||
| 978 | if (firstRemote) { | ||
| 979 | return firstRemote; | ||
| 980 | } | ||
| 981 | return yield* createGitCommandError( | ||
| 982 | "GitVcsDriver.resolvePrimaryRemoteName", | ||
| 983 | cwd, | ||
| 984 | ["remote"], | ||
| 985 | "No git remote is configured for this repository.", | ||
| 986 | ); | ||
| 987 | }); | ||
| 988 | |||
| 989 | const resolvePushRemoteName = Effect.fn("resolvePushRemoteName")(function* ( | ||
| 990 | cwd: string, | ||
| 991 | refName: string, | ||
| 992 | ) { | ||
| 993 | const branchPushRemote = yield* runGitStdout( | ||
| 994 | "GitVcsDriver.resolvePushRemoteName.branchPushRemote", | ||
| 995 | cwd, | ||
| 996 | ["config", "--get", `branch.${refName}.pushRemote`], | ||
| 997 | true, | ||
| 998 | ).pipe(Effect.map((stdout) => stdout.trim())); | ||
| 999 | if (branchPushRemote.length > 0) { | ||
| 1000 | return branchPushRemote; | ||
| 1001 | } | ||
| 1002 | |||
| 1003 | const pushDefaultRemote = yield* runGitStdout( | ||
| 1004 | "GitVcsDriver.resolvePushRemoteName.remotePushDefault", | ||
| 1005 | cwd, | ||
| 1006 | ["config", "--get", "remote.pushDefault"], | ||
| 1007 | true, | ||
| 1008 | ).pipe(Effect.map((stdout) => stdout.trim())); | ||
| 1009 | if (pushDefaultRemote.length > 0) { | ||
| 1010 | return pushDefaultRemote; | ||
| 1011 | } | ||
| 1012 | |||
| 1013 | return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.catch(() => Effect.succeed(null))); | ||
| 1014 | }); | ||
| 1015 | |||
| 1016 | const ensureRemote: GitVcsDriverShape["ensureRemote"] = Effect.fn("ensureRemote")( | ||
| 1017 | function* (input) { | ||
| 1018 | const preferredName = sanitizeRemoteName(input.preferredName); | ||
| 1019 | const normalizedTargetUrl = normalizeRemoteUrl(input.url); | ||
| 1020 | const remoteFetchUrls = yield* runGitStdout( | ||
| 1021 | "GitVcsDriver.ensureRemote.listRemoteUrls", | ||
| 1022 | input.cwd, | ||
| 1023 | ["remote", "-v"], | ||
| 1024 | ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout))); | ||
| 1025 | |||
| 1026 | for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { | ||
| 1027 | if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) { | ||
| 1028 | return remoteName; | ||
| 1029 | } | ||
| 1030 | } | ||
| 1031 | |||
| 1032 | let remoteName = preferredName; | ||
| 1033 | let suffix = 1; | ||
| 1034 | while (remoteFetchUrls.has(remoteName)) { | ||
| 1035 | remoteName = `${preferredName}-${suffix}`; | ||
| 1036 | suffix += 1; | ||
| 1037 | } | ||
| 1038 | |||
| 1039 | yield* runGit("GitVcsDriver.ensureRemote.add", input.cwd, [ | ||
| 1040 | "remote", | ||
| 1041 | "add", | ||
| 1042 | remoteName, | ||
| 1043 | input.url, | ||
| 1044 | ]); | ||
| 1045 | return remoteName; | ||
| 1046 | }, | ||
| 1047 | ); | ||
| 1048 | |||
| 1049 | const resolveBaseBranchForNoUpstream = Effect.fn("resolveBaseBranchForNoUpstream")(function* ( | ||
| 1050 | cwd: string, | ||
| 1051 | refName: string, | ||
| 1052 | ) { | ||
| 1053 | const configuredBaseBranch = yield* runGitStdout( | ||
| 1054 | "GitVcsDriver.resolveBaseBranchForNoUpstream.config", | ||
| 1055 | cwd, | ||
| 1056 | ["config", "--get", `branch.${refName}.gh-merge-base`], | ||
| 1057 | true, | ||
| 1058 | ).pipe(Effect.map((stdout) => stdout.trim())); | ||
| 1059 | |||
| 1060 | const primaryRemoteName = yield* resolvePrimaryRemoteName(cwd).pipe( | ||
| 1061 | Effect.catch(() => Effect.succeed(null)), | ||
| 1062 | ); | ||
| 1063 | const defaultBranch = | ||
| 1064 | primaryRemoteName === null ? null : yield* resolveDefaultBranchName(cwd, primaryRemoteName); | ||
| 1065 | const candidates = [ | ||
| 1066 | configuredBaseBranch.length > 0 ? configuredBaseBranch : null, | ||
| 1067 | defaultBranch, | ||
| 1068 | ...DEFAULT_BASE_BRANCH_CANDIDATES, | ||
| 1069 | ]; | ||
| 1070 | |||
| 1071 | for (const candidate of candidates) { | ||
| 1072 | if (!candidate) { | ||
| 1073 | continue; | ||
| 1074 | } | ||
| 1075 | |||
| 1076 | const remotePrefix = | ||
| 1077 | primaryRemoteName && primaryRemoteName !== "origin" ? `${primaryRemoteName}/` : null; | ||
| 1078 | const normalizedCandidate = candidate.startsWith("origin/") | ||
| 1079 | ? candidate.slice("origin/".length) | ||
| 1080 | : remotePrefix && candidate.startsWith(remotePrefix) | ||
| 1081 | ? candidate.slice(remotePrefix.length) | ||
| 1082 | : candidate; | ||
| 1083 | if (normalizedCandidate.length === 0 || normalizedCandidate === refName) { | ||
| 1084 | continue; | ||
| 1085 | } | ||
| 1086 | |||
| 1087 | if (yield* branchExists(cwd, normalizedCandidate)) { | ||
| 1088 | return normalizedCandidate; | ||
| 1089 | } | ||
| 1090 | |||
| 1091 | if ( | ||
| 1092 | primaryRemoteName && | ||
| 1093 | (yield* remoteBranchExists(cwd, primaryRemoteName, normalizedCandidate)) | ||
| 1094 | ) { | ||
| 1095 | return `${primaryRemoteName}/${normalizedCandidate}`; | ||
| 1096 | } | ||
| 1097 | } | ||
| 1098 | |||
| 1099 | return null; | ||
| 1100 | }); | ||
| 1101 | |||
| 1102 | const computeAheadCountAgainstBase = Effect.fn("computeAheadCountAgainstBase")(function* ( | ||
| 1103 | cwd: string, | ||
| 1104 | refName: string, | ||
| 1105 | ) { | ||
| 1106 | const baseRef = yield* resolveBaseBranchForNoUpstream(cwd, refName); | ||
| 1107 | if (!baseRef) { | ||
| 1108 | return 0; | ||
| 1109 | } | ||
| 1110 | |||
| 1111 | const result = yield* executeGit( | ||
| 1112 | "GitVcsDriver.computeAheadCountAgainstBase", | ||
| 1113 | cwd, | ||
| 1114 | ["rev-list", "--count", `${baseRef}..HEAD`], | ||
| 1115 | { allowNonZeroExit: true }, | ||
| 1116 | ); | ||
| 1117 | if (result.exitCode !== 0) { | ||
| 1118 | return 0; | ||
| 1119 | } | ||
| 1120 | |||
| 1121 | const parsed = Number.parseInt(result.stdout.trim(), 10); | ||
| 1122 | return Number.isFinite(parsed) ? Math.max(0, parsed) : 0; | ||
| 1123 | }); | ||
| 1124 | |||
| 1125 | const readBranchRecency = Effect.fn("readBranchRecency")(function* (cwd: string) { | ||
| 1126 | const branchRecency = yield* executeGit( | ||
| 1127 | "GitVcsDriver.readBranchRecency", | ||
| 1128 | cwd, | ||
| 1129 | [ | ||
| 1130 | "for-each-ref", | ||
| 1131 | "--format=%(refname:short)%09%(committerdate:unix)", | ||
| 1132 | "refs/heads", | ||
| 1133 | "refs/remotes", | ||
| 1134 | ], | ||
| 1135 | { | ||
| 1136 | timeoutMs: 15_000, | ||
| 1137 | allowNonZeroExit: true, | ||
| 1138 | }, | ||
| 1139 | ); | ||
| 1140 | |||
| 1141 | const branchLastCommit = new Map<string, number>(); | ||
| 1142 | if (branchRecency.exitCode !== 0) { | ||
| 1143 | return branchLastCommit; | ||
| 1144 | } | ||
| 1145 | |||
| 1146 | for (const line of branchRecency.stdout.split("\n")) { | ||
| 1147 | if (line.length === 0) { | ||
| 1148 | continue; | ||
| 1149 | } | ||
| 1150 | const [name, lastCommitRaw] = line.split("\t"); | ||
| 1151 | if (!name) { | ||
| 1152 | continue; | ||
| 1153 | } | ||
| 1154 | const lastCommit = Number.parseInt(lastCommitRaw ?? "0", 10); | ||
| 1155 | branchLastCommit.set(name, Number.isFinite(lastCommit) ? lastCommit : 0); | ||
| 1156 | } | ||
| 1157 | |||
| 1158 | return branchLastCommit; | ||
| 1159 | }); | ||
| 1160 | |||
| 1161 | const readStatusDetailsLocal = Effect.fn("readStatusDetailsLocal")(function* (cwd: string) { | ||
| 1162 | const statusResult = yield* executeGit( | ||
| 1163 | "GitVcsDriver.statusDetails.status", | ||
| 1164 | cwd, | ||
| 1165 | ["status", "--porcelain=2", "--branch"], | ||
| 1166 | { | ||
| 1167 | allowNonZeroExit: true, | ||
| 1168 | }, | ||
| 1169 | ).pipe(Effect.catchIf(isMissingGitCwdError, () => Effect.succeed(null))); | ||
| 1170 | |||
| 1171 | if (statusResult === null) { | ||
| 1172 | return NON_REPOSITORY_STATUS_DETAILS; | ||
| 1173 | } | ||
| 1174 | |||
| 1175 | if (statusResult.exitCode !== 0) { | ||
| 1176 | const stderr = statusResult.stderr.trim(); | ||
| 1177 | return yield* createGitCommandError( | ||
| 1178 | "GitVcsDriver.statusDetails.status", | ||
| 1179 | cwd, | ||
| 1180 | ["status", "--porcelain=2", "--branch"], | ||
| 1181 | stderr || "git status failed", | ||
| 1182 | ); | ||
| 1183 | } | ||
| 1184 | |||
| 1185 | const [unstagedNumstatStdout, stagedNumstatStdout, defaultRefResult, hasPrimaryRemote] = | ||
| 1186 | yield* Effect.all( | ||
| 1187 | [ | ||
| 1188 | runGitStdout("GitVcsDriver.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]), | ||
| 1189 | runGitStdout("GitVcsDriver.statusDetails.stagedNumstat", cwd, [ | ||
| 1190 | "diff", | ||
| 1191 | "--cached", | ||
| 1192 | "--numstat", | ||
| 1193 | ]), | ||
| 1194 | executeGit( | ||
| 1195 | "GitVcsDriver.statusDetails.defaultRef", | ||
| 1196 | cwd, | ||
| 1197 | ["symbolic-ref", "refs/remotes/origin/HEAD"], | ||
| 1198 | { | ||
| 1199 | allowNonZeroExit: true, | ||
| 1200 | }, | ||
| 1201 | ), | ||
| 1202 | originRemoteExists(cwd).pipe(Effect.catch(() => Effect.succeed(false))), | ||
| 1203 | ], | ||
| 1204 | { concurrency: "unbounded" }, | ||
| 1205 | ); | ||
| 1206 | const statusStdout = statusResult.stdout; | ||
| 1207 | const defaultBranch = | ||
| 1208 | defaultRefResult.exitCode === 0 | ||
| 1209 | ? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "") | ||
| 1210 | : null; | ||
| 1211 | |||
| 1212 | let refName: string | null = null; | ||
| 1213 | let upstreamRef: string | null = null; | ||
| 1214 | let aheadCount = 0; | ||
| 1215 | let behindCount = 0; | ||
| 1216 | let hasWorkingTreeChanges = false; | ||
| 1217 | const changedFilesWithoutNumstat = new Set<string>(); | ||
| 1218 | |||
| 1219 | for (const line of statusStdout.split(/\r?\n/g)) { | ||
| 1220 | if (line.startsWith("# branch.head ")) { | ||
| 1221 | const value = line.slice("# branch.head ".length).trim(); | ||
| 1222 | refName = value.startsWith("(") ? null : value; | ||
| 1223 | continue; | ||
| 1224 | } | ||
| 1225 | if (line.startsWith("# branch.upstream ")) { | ||
| 1226 | const value = line.slice("# branch.upstream ".length).trim(); | ||
| 1227 | upstreamRef = value.length > 0 ? value : null; | ||
| 1228 | continue; | ||
| 1229 | } | ||
| 1230 | if (line.startsWith("# branch.ab ")) { | ||
| 1231 | const value = line.slice("# branch.ab ".length).trim(); | ||
| 1232 | const parsed = parseBranchAb(value); | ||
| 1233 | aheadCount = parsed.ahead; | ||
| 1234 | behindCount = parsed.behind; | ||
| 1235 | continue; | ||
| 1236 | } | ||
| 1237 | if (line.trim().length > 0 && !line.startsWith("#")) { | ||
| 1238 | hasWorkingTreeChanges = true; | ||
| 1239 | const pathValue = parsePorcelainPath(line); | ||
| 1240 | if (pathValue) changedFilesWithoutNumstat.add(pathValue); | ||
| 1241 | } | ||
| 1242 | } | ||
| 1243 | |||
| 1244 | if (!upstreamRef && refName) { | ||
| 1245 | aheadCount = yield* computeAheadCountAgainstBase(cwd, refName).pipe( | ||
| 1246 | Effect.catch(() => Effect.succeed(0)), | ||
| 1247 | ); | ||
| 1248 | behindCount = 0; | ||
| 1249 | } | ||
| 1250 | |||
| 1251 | const stagedEntries = parseNumstatEntries(stagedNumstatStdout); | ||
| 1252 | const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout); | ||
| 1253 | const fileStatMap = new Map<string, { insertions: number; deletions: number }>(); | ||
| 1254 | for (const entry of [...stagedEntries, ...unstagedEntries]) { | ||
| 1255 | const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 }; | ||
| 1256 | existing.insertions += entry.insertions; | ||
| 1257 | existing.deletions += entry.deletions; | ||
| 1258 | fileStatMap.set(entry.path, existing); | ||
| 1259 | } | ||
| 1260 | |||
| 1261 | let insertions = 0; | ||
| 1262 | let deletions = 0; | ||
| 1263 | const files = Array.from(fileStatMap.entries()) | ||
| 1264 | .map(([filePath, stat]) => { | ||
| 1265 | insertions += stat.insertions; | ||
| 1266 | deletions += stat.deletions; | ||
| 1267 | return { path: filePath, insertions: stat.insertions, deletions: stat.deletions }; | ||
| 1268 | }) | ||
| 1269 | .toSorted((a, b) => a.path.localeCompare(b.path)); | ||
| 1270 | |||
| 1271 | for (const filePath of changedFilesWithoutNumstat) { | ||
| 1272 | if (fileStatMap.has(filePath)) continue; | ||
| 1273 | files.push({ path: filePath, insertions: 0, deletions: 0 }); | ||
| 1274 | } | ||
| 1275 | files.sort((a, b) => a.path.localeCompare(b.path)); | ||
| 1276 | |||
| 1277 | return { | ||
| 1278 | isRepo: true, | ||
| 1279 | hasOriginRemote: hasPrimaryRemote, | ||
| 1280 | isDefaultBranch: | ||
| 1281 | refName !== null && | ||
| 1282 | (refName === defaultBranch || | ||
| 1283 | (defaultBranch === null && (refName === "main" || refName === "master"))), | ||
| 1284 | branch: refName, | ||
| 1285 | upstreamRef, | ||
| 1286 | hasWorkingTreeChanges, | ||
| 1287 | workingTree: { | ||
| 1288 | files, | ||
| 1289 | insertions, | ||
| 1290 | deletions, | ||
| 1291 | }, | ||
| 1292 | hasUpstream: upstreamRef !== null, | ||
| 1293 | aheadCount, | ||
| 1294 | behindCount, | ||
| 1295 | }; | ||
| 1296 | }); | ||
| 1297 | |||
| 1298 | const statusDetailsLocal: GitVcsDriverShape["statusDetailsLocal"] = Effect.fn( | ||
| 1299 | "statusDetailsLocal", | ||
| 1300 | )(function* (cwd) { | ||
| 1301 | return yield* readStatusDetailsLocal(cwd); | ||
| 1302 | }); | ||
| 1303 | |||
| 1304 | const statusDetails: GitVcsDriverShape["statusDetails"] = Effect.fn("statusDetails")( | ||
| 1305 | function* (cwd) { | ||
| 1306 | yield* refreshStatusUpstreamIfStale(cwd).pipe( | ||
| 1307 | Effect.catchIf(isMissingGitCwdError, () => Effect.void), | ||
| 1308 | Effect.ignoreCause({ log: true }), | ||
| 1309 | ); | ||
| 1310 | return yield* readStatusDetailsLocal(cwd); | ||
| 1311 | }, | ||
| 1312 | ); | ||
| 1313 | |||
| 1314 | const status: GitVcsDriverShape["status"] = (input) => | ||
| 1315 | statusDetails(input.cwd).pipe( | ||
| 1316 | Effect.map((details) => ({ | ||
| 1317 | isRepo: details.isRepo, | ||
| 1318 | hasPrimaryRemote: details.hasOriginRemote, | ||
| 1319 | isDefaultRef: details.isDefaultBranch, | ||
| 1320 | refName: details.branch, | ||
| 1321 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, | ||
| 1322 | workingTree: details.workingTree, | ||
| 1323 | hasUpstream: details.hasUpstream, | ||
| 1324 | aheadCount: details.aheadCount, | ||
| 1325 | behindCount: details.behindCount, | ||
| 1326 | pr: null, | ||
| 1327 | })), | ||
| 1328 | ); | ||
| 1329 | |||
| 1330 | const prepareCommitContext: GitVcsDriverShape["prepareCommitContext"] = Effect.fn( | ||
| 1331 | "prepareCommitContext", | ||
| 1332 | )(function* (cwd, filePaths) { | ||
| 1333 | if (filePaths && filePaths.length > 0) { | ||
| 1334 | yield* runGit("GitVcsDriver.prepareCommitContext.reset", cwd, ["reset"]).pipe( | ||
| 1335 | Effect.catch(() => Effect.void), | ||
| 1336 | ); | ||
| 1337 | yield* runGit("GitVcsDriver.prepareCommitContext.addSelected", cwd, [ | ||
| 1338 | "add", | ||
| 1339 | "-A", | ||
| 1340 | "--", | ||
| 1341 | ...filePaths, | ||
| 1342 | ]); | ||
| 1343 | } else { | ||
| 1344 | yield* runGit("GitVcsDriver.prepareCommitContext.addAll", cwd, ["add", "-A"]); | ||
| 1345 | } | ||
| 1346 | |||
| 1347 | const stagedSummary = yield* runGitStdout( | ||
| 1348 | "GitVcsDriver.prepareCommitContext.stagedSummary", | ||
| 1349 | cwd, | ||
| 1350 | ["diff", "--cached", "--name-status"], | ||
| 1351 | ).pipe(Effect.map((stdout) => stdout.trim())); | ||
| 1352 | if (stagedSummary.length === 0) { | ||
| 1353 | return null; | ||
| 1354 | } | ||
| 1355 | |||
| 1356 | const stagedPatch = yield* runGitStdoutWithOptions( | ||
| 1357 | "GitVcsDriver.prepareCommitContext.stagedPatch", | ||
| 1358 | cwd, | ||
| 1359 | ["diff", "--cached", "--patch", "--minimal"], | ||
| 1360 | { | ||
| 1361 | maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES, | ||
| 1362 | truncateOutputAtMaxBytes: true, | ||
| 1363 | }, | ||
| 1364 | ); | ||
| 1365 | |||
| 1366 | return { | ||
| 1367 | stagedSummary, | ||
| 1368 | stagedPatch, | ||
| 1369 | }; | ||
| 1370 | }); | ||
| 1371 | |||
| 1372 | const commit: GitVcsDriverShape["commit"] = Effect.fn("commit")(function* ( | ||
| 1373 | cwd, | ||
| 1374 | subject, | ||
| 1375 | body, | ||
| 1376 | options?: GitCommitOptions, | ||
| 1377 | ) { | ||
| 1378 | const args = ["commit", "-m", subject]; | ||
| 1379 | const trimmedBody = body.trim(); | ||
| 1380 | if (trimmedBody.length > 0) { | ||
| 1381 | args.push("-m", trimmedBody); | ||
| 1382 | } | ||
| 1383 | const progress = | ||
| 1384 | options?.progress?.onOutputLine === undefined | ||
| 1385 | ? options?.progress | ||
| 1386 | : { | ||
| 1387 | ...options.progress, | ||
| 1388 | onStdoutLine: (line: string) => | ||
| 1389 | options.progress?.onOutputLine?.({ stream: "stdout", text: line }) ?? Effect.void, | ||
| 1390 | onStderrLine: (line: string) => | ||
| 1391 | options.progress?.onOutputLine?.({ stream: "stderr", text: line }) ?? Effect.void, | ||
| 1392 | }; | ||
| 1393 | yield* executeGit("GitVcsDriver.commit.commit", cwd, args, { | ||
| 1394 | ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), | ||
| 1395 | ...(progress ? { progress } : {}), | ||
| 1396 | }).pipe(Effect.asVoid); | ||
| 1397 | const commitSha = yield* runGitStdout("GitVcsDriver.commit.revParseHead", cwd, [ | ||
| 1398 | "rev-parse", | ||
| 1399 | "HEAD", | ||
| 1400 | ]).pipe(Effect.map((stdout) => stdout.trim())); | ||
| 1401 | |||
| 1402 | return { commitSha }; | ||
| 1403 | }); | ||
| 1404 | |||
| 1405 | const pushCurrentBranch: GitVcsDriverShape["pushCurrentBranch"] = Effect.fn("pushCurrentBranch")( | ||
| 1406 | function* (cwd, fallbackBranch) { | ||
| 1407 | const details = yield* statusDetails(cwd); | ||
| 1408 | const branch = details.branch ?? fallbackBranch; | ||
| 1409 | if (!branch) { | ||
| 1410 | return yield* createGitCommandError( | ||
| 1411 | "GitVcsDriver.pushCurrentBranch", | ||
| 1412 | cwd, | ||
| 1413 | ["push"], | ||
| 1414 | "Cannot push from detached HEAD.", | ||
| 1415 | ); | ||
| 1416 | } | ||
| 1417 | |||
| 1418 | const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0; | ||
| 1419 | if (hasNoLocalDelta) { | ||
| 1420 | if (details.hasUpstream) { | ||
| 1421 | return { | ||
| 1422 | status: "skipped_up_to_date" as const, | ||
| 1423 | branch, | ||
| 1424 | ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), | ||
| 1425 | }; | ||
| 1426 | } | ||
| 1427 | |||
| 1428 | const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe( | ||
| 1429 | Effect.catch(() => Effect.succeed(null)), | ||
| 1430 | ); | ||
| 1431 | if (comparableBaseBranch) { | ||
| 1432 | const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( | ||
| 1433 | Effect.catch(() => Effect.succeed(null)), | ||
| 1434 | ); | ||
| 1435 | if (!publishRemoteName) { | ||
| 1436 | return { | ||
| 1437 | status: "skipped_up_to_date" as const, | ||
| 1438 | branch, | ||
| 1439 | }; | ||
| 1440 | } | ||
| 1441 | |||
| 1442 | const hasRemoteBranch = yield* remoteBranchExists(cwd, publishRemoteName, branch).pipe( | ||
| 1443 | Effect.catch(() => Effect.succeed(false)), | ||
| 1444 | ); | ||
| 1445 | if (hasRemoteBranch) { | ||
| 1446 | return { | ||
| 1447 | status: "skipped_up_to_date" as const, | ||
| 1448 | branch, | ||
| 1449 | }; | ||
| 1450 | } | ||
| 1451 | } | ||
| 1452 | } | ||
| 1453 | |||
| 1454 | if (!details.hasUpstream) { | ||
| 1455 | const publishRemoteName = yield* resolvePushRemoteName(cwd, branch); | ||
| 1456 | if (!publishRemoteName) { | ||
| 1457 | return yield* createGitCommandError( | ||
| 1458 | "GitVcsDriver.pushCurrentBranch", | ||
| 1459 | cwd, | ||
| 1460 | ["push"], | ||
| 1461 | "Cannot push because no git remote is configured for this repository.", | ||
| 1462 | ); | ||
| 1463 | } | ||
| 1464 | yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithUpstream", cwd, [ | ||
| 1465 | "push", | ||
| 1466 | "-u", | ||
| 1467 | publishRemoteName, | ||
| 1468 | `HEAD:refs/heads/${branch}`, | ||
| 1469 | ]); | ||
| 1470 | return { | ||
| 1471 | status: "pushed" as const, | ||
| 1472 | branch, | ||
| 1473 | upstreamBranch: `${publishRemoteName}/${branch}`, | ||
| 1474 | setUpstream: true, | ||
| 1475 | }; | ||
| 1476 | } | ||
| 1477 | |||
| 1478 | const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe( | ||
| 1479 | Effect.catch(() => Effect.succeed(null)), | ||
| 1480 | ); | ||
| 1481 | if (currentUpstream) { | ||
| 1482 | yield* runGit("GitVcsDriver.pushCurrentBranch.pushUpstream", cwd, [ | ||
| 1483 | "push", | ||
| 1484 | currentUpstream.remoteName, | ||
| 1485 | `HEAD:${currentUpstream.upstreamRef}`, | ||
| 1486 | ]); | ||
| 1487 | return { | ||
| 1488 | status: "pushed" as const, | ||
| 1489 | branch, | ||
| 1490 | upstreamBranch: currentUpstream.upstreamRef, | ||
| 1491 | setUpstream: false, | ||
| 1492 | }; | ||
| 1493 | } | ||
| 1494 | |||
| 1495 | yield* runGit("GitVcsDriver.pushCurrentBranch.push", cwd, ["push"]); | ||
| 1496 | return { | ||
| 1497 | status: "pushed" as const, | ||
| 1498 | branch, | ||
| 1499 | ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), | ||
| 1500 | setUpstream: false, | ||
| 1501 | }; | ||
| 1502 | }, | ||
| 1503 | ); | ||
| 1504 | |||
| 1505 | const pullCurrentBranch: GitVcsDriverShape["pullCurrentBranch"] = Effect.fn("pullCurrentBranch")( | ||
| 1506 | function* (cwd) { | ||
| 1507 | const details = yield* statusDetails(cwd); | ||
| 1508 | const refName = details.branch; | ||
| 1509 | if (!refName) { | ||
| 1510 | return yield* createGitCommandError( | ||
| 1511 | "GitVcsDriver.pullCurrentBranch", | ||
| 1512 | cwd, | ||
| 1513 | ["pull", "--ff-only"], | ||
| 1514 | "Cannot pull from detached HEAD.", | ||
| 1515 | ); | ||
| 1516 | } | ||
| 1517 | if (!details.hasUpstream) { | ||
| 1518 | return yield* createGitCommandError( | ||
| 1519 | "GitVcsDriver.pullCurrentBranch", | ||
| 1520 | cwd, | ||
| 1521 | ["pull", "--ff-only"], | ||
| 1522 | "Current branch has no upstream configured. Push with upstream first.", | ||
| 1523 | ); | ||
| 1524 | } | ||
| 1525 | const beforeSha = yield* runGitStdout( | ||
| 1526 | "GitVcsDriver.pullCurrentBranch.beforeSha", | ||
| 1527 | cwd, | ||
| 1528 | ["rev-parse", "HEAD"], | ||
| 1529 | true, | ||
| 1530 | ).pipe(Effect.map((stdout) => stdout.trim())); | ||
| 1531 | yield* executeGit("GitVcsDriver.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { | ||
| 1532 | timeoutMs: 30_000, | ||
| 1533 | fallbackErrorMessage: "git pull failed", | ||
| 1534 | }); | ||
| 1535 | const afterSha = yield* runGitStdout( | ||
| 1536 | "GitVcsDriver.pullCurrentBranch.afterSha", | ||
| 1537 | cwd, | ||
| 1538 | ["rev-parse", "HEAD"], | ||
| 1539 | true, | ||
| 1540 | ).pipe(Effect.map((stdout) => stdout.trim())); | ||
| 1541 | |||
| 1542 | const refreshed = yield* statusDetails(cwd); | ||
| 1543 | return { | ||
| 1544 | status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", | ||
| 1545 | refName, | ||
| 1546 | upstreamRef: refreshed.upstreamRef, | ||
| 1547 | }; | ||
| 1548 | }, | ||
| 1549 | ); | ||
| 1550 | |||
| 1551 | const readRangeContext: GitVcsDriverShape["readRangeContext"] = Effect.fn("readRangeContext")( | ||
| 1552 | function* (cwd, baseRef) { | ||
| 1553 | const range = `${baseRef}..HEAD`; | ||
| 1554 | const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( | ||
| 1555 | [ | ||
| 1556 | runGitStdoutWithOptions( | ||
| 1557 | "GitVcsDriver.readRangeContext.log", | ||
| 1558 | cwd, | ||
| 1559 | ["log", "--oneline", range], | ||
| 1560 | { | ||
| 1561 | maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, | ||
| 1562 | truncateOutputAtMaxBytes: true, | ||
| 1563 | }, | ||
| 1564 | ), | ||
| 1565 | runGitStdoutWithOptions( | ||
| 1566 | "GitVcsDriver.readRangeContext.diffStat", | ||
| 1567 | cwd, | ||
| 1568 | ["diff", "--stat", range], | ||
| 1569 | { | ||
| 1570 | maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES, | ||
| 1571 | truncateOutputAtMaxBytes: true, | ||
| 1572 | }, | ||
| 1573 | ), | ||
| 1574 | runGitStdoutWithOptions( | ||
| 1575 | "GitVcsDriver.readRangeContext.diffPatch", | ||
| 1576 | cwd, | ||
| 1577 | ["diff", "--patch", "--minimal", range], | ||
| 1578 | { | ||
| 1579 | maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, | ||
| 1580 | truncateOutputAtMaxBytes: true, | ||
| 1581 | }, | ||
| 1582 | ), | ||
| 1583 | ], | ||
| 1584 | { concurrency: "unbounded" }, | ||
| 1585 | ); | ||
| 1586 | |||
| 1587 | return { | ||
| 1588 | commitSummary, | ||
| 1589 | diffSummary, | ||
| 1590 | diffPatch, | ||
| 1591 | }; | ||
| 1592 | }, | ||
| 1593 | ); | ||
| 1594 | |||
| 1595 | const readConfigValue: GitVcsDriverShape["readConfigValue"] = (cwd, key) => | ||
| 1596 | runGitStdout("GitVcsDriver.readConfigValue", cwd, ["config", "--get", key], true).pipe( | ||
| 1597 | Effect.map((stdout) => stdout.trim()), | ||
| 1598 | Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), | ||
| 1599 | ); | ||
| 1600 | |||
| 1601 | const listRefs: GitVcsDriverShape["listRefs"] = Effect.fn("listRefs")(function* (input) { | ||
| 1602 | const branchRecencyPromise = readBranchRecency(input.cwd).pipe( | ||
| 1603 | Effect.catch(() => Effect.succeed(new Map<string, number>())), | ||
| 1604 | ); | ||
| 1605 | const localBranchResult = yield* executeGit( | ||
| 1606 | "GitVcsDriver.listRefs.branchNoColor", | ||
| 1607 | input.cwd, | ||
| 1608 | ["branch", "--no-color", "--no-column"], | ||
| 1609 | { | ||
| 1610 | timeoutMs: 10_000, | ||
| 1611 | allowNonZeroExit: true, | ||
| 1612 | }, | ||
| 1613 | ).pipe( | ||
| 1614 | Effect.catchIf(isMissingGitCwdError, () => | ||
| 1615 | Effect.succeed({ | ||
| 1616 | exitCode: ChildProcessSpawner.ExitCode(128), | ||
| 1617 | stdout: "", | ||
| 1618 | stderr: "fatal: not a git repository", | ||
| 1619 | stdoutTruncated: false, | ||
| 1620 | stderrTruncated: false, | ||
| 1621 | }), | ||
| 1622 | ), | ||
| 1623 | ); | ||
| 1624 | |||
| 1625 | if (localBranchResult.exitCode !== 0) { | ||
| 1626 | const stderr = localBranchResult.stderr.trim(); | ||
| 1627 | if (stderr.toLowerCase().includes("not a git repository")) { | ||
| 1628 | return { | ||
| 1629 | refs: [], | ||
| 1630 | isRepo: false, | ||
| 1631 | hasPrimaryRemote: false, | ||
| 1632 | nextCursor: null, | ||
| 1633 | totalCount: 0, | ||
| 1634 | }; | ||
| 1635 | } | ||
| 1636 | return yield* createGitCommandError( | ||
| 1637 | "GitVcsDriver.listRefs", | ||
| 1638 | input.cwd, | ||
| 1639 | ["branch", "--no-color", "--no-column"], | ||
| 1640 | stderr || "git branch failed", | ||
| 1641 | ); | ||
| 1642 | } | ||
| 1643 | |||
| 1644 | const remoteBranchResultEffect = executeGit( | ||
| 1645 | "GitVcsDriver.listRefs.remoteBranches", | ||
| 1646 | input.cwd, | ||
| 1647 | ["branch", "--no-color", "--no-column", "--remotes"], | ||
| 1648 | { | ||
| 1649 | timeoutMs: 10_000, | ||
| 1650 | allowNonZeroExit: true, | ||
| 1651 | }, | ||
| 1652 | ).pipe( | ||
| 1653 | Effect.catch((error) => | ||
| 1654 | Effect.logWarning( | ||
| 1655 | `GitVcsDriver.listRefs: remote refName lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote refName list.`, | ||
| 1656 | ).pipe( | ||
| 1657 | Effect.as({ | ||
| 1658 | exitCode: ChildProcessSpawner.ExitCode(1), | ||
| 1659 | stdout: "", | ||
| 1660 | stderr: "", | ||
| 1661 | stdoutTruncated: false, | ||
| 1662 | stderrTruncated: false, | ||
| 1663 | } satisfies ExecuteGitResult), | ||
| 1664 | ), | ||
| 1665 | ), | ||
| 1666 | ); | ||
| 1667 | |||
| 1668 | const remoteNamesResultEffect = executeGit( | ||
| 1669 | "GitVcsDriver.listRefs.remoteNames", | ||
| 1670 | input.cwd, | ||
| 1671 | ["remote"], | ||
| 1672 | { | ||
| 1673 | timeoutMs: 5_000, | ||
| 1674 | allowNonZeroExit: true, | ||
| 1675 | }, | ||
| 1676 | ).pipe( | ||
| 1677 | Effect.catch((error) => | ||
| 1678 | Effect.logWarning( | ||
| 1679 | `GitVcsDriver.listRefs: remote name lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote name list.`, | ||
| 1680 | ).pipe( | ||
| 1681 | Effect.as({ | ||
| 1682 | exitCode: ChildProcessSpawner.ExitCode(1), | ||
| 1683 | stdout: "", | ||
| 1684 | stderr: "", | ||
| 1685 | stdoutTruncated: false, | ||
| 1686 | stderrTruncated: false, | ||
| 1687 | } satisfies ExecuteGitResult), | ||
| 1688 | ), | ||
| 1689 | ), | ||
| 1690 | ); | ||
| 1691 | |||
| 1692 | const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] = | ||
| 1693 | yield* Effect.all( | ||
| 1694 | [ | ||
| 1695 | executeGit( | ||
| 1696 | "GitVcsDriver.listRefs.defaultRef", | ||
| 1697 | input.cwd, | ||
| 1698 | ["symbolic-ref", "refs/remotes/origin/HEAD"], | ||
| 1699 | { | ||
| 1700 | timeoutMs: 5_000, | ||
| 1701 | allowNonZeroExit: true, | ||
| 1702 | }, | ||
| 1703 | ), | ||
| 1704 | executeGit( | ||
| 1705 | "GitVcsDriver.listRefs.worktreeList", | ||
| 1706 | input.cwd, | ||
| 1707 | ["worktree", "list", "--porcelain"], | ||
| 1708 | { | ||
| 1709 | timeoutMs: 5_000, | ||
| 1710 | allowNonZeroExit: true, | ||
| 1711 | }, | ||
| 1712 | ), | ||
| 1713 | remoteBranchResultEffect, | ||
| 1714 | remoteNamesResultEffect, | ||
| 1715 | branchRecencyPromise, | ||
| 1716 | ], | ||
| 1717 | { concurrency: "unbounded" }, | ||
| 1718 | ); | ||
| 1719 | |||
| 1720 | const remoteNames = | ||
| 1721 | remoteNamesResult.exitCode === 0 ? parseRemoteNames(remoteNamesResult.stdout) : []; | ||
| 1722 | if (remoteBranchResult.exitCode !== 0 && remoteBranchResult.stderr.trim().length > 0) { | ||
| 1723 | yield* Effect.logWarning( | ||
| 1724 | `GitVcsDriver.listRefs: remote refName lookup returned code ${remoteBranchResult.exitCode} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote refName list.`, | ||
| 1725 | ); | ||
| 1726 | } | ||
| 1727 | if (remoteNamesResult.exitCode !== 0 && remoteNamesResult.stderr.trim().length > 0) { | ||
| 1728 | yield* Effect.logWarning( | ||
| 1729 | `GitVcsDriver.listRefs: remote name lookup returned code ${remoteNamesResult.exitCode} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`, | ||
| 1730 | ); | ||
| 1731 | } | ||
| 1732 | |||
| 1733 | const defaultBranch = | ||
| 1734 | defaultRef.exitCode === 0 | ||
| 1735 | ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") | ||
| 1736 | : null; | ||
| 1737 | |||
| 1738 | const worktreeMap = new Map<string, string>(); | ||
| 1739 | if (worktreeList.exitCode === 0) { | ||
| 1740 | let currentPath: string | null = null; | ||
| 1741 | for (const line of worktreeList.stdout.split("\n")) { | ||
| 1742 | if (line.startsWith("worktree ")) { | ||
| 1743 | const candidatePath = line.slice("worktree ".length); | ||
| 1744 | const exists = yield* fileSystem.stat(candidatePath).pipe( | ||
| 1745 | Effect.map(() => true), | ||
| 1746 | Effect.catch(() => Effect.succeed(false)), | ||
| 1747 | ); | ||
| 1748 | currentPath = exists ? candidatePath : null; | ||
| 1749 | } else if (line.startsWith("branch refs/heads/") && currentPath) { | ||
| 1750 | worktreeMap.set(line.slice("branch refs/heads/".length), currentPath); | ||
| 1751 | } else if (line === "") { | ||
| 1752 | currentPath = null; | ||
| 1753 | } | ||
| 1754 | } | ||
| 1755 | } | ||
| 1756 | |||
| 1757 | const localBranches = localBranchResult.stdout | ||
| 1758 | .split("\n") | ||
| 1759 | .map(parseBranchLine) | ||
| 1760 | .filter((refName): refName is { name: string; current: boolean } => refName !== null) | ||
| 1761 | .map((refName) => ({ | ||
| 1762 | name: refName.name, | ||
| 1763 | current: refName.current, | ||
| 1764 | isRemote: false, | ||
| 1765 | isDefault: refName.name === defaultBranch, | ||
| 1766 | worktreePath: worktreeMap.get(refName.name) ?? null, | ||
| 1767 | })) | ||
| 1768 | .toSorted((a, b) => { | ||
| 1769 | const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; | ||
| 1770 | const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; | ||
| 1771 | if (aPriority !== bPriority) return aPriority - bPriority; | ||
| 1772 | |||
| 1773 | const aLastCommit = branchLastCommit.get(a.name) ?? 0; | ||
| 1774 | const bLastCommit = branchLastCommit.get(b.name) ?? 0; | ||
| 1775 | if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; | ||
| 1776 | return a.name.localeCompare(b.name); | ||
| 1777 | }); | ||
| 1778 | |||
| 1779 | const remoteBranches = | ||
| 1780 | remoteBranchResult.exitCode === 0 | ||
| 1781 | ? remoteBranchResult.stdout | ||
| 1782 | .split("\n") | ||
| 1783 | .map(parseBranchLine) | ||
| 1784 | .filter((refName): refName is { name: string; current: boolean } => refName !== null) | ||
| 1785 | .map((refName) => { | ||
| 1786 | const parsedRemoteRef = parseRemoteRefWithRemoteNames(refName.name, remoteNames); | ||
| 1787 | const remoteBranch: { | ||
| 1788 | name: string; | ||
| 1789 | current: boolean; | ||
| 1790 | isRemote: boolean; | ||
| 1791 | remoteName?: string; | ||
| 1792 | isDefault: boolean; | ||
| 1793 | worktreePath: string | null; | ||
| 1794 | } = { | ||
| 1795 | name: refName.name, | ||
| 1796 | current: false, | ||
| 1797 | isRemote: true, | ||
| 1798 | isDefault: false, | ||
| 1799 | worktreePath: null, | ||
| 1800 | }; | ||
| 1801 | if (parsedRemoteRef) { | ||
| 1802 | remoteBranch.remoteName = parsedRemoteRef.remoteName; | ||
| 1803 | } | ||
| 1804 | return remoteBranch; | ||
| 1805 | }) | ||
| 1806 | .toSorted((a, b) => { | ||
| 1807 | const aLastCommit = branchLastCommit.get(a.name) ?? 0; | ||
| 1808 | const bLastCommit = branchLastCommit.get(b.name) ?? 0; | ||
| 1809 | if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; | ||
| 1810 | return a.name.localeCompare(b.name); | ||
| 1811 | }) | ||
| 1812 | : []; | ||
| 1813 | |||
| 1814 | const refs = paginateBranches({ | ||
| 1815 | refs: filterBranchesForListQuery( | ||
| 1816 | dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]), | ||
| 1817 | input.query, | ||
| 1818 | ), | ||
| 1819 | cursor: input.cursor, | ||
| 1820 | limit: input.limit, | ||
| 1821 | }); | ||
| 1822 | |||
| 1823 | return { | ||
| 1824 | refs: [...refs.refs], | ||
| 1825 | isRepo: true, | ||
| 1826 | hasPrimaryRemote: remoteNames.includes("origin"), | ||
| 1827 | nextCursor: refs.nextCursor, | ||
| 1828 | totalCount: refs.totalCount, | ||
| 1829 | }; | ||
| 1830 | }); | ||
| 1831 | |||
| 1832 | const createWorktree: GitVcsDriverShape["createWorktree"] = Effect.fn("createWorktree")( | ||
| 1833 | function* (input) { | ||
| 1834 | const targetBranch = input.newRefName ?? input.refName; | ||
| 1835 | const sanitizedBranch = targetBranch.replace(/\//g, "-"); | ||
| 1836 | const repoName = path.basename(input.cwd); | ||
| 1837 | const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); | ||
| 1838 | const args = input.newRefName | ||
| 1839 | ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] | ||
| 1840 | : ["worktree", "add", worktreePath, input.refName]; | ||
| 1841 | |||
| 1842 | yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, { | ||
| 1843 | fallbackErrorMessage: "git worktree add failed", | ||
| 1844 | }); | ||
| 1845 | |||
| 1846 | return { | ||
| 1847 | worktree: { | ||
| 1848 | path: worktreePath, | ||
| 1849 | refName: targetBranch, | ||
| 1850 | }, | ||
| 1851 | }; | ||
| 1852 | }, | ||
| 1853 | ); | ||
| 1854 | |||
| 1855 | const fetchPullRequestBranch: GitVcsDriverShape["fetchPullRequestBranch"] = Effect.fn( | ||
| 1856 | "fetchPullRequestBranch", | ||
| 1857 | )(function* (input) { | ||
| 1858 | const remoteName = yield* resolvePrimaryRemoteName(input.cwd); | ||
| 1859 | yield* executeGit( | ||
| 1860 | "GitVcsDriver.fetchPullRequestBranch", | ||
| 1861 | input.cwd, | ||
| 1862 | [ | ||
| 1863 | "fetch", | ||
| 1864 | "--quiet", | ||
| 1865 | "--no-tags", | ||
| 1866 | remoteName, | ||
| 1867 | `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, | ||
| 1868 | ], | ||
| 1869 | { | ||
| 1870 | fallbackErrorMessage: "git fetch pull request branch failed", | ||
| 1871 | }, | ||
| 1872 | ); | ||
| 1873 | }); | ||
| 1874 | |||
| 1875 | const fetchRemoteBranch: GitVcsDriverShape["fetchRemoteBranch"] = Effect.fn("fetchRemoteBranch")( | ||
| 1876 | function* (input) { | ||
| 1877 | yield* runGit("GitVcsDriver.fetchRemoteBranch.fetch", input.cwd, [ | ||
| 1878 | "fetch", | ||
| 1879 | "--quiet", | ||
| 1880 | "--no-tags", | ||
| 1881 | input.remoteName, | ||
| 1882 | `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, | ||
| 1883 | ]); | ||
| 1884 | |||
| 1885 | const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch); | ||
| 1886 | const targetRef = `${input.remoteName}/${input.remoteBranch}`; | ||
| 1887 | yield* runGit( | ||
| 1888 | "GitVcsDriver.fetchRemoteBranch.materialize", | ||
| 1889 | input.cwd, | ||
| 1890 | localBranchAlreadyExists | ||
| 1891 | ? ["branch", "--force", input.localBranch, targetRef] | ||
| 1892 | : ["branch", input.localBranch, targetRef], | ||
| 1893 | ); | ||
| 1894 | }, | ||
| 1895 | ); | ||
| 1896 | |||
| 1897 | const setBranchUpstream: GitVcsDriverShape["setBranchUpstream"] = (input) => | ||
| 1898 | runGit("GitVcsDriver.setBranchUpstream", input.cwd, [ | ||
| 1899 | "branch", | ||
| 1900 | "--set-upstream-to", | ||
| 1901 | `${input.remoteName}/${input.remoteBranch}`, | ||
| 1902 | input.branch, | ||
| 1903 | ]); | ||
| 1904 | |||
| 1905 | const removeWorktree: GitVcsDriverShape["removeWorktree"] = Effect.fn("removeWorktree")( | ||
| 1906 | function* (input) { | ||
| 1907 | const args = ["worktree", "remove"]; | ||
| 1908 | if (input.force) { | ||
| 1909 | args.push("--force"); | ||
| 1910 | } | ||
| 1911 | args.push(input.path); | ||
| 1912 | yield* executeGit("GitVcsDriver.removeWorktree", input.cwd, args, { | ||
| 1913 | timeoutMs: 15_000, | ||
| 1914 | fallbackErrorMessage: "git worktree remove failed", | ||
| 1915 | }).pipe( | ||
| 1916 | Effect.mapError((error) => | ||
| 1917 | createGitCommandError( | ||
| 1918 | "GitVcsDriver.removeWorktree", | ||
| 1919 | input.cwd, | ||
| 1920 | args, | ||
| 1921 | `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error.message}`, | ||
| 1922 | error, | ||
| 1923 | ), | ||
| 1924 | ), | ||
| 1925 | ); | ||
| 1926 | }, | ||
| 1927 | ); | ||
| 1928 | |||
| 1929 | const renameBranch: GitVcsDriverShape["renameBranch"] = Effect.fn("renameBranch")( | ||
| 1930 | function* (input) { | ||
| 1931 | if (input.oldBranch === input.newBranch) { | ||
| 1932 | return { branch: input.newBranch }; | ||
| 1933 | } | ||
| 1934 | const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch); | ||
| 1935 | |||
| 1936 | yield* executeGit( | ||
| 1937 | "GitVcsDriver.renameBranch", | ||
| 1938 | input.cwd, | ||
| 1939 | ["branch", "-m", "--", input.oldBranch, targetBranch], | ||
| 1940 | { | ||
| 1941 | timeoutMs: 10_000, | ||
| 1942 | fallbackErrorMessage: "git branch rename failed", | ||
| 1943 | }, | ||
| 1944 | ); | ||
| 1945 | |||
| 1946 | return { branch: targetBranch }; | ||
| 1947 | }, | ||
| 1948 | ); | ||
| 1949 | |||
| 1950 | const switchRef: GitVcsDriverShape["switchRef"] = Effect.fn("switchRef")(function* (input) { | ||
| 1951 | const [localInputExists, remoteExists] = yield* Effect.all( | ||
| 1952 | [ | ||
| 1953 | executeGit( | ||
| 1954 | "GitVcsDriver.switchRef.localInputExists", | ||
| 1955 | input.cwd, | ||
| 1956 | ["show-ref", "--verify", "--quiet", `refs/heads/${input.refName}`], | ||
| 1957 | { | ||
| 1958 | timeoutMs: 5_000, | ||
| 1959 | allowNonZeroExit: true, | ||
| 1960 | }, | ||
| 1961 | ).pipe(Effect.map((result) => result.exitCode === 0)), | ||
| 1962 | executeGit( | ||
| 1963 | "GitVcsDriver.switchRef.remoteExists", | ||
| 1964 | input.cwd, | ||
| 1965 | ["show-ref", "--verify", "--quiet", `refs/remotes/${input.refName}`], | ||
| 1966 | { | ||
| 1967 | timeoutMs: 5_000, | ||
| 1968 | allowNonZeroExit: true, | ||
| 1969 | }, | ||
| 1970 | ).pipe(Effect.map((result) => result.exitCode === 0)), | ||
| 1971 | ], | ||
| 1972 | { concurrency: "unbounded" }, | ||
| 1973 | ); | ||
| 1974 | |||
| 1975 | const localTrackingBranch = remoteExists | ||
| 1976 | ? yield* executeGit( | ||
| 1977 | "GitVcsDriver.switchRef.localTrackingBranch", | ||
| 1978 | input.cwd, | ||
| 1979 | ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"], | ||
| 1980 | { | ||
| 1981 | timeoutMs: 5_000, | ||
| 1982 | allowNonZeroExit: true, | ||
| 1983 | }, | ||
| 1984 | ).pipe( | ||
| 1985 | Effect.map((result) => | ||
| 1986 | result.exitCode === 0 | ||
| 1987 | ? parseTrackingBranchByUpstreamRef(result.stdout, input.refName) | ||
| 1988 | : null, | ||
| 1989 | ), | ||
| 1990 | ) | ||
| 1991 | : null; | ||
| 1992 | |||
| 1993 | const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.refName); | ||
| 1994 | const localTrackedBranchTargetExists = | ||
| 1995 | remoteExists && localTrackedBranchCandidate | ||
| 1996 | ? yield* executeGit( | ||
| 1997 | "GitVcsDriver.switchRef.localTrackedBranchTargetExists", | ||
| 1998 | input.cwd, | ||
| 1999 | ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`], | ||
| 2000 | { | ||
| 2001 | timeoutMs: 5_000, | ||
| 2002 | allowNonZeroExit: true, | ||
| 2003 | }, | ||
| 2004 | ).pipe(Effect.map((result) => result.exitCode === 0)) | ||
| 2005 | : false; | ||
| 2006 | |||
| 2007 | const checkoutArgs = localInputExists | ||
| 2008 | ? ["checkout", input.refName] | ||
| 2009 | : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists | ||
| 2010 | ? ["checkout", input.refName] | ||
| 2011 | : remoteExists && !localTrackingBranch | ||
| 2012 | ? ["checkout", "--track", input.refName] | ||
| 2013 | : remoteExists && localTrackingBranch | ||
| 2014 | ? ["checkout", localTrackingBranch] | ||
| 2015 | : ["checkout", input.refName]; | ||
| 2016 | |||
| 2017 | yield* executeGit("GitVcsDriver.switchRef.checkout", input.cwd, checkoutArgs, { | ||
| 2018 | timeoutMs: 10_000, | ||
| 2019 | fallbackErrorMessage: "git checkout failed", | ||
| 2020 | }); | ||
| 2021 | |||
| 2022 | const refName = yield* runGitStdout("GitVcsDriver.switchRef.currentBranch", input.cwd, [ | ||
| 2023 | "branch", | ||
| 2024 | "--show-current", | ||
| 2025 | ]).pipe(Effect.map((stdout) => stdout.trim() || null)); | ||
| 2026 | |||
| 2027 | return { refName }; | ||
| 2028 | }); | ||
| 2029 | |||
| 2030 | const createRef: GitVcsDriverShape["createRef"] = Effect.fn("createRef")(function* (input) { | ||
| 2031 | yield* executeGit("GitVcsDriver.createRef", input.cwd, ["branch", input.refName], { | ||
| 2032 | timeoutMs: 10_000, | ||
| 2033 | fallbackErrorMessage: "git branch create failed", | ||
| 2034 | }); | ||
| 2035 | if (input.switchRef) { | ||
| 2036 | yield* switchRef({ cwd: input.cwd, refName: input.refName }); | ||
| 2037 | } | ||
| 2038 | |||
| 2039 | return { refName: input.refName }; | ||
| 2040 | }); | ||
| 2041 | |||
| 2042 | const initRepo: GitVcsDriverShape["initRepo"] = (input) => | ||
| 2043 | executeGit("GitVcsDriver.initRepo", input.cwd, ["init"], { | ||
| 2044 | timeoutMs: 10_000, | ||
| 2045 | fallbackErrorMessage: "git init failed", | ||
| 2046 | }).pipe(Effect.asVoid); | ||
| 2047 | |||
| 2048 | const listLocalBranchNames: GitVcsDriverShape["listLocalBranchNames"] = (cwd) => | ||
| 2049 | runGitStdout("GitVcsDriver.listLocalBranchNames", cwd, [ | ||
| 2050 | "branch", | ||
| 2051 | "--list", | ||
| 2052 | "--no-column", | ||
| 2053 | "--format=%(refname:short)", | ||
| 2054 | ]).pipe( | ||
| 2055 | Effect.map((stdout) => | ||
| 2056 | stdout | ||
| 2057 | .split("\n") | ||
| 2058 | .map((line) => line.trim()) | ||
| 2059 | .filter((line) => line.length > 0), | ||
| 2060 | ), | ||
| 2061 | ); | ||
| 2062 | |||
| 2063 | return { | ||
| 2064 | execute, | ||
| 2065 | status, | ||
| 2066 | statusDetails, | ||
| 2067 | statusDetailsLocal, | ||
| 2068 | prepareCommitContext, | ||
| 2069 | commit, | ||
| 2070 | pushCurrentBranch, | ||
| 2071 | pullCurrentBranch, | ||
| 2072 | readRangeContext, | ||
| 2073 | readConfigValue, | ||
| 2074 | listRefs, | ||
| 2075 | createWorktree, | ||
| 2076 | fetchPullRequestBranch, | ||
| 2077 | ensureRemote, | ||
| 2078 | fetchRemoteBranch, | ||
| 2079 | setBranchUpstream, | ||
| 2080 | removeWorktree, | ||
| 2081 | renameBranch, | ||
| 2082 | createRef, | ||
| 2083 | switchRef, | ||
| 2084 | initRepo, | ||
| 2085 | listLocalBranchNames, | ||
| 2086 | } satisfies GitVcsDriverShape; | ||
| 2087 | }); | ||
apps/server/src/vcs/VcsDriver.ts created+31| ... | @@ -0,0 +1,31 @@ | ||
| 1 | import { Context, type Effect } from "effect"; | ||
| 2 | |||
| 3 | import type { | ||
| 4 | VcsDriverCapabilities, | ||
| 5 | VcsError, | ||
| 6 | VcsInitInput, | ||
| 7 | VcsListRemotesResult, | ||
| 8 | VcsListWorkspaceFilesResult, | ||
| 9 | VcsRepositoryIdentity, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | import type { VcsProcessInput, VcsProcessOutput } from "./VcsProcess.ts"; | ||
| 12 | |||
| 13 | export interface VcsDriverShape { | ||
| 14 | readonly capabilities: VcsDriverCapabilities; | ||
| 15 | readonly execute: ( | ||
| 16 | input: Omit<VcsProcessInput, "command">, | ||
| 17 | ) => Effect.Effect<VcsProcessOutput, VcsError>; | ||
| 18 | readonly detectRepository: (cwd: string) => Effect.Effect<VcsRepositoryIdentity | null, VcsError>; | ||
| 19 | readonly isInsideWorkTree: (cwd: string) => Effect.Effect<boolean, VcsError>; | ||
| 20 | readonly listWorkspaceFiles: ( | ||
| 21 | cwd: string, | ||
| 22 | ) => Effect.Effect<VcsListWorkspaceFilesResult, VcsError>; | ||
| 23 | readonly listRemotes: (cwd: string) => Effect.Effect<VcsListRemotesResult, VcsError>; | ||
| 24 | readonly filterIgnoredPaths: ( | ||
| 25 | cwd: string, | ||
| 26 | relativePaths: ReadonlyArray<string>, | ||
| 27 | ) => Effect.Effect<ReadonlyArray<string>, VcsError>; | ||
| 28 | readonly initRepository: (input: VcsInitInput) => Effect.Effect<void, VcsError>; | ||
| 29 | } | ||
| 30 | |||
| 31 | export class VcsDriver extends Context.Service<VcsDriver, VcsDriverShape>()("t3/vcs/VcsDriver") {} | ||
apps/server/src/vcs/VcsDriverRegistry.test.ts created+87| ... | @@ -0,0 +1,87 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Layer } from "effect"; | ||
| 3 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 4 | import { describe } from "vitest"; | ||
| 5 | |||
| 6 | import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "./VcsProcess.ts"; | ||
| 7 | import { VcsProjectConfig } from "./VcsProjectConfig.ts"; | ||
| 8 | import { VcsDriverRegistry, make as makeVcsDriverRegistry } from "./VcsDriverRegistry.ts"; | ||
| 9 | |||
| 10 | const processOutput = (stdout: string): VcsProcessOutput => ({ | ||
| 11 | exitCode: ChildProcessSpawner.ExitCode(0), | ||
| 12 | stdout, | ||
| 13 | stderr: "", | ||
| 14 | stdoutTruncated: false, | ||
| 15 | stderrTruncated: false, | ||
| 16 | }); | ||
| 17 | |||
| 18 | describe("VcsDriverRegistry", () => { | ||
| 19 | it.effect("routes directly by VCS driver kind for non-repository workflows", () => { | ||
| 20 | const layer = Layer.effect(VcsDriverRegistry, makeVcsDriverRegistry()).pipe( | ||
| 21 | Layer.provide( | ||
| 22 | Layer.mock(VcsProjectConfig)({ | ||
| 23 | resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), | ||
| 24 | }), | ||
| 25 | ), | ||
| 26 | Layer.provide( | ||
| 27 | Layer.mock(VcsProcess)({ | ||
| 28 | run: () => Effect.succeed(processOutput("")), | ||
| 29 | }), | ||
| 30 | ), | ||
| 31 | ); | ||
| 32 | |||
| 33 | return Effect.gen(function* () { | ||
| 34 | const registry = yield* VcsDriverRegistry; | ||
| 35 | const driver = yield* registry.get("git"); | ||
| 36 | |||
| 37 | assert.strictEqual(driver.capabilities.kind, "git"); | ||
| 38 | }).pipe(Effect.provide(layer)); | ||
| 39 | }); | ||
| 40 | |||
| 41 | it.effect("caches repository detection for repeated resolves in the same cwd and kind", () => { | ||
| 42 | const calls: VcsProcessInput[] = []; | ||
| 43 | const layer = Layer.effect(VcsDriverRegistry, makeVcsDriverRegistry()).pipe( | ||
| 44 | Layer.provide( | ||
| 45 | Layer.mock(VcsProjectConfig)({ | ||
| 46 | resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), | ||
| 47 | }), | ||
| 48 | ), | ||
| 49 | Layer.provide( | ||
| 50 | Layer.mock(VcsProcess)({ | ||
| 51 | run: (input) => | ||
| 52 | Effect.sync(() => { | ||
| 53 | calls.push(input); | ||
| 54 | const command = input.args.join(" "); | ||
| 55 | if (command === "rev-parse --is-inside-work-tree") { | ||
| 56 | return processOutput("true\n"); | ||
| 57 | } | ||
| 58 | if (command === "rev-parse --show-toplevel") { | ||
| 59 | return processOutput("/repo\n"); | ||
| 60 | } | ||
| 61 | if (command === "rev-parse --git-common-dir") { | ||
| 62 | return processOutput("/repo/.git\n"); | ||
| 63 | } | ||
| 64 | return processOutput(""); | ||
| 65 | }), | ||
| 66 | }), | ||
| 67 | ), | ||
| 68 | ); | ||
| 69 | |||
| 70 | return Effect.gen(function* () { | ||
| 71 | const registry = yield* VcsDriverRegistry; | ||
| 72 | const first = yield* registry.resolve({ cwd: "/repo", requestedKind: "git" }); | ||
| 73 | const second = yield* registry.resolve({ cwd: "/repo", requestedKind: "git" }); | ||
| 74 | |||
| 75 | assert.equal(first.repository.rootPath, "/repo"); | ||
| 76 | assert.equal(second.repository.rootPath, "/repo"); | ||
| 77 | assert.deepStrictEqual( | ||
| 78 | calls.map((call) => call.args.join(" ")), | ||
| 79 | [ | ||
| 80 | "rev-parse --is-inside-work-tree", | ||
| 81 | "rev-parse --show-toplevel", | ||
| 82 | "rev-parse --git-common-dir", | ||
| 83 | ], | ||
| 84 | ); | ||
| 85 | }).pipe(Effect.provide(layer)); | ||
| 86 | }); | ||
| 87 | }); | ||
apps/server/src/vcs/VcsDriverRegistry.ts created+155| ... | @@ -0,0 +1,155 @@ | ||
| 1 | import { Cache, Context, Duration, Effect, Exit, Layer } from "effect"; | ||
| 2 | |||
| 3 | import type { VcsDriverKind, VcsError, VcsRepositoryIdentity } from "@t3tools/contracts"; | ||
| 4 | import { VcsUnsupportedOperationError } from "@t3tools/contracts"; | ||
| 5 | import * as GitVcsDriver from "./GitVcsDriver.ts"; | ||
| 6 | import * as VcsProjectConfig from "./VcsProjectConfig.ts"; | ||
| 7 | import type { VcsDriverShape } from "./VcsDriver.ts"; | ||
| 8 | |||
| 9 | const DETECTION_CACHE_CAPACITY = 2_048; | ||
| 10 | const DETECTION_CACHE_TTL = Duration.seconds(2); | ||
| 11 | |||
| 12 | export interface VcsDriverResolveInput { | ||
| 13 | readonly cwd: string; | ||
| 14 | readonly requestedKind?: VcsDriverKind | "auto"; | ||
| 15 | } | ||
| 16 | |||
| 17 | export interface VcsDriverHandle { | ||
| 18 | readonly kind: VcsDriverKind; | ||
| 19 | readonly repository: VcsRepositoryIdentity; | ||
| 20 | readonly driver: VcsDriverShape; | ||
| 21 | } | ||
| 22 | |||
| 23 | export interface VcsDriverRegistryShape { | ||
| 24 | readonly get: (kind: VcsDriverKind) => Effect.Effect<VcsDriverShape, VcsError>; | ||
| 25 | readonly detect: ( | ||
| 26 | input: VcsDriverResolveInput, | ||
| 27 | ) => Effect.Effect<VcsDriverHandle | null, VcsError>; | ||
| 28 | readonly resolve: (input: VcsDriverResolveInput) => Effect.Effect<VcsDriverHandle, VcsError>; | ||
| 29 | } | ||
| 30 | |||
| 31 | export class VcsDriverRegistry extends Context.Service<VcsDriverRegistry, VcsDriverRegistryShape>()( | ||
| 32 | "t3/vcs/VcsDriverRegistry", | ||
| 33 | ) {} | ||
| 34 | |||
| 35 | const unsupported = (operation: string, kind: VcsDriverKind, detail: string) => | ||
| 36 | new VcsUnsupportedOperationError({ | ||
| 37 | operation, | ||
| 38 | kind, | ||
| 39 | detail, | ||
| 40 | }); | ||
| 41 | |||
| 42 | function detectionCacheKey(input: { | ||
| 43 | readonly cwd: string; | ||
| 44 | readonly requestedKind: VcsDriverKind | "auto"; | ||
| 45 | }): string { | ||
| 46 | return `${input.requestedKind}\0${input.cwd}`; | ||
| 47 | } | ||
| 48 | |||
| 49 | function parseDetectionCacheKey(key: string): { | ||
| 50 | readonly cwd: string; | ||
| 51 | readonly requestedKind: VcsDriverKind | "auto"; | ||
| 52 | } { | ||
| 53 | const separatorIndex = key.indexOf("\0"); | ||
| 54 | if (separatorIndex === -1) { | ||
| 55 | return { | ||
| 56 | cwd: key, | ||
| 57 | requestedKind: "auto", | ||
| 58 | }; | ||
| 59 | } | ||
| 60 | return { | ||
| 61 | requestedKind: key.slice(0, separatorIndex) as VcsDriverKind | "auto", | ||
| 62 | cwd: key.slice(separatorIndex + 1), | ||
| 63 | }; | ||
| 64 | } | ||
| 65 | |||
| 66 | export const make = Effect.fn("makeVcsDriverRegistry")(function* () { | ||
| 67 | const projectConfig = yield* VcsProjectConfig.VcsProjectConfig; | ||
| 68 | const git = yield* GitVcsDriver.makeVcsDriverShape(); | ||
| 69 | const drivers: Partial<Record<VcsDriverKind, VcsDriverShape>> = { | ||
| 70 | git, | ||
| 71 | }; | ||
| 72 | |||
| 73 | const get: VcsDriverRegistryShape["get"] = (kind) => { | ||
| 74 | const driver = drivers[kind]; | ||
| 75 | if (!driver) { | ||
| 76 | return Effect.fail( | ||
| 77 | unsupported("VcsDriverRegistry.get", kind, `No ${kind} VCS driver is registered.`), | ||
| 78 | ); | ||
| 79 | } | ||
| 80 | return Effect.succeed(driver); | ||
| 81 | }; | ||
| 82 | |||
| 83 | const detectWithDriver = Effect.fn("VcsDriverRegistry.detectWithDriver")(function* ( | ||
| 84 | kind: VcsDriverKind, | ||
| 85 | driver: VcsDriverShape, | ||
| 86 | cwd: string, | ||
| 87 | ) { | ||
| 88 | const repository = yield* driver.detectRepository(cwd); | ||
| 89 | if (!repository) { | ||
| 90 | return null; | ||
| 91 | } | ||
| 92 | return { | ||
| 93 | kind, | ||
| 94 | repository, | ||
| 95 | driver, | ||
| 96 | } satisfies VcsDriverHandle; | ||
| 97 | }); | ||
| 98 | |||
| 99 | const detectResolvedKind = Effect.fn("VcsDriverRegistry.detectResolvedKind")(function* (input: { | ||
| 100 | readonly cwd: string; | ||
| 101 | readonly requestedKind: VcsDriverKind | "auto"; | ||
| 102 | }) { | ||
| 103 | const requestedKind = input.requestedKind; | ||
| 104 | |||
| 105 | if (requestedKind !== "auto" && requestedKind !== "unknown") { | ||
| 106 | const driver = yield* get(requestedKind); | ||
| 107 | return yield* detectWithDriver(requestedKind, driver, input.cwd); | ||
| 108 | } | ||
| 109 | |||
| 110 | return yield* detectWithDriver("git", git, input.cwd); | ||
| 111 | }); | ||
| 112 | |||
| 113 | const detectionCache = yield* Cache.makeWith<string, VcsDriverHandle | null, VcsError>( | ||
| 114 | (key) => detectResolvedKind(parseDetectionCacheKey(key)), | ||
| 115 | { | ||
| 116 | capacity: DETECTION_CACHE_CAPACITY, | ||
| 117 | timeToLive: (exit) => (Exit.isSuccess(exit) ? DETECTION_CACHE_TTL : Duration.zero), | ||
| 118 | }, | ||
| 119 | ); | ||
| 120 | |||
| 121 | const detect: VcsDriverRegistryShape["detect"] = Effect.fn("VcsDriverRegistry.detect")( | ||
| 122 | function* (input) { | ||
| 123 | const requestedKind = yield* projectConfig.resolveKind(input); | ||
| 124 | return yield* Cache.get(detectionCache, detectionCacheKey({ cwd: input.cwd, requestedKind })); | ||
| 125 | }, | ||
| 126 | ); | ||
| 127 | |||
| 128 | const resolve: VcsDriverRegistryShape["resolve"] = Effect.fn("VcsDriverRegistry.resolve")( | ||
| 129 | function* (input) { | ||
| 130 | const detected = yield* detect(input); | ||
| 131 | if (detected) { | ||
| 132 | return detected; | ||
| 133 | } | ||
| 134 | |||
| 135 | const requestedKind = input.requestedKind ?? "auto"; | ||
| 136 | return yield* unsupported( | ||
| 137 | "VcsDriverRegistry.resolve", | ||
| 138 | requestedKind === "auto" ? "unknown" : requestedKind, | ||
| 139 | requestedKind === "auto" | ||
| 140 | ? `No supported VCS repository was detected at ${input.cwd}.` | ||
| 141 | : `No ${requestedKind} repository was detected at ${input.cwd}.`, | ||
| 142 | ); | ||
| 143 | }, | ||
| 144 | ); | ||
| 145 | |||
| 146 | return VcsDriverRegistry.of({ | ||
| 147 | get, | ||
| 148 | detect, | ||
| 149 | resolve, | ||
| 150 | }); | ||
| 151 | }); | ||
| 152 | |||
| 153 | export const layer = Layer.effect(VcsDriverRegistry, make()).pipe( | ||
| 154 | Layer.provide(VcsProjectConfig.layer), | ||
| 155 | ); | ||
apps/server/src/vcs/VcsProcess.ts created+259| ... | @@ -0,0 +1,259 @@ | ||
| 1 | import { Duration, Context, Effect, Layer, Option, PlatformError, Sink, Stream } from "effect"; | ||
| 2 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | VcsOutputDecodeError, | ||
| 6 | type VcsError, | ||
| 7 | VcsProcessExitError, | ||
| 8 | VcsProcessSpawnError, | ||
| 9 | VcsProcessTimeoutError, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | |||
| 12 | export interface VcsProcessInput { | ||
| 13 | readonly operation: string; | ||
| 14 | readonly command: string; | ||
| 15 | readonly args: ReadonlyArray<string>; | ||
| 16 | readonly cwd: string; | ||
| 17 | readonly stdin?: string; | ||
| 18 | readonly env?: NodeJS.ProcessEnv; | ||
| 19 | readonly allowNonZeroExit?: boolean; | ||
| 20 | readonly timeoutMs?: number; | ||
| 21 | readonly maxOutputBytes?: number; | ||
| 22 | readonly truncateOutputAtMaxBytes?: boolean; | ||
| 23 | } | ||
| 24 | |||
| 25 | export interface VcsProcessOutput { | ||
| 26 | readonly exitCode: ChildProcessSpawner.ExitCode; | ||
| 27 | readonly stdout: string; | ||
| 28 | readonly stderr: string; | ||
| 29 | readonly stdoutTruncated: boolean; | ||
| 30 | readonly stderrTruncated: boolean; | ||
| 31 | } | ||
| 32 | |||
| 33 | export interface VcsProcessCollectedText { | ||
| 34 | readonly text: string; | ||
| 35 | readonly truncated: boolean; | ||
| 36 | } | ||
| 37 | |||
| 38 | export interface VcsProcessHandle { | ||
| 39 | readonly pid: ChildProcessSpawner.ProcessId; | ||
| 40 | readonly stdin: Sink.Sink<void, Uint8Array, never, VcsError>; | ||
| 41 | readonly stdout: Stream.Stream<Uint8Array, VcsError>; | ||
| 42 | readonly stderr: Stream.Stream<Uint8Array, VcsError>; | ||
| 43 | readonly exitCode: Effect.Effect<ChildProcessSpawner.ExitCode, VcsError>; | ||
| 44 | readonly writeStdin: (input: string) => Effect.Effect<void, VcsError>; | ||
| 45 | } | ||
| 46 | |||
| 47 | export interface VcsProcessShape { | ||
| 48 | readonly withProcess: <A, E, R>( | ||
| 49 | input: VcsProcessInput, | ||
| 50 | use: (handle: VcsProcessHandle) => Effect.Effect<A, E, R>, | ||
| 51 | ) => Effect.Effect<A, E | VcsError, R>; | ||
| 52 | readonly run: (input: VcsProcessInput) => Effect.Effect<VcsProcessOutput, VcsError>; | ||
| 53 | } | ||
| 54 | |||
| 55 | export class VcsProcess extends Context.Service<VcsProcess, VcsProcessShape>()( | ||
| 56 | "t3/vcs/VcsProcess", | ||
| 57 | ) {} | ||
| 58 | |||
| 59 | const DEFAULT_TIMEOUT_MS = 30_000; | ||
| 60 | const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; | ||
| 61 | const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; | ||
| 62 | |||
| 63 | function commandLabel(command: string, args: ReadonlyArray<string>): string { | ||
| 64 | return [command, ...args].join(" "); | ||
| 65 | } | ||
| 66 | |||
| 67 | function outputDecodeError( | ||
| 68 | input: VcsProcessInput, | ||
| 69 | detail: string, | ||
| 70 | cause: unknown, | ||
| 71 | ): VcsOutputDecodeError { | ||
| 72 | return new VcsOutputDecodeError({ | ||
| 73 | operation: input.operation, | ||
| 74 | command: commandLabel(input.command, input.args), | ||
| 75 | cwd: input.cwd, | ||
| 76 | detail, | ||
| 77 | cause, | ||
| 78 | }); | ||
| 79 | } | ||
| 80 | |||
| 81 | export const collectText = Effect.fn("VcsProcess.collectText")(function* (input: { | ||
| 82 | readonly operation: string; | ||
| 83 | readonly command: string; | ||
| 84 | readonly cwd: string; | ||
| 85 | readonly stream: Stream.Stream<Uint8Array, VcsError>; | ||
| 86 | readonly maxOutputBytes?: number; | ||
| 87 | readonly truncateOutputAtMaxBytes?: boolean; | ||
| 88 | }) { | ||
| 89 | const decoder = new TextDecoder(); | ||
| 90 | let text = ""; | ||
| 91 | let bytes = 0; | ||
| 92 | let truncated = false; | ||
| 93 | const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; | ||
| 94 | const truncateOutputAtMaxBytes = input.truncateOutputAtMaxBytes ?? false; | ||
| 95 | |||
| 96 | yield* Stream.runForEach(input.stream, (chunk) => | ||
| 97 | Effect.sync(() => { | ||
| 98 | if (truncated) return; | ||
| 99 | |||
| 100 | const remainingBytes = maxOutputBytes - bytes; | ||
| 101 | if (remainingBytes <= 0) { | ||
| 102 | truncated = true; | ||
| 103 | if (truncateOutputAtMaxBytes) { | ||
| 104 | text += OUTPUT_TRUNCATED_MARKER; | ||
| 105 | } | ||
| 106 | return; | ||
| 107 | } | ||
| 108 | |||
| 109 | const nextChunk = chunk.byteLength > remainingBytes ? chunk.slice(0, remainingBytes) : chunk; | ||
| 110 | text += decoder.decode(nextChunk, { stream: true }); | ||
| 111 | bytes += nextChunk.byteLength; | ||
| 112 | |||
| 113 | if (chunk.byteLength > remainingBytes) { | ||
| 114 | truncated = true; | ||
| 115 | if (truncateOutputAtMaxBytes) { | ||
| 116 | text += OUTPUT_TRUNCATED_MARKER; | ||
| 117 | } | ||
| 118 | } | ||
| 119 | }), | ||
| 120 | ); | ||
| 121 | |||
| 122 | if (!truncated) { | ||
| 123 | text += decoder.decode(); | ||
| 124 | } | ||
| 125 | |||
| 126 | return { text, truncated } satisfies VcsProcessCollectedText; | ||
| 127 | }); | ||
| 128 | |||
| 129 | export const make = Effect.fn("makeVcsProcess")(function* () { | ||
| 130 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | ||
| 131 | |||
| 132 | const spawn = Effect.fn("VcsProcess.spawn")(function* (input: VcsProcessInput) { | ||
| 133 | const label = commandLabel(input.command, input.args); | ||
| 134 | const child = yield* spawner | ||
| 135 | .spawn( | ||
| 136 | ChildProcess.make(input.command, [...input.args], { | ||
| 137 | cwd: input.cwd, | ||
| 138 | env: { | ||
| 139 | ...process.env, | ||
| 140 | ...input.env, | ||
| 141 | }, | ||
| 142 | }), | ||
| 143 | ) | ||
| 144 | .pipe( | ||
| 145 | Effect.mapError( | ||
| 146 | (cause) => | ||
| 147 | new VcsProcessSpawnError({ | ||
| 148 | operation: input.operation, | ||
| 149 | command: label, | ||
| 150 | cwd: input.cwd, | ||
| 151 | cause, | ||
| 152 | }), | ||
| 153 | ), | ||
| 154 | ); | ||
| 155 | yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore)); | ||
| 156 | |||
| 157 | const mapStreamError = (streamName: "stdout" | "stderr") => | ||
| 158 | Stream.mapError((cause: PlatformError.PlatformError) => | ||
| 159 | outputDecodeError(input, `failed to read process ${streamName}`, cause), | ||
| 160 | ); | ||
| 161 | const mapEffectError = (detail: string) => | ||
| 162 | Effect.mapError((cause: PlatformError.PlatformError) => | ||
| 163 | outputDecodeError(input, detail, cause), | ||
| 164 | ); | ||
| 165 | const writeStdin = (stdin: string) => | ||
| 166 | Stream.run(Stream.encodeText(Stream.make(stdin)), child.stdin).pipe( | ||
| 167 | mapEffectError("failed to write process stdin"), | ||
| 168 | ); | ||
| 169 | |||
| 170 | return { | ||
| 171 | pid: child.pid, | ||
| 172 | stdin: child.stdin.pipe( | ||
| 173 | Sink.mapError((cause) => outputDecodeError(input, "failed to write process stdin", cause)), | ||
| 174 | ), | ||
| 175 | stdout: child.stdout.pipe(mapStreamError("stdout")), | ||
| 176 | stderr: child.stderr.pipe(mapStreamError("stderr")), | ||
| 177 | exitCode: child.exitCode.pipe(mapEffectError("failed to read process exit code")), | ||
| 178 | writeStdin, | ||
| 179 | } satisfies VcsProcessHandle; | ||
| 180 | }); | ||
| 181 | |||
| 182 | const withProcess: VcsProcessShape["withProcess"] = (input, use) => | ||
| 183 | Effect.scoped(spawn(input).pipe(Effect.flatMap(use))); | ||
| 184 | |||
| 185 | const run = Effect.fn("VcsProcess.run")(function* (input: VcsProcessInput) { | ||
| 186 | const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; | ||
| 187 | const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; | ||
| 188 | const label = commandLabel(input.command, input.args); | ||
| 189 | |||
| 190 | const runProcess = Effect.gen(function* () { | ||
| 191 | const [stdout, stderr, exitCode] = yield* withProcess(input, (child) => | ||
| 192 | Effect.all( | ||
| 193 | [ | ||
| 194 | collectText({ | ||
| 195 | operation: input.operation, | ||
| 196 | command: label, | ||
| 197 | cwd: input.cwd, | ||
| 198 | stream: child.stdout, | ||
| 199 | maxOutputBytes, | ||
| 200 | truncateOutputAtMaxBytes: input.truncateOutputAtMaxBytes ?? false, | ||
| 201 | }), | ||
| 202 | collectText({ | ||
| 203 | operation: input.operation, | ||
| 204 | command: label, | ||
| 205 | cwd: input.cwd, | ||
| 206 | stream: child.stderr, | ||
| 207 | maxOutputBytes, | ||
| 208 | truncateOutputAtMaxBytes: input.truncateOutputAtMaxBytes ?? false, | ||
| 209 | }), | ||
| 210 | child.exitCode, | ||
| 211 | input.stdin === undefined ? Effect.void : child.writeStdin(input.stdin), | ||
| 212 | ], | ||
| 213 | { concurrency: "unbounded" }, | ||
| 214 | ), | ||
| 215 | ).pipe(Effect.map(([stdout, stderr, exitCode]) => [stdout, stderr, exitCode] as const)); | ||
| 216 | |||
| 217 | if (!input.allowNonZeroExit && exitCode !== 0) { | ||
| 218 | return yield* new VcsProcessExitError({ | ||
| 219 | operation: input.operation, | ||
| 220 | command: label, | ||
| 221 | cwd: input.cwd, | ||
| 222 | exitCode, | ||
| 223 | detail: stderr.text.trim() || `${label} exited with code ${exitCode}.`, | ||
| 224 | }); | ||
| 225 | } | ||
| 226 | |||
| 227 | return { | ||
| 228 | exitCode, | ||
| 229 | stdout: stdout.text, | ||
| 230 | stderr: stderr.text, | ||
| 231 | stdoutTruncated: stdout.truncated, | ||
| 232 | stderrTruncated: stderr.truncated, | ||
| 233 | } satisfies VcsProcessOutput; | ||
| 234 | }); | ||
| 235 | |||
| 236 | return yield* runProcess.pipe( | ||
| 237 | Effect.scoped, | ||
| 238 | Effect.timeoutOption(Duration.millis(timeoutMs)), | ||
| 239 | Effect.flatMap((result) => | ||
| 240 | Option.match(result, { | ||
| 241 | onSome: Effect.succeed, | ||
| 242 | onNone: () => | ||
| 243 | Effect.fail( | ||
| 244 | new VcsProcessTimeoutError({ | ||
| 245 | operation: input.operation, | ||
| 246 | command: label, | ||
| 247 | cwd: input.cwd, | ||
| 248 | timeoutMs, | ||
| 249 | }), | ||
| 250 | ), | ||
| 251 | }), | ||
| 252 | ), | ||
| 253 | ); | ||
| 254 | }); | ||
| 255 | |||
| 256 | return VcsProcess.of({ withProcess, run }); | ||
| 257 | }); | ||
| 258 | |||
| 259 | export const layer = Layer.effect(VcsProcess, make()); | ||
apps/server/src/vcs/VcsProjectConfig.test.ts created+67| ... | @@ -0,0 +1,67 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 3 | import { Effect, FileSystem, Layer, Path } from "effect"; | ||
| 4 | import { describe } from "vitest"; | ||
| 5 | |||
| 6 | import { VcsProjectConfig, layer as VcsProjectConfigLayer } from "./VcsProjectConfig.ts"; | ||
| 7 | |||
| 8 | const TestLayer = VcsProjectConfigLayer.pipe( | ||
| 9 | Layer.provide(NodeServices.layer), | ||
| 10 | Layer.provideMerge(NodeServices.layer), | ||
| 11 | ); | ||
| 12 | |||
| 13 | describe("VcsProjectConfig", () => { | ||
| 14 | it.layer(TestLayer)("uses an explicit requested VCS kind before config", (it) => { | ||
| 15 | it.effect("returns the requested kind", () => | ||
| 16 | Effect.gen(function* () { | ||
| 17 | const config = yield* VcsProjectConfig; | ||
| 18 | const kind = yield* config.resolveKind({ | ||
| 19 | cwd: "/repo", | ||
| 20 | requestedKind: "jj", | ||
| 21 | }); | ||
| 22 | |||
| 23 | assert.equal(kind, "jj"); | ||
| 24 | }), | ||
| 25 | ); | ||
| 26 | }); | ||
| 27 | |||
| 28 | it.layer(TestLayer)("discovers .t3code/vcs.json from nested workspaces", (it) => { | ||
| 29 | it.effect("returns the configured kind", () => | ||
| 30 | Effect.gen(function* () { | ||
| 31 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 32 | const path = yield* Path.Path; | ||
| 33 | const root = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 34 | prefix: "t3-vcs-config-test-", | ||
| 35 | }); | ||
| 36 | const configDir = path.join(root, ".t3code"); | ||
| 37 | const nested = path.join(root, "packages", "app"); | ||
| 38 | yield* fileSystem.makeDirectory(configDir, { recursive: true }); | ||
| 39 | yield* fileSystem.makeDirectory(nested, { recursive: true }); | ||
| 40 | yield* fileSystem.writeFileString( | ||
| 41 | path.join(configDir, "vcs.json"), | ||
| 42 | JSON.stringify({ vcs: { kind: "jj" } }), | ||
| 43 | ); | ||
| 44 | |||
| 45 | const config = yield* VcsProjectConfig; | ||
| 46 | const kind = yield* config.resolveKind({ cwd: nested }); | ||
| 47 | |||
| 48 | assert.equal(kind, "jj"); | ||
| 49 | }), | ||
| 50 | ); | ||
| 51 | }); | ||
| 52 | |||
| 53 | it.layer(TestLayer)("falls back to auto when no config exists", (it) => { | ||
| 54 | it.effect("returns auto", () => | ||
| 55 | Effect.gen(function* () { | ||
| 56 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 57 | const root = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 58 | prefix: "t3-vcs-config-test-", | ||
| 59 | }); | ||
| 60 | const config = yield* VcsProjectConfig; | ||
| 61 | const kind = yield* config.resolveKind({ cwd: root }); | ||
| 62 | |||
| 63 | assert.equal(kind, "auto"); | ||
| 64 | }), | ||
| 65 | ); | ||
| 66 | }); | ||
| 67 | }); | ||
apps/server/src/vcs/VcsProjectConfig.ts created+117| ... | @@ -0,0 +1,117 @@ | ||
| 1 | import { Context, Effect, FileSystem, Layer, Path, Schema } from "effect"; | ||
| 2 | |||
| 3 | import { VcsDriverKind, type VcsDriverKind as VcsDriverKindType } from "@t3tools/contracts"; | ||
| 4 | |||
| 5 | const ProjectVcsConfig = Schema.Struct({ | ||
| 6 | vcs: Schema.optional( | ||
| 7 | Schema.Struct({ | ||
| 8 | kind: Schema.optional(VcsDriverKind), | ||
| 9 | }), | ||
| 10 | ), | ||
| 11 | vcsKind: Schema.optional(VcsDriverKind), | ||
| 12 | }); | ||
| 13 | |||
| 14 | interface ProjectVcsConfigFile { | ||
| 15 | readonly vcs?: | ||
| 16 | | { | ||
| 17 | readonly kind?: VcsDriverKindType | undefined; | ||
| 18 | } | ||
| 19 | | undefined; | ||
| 20 | readonly vcsKind?: VcsDriverKindType | undefined; | ||
| 21 | } | ||
| 22 | |||
| 23 | export interface VcsProjectConfigResolveInput { | ||
| 24 | readonly cwd: string; | ||
| 25 | readonly requestedKind?: VcsDriverKindType | "auto"; | ||
| 26 | } | ||
| 27 | |||
| 28 | export interface VcsProjectConfigShape { | ||
| 29 | readonly resolveKind: ( | ||
| 30 | input: VcsProjectConfigResolveInput, | ||
| 31 | ) => Effect.Effect<VcsDriverKindType | "auto">; | ||
| 32 | } | ||
| 33 | |||
| 34 | export class VcsProjectConfig extends Context.Service<VcsProjectConfig, VcsProjectConfigShape>()( | ||
| 35 | "t3/vcs/VcsProjectConfig", | ||
| 36 | ) {} | ||
| 37 | |||
| 38 | function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto" { | ||
| 39 | return config.vcs?.kind ?? config.vcsKind ?? "auto"; | ||
| 40 | } | ||
| 41 | |||
| 42 | function parseConfig(raw: string): ProjectVcsConfigFile | null { | ||
| 43 | try { | ||
| 44 | const parsed = JSON.parse(raw) as unknown; | ||
| 45 | return Schema.is(ProjectVcsConfig)(parsed) ? parsed : null; | ||
| 46 | } catch { | ||
| 47 | return null; | ||
| 48 | } | ||
| 49 | } | ||
| 50 | |||
| 51 | export const make = Effect.fn("makeVcsProjectConfig")(function* () { | ||
| 52 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 53 | const path = yield* Path.Path; | ||
| 54 | |||
| 55 | const findConfigPath = Effect.fn("VcsProjectConfig.findConfigPath")(function* (cwd: string) { | ||
| 56 | let current = cwd; | ||
| 57 | while (true) { | ||
| 58 | const candidate = path.join(current, ".t3code", "vcs.json"); | ||
| 59 | if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { | ||
| 60 | return candidate; | ||
| 61 | } | ||
| 62 | |||
| 63 | const parent = path.dirname(current); | ||
| 64 | if (parent === current) { | ||
| 65 | return null; | ||
| 66 | } | ||
| 67 | current = parent; | ||
| 68 | } | ||
| 69 | }); | ||
| 70 | |||
| 71 | const readConfiguredKind = Effect.fn("VcsProjectConfig.readConfiguredKind")(function* ( | ||
| 72 | configPath: string, | ||
| 73 | ) { | ||
| 74 | const raw = yield* fileSystem.readFileString(configPath).pipe( | ||
| 75 | Effect.catch((error) => | ||
| 76 | Effect.logWarning("failed to read VCS project config", { | ||
| 77 | configPath, | ||
| 78 | error, | ||
| 79 | }).pipe(Effect.as(null)), | ||
| 80 | ), | ||
| 81 | ); | ||
| 82 | if (raw === null) { | ||
| 83 | return "auto" as const; | ||
| 84 | } | ||
| 85 | |||
| 86 | const parsed = parseConfig(raw); | ||
| 87 | if (parsed === null) { | ||
| 88 | yield* Effect.logWarning("invalid VCS project config", { | ||
| 89 | configPath, | ||
| 90 | }); | ||
| 91 | return "auto" as const; | ||
| 92 | } | ||
| 93 | |||
| 94 | return configuredKind(parsed); | ||
| 95 | }); | ||
| 96 | |||
| 97 | const resolveKind: VcsProjectConfigShape["resolveKind"] = Effect.fn( | ||
| 98 | "VcsProjectConfig.resolveKind", | ||
| 99 | )(function* (input) { | ||
| 100 | if (input.requestedKind !== undefined && input.requestedKind !== "auto") { | ||
| 101 | return input.requestedKind; | ||
| 102 | } | ||
| 103 | |||
| 104 | const configPath = yield* findConfigPath(input.cwd); | ||
| 105 | if (configPath === null) { | ||
| 106 | return "auto"; | ||
| 107 | } | ||
| 108 | |||
| 109 | return yield* readConfiguredKind(configPath); | ||
| 110 | }); | ||
| 111 | |||
| 112 | return VcsProjectConfig.of({ | ||
| 113 | resolveKind, | ||
| 114 | }); | ||
| 115 | }); | ||
| 116 | |||
| 117 | export const layer = Layer.effect(VcsProjectConfig, make()); | ||
apps/server/src/vcs/VcsProvisioningService.test.ts created+94| ... | @@ -0,0 +1,94 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import { DateTime, Effect, Layer, Option } from "effect"; | ||
| 3 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 4 | |||
| 5 | import type { VcsDriverShape } from "./VcsDriver.ts"; | ||
| 6 | import { VcsDriverRegistry } from "./VcsDriverRegistry.ts"; | ||
| 7 | import { VcsProvisioningService, layer } from "./VcsProvisioningService.ts"; | ||
| 8 | |||
| 9 | const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); | ||
| 10 | |||
| 11 | function makeDriver(calls: string[]): VcsDriverShape { | ||
| 12 | return { | ||
| 13 | capabilities: { | ||
| 14 | kind: "git", | ||
| 15 | supportsWorktrees: true, | ||
| 16 | supportsBookmarks: false, | ||
| 17 | supportsAtomicSnapshot: false, | ||
| 18 | supportsPushDefaultRemote: true, | ||
| 19 | ignoreClassifier: "native", | ||
| 20 | }, | ||
| 21 | execute: () => | ||
| 22 | Effect.succeed({ | ||
| 23 | exitCode: ChildProcessSpawner.ExitCode(0), | ||
| 24 | stdout: "", | ||
| 25 | stderr: "", | ||
| 26 | stdoutTruncated: false, | ||
| 27 | stderrTruncated: false, | ||
| 28 | }), | ||
| 29 | detectRepository: () => Effect.succeed(null), | ||
| 30 | isInsideWorkTree: () => Effect.succeed(false), | ||
| 31 | listWorkspaceFiles: () => | ||
| 32 | Effect.succeed({ | ||
| 33 | paths: [], | ||
| 34 | truncated: false, | ||
| 35 | freshness: { | ||
| 36 | source: "live-local", | ||
| 37 | observedAt: TEST_EPOCH, | ||
| 38 | expiresAt: Option.none(), | ||
| 39 | }, | ||
| 40 | }), | ||
| 41 | listRemotes: () => | ||
| 42 | Effect.succeed({ | ||
| 43 | remotes: [], | ||
| 44 | freshness: { | ||
| 45 | source: "live-local", | ||
| 46 | observedAt: TEST_EPOCH, | ||
| 47 | expiresAt: Option.none(), | ||
| 48 | }, | ||
| 49 | }), | ||
| 50 | filterIgnoredPaths: (_cwd, relativePaths) => Effect.succeed(relativePaths), | ||
| 51 | initRepository: (input) => | ||
| 52 | Effect.sync(() => { | ||
| 53 | calls.push(`${input.kind ?? "default"}:${input.cwd}`); | ||
| 54 | }), | ||
| 55 | }; | ||
| 56 | } | ||
| 57 | |||
| 58 | it.effect("routes repository initialization through an explicit VCS driver kind", () => { | ||
| 59 | const calls: string[] = []; | ||
| 60 | const driver = makeDriver(calls); | ||
| 61 | const testLayer = layer.pipe( | ||
| 62 | Layer.provide( | ||
| 63 | Layer.mock(VcsDriverRegistry)({ | ||
| 64 | get: (kind) => (kind === "git" ? Effect.succeed(driver) : Effect.die("unexpected kind")), | ||
| 65 | }), | ||
| 66 | ), | ||
| 67 | ); | ||
| 68 | |||
| 69 | return Effect.gen(function* () { | ||
| 70 | const provisioning = yield* VcsProvisioningService; | ||
| 71 | yield* provisioning.initRepository({ cwd: "/repo", kind: "git" }); | ||
| 72 | |||
| 73 | assert.deepStrictEqual(calls, ["git:/repo"]); | ||
| 74 | }).pipe(Effect.provide(testLayer)); | ||
| 75 | }); | ||
| 76 | |||
| 77 | it.effect("defaults repository initialization to Git until callers choose a VCS kind", () => { | ||
| 78 | const calls: string[] = []; | ||
| 79 | const driver = makeDriver(calls); | ||
| 80 | const testLayer = layer.pipe( | ||
| 81 | Layer.provide( | ||
| 82 | Layer.mock(VcsDriverRegistry)({ | ||
| 83 | get: (kind) => (kind === "git" ? Effect.succeed(driver) : Effect.die("unexpected kind")), | ||
| 84 | }), | ||
| 85 | ), | ||
| 86 | ); | ||
| 87 | |||
| 88 | return Effect.gen(function* () { | ||
| 89 | const provisioning = yield* VcsProvisioningService; | ||
| 90 | yield* provisioning.initRepository({ cwd: "/repo" }); | ||
| 91 | |||
| 92 | assert.deepStrictEqual(calls, ["default:/repo"]); | ||
| 93 | }).pipe(Effect.provide(testLayer)); | ||
| 94 | }); | ||
apps/server/src/vcs/VcsProvisioningService.ts created+54| ... | @@ -0,0 +1,54 @@ | ||
| 1 | import { Context, Effect, Layer } from "effect"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | type VcsDriverKind, | ||
| 5 | type VcsError, | ||
| 6 | type VcsInitInput, | ||
| 7 | VcsUnsupportedOperationError, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import { VcsDriverRegistry } from "./VcsDriverRegistry.ts"; | ||
| 10 | |||
| 11 | export interface VcsProvisioningServiceShape { | ||
| 12 | readonly initRepository: (input: VcsInitInput) => Effect.Effect<void, VcsError>; | ||
| 13 | } | ||
| 14 | |||
| 15 | export class VcsProvisioningService extends Context.Service< | ||
| 16 | VcsProvisioningService, | ||
| 17 | VcsProvisioningServiceShape | ||
| 18 | >()("t3/vcs/VcsProvisioningService") {} | ||
| 19 | |||
| 20 | function resolveRequestedKind( | ||
| 21 | kind: VcsDriverKind | undefined, | ||
| 22 | ): Effect.Effect<VcsDriverKind, VcsUnsupportedOperationError> { | ||
| 23 | if (kind === undefined) { | ||
| 24 | return Effect.succeed("git"); | ||
| 25 | } | ||
| 26 | if (kind === "unknown") { | ||
| 27 | return Effect.fail( | ||
| 28 | new VcsUnsupportedOperationError({ | ||
| 29 | operation: "VcsProvisioningService.resolveRequestedKind", | ||
| 30 | kind, | ||
| 31 | detail: "A concrete VCS driver kind is required for repository provisioning.", | ||
| 32 | }), | ||
| 33 | ); | ||
| 34 | } | ||
| 35 | return Effect.succeed(kind); | ||
| 36 | } | ||
| 37 | |||
| 38 | export const make = Effect.fn("makeVcsProvisioningService")(function* () { | ||
| 39 | const registry = yield* VcsDriverRegistry; | ||
| 40 | |||
| 41 | const initRepository: VcsProvisioningServiceShape["initRepository"] = Effect.fn( | ||
| 42 | "VcsProvisioningService.initRepository", | ||
| 43 | )(function* (input) { | ||
| 44 | const kind = yield* resolveRequestedKind(input.kind); | ||
| 45 | const driver = yield* registry.get(kind); | ||
| 46 | return yield* driver.initRepository(input); | ||
| 47 | }); | ||
| 48 | |||
| 49 | return VcsProvisioningService.of({ | ||
| 50 | initRepository, | ||
| 51 | }); | ||
| 52 | }); | ||
| 53 | |||
| 54 | export const layer = Layer.effect(VcsProvisioningService, make()); | ||
apps/server/src/vcs/VcsStatusBroadcaster.test.ts created+364| ... | @@ -0,0 +1,364 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 3 | import { Deferred, Effect, Exit, FileSystem, Layer, Option, Path, Scope, Stream } from "effect"; | ||
| 4 | import type { | ||
| 5 | VcsStatusLocalResult, | ||
| 6 | VcsStatusRemoteResult, | ||
| 7 | VcsStatusResult, | ||
| 8 | VcsStatusStreamEvent, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import { describe } from "vitest"; | ||
| 11 | |||
| 12 | import { | ||
| 13 | VcsStatusBroadcaster, | ||
| 14 | layer as VcsStatusBroadcasterLayer, | ||
| 15 | } from "./VcsStatusBroadcaster.ts"; | ||
| 16 | import { GitWorkflowService, type GitWorkflowServiceShape } from "../git/GitWorkflowService.ts"; | ||
| 17 | |||
| 18 | const baseLocalStatus: VcsStatusLocalResult = { | ||
| 19 | isRepo: true, | ||
| 20 | sourceControlProvider: { | ||
| 21 | kind: "github", | ||
| 22 | name: "GitHub", | ||
| 23 | baseUrl: "https://github.com", | ||
| 24 | }, | ||
| 25 | hasPrimaryRemote: true, | ||
| 26 | isDefaultRef: false, | ||
| 27 | refName: "feature/status-broadcast", | ||
| 28 | hasWorkingTreeChanges: false, | ||
| 29 | workingTree: { files: [], insertions: 0, deletions: 0 }, | ||
| 30 | }; | ||
| 31 | |||
| 32 | const baseRemoteStatus: VcsStatusRemoteResult = { | ||
| 33 | hasUpstream: true, | ||
| 34 | aheadCount: 0, | ||
| 35 | behindCount: 0, | ||
| 36 | pr: null, | ||
| 37 | }; | ||
| 38 | |||
| 39 | const baseStatus: VcsStatusResult = { | ||
| 40 | ...baseLocalStatus, | ||
| 41 | ...baseRemoteStatus, | ||
| 42 | }; | ||
| 43 | |||
| 44 | function makeTestLayer(state: { | ||
| 45 | currentLocalStatus: VcsStatusLocalResult; | ||
| 46 | currentRemoteStatus: VcsStatusRemoteResult | null; | ||
| 47 | localStatusCalls: number; | ||
| 48 | remoteStatusCalls: number; | ||
| 49 | localInvalidationCalls: number; | ||
| 50 | remoteInvalidationCalls: number; | ||
| 51 | }) { | ||
| 52 | return VcsStatusBroadcasterLayer.pipe( | ||
| 53 | Layer.provide( | ||
| 54 | Layer.mock(GitWorkflowService)({ | ||
| 55 | localStatus: () => | ||
| 56 | Effect.sync(() => { | ||
| 57 | state.localStatusCalls += 1; | ||
| 58 | return state.currentLocalStatus; | ||
| 59 | }), | ||
| 60 | remoteStatus: () => | ||
| 61 | Effect.sync(() => { | ||
| 62 | state.remoteStatusCalls += 1; | ||
| 63 | return state.currentRemoteStatus; | ||
| 64 | }), | ||
| 65 | invalidateLocalStatus: () => | ||
| 66 | Effect.sync(() => { | ||
| 67 | state.localInvalidationCalls += 1; | ||
| 68 | }), | ||
| 69 | invalidateRemoteStatus: () => | ||
| 70 | Effect.sync(() => { | ||
| 71 | state.remoteInvalidationCalls += 1; | ||
| 72 | }), | ||
| 73 | }), | ||
| 74 | ), | ||
| 75 | ); | ||
| 76 | } | ||
| 77 | |||
| 78 | describe("VcsStatusBroadcaster", () => { | ||
| 79 | it.effect("reuses the cached VCS status across repeated reads", () => { | ||
| 80 | const state = { | ||
| 81 | currentLocalStatus: baseLocalStatus, | ||
| 82 | currentRemoteStatus: baseRemoteStatus, | ||
| 83 | localStatusCalls: 0, | ||
| 84 | remoteStatusCalls: 0, | ||
| 85 | localInvalidationCalls: 0, | ||
| 86 | remoteInvalidationCalls: 0, | ||
| 87 | }; | ||
| 88 | |||
| 89 | return Effect.gen(function* () { | ||
| 90 | const broadcaster = yield* VcsStatusBroadcaster; | ||
| 91 | |||
| 92 | const first = yield* broadcaster.getStatus({ cwd: "/repo" }); | ||
| 93 | const second = yield* broadcaster.getStatus({ cwd: "/repo" }); | ||
| 94 | |||
| 95 | assert.deepStrictEqual(first, baseStatus); | ||
| 96 | assert.deepStrictEqual(second, baseStatus); | ||
| 97 | assert.equal(state.localStatusCalls, 1); | ||
| 98 | assert.equal(state.remoteStatusCalls, 1); | ||
| 99 | assert.equal(state.localInvalidationCalls, 0); | ||
| 100 | assert.equal(state.remoteInvalidationCalls, 0); | ||
| 101 | }).pipe(Effect.provide(makeTestLayer(state))); | ||
| 102 | }); | ||
| 103 | |||
| 104 | it.effect("refreshes the cached snapshot after explicit invalidation", () => { | ||
| 105 | const state = { | ||
| 106 | currentLocalStatus: baseLocalStatus, | ||
| 107 | currentRemoteStatus: baseRemoteStatus, | ||
| 108 | localStatusCalls: 0, | ||
| 109 | remoteStatusCalls: 0, | ||
| 110 | localInvalidationCalls: 0, | ||
| 111 | remoteInvalidationCalls: 0, | ||
| 112 | }; | ||
| 113 | |||
| 114 | return Effect.gen(function* () { | ||
| 115 | const broadcaster = yield* VcsStatusBroadcaster; | ||
| 116 | const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); | ||
| 117 | |||
| 118 | state.currentLocalStatus = { | ||
| 119 | ...baseLocalStatus, | ||
| 120 | refName: "feature/updated-status", | ||
| 121 | }; | ||
| 122 | state.currentRemoteStatus = { | ||
| 123 | ...baseRemoteStatus, | ||
| 124 | aheadCount: 2, | ||
| 125 | }; | ||
| 126 | const refreshed = yield* broadcaster.refreshStatus("/repo"); | ||
| 127 | const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); | ||
| 128 | |||
| 129 | assert.deepStrictEqual(initial, baseStatus); | ||
| 130 | assert.deepStrictEqual(refreshed, { | ||
| 131 | ...state.currentLocalStatus, | ||
| 132 | ...state.currentRemoteStatus, | ||
| 133 | }); | ||
| 134 | assert.deepStrictEqual(cached, { | ||
| 135 | ...state.currentLocalStatus, | ||
| 136 | ...state.currentRemoteStatus, | ||
| 137 | }); | ||
| 138 | assert.equal(state.localStatusCalls, 2); | ||
| 139 | assert.equal(state.remoteStatusCalls, 2); | ||
| 140 | assert.equal(state.localInvalidationCalls, 1); | ||
| 141 | assert.equal(state.remoteInvalidationCalls, 1); | ||
| 142 | }).pipe(Effect.provide(makeTestLayer(state))); | ||
| 143 | }); | ||
| 144 | |||
| 145 | it.effect("refreshes only the cached local snapshot when requested", () => { | ||
| 146 | const state = { | ||
| 147 | currentLocalStatus: baseLocalStatus, | ||
| 148 | currentRemoteStatus: baseRemoteStatus, | ||
| 149 | localStatusCalls: 0, | ||
| 150 | remoteStatusCalls: 0, | ||
| 151 | localInvalidationCalls: 0, | ||
| 152 | remoteInvalidationCalls: 0, | ||
| 153 | }; | ||
| 154 | |||
| 155 | return Effect.gen(function* () { | ||
| 156 | const broadcaster = yield* VcsStatusBroadcaster; | ||
| 157 | const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); | ||
| 158 | |||
| 159 | state.currentLocalStatus = { | ||
| 160 | ...baseLocalStatus, | ||
| 161 | refName: "feature/local-only-refresh", | ||
| 162 | hasWorkingTreeChanges: true, | ||
| 163 | }; | ||
| 164 | |||
| 165 | const refreshedLocal = yield* broadcaster.refreshLocalStatus("/repo"); | ||
| 166 | const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); | ||
| 167 | |||
| 168 | assert.deepStrictEqual(initial, baseStatus); | ||
| 169 | assert.deepStrictEqual(refreshedLocal, state.currentLocalStatus); | ||
| 170 | assert.deepStrictEqual(cached, { | ||
| 171 | ...state.currentLocalStatus, | ||
| 172 | ...baseRemoteStatus, | ||
| 173 | }); | ||
| 174 | assert.equal(state.localStatusCalls, 2); | ||
| 175 | assert.equal(state.remoteStatusCalls, 1); | ||
| 176 | assert.equal(state.localInvalidationCalls, 1); | ||
| 177 | assert.equal(state.remoteInvalidationCalls, 0); | ||
| 178 | }).pipe(Effect.provide(makeTestLayer(state))); | ||
| 179 | }); | ||
| 180 | |||
| 181 | it.effect("normalizes symlinked CWDs before cache lookup and workflow calls", () => { | ||
| 182 | const seenCwds: string[] = []; | ||
| 183 | const state = { | ||
| 184 | currentLocalStatus: baseLocalStatus, | ||
| 185 | currentRemoteStatus: baseRemoteStatus, | ||
| 186 | localStatusCalls: 0, | ||
| 187 | remoteStatusCalls: 0, | ||
| 188 | localInvalidationCalls: 0, | ||
| 189 | remoteInvalidationCalls: 0, | ||
| 190 | }; | ||
| 191 | const testLayer = VcsStatusBroadcasterLayer.pipe( | ||
| 192 | Layer.provide( | ||
| 193 | Layer.mock(GitWorkflowService)({ | ||
| 194 | localStatus: (input) => | ||
| 195 | Effect.sync(() => { | ||
| 196 | seenCwds.push(input.cwd); | ||
| 197 | state.localStatusCalls += 1; | ||
| 198 | return state.currentLocalStatus; | ||
| 199 | }), | ||
| 200 | remoteStatus: (input) => | ||
| 201 | Effect.sync(() => { | ||
| 202 | seenCwds.push(input.cwd); | ||
| 203 | state.remoteStatusCalls += 1; | ||
| 204 | return state.currentRemoteStatus; | ||
| 205 | }), | ||
| 206 | invalidateLocalStatus: () => | ||
| 207 | Effect.sync(() => { | ||
| 208 | state.localInvalidationCalls += 1; | ||
| 209 | }), | ||
| 210 | invalidateRemoteStatus: () => | ||
| 211 | Effect.sync(() => { | ||
| 212 | state.remoteInvalidationCalls += 1; | ||
| 213 | }), | ||
| 214 | } satisfies Partial<GitWorkflowServiceShape>), | ||
| 215 | ), | ||
| 216 | ); | ||
| 217 | |||
| 218 | return Effect.gen(function* () { | ||
| 219 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 220 | const path = yield* Path.Path; | ||
| 221 | const realDir = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 222 | prefix: "t3-vcs-status-real-", | ||
| 223 | }); | ||
| 224 | const linkParent = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 225 | prefix: "t3-vcs-status-link-", | ||
| 226 | }); | ||
| 227 | const linkDir = path.join(linkParent, "repo-link"); | ||
| 228 | yield* fileSystem.symlink(realDir, linkDir); | ||
| 229 | const realPath = yield* fileSystem.realPath(realDir); | ||
| 230 | |||
| 231 | const broadcaster = yield* VcsStatusBroadcaster; | ||
| 232 | yield* broadcaster.getStatus({ cwd: linkDir }); | ||
| 233 | yield* broadcaster.getStatus({ cwd: realDir }); | ||
| 234 | |||
| 235 | assert.deepStrictEqual(seenCwds, [realPath, realPath]); | ||
| 236 | assert.equal(state.localStatusCalls, 1); | ||
| 237 | assert.equal(state.remoteStatusCalls, 1); | ||
| 238 | }).pipe(Effect.provide(Layer.mergeAll(testLayer, NodeServices.layer))); | ||
| 239 | }); | ||
| 240 | |||
| 241 | it.effect("streams a local snapshot first and remote updates later", () => { | ||
| 242 | const state = { | ||
| 243 | currentLocalStatus: baseLocalStatus, | ||
| 244 | currentRemoteStatus: baseRemoteStatus, | ||
| 245 | localStatusCalls: 0, | ||
| 246 | remoteStatusCalls: 0, | ||
| 247 | localInvalidationCalls: 0, | ||
| 248 | remoteInvalidationCalls: 0, | ||
| 249 | }; | ||
| 250 | |||
| 251 | return Effect.gen(function* () { | ||
| 252 | const broadcaster = yield* VcsStatusBroadcaster; | ||
| 253 | const snapshotDeferred = yield* Deferred.make<VcsStatusStreamEvent>(); | ||
| 254 | const remoteUpdatedDeferred = yield* Deferred.make<VcsStatusStreamEvent>(); | ||
| 255 | yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => { | ||
| 256 | if (event._tag === "snapshot") { | ||
| 257 | return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); | ||
| 258 | } | ||
| 259 | if (event._tag === "remoteUpdated") { | ||
| 260 | return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore); | ||
| 261 | } | ||
| 262 | return Effect.void; | ||
| 263 | }).pipe(Effect.forkScoped); | ||
| 264 | |||
| 265 | const snapshot = yield* Deferred.await(snapshotDeferred); | ||
| 266 | yield* broadcaster.refreshStatus("/repo"); | ||
| 267 | const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred); | ||
| 268 | |||
| 269 | assert.deepStrictEqual(snapshot, { | ||
| 270 | _tag: "snapshot", | ||
| 271 | local: baseLocalStatus, | ||
| 272 | remote: null, | ||
| 273 | } satisfies VcsStatusStreamEvent); | ||
| 274 | assert.deepStrictEqual(remoteUpdated, { | ||
| 275 | _tag: "remoteUpdated", | ||
| 276 | remote: baseRemoteStatus, | ||
| 277 | } satisfies VcsStatusStreamEvent); | ||
| 278 | }).pipe(Effect.provide(makeTestLayer(state))); | ||
| 279 | }); | ||
| 280 | |||
| 281 | it.effect("stops the remote poller after the last stream subscriber disconnects", () => { | ||
| 282 | const state = { | ||
| 283 | currentLocalStatus: baseLocalStatus, | ||
| 284 | currentRemoteStatus: baseRemoteStatus, | ||
| 285 | localStatusCalls: 0, | ||
| 286 | remoteStatusCalls: 0, | ||
| 287 | localInvalidationCalls: 0, | ||
| 288 | remoteInvalidationCalls: 0, | ||
| 289 | }; | ||
| 290 | let remoteInterruptedDeferred: Deferred.Deferred<void, never> | null = null; | ||
| 291 | let remoteStartedDeferred: Deferred.Deferred<void, never> | null = null; | ||
| 292 | const testLayer = VcsStatusBroadcasterLayer.pipe( | ||
| 293 | Layer.provide( | ||
| 294 | Layer.mock(GitWorkflowService)({ | ||
| 295 | localStatus: () => | ||
| 296 | Effect.sync(() => { | ||
| 297 | state.localStatusCalls += 1; | ||
| 298 | return state.currentLocalStatus; | ||
| 299 | }), | ||
| 300 | remoteStatus: () => | ||
| 301 | Effect.sync(() => { | ||
| 302 | state.remoteStatusCalls += 1; | ||
| 303 | }).pipe( | ||
| 304 | Effect.andThen( | ||
| 305 | remoteStartedDeferred | ||
| 306 | ? Deferred.succeed(remoteStartedDeferred, undefined).pipe(Effect.ignore) | ||
| 307 | : Effect.void, | ||
| 308 | ), | ||
| 309 | Effect.andThen(Effect.never as Effect.Effect<VcsStatusRemoteResult | null, never>), | ||
| 310 | Effect.onInterrupt(() => | ||
| 311 | remoteInterruptedDeferred | ||
| 312 | ? Deferred.succeed(remoteInterruptedDeferred, undefined).pipe(Effect.ignore) | ||
| 313 | : Effect.void, | ||
| 314 | ), | ||
| 315 | ), | ||
| 316 | invalidateLocalStatus: () => | ||
| 317 | Effect.sync(() => { | ||
| 318 | state.localInvalidationCalls += 1; | ||
| 319 | }), | ||
| 320 | invalidateRemoteStatus: () => | ||
| 321 | Effect.sync(() => { | ||
| 322 | state.remoteInvalidationCalls += 1; | ||
| 323 | }), | ||
| 324 | } satisfies Partial<GitWorkflowServiceShape>), | ||
| 325 | ), | ||
| 326 | ); | ||
| 327 | |||
| 328 | return Effect.gen(function* () { | ||
| 329 | const remoteInterrupted = yield* Deferred.make<void>(); | ||
| 330 | const remoteStarted = yield* Deferred.make<void>(); | ||
| 331 | remoteInterruptedDeferred = remoteInterrupted; | ||
| 332 | remoteStartedDeferred = remoteStarted; | ||
| 333 | |||
| 334 | const broadcaster = yield* VcsStatusBroadcaster; | ||
| 335 | const firstSnapshot = yield* Deferred.make<VcsStatusStreamEvent>(); | ||
| 336 | const secondSnapshot = yield* Deferred.make<VcsStatusStreamEvent>(); | ||
| 337 | const firstScope = yield* Scope.make(); | ||
| 338 | const secondScope = yield* Scope.make(); | ||
| 339 | yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => | ||
| 340 | event._tag === "snapshot" | ||
| 341 | ? Deferred.succeed(firstSnapshot, event).pipe(Effect.ignore) | ||
| 342 | : Effect.void, | ||
| 343 | ).pipe(Effect.forkIn(firstScope)); | ||
| 344 | yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => | ||
| 345 | event._tag === "snapshot" | ||
| 346 | ? Deferred.succeed(secondSnapshot, event).pipe(Effect.ignore) | ||
| 347 | : Effect.void, | ||
| 348 | ).pipe(Effect.forkIn(secondScope)); | ||
| 349 | |||
| 350 | yield* Deferred.await(firstSnapshot); | ||
| 351 | yield* Deferred.await(secondSnapshot); | ||
| 352 | yield* Deferred.await(remoteStarted); | ||
| 353 | |||
| 354 | assert.equal(state.remoteStatusCalls, 1); | ||
| 355 | |||
| 356 | yield* Scope.close(firstScope, Exit.void); | ||
| 357 | assert.isTrue(Option.isNone(yield* Deferred.poll(remoteInterrupted))); | ||
| 358 | |||
| 359 | yield* Scope.close(secondScope, Exit.void).pipe(Effect.forkScoped); | ||
| 360 | yield* Deferred.await(remoteInterrupted); | ||
| 361 | assert.isTrue(Option.isSome(yield* Deferred.poll(remoteInterrupted))); | ||
| 362 | }).pipe(Effect.provide(testLayer)); | ||
| 363 | }); | ||
| 364 | }); | ||
apps/server/src/vcs/VcsStatusBroadcaster.ts created+344| ... | @@ -0,0 +1,344 @@ | ||
| 1 | import { realpathSync } from "node:fs"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | Duration, | ||
| 5 | Effect, | ||
| 6 | Exit, | ||
| 7 | Fiber, | ||
| 8 | Layer, | ||
| 9 | PubSub, | ||
| 10 | Ref, | ||
| 11 | Scope, | ||
| 12 | Stream, | ||
| 13 | SynchronizedRef, | ||
| 14 | } from "effect"; | ||
| 15 | import type { | ||
| 16 | GitManagerServiceError, | ||
| 17 | VcsStatusInput, | ||
| 18 | VcsStatusLocalResult, | ||
| 19 | VcsStatusRemoteResult, | ||
| 20 | VcsStatusResult, | ||
| 21 | VcsStatusStreamEvent, | ||
| 22 | } from "@t3tools/contracts"; | ||
| 23 | import { Context } from "effect"; | ||
| 24 | import { mergeGitStatusParts } from "@t3tools/shared/git"; | ||
| 25 | |||
| 26 | import { GitWorkflowService } from "../git/GitWorkflowService.ts"; | ||
| 27 | |||
| 28 | const VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); | ||
| 29 | |||
| 30 | interface VcsStatusChange { | ||
| 31 | readonly cwd: string; | ||
| 32 | readonly event: VcsStatusStreamEvent; | ||
| 33 | } | ||
| 34 | |||
| 35 | interface CachedValue<T> { | ||
| 36 | readonly fingerprint: string; | ||
| 37 | readonly value: T; | ||
| 38 | } | ||
| 39 | |||
| 40 | interface CachedVcsStatus { | ||
| 41 | readonly local: CachedValue<VcsStatusLocalResult> | null; | ||
| 42 | readonly remote: CachedValue<VcsStatusRemoteResult | null> | null; | ||
| 43 | } | ||
| 44 | |||
| 45 | interface ActiveRemotePoller { | ||
| 46 | readonly fiber: Fiber.Fiber<void, never>; | ||
| 47 | readonly subscriberCount: number; | ||
| 48 | } | ||
| 49 | |||
| 50 | export interface VcsStatusBroadcasterShape { | ||
| 51 | readonly getStatus: ( | ||
| 52 | input: VcsStatusInput, | ||
| 53 | ) => Effect.Effect<VcsStatusResult, GitManagerServiceError>; | ||
| 54 | readonly refreshLocalStatus: ( | ||
| 55 | cwd: string, | ||
| 56 | ) => Effect.Effect<VcsStatusLocalResult, GitManagerServiceError>; | ||
| 57 | readonly refreshStatus: (cwd: string) => Effect.Effect<VcsStatusResult, GitManagerServiceError>; | ||
| 58 | readonly streamStatus: ( | ||
| 59 | input: VcsStatusInput, | ||
| 60 | ) => Stream.Stream<VcsStatusStreamEvent, GitManagerServiceError>; | ||
| 61 | } | ||
| 62 | |||
| 63 | export class VcsStatusBroadcaster extends Context.Service< | ||
| 64 | VcsStatusBroadcaster, | ||
| 65 | VcsStatusBroadcasterShape | ||
| 66 | >()("t3/vcs/VcsStatusBroadcaster") {} | ||
| 67 | |||
| 68 | function fingerprintStatusPart(status: unknown): string { | ||
| 69 | return JSON.stringify(status); | ||
| 70 | } | ||
| 71 | |||
| 72 | function normalizeCwd(cwd: string): string { | ||
| 73 | try { | ||
| 74 | return realpathSync.native(cwd); | ||
| 75 | } catch { | ||
| 76 | return cwd; | ||
| 77 | } | ||
| 78 | } | ||
| 79 | |||
| 80 | export const layer = Layer.effect( | ||
| 81 | VcsStatusBroadcaster, | ||
| 82 | Effect.gen(function* () { | ||
| 83 | const workflow = yield* GitWorkflowService; | ||
| 84 | const changesPubSub = yield* Effect.acquireRelease( | ||
| 85 | PubSub.unbounded<VcsStatusChange>(), | ||
| 86 | (pubsub) => PubSub.shutdown(pubsub), | ||
| 87 | ); | ||
| 88 | const broadcasterScope = yield* Effect.acquireRelease(Scope.make(), (scope) => | ||
| 89 | Scope.close(scope, Exit.void), | ||
| 90 | ); | ||
| 91 | const cacheRef = yield* Ref.make(new Map<string, CachedVcsStatus>()); | ||
| 92 | const pollersRef = yield* SynchronizedRef.make(new Map<string, ActiveRemotePoller>()); | ||
| 93 | |||
| 94 | const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* ( | ||
| 95 | cwd: string, | ||
| 96 | ) { | ||
| 97 | return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null)); | ||
| 98 | }); | ||
| 99 | |||
| 100 | const updateCachedLocalStatus = Effect.fn("VcsStatusBroadcaster.updateCachedLocalStatus")( | ||
| 101 | function* (cwd: string, local: VcsStatusLocalResult, options?: { publish?: boolean }) { | ||
| 102 | const nextLocal = { | ||
| 103 | fingerprint: fingerprintStatusPart(local), | ||
| 104 | value: local, | ||
| 105 | } satisfies CachedValue<VcsStatusLocalResult>; | ||
| 106 | const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { | ||
| 107 | const previous = cache.get(cwd) ?? { local: null, remote: null }; | ||
| 108 | const nextCache = new Map(cache); | ||
| 109 | nextCache.set(cwd, { | ||
| 110 | ...previous, | ||
| 111 | local: nextLocal, | ||
| 112 | }); | ||
| 113 | return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const; | ||
| 114 | }); | ||
| 115 | |||
| 116 | if (options?.publish && shouldPublish) { | ||
| 117 | yield* PubSub.publish(changesPubSub, { | ||
| 118 | cwd, | ||
| 119 | event: { | ||
| 120 | _tag: "localUpdated", | ||
| 121 | local, | ||
| 122 | }, | ||
| 123 | }); | ||
| 124 | } | ||
| 125 | |||
| 126 | return local; | ||
| 127 | }, | ||
| 128 | ); | ||
| 129 | |||
| 130 | const updateCachedRemoteStatus = Effect.fn("VcsStatusBroadcaster.updateCachedRemoteStatus")( | ||
| 131 | function* ( | ||
| 132 | cwd: string, | ||
| 133 | remote: VcsStatusRemoteResult | null, | ||
| 134 | options?: { publish?: boolean }, | ||
| 135 | ) { | ||
| 136 | const nextRemote = { | ||
| 137 | fingerprint: fingerprintStatusPart(remote), | ||
| 138 | value: remote, | ||
| 139 | } satisfies CachedValue<VcsStatusRemoteResult | null>; | ||
| 140 | const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { | ||
| 141 | const previous = cache.get(cwd) ?? { local: null, remote: null }; | ||
| 142 | const nextCache = new Map(cache); | ||
| 143 | nextCache.set(cwd, { | ||
| 144 | ...previous, | ||
| 145 | remote: nextRemote, | ||
| 146 | }); | ||
| 147 | return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const; | ||
| 148 | }); | ||
| 149 | |||
| 150 | if (options?.publish && shouldPublish) { | ||
| 151 | yield* PubSub.publish(changesPubSub, { | ||
| 152 | cwd, | ||
| 153 | event: { | ||
| 154 | _tag: "remoteUpdated", | ||
| 155 | remote, | ||
| 156 | }, | ||
| 157 | }); | ||
| 158 | } | ||
| 159 | |||
| 160 | return remote; | ||
| 161 | }, | ||
| 162 | ); | ||
| 163 | |||
| 164 | const loadLocalStatus = Effect.fn("VcsStatusBroadcaster.loadLocalStatus")(function* ( | ||
| 165 | cwd: string, | ||
| 166 | ) { | ||
| 167 | const local = yield* workflow.localStatus({ cwd }); | ||
| 168 | return yield* updateCachedLocalStatus(cwd, local); | ||
| 169 | }); | ||
| 170 | |||
| 171 | const loadRemoteStatus = Effect.fn("VcsStatusBroadcaster.loadRemoteStatus")(function* ( | ||
| 172 | cwd: string, | ||
| 173 | ) { | ||
| 174 | const remote = yield* workflow.remoteStatus({ cwd }); | ||
| 175 | return yield* updateCachedRemoteStatus(cwd, remote); | ||
| 176 | }); | ||
| 177 | |||
| 178 | const getOrLoadLocalStatus = Effect.fn("VcsStatusBroadcaster.getOrLoadLocalStatus")(function* ( | ||
| 179 | cwd: string, | ||
| 180 | ) { | ||
| 181 | const cached = yield* getCachedStatus(cwd); | ||
| 182 | if (cached?.local) { | ||
| 183 | return cached.local.value; | ||
| 184 | } | ||
| 185 | return yield* loadLocalStatus(cwd); | ||
| 186 | }); | ||
| 187 | |||
| 188 | const getOrLoadRemoteStatus = Effect.fn("VcsStatusBroadcaster.getOrLoadRemoteStatus")( | ||
| 189 | function* (cwd: string) { | ||
| 190 | const cached = yield* getCachedStatus(cwd); | ||
| 191 | if (cached?.remote) { | ||
| 192 | return cached.remote.value; | ||
| 193 | } | ||
| 194 | return yield* loadRemoteStatus(cwd); | ||
| 195 | }, | ||
| 196 | ); | ||
| 197 | |||
| 198 | const getStatus: VcsStatusBroadcasterShape["getStatus"] = Effect.fn( | ||
| 199 | "VcsStatusBroadcaster.getStatus", | ||
| 200 | )(function* (input) { | ||
| 201 | const cwd = normalizeCwd(input.cwd); | ||
| 202 | const [local, remote] = yield* Effect.all([ | ||
| 203 | getOrLoadLocalStatus(cwd), | ||
| 204 | getOrLoadRemoteStatus(cwd), | ||
| 205 | ]); | ||
| 206 | return mergeGitStatusParts(local, remote); | ||
| 207 | }); | ||
| 208 | |||
| 209 | const refreshLocalStatus: VcsStatusBroadcasterShape["refreshLocalStatus"] = Effect.fn( | ||
| 210 | "VcsStatusBroadcaster.refreshLocalStatus", | ||
| 211 | )(function* (rawCwd) { | ||
| 212 | const cwd = normalizeCwd(rawCwd); | ||
| 213 | yield* workflow.invalidateLocalStatus(cwd); | ||
| 214 | const local = yield* workflow.localStatus({ cwd }); | ||
| 215 | return yield* updateCachedLocalStatus(cwd, local, { publish: true }); | ||
| 216 | }); | ||
| 217 | |||
| 218 | const refreshRemoteStatus = Effect.fn("VcsStatusBroadcaster.refreshRemoteStatus")(function* ( | ||
| 219 | cwd: string, | ||
| 220 | ) { | ||
| 221 | yield* workflow.invalidateRemoteStatus(cwd); | ||
| 222 | const remote = yield* workflow.remoteStatus({ cwd }); | ||
| 223 | return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); | ||
| 224 | }); | ||
| 225 | |||
| 226 | const refreshStatus: VcsStatusBroadcasterShape["refreshStatus"] = Effect.fn( | ||
| 227 | "VcsStatusBroadcaster.refreshStatus", | ||
| 228 | )(function* (rawCwd) { | ||
| 229 | const cwd = normalizeCwd(rawCwd); | ||
| 230 | const [local, remote] = yield* Effect.all([ | ||
| 231 | refreshLocalStatus(cwd), | ||
| 232 | refreshRemoteStatus(cwd), | ||
| 233 | ]); | ||
| 234 | return mergeGitStatusParts(local, remote); | ||
| 235 | }); | ||
| 236 | |||
| 237 | const makeRemoteRefreshLoop = (cwd: string) => { | ||
| 238 | const logRefreshFailure = (error: Error) => | ||
| 239 | Effect.logWarning("VCS remote status refresh failed", { | ||
| 240 | cwd, | ||
| 241 | detail: error.message, | ||
| 242 | }); | ||
| 243 | |||
| 244 | return refreshRemoteStatus(cwd).pipe( | ||
| 245 | Effect.catch(logRefreshFailure), | ||
| 246 | Effect.andThen( | ||
| 247 | Effect.forever( | ||
| 248 | Effect.sleep(VCS_STATUS_REFRESH_INTERVAL).pipe( | ||
| 249 | Effect.andThen(refreshRemoteStatus(cwd).pipe(Effect.catch(logRefreshFailure))), | ||
| 250 | ), | ||
| 251 | ), | ||
| 252 | ), | ||
| 253 | ); | ||
| 254 | }; | ||
| 255 | |||
| 256 | const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* ( | ||
| 257 | cwd: string, | ||
| 258 | ) { | ||
| 259 | yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { | ||
| 260 | const existing = activePollers.get(cwd); | ||
| 261 | if (existing) { | ||
| 262 | const nextPollers = new Map(activePollers); | ||
| 263 | nextPollers.set(cwd, { | ||
| 264 | ...existing, | ||
| 265 | subscriberCount: existing.subscriberCount + 1, | ||
| 266 | }); | ||
| 267 | return Effect.succeed([undefined, nextPollers] as const); | ||
| 268 | } | ||
| 269 | |||
| 270 | return makeRemoteRefreshLoop(cwd).pipe( | ||
| 271 | Effect.forkIn(broadcasterScope), | ||
| 272 | Effect.map((fiber) => { | ||
| 273 | const nextPollers = new Map(activePollers); | ||
| 274 | nextPollers.set(cwd, { | ||
| 275 | fiber, | ||
| 276 | subscriberCount: 1, | ||
| 277 | }); | ||
| 278 | return [undefined, nextPollers] as const; | ||
| 279 | }), | ||
| 280 | ); | ||
| 281 | }); | ||
| 282 | }); | ||
| 283 | |||
| 284 | const releaseRemotePoller = Effect.fn("VcsStatusBroadcaster.releaseRemotePoller")(function* ( | ||
| 285 | cwd: string, | ||
| 286 | ) { | ||
| 287 | const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => { | ||
| 288 | const existing = activePollers.get(cwd); | ||
| 289 | if (!existing) { | ||
| 290 | return [null, activePollers] as const; | ||
| 291 | } | ||
| 292 | |||
| 293 | if (existing.subscriberCount > 1) { | ||
| 294 | const nextPollers = new Map(activePollers); | ||
| 295 | nextPollers.set(cwd, { | ||
| 296 | ...existing, | ||
| 297 | subscriberCount: existing.subscriberCount - 1, | ||
| 298 | }); | ||
| 299 | return [null, nextPollers] as const; | ||
| 300 | } | ||
| 301 | |||
| 302 | const nextPollers = new Map(activePollers); | ||
| 303 | nextPollers.delete(cwd); | ||
| 304 | return [existing.fiber, nextPollers] as const; | ||
| 305 | }); | ||
| 306 | |||
| 307 | if (pollerToInterrupt) { | ||
| 308 | yield* Fiber.interrupt(pollerToInterrupt).pipe(Effect.ignore); | ||
| 309 | } | ||
| 310 | }); | ||
| 311 | |||
| 312 | const streamStatus: VcsStatusBroadcasterShape["streamStatus"] = (input) => | ||
| 313 | Stream.unwrap( | ||
| 314 | Effect.gen(function* () { | ||
| 315 | const cwd = normalizeCwd(input.cwd); | ||
| 316 | const subscription = yield* PubSub.subscribe(changesPubSub); | ||
| 317 | const initialLocal = yield* getOrLoadLocalStatus(cwd); | ||
| 318 | const initialRemote = (yield* getCachedStatus(cwd))?.remote?.value ?? null; | ||
| 319 | yield* retainRemotePoller(cwd); | ||
| 320 | |||
| 321 | const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid); | ||
| 322 | |||
| 323 | return Stream.concat( | ||
| 324 | Stream.make({ | ||
| 325 | _tag: "snapshot" as const, | ||
| 326 | local: initialLocal, | ||
| 327 | remote: initialRemote, | ||
| 328 | }), | ||
| 329 | Stream.fromSubscription(subscription).pipe( | ||
| 330 | Stream.filter((event) => event.cwd === cwd), | ||
| 331 | Stream.map((event) => event.event), | ||
| 332 | ), | ||
| 333 | ).pipe(Stream.ensuring(release)); | ||
| 334 | }), | ||
| 335 | ); | ||
| 336 | |||
| 337 | return VcsStatusBroadcaster.of({ | ||
| 338 | getStatus, | ||
| 339 | refreshLocalStatus, | ||
| 340 | refreshStatus, | ||
| 341 | streamStatus, | ||
| 342 | }); | ||
| 343 | }), | ||
| 344 | ); | ||
apps/server/src/vcs/testing/VcsDriverContractHarness.ts created+152| ... | @@ -0,0 +1,152 @@ | ||
| 1 | import { assert, it } from "@effect/vitest"; | ||
| 2 | import { DateTime, Option } from "effect"; | ||
| 3 | import { Effect, FileSystem, Layer, Path, type PlatformError, type Scope } from "effect"; | ||
| 4 | import { describe } from "vitest"; | ||
| 5 | |||
| 6 | import type { VcsDriverKind } from "@t3tools/contracts"; | ||
| 7 | import { VcsDriver } from "../VcsDriver.ts"; | ||
| 8 | |||
| 9 | export interface VcsDriverFixture<R, E> { | ||
| 10 | readonly createRepo: (cwd: string) => Effect.Effect<void, E, R>; | ||
| 11 | readonly writeFile: ( | ||
| 12 | cwd: string, | ||
| 13 | relativePath: string, | ||
| 14 | contents: string, | ||
| 15 | ) => Effect.Effect<void, E, R | FileSystem.FileSystem | Path.Path>; | ||
| 16 | readonly trackFile?: (cwd: string, relativePath: string) => Effect.Effect<void, E, R>; | ||
| 17 | readonly commit?: (cwd: string, message: string) => Effect.Effect<void, E, R>; | ||
| 18 | readonly ignorePath: ( | ||
| 19 | cwd: string, | ||
| 20 | pattern: string, | ||
| 21 | ) => Effect.Effect<void, E, R | FileSystem.FileSystem | Path.Path>; | ||
| 22 | } | ||
| 23 | |||
| 24 | export interface VcsDriverContractSuiteInput<R, E> { | ||
| 25 | readonly name: string; | ||
| 26 | readonly kind: VcsDriverKind; | ||
| 27 | readonly layer: Layer.Layer<VcsDriver | R | FileSystem.FileSystem | Path.Path, E, never>; | ||
| 28 | readonly fixture: VcsDriverFixture<R, E>; | ||
| 29 | } | ||
| 30 | |||
| 31 | export function runVcsDriverContractSuite<R, E>(input: VcsDriverContractSuiteInput<R, E>) { | ||
| 32 | const makeTmpDir = ( | ||
| 33 | prefix = `t3-${input.kind}-vcs-contract-`, | ||
| 34 | ): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> => | ||
| 35 | Effect.gen(function* () { | ||
| 36 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 37 | return yield* fileSystem.makeTempDirectoryScoped({ prefix }); | ||
| 38 | }); | ||
| 39 | |||
| 40 | it.layer(input.layer)(`${input.name} VCS driver contract`, (it) => { | ||
| 41 | describe("repository detection", () => { | ||
| 42 | it.effect("returns null outside a repository", () => | ||
| 43 | Effect.gen(function* () { | ||
| 44 | const cwd = yield* makeTmpDir(); | ||
| 45 | const driver = yield* VcsDriver; | ||
| 46 | |||
| 47 | assert.equal(yield* driver.detectRepository(cwd), null); | ||
| 48 | assert.equal(yield* driver.isInsideWorkTree(cwd), false); | ||
| 49 | }), | ||
| 50 | ); | ||
| 51 | |||
| 52 | it.effect("detects repository identity inside a repository and nested directories", () => | ||
| 53 | Effect.gen(function* () { | ||
| 54 | const cwd = yield* makeTmpDir(); | ||
| 55 | const driver = yield* VcsDriver; | ||
| 56 | |||
| 57 | yield* input.fixture.createRepo(cwd); | ||
| 58 | yield* input.fixture.writeFile(cwd, "src/index.ts", "export const value = 1;\n"); | ||
| 59 | const identity = yield* driver.detectRepository(cwd); | ||
| 60 | assert.equal(identity?.kind, input.kind); | ||
| 61 | assert.isTrue(identity?.rootPath.endsWith(cwd)); | ||
| 62 | assert.equal(identity?.freshness.source, "live-local"); | ||
| 63 | assert.isTrue(DateTime.isDateTime(identity?.freshness.observedAt)); | ||
| 64 | assert.isTrue(Option.isNone(identity?.freshness.expiresAt ?? Option.none())); | ||
| 65 | assert.equal(yield* driver.isInsideWorkTree(cwd), true); | ||
| 66 | |||
| 67 | const path = yield* Path.Path; | ||
| 68 | const nestedDir = path.join(cwd, "src"); | ||
| 69 | const nestedIdentity = yield* driver.detectRepository(nestedDir); | ||
| 70 | assert.equal(nestedIdentity?.rootPath, identity?.rootPath); | ||
| 71 | assert.equal(yield* driver.isInsideWorkTree(nestedDir), true); | ||
| 72 | }), | ||
| 73 | ); | ||
| 74 | }); | ||
| 75 | |||
| 76 | describe("workspace files", () => { | ||
| 77 | it.effect("lists tracked and untracked non-ignored files", () => | ||
| 78 | Effect.gen(function* () { | ||
| 79 | const cwd = yield* makeTmpDir(); | ||
| 80 | const driver = yield* VcsDriver; | ||
| 81 | |||
| 82 | yield* input.fixture.createRepo(cwd); | ||
| 83 | yield* input.fixture.writeFile(cwd, "tracked.ts", "export const tracked = true;\n"); | ||
| 84 | if (input.fixture.trackFile && input.fixture.commit) { | ||
| 85 | yield* input.fixture.trackFile(cwd, "tracked.ts"); | ||
| 86 | yield* input.fixture.commit(cwd, "Track file"); | ||
| 87 | } | ||
| 88 | yield* input.fixture.writeFile(cwd, "untracked.ts", "export const untracked = true;\n"); | ||
| 89 | |||
| 90 | const result = yield* driver.listWorkspaceFiles(cwd); | ||
| 91 | |||
| 92 | assert.include(result.paths, "tracked.ts"); | ||
| 93 | assert.include(result.paths, "untracked.ts"); | ||
| 94 | assert.equal(result.truncated, false); | ||
| 95 | assert.equal(result.freshness.source, "live-local"); | ||
| 96 | assert.isTrue(DateTime.isDateTime(result.freshness.observedAt)); | ||
| 97 | assert.isTrue(Option.isNone(result.freshness.expiresAt)); | ||
| 98 | }), | ||
| 99 | ); | ||
| 100 | |||
| 101 | it.effect("excludes ignored files from workspace listing", () => | ||
| 102 | Effect.gen(function* () { | ||
| 103 | const cwd = yield* makeTmpDir(); | ||
| 104 | const driver = yield* VcsDriver; | ||
| 105 | |||
| 106 | yield* input.fixture.createRepo(cwd); | ||
| 107 | yield* input.fixture.ignorePath(cwd, "*.log"); | ||
| 108 | yield* input.fixture.writeFile(cwd, "included.ts", "export const included = true;\n"); | ||
| 109 | yield* input.fixture.writeFile(cwd, "debug.log", "ignore me\n"); | ||
| 110 | yield* input.fixture.writeFile(cwd, "nested/error.log", "ignore me too\n"); | ||
| 111 | |||
| 112 | const result = yield* driver.listWorkspaceFiles(cwd); | ||
| 113 | |||
| 114 | assert.include(result.paths, "included.ts"); | ||
| 115 | assert.notInclude(result.paths, "debug.log"); | ||
| 116 | assert.notInclude(result.paths, "nested/error.log"); | ||
| 117 | }), | ||
| 118 | ); | ||
| 119 | }); | ||
| 120 | |||
| 121 | describe("ignored path filtering", () => { | ||
| 122 | it.effect("filters ignored paths", () => | ||
| 123 | Effect.gen(function* () { | ||
| 124 | const cwd = yield* makeTmpDir(); | ||
| 125 | const driver = yield* VcsDriver; | ||
| 126 | |||
| 127 | yield* input.fixture.createRepo(cwd); | ||
| 128 | yield* input.fixture.ignorePath(cwd, "*.log"); | ||
| 129 | |||
| 130 | const result = yield* driver.filterIgnoredPaths(cwd, [ | ||
| 131 | "keep.ts", | ||
| 132 | "debug.log", | ||
| 133 | "nested/error.log", | ||
| 134 | ]); | ||
| 135 | |||
| 136 | assert.deepStrictEqual(result, ["keep.ts"]); | ||
| 137 | }), | ||
| 138 | ); | ||
| 139 | |||
| 140 | it.effect("returns empty input unchanged", () => | ||
| 141 | Effect.gen(function* () { | ||
| 142 | const cwd = yield* makeTmpDir(); | ||
| 143 | const driver = yield* VcsDriver; | ||
| 144 | |||
| 145 | yield* input.fixture.createRepo(cwd); | ||
| 146 | |||
| 147 | assert.deepStrictEqual(yield* driver.filterIgnoredPaths(cwd, []), []); | ||
| 148 | }), | ||
| 149 | ); | ||
| 150 | }); | ||
| 151 | }); | ||
| 152 | } | ||
apps/server/src/workspace/Layers/WorkspaceEntries.test.ts+8-7| ... | @@ -5,8 +5,8 @@ import { it, afterEach, describe, expect, vi } from "@effect/vitest"; | ... | @@ -5,8 +5,8 @@ import { it, afterEach, describe, expect, vi } from "@effect/vitest"; |
| 5 | import { Effect, FileSystem, Layer, Path, PlatformError } from "effect"; | 5 | import { Effect, FileSystem, Layer, Path, PlatformError } from "effect"; |
| 6 | 6 | ||
| 7 | import { ServerConfig } from "../../config.ts"; | 7 | import { ServerConfig } from "../../config.ts"; |
| 8 | import { GitCoreLive } from "../../git/Layers/GitCore.ts"; | 8 | import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; |
| 9 | import { GitCore } from "../../git/Services/GitCore.ts"; | 9 | import * as VcsProcess from "../../vcs/VcsProcess.ts"; |
| 10 | import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; | 10 | import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; |
| 11 | import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; | 11 | import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; |
| 12 | import { WorkspacePathsLive } from "./WorkspacePaths.ts"; | 12 | import { WorkspacePathsLive } from "./WorkspacePaths.ts"; |
| ... | @@ -14,7 +14,8 @@ import { WorkspacePathsLive } from "./WorkspacePaths.ts"; | ... | @@ -14,7 +14,8 @@ import { WorkspacePathsLive } from "./WorkspacePaths.ts"; |
| 14 | const TestLayer = Layer.empty.pipe( | 14 | const TestLayer = Layer.empty.pipe( |
| 15 | Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), | 15 | Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), |
| 16 | Layer.provideMerge(WorkspacePathsLive), | 16 | Layer.provideMerge(WorkspacePathsLive), |
| 17 | Layer.provideMerge(GitCoreLive), | 17 | Layer.provideMerge(VcsProcess.layer), |
| 18 | Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), | ||
| 18 | Layer.provide( | 19 | Layer.provide( |
| 19 | ServerConfig.layerTest(process.cwd(), { | 20 | ServerConfig.layerTest(process.cwd(), { |
| 20 | prefix: "t3-workspace-entries-test-", | 21 | prefix: "t3-workspace-entries-test-", |
| ... | @@ -25,12 +26,11 @@ const TestLayer = Layer.empty.pipe( | ... | @@ -25,12 +26,11 @@ const TestLayer = Layer.empty.pipe( |
| 25 | 26 | ||
| 26 | const makeTempDir = Effect.fn(function* (opts?: { prefix?: string; git?: boolean }) { | 27 | const makeTempDir = Effect.fn(function* (opts?: { prefix?: string; git?: boolean }) { |
| 27 | const fileSystem = yield* FileSystem.FileSystem; | 28 | const fileSystem = yield* FileSystem.FileSystem; |
| 28 | const gitCore = yield* GitCore; | ||
| 29 | const dir = yield* fileSystem.makeTempDirectoryScoped({ | 29 | const dir = yield* fileSystem.makeTempDirectoryScoped({ |
| 30 | prefix: opts?.prefix ?? "t3code-workspace-entries-", | 30 | prefix: opts?.prefix ?? "t3code-workspace-entries-", |
| 31 | }); | 31 | }); |
| 32 | if (opts?.git) { | 32 | if (opts?.git) { |
| 33 | yield* gitCore.initRepo({ cwd: dir }); | 33 | yield* git(dir, ["init"]); |
| 34 | } | 34 | } |
| 35 | return dir; | 35 | return dir; |
| 36 | }); | 36 | }); |
| ... | @@ -51,9 +51,10 @@ function writeTextFile( | ... | @@ -51,9 +51,10 @@ function writeTextFile( |
| 51 | 51 | ||
| 52 | const git = (cwd: string, args: ReadonlyArray<string>, env?: NodeJS.ProcessEnv) => | 52 | const git = (cwd: string, args: ReadonlyArray<string>, env?: NodeJS.ProcessEnv) => |
| 53 | Effect.gen(function* () { | 53 | Effect.gen(function* () { |
| 54 | const gitCore = yield* GitCore; | 54 | const process = yield* VcsProcess.VcsProcess; |
| 55 | const result = yield* gitCore.execute({ | 55 | const result = yield* process.run({ |
| 56 | operation: "WorkspaceEntries.test.git", | 56 | operation: "WorkspaceEntries.test.git", |
| 57 | command: "git", | ||
| 57 | cwd, | 58 | cwd, |
| 58 | args, | 59 | args, |
| 59 | ...(env ? { env } : {}), | 60 | ...(env ? { env } : {}), |
apps/server/src/workspace/Layers/WorkspaceEntries.ts+38-32| ... | @@ -2,7 +2,7 @@ import * as OS from "node:os"; | ... | @@ -2,7 +2,7 @@ import * as OS from "node:os"; |
| 2 | import fsPromises from "node:fs/promises"; | 2 | import fsPromises from "node:fs/promises"; |
| 3 | import type { Dirent } from "node:fs"; | 3 | import type { Dirent } from "node:fs"; |
| 4 | 4 | ||
| 5 | import { Cache, Duration, Effect, Exit, Layer, Option, Path } from "effect"; | 5 | import { Cache, DateTime, Duration, Effect, Exit, Layer, Path } from "effect"; |
| 6 | 6 | ||
| 7 | import { type FilesystemBrowseInput, type ProjectEntry } from "@t3tools/contracts"; | 7 | import { type FilesystemBrowseInput, type ProjectEntry } from "@t3tools/contracts"; |
| 8 | import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; | 8 | import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; |
| ... | @@ -13,7 +13,7 @@ import { | ... | @@ -13,7 +13,7 @@ import { |
| 13 | type RankedSearchResult, | 13 | type RankedSearchResult, |
| 14 | } from "@t3tools/shared/searchRanking"; | 14 | } from "@t3tools/shared/searchRanking"; |
| 15 | 15 | ||
| 16 | import { GitCore } from "../../git/Services/GitCore.ts"; | 16 | import { VcsDriverRegistry } from "../../vcs/VcsDriverRegistry.ts"; |
| 17 | import { | 17 | import { |
| 18 | WorkspaceEntries, | 18 | WorkspaceEntries, |
| 19 | WorkspaceEntriesBrowseError, | 19 | WorkspaceEntriesBrowseError, |
| ... | @@ -174,38 +174,39 @@ const resolveBrowseTarget = ( | ... | @@ -174,38 +174,39 @@ const resolveBrowseTarget = ( |
| 174 | 174 | ||
| 175 | export const makeWorkspaceEntries = Effect.gen(function* () { | 175 | export const makeWorkspaceEntries = Effect.gen(function* () { |
| 176 | const path = yield* Path.Path; | 176 | const path = yield* Path.Path; |
| 177 | const gitOption = yield* Effect.serviceOption(GitCore); | 177 | const vcsRegistry = yield* VcsDriverRegistry; |
| 178 | const workspacePaths = yield* WorkspacePaths; | 178 | const workspacePaths = yield* WorkspacePaths; |
| 179 | 179 | ||
| 180 | const isInsideGitWorkTree = (cwd: string): Effect.Effect<boolean> => | 180 | const isInsideVcsWorkTree = (cwd: string): Effect.Effect<boolean> => |
| 181 | Option.match(gitOption, { | 181 | vcsRegistry.detect({ cwd }).pipe( |
| 182 | onSome: (git) => git.isInsideWorkTree(cwd).pipe(Effect.catch(() => Effect.succeed(false))), | 182 | Effect.map((handle) => handle !== null), |
| 183 | onNone: () => Effect.succeed(false), | 183 | Effect.catch(() => Effect.succeed(false)), |
| 184 | }); | 184 | ); |
| 185 | 185 | ||
| 186 | const filterGitIgnoredPaths = ( | 186 | const filterVcsIgnoredPaths = ( |
| 187 | cwd: string, | 187 | cwd: string, |
| 188 | relativePaths: string[], | 188 | relativePaths: string[], |
| 189 | ): Effect.Effect<string[], never> => | 189 | ): Effect.Effect<string[], never> => |
| 190 | Option.match(gitOption, { | 190 | vcsRegistry.detect({ cwd }).pipe( |
| 191 | onSome: (git) => | 191 | Effect.flatMap((handle) => |
| 192 | git.filterIgnoredPaths(cwd, relativePaths).pipe( | 192 | handle |
| 193 | Effect.map((paths) => [...paths]), | 193 | ? handle.driver.filterIgnoredPaths(cwd, relativePaths).pipe( |
| 194 | Effect.catch(() => Effect.succeed(relativePaths)), | 194 | Effect.map((paths) => [...paths]), |
| 195 | ), | 195 | Effect.catch(() => Effect.succeed(relativePaths)), |
| 196 | onNone: () => Effect.succeed(relativePaths), | 196 | ) |
| 197 | }); | 197 | : Effect.succeed(relativePaths), |
| 198 | 198 | ), | |
| 199 | const buildWorkspaceIndexFromGit = Effect.fn("WorkspaceEntries.buildWorkspaceIndexFromGit")( | 199 | Effect.catch(() => Effect.succeed(relativePaths)), |
| 200 | ); | ||
| 201 | |||
| 202 | const buildWorkspaceIndexFromVcs = Effect.fn("WorkspaceEntries.buildWorkspaceIndexFromVcs")( | ||
| 200 | function* (cwd: string) { | 203 | function* (cwd: string) { |
| 201 | if (Option.isNone(gitOption)) { | 204 | const vcs = yield* vcsRegistry.detect({ cwd }).pipe(Effect.catch(() => Effect.succeed(null))); |
| 202 | return null; | 205 | if (!vcs) { |
| 203 | } | ||
| 204 | if (!(yield* isInsideGitWorkTree(cwd))) { | ||
| 205 | return null; | 206 | return null; |
| 206 | } | 207 | } |
| 207 | 208 | ||
| 208 | const listedFiles = yield* gitOption.value | 209 | const listedFiles = yield* vcs.driver |
| 209 | .listWorkspaceFiles(cwd) | 210 | .listWorkspaceFiles(cwd) |
| 210 | .pipe(Effect.catch(() => Effect.succeed(null))); | 211 | .pipe(Effect.catch(() => Effect.succeed(null))); |
| 211 | 212 | ||
| ... | @@ -216,7 +217,10 @@ export const makeWorkspaceEntries = Effect.gen(function* () { | ... | @@ -216,7 +217,10 @@ export const makeWorkspaceEntries = Effect.gen(function* () { |
| 216 | const listedPaths = [...listedFiles.paths] | 217 | const listedPaths = [...listedFiles.paths] |
| 217 | .map((entry) => toPosixPath(entry)) | 218 | .map((entry) => toPosixPath(entry)) |
| 218 | .filter((entry) => entry.length > 0 && !isPathInIgnoredDirectory(entry)); | 219 | .filter((entry) => entry.length > 0 && !isPathInIgnoredDirectory(entry)); |
| 219 | const filePaths = yield* filterGitIgnoredPaths(cwd, listedPaths); | 220 | const filePaths = yield* vcs.driver.filterIgnoredPaths(cwd, listedPaths).pipe( |
| 221 | Effect.map((paths) => [...paths]), | ||
| 222 | Effect.catch(() => filterVcsIgnoredPaths(cwd, listedPaths)), | ||
| 223 | ); | ||
| 220 | 224 | ||
| 221 | const directorySet = new Set<string>(); | 225 | const directorySet = new Set<string>(); |
| 222 | for (const filePath of filePaths) { | 226 | for (const filePath of filePaths) { |
| ... | @@ -248,9 +252,10 @@ export const makeWorkspaceEntries = Effect.gen(function* () { | ... | @@ -248,9 +252,10 @@ export const makeWorkspaceEntries = Effect.gen(function* () { |
| 248 | ) | 252 | ) |
| 249 | .map(toSearchableWorkspaceEntry); | 253 | .map(toSearchableWorkspaceEntry); |
| 250 | 254 | ||
| 255 | const now = yield* DateTime.now; | ||
| 251 | const entries = [...directoryEntries, ...fileEntries]; | 256 | const entries = [...directoryEntries, ...fileEntries]; |
| 252 | return { | 257 | return { |
| 253 | scannedAt: Date.now(), | 258 | scannedAt: now.epochMilliseconds, |
| 254 | entries: entries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES), | 259 | entries: entries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES), |
| 255 | truncated: listedFiles.truncated || entries.length > WORKSPACE_INDEX_MAX_ENTRIES, | 260 | truncated: listedFiles.truncated || entries.length > WORKSPACE_INDEX_MAX_ENTRIES, |
| 256 | }; | 261 | }; |
| ... | @@ -288,7 +293,7 @@ export const makeWorkspaceEntries = Effect.gen(function* () { | ... | @@ -288,7 +293,7 @@ export const makeWorkspaceEntries = Effect.gen(function* () { |
| 288 | const buildWorkspaceIndexFromFilesystem = Effect.fn( | 293 | const buildWorkspaceIndexFromFilesystem = Effect.fn( |
| 289 | "WorkspaceEntries.buildWorkspaceIndexFromFilesystem", | 294 | "WorkspaceEntries.buildWorkspaceIndexFromFilesystem", |
| 290 | )(function* (cwd: string): Effect.fn.Return<WorkspaceIndex, WorkspaceEntriesError> { | 295 | )(function* (cwd: string): Effect.fn.Return<WorkspaceIndex, WorkspaceEntriesError> { |
| 291 | const shouldFilterWithGitIgnore = yield* isInsideGitWorkTree(cwd); | 296 | const shouldFilterWithGitIgnore = yield* isInsideVcsWorkTree(cwd); |
| 292 | 297 | ||
| 293 | let pendingDirectories: string[] = [""]; | 298 | let pendingDirectories: string[] = [""]; |
| 294 | const entries: SearchableWorkspaceEntry[] = []; | 299 | const entries: SearchableWorkspaceEntry[] = []; |
| ... | @@ -336,7 +341,7 @@ export const makeWorkspaceEntries = Effect.gen(function* () { | ... | @@ -336,7 +341,7 @@ export const makeWorkspaceEntries = Effect.gen(function* () { |
| 336 | candidateEntries.map((entry) => entry.relativePath), | 341 | candidateEntries.map((entry) => entry.relativePath), |
| 337 | ); | 342 | ); |
| 338 | const allowedPathSet = shouldFilterWithGitIgnore | 343 | const allowedPathSet = shouldFilterWithGitIgnore |
| 339 | ? new Set(yield* filterGitIgnoredPaths(cwd, candidatePaths)) | 344 | ? new Set(yield* filterVcsIgnoredPaths(cwd, candidatePaths)) |
| 340 | : null; | 345 | : null; |
| 341 | 346 | ||
| 342 | for (const candidateEntries of candidateEntriesByDirectory) { | 347 | for (const candidateEntries of candidateEntriesByDirectory) { |
| ... | @@ -368,8 +373,9 @@ export const makeWorkspaceEntries = Effect.gen(function* () { | ... | @@ -368,8 +373,9 @@ export const makeWorkspaceEntries = Effect.gen(function* () { |
| 368 | } | 373 | } |
| 369 | } | 374 | } |
| 370 | 375 | ||
| 376 | const now = yield* DateTime.now; | ||
| 371 | return { | 377 | return { |
| 372 | scannedAt: Date.now(), | 378 | scannedAt: now.epochMilliseconds, |
| 373 | entries, | 379 | entries, |
| 374 | truncated, | 380 | truncated, |
| 375 | }; | 381 | }; |
| ... | @@ -378,9 +384,9 @@ export const makeWorkspaceEntries = Effect.gen(function* () { | ... | @@ -378,9 +384,9 @@ export const makeWorkspaceEntries = Effect.gen(function* () { |
| 378 | const buildWorkspaceIndex = Effect.fn("WorkspaceEntries.buildWorkspaceIndex")(function* ( | 384 | const buildWorkspaceIndex = Effect.fn("WorkspaceEntries.buildWorkspaceIndex")(function* ( |
| 379 | cwd: string, | 385 | cwd: string, |
| 380 | ): Effect.fn.Return<WorkspaceIndex, WorkspaceEntriesError> { | 386 | ): Effect.fn.Return<WorkspaceIndex, WorkspaceEntriesError> { |
| 381 | const gitIndexed = yield* buildWorkspaceIndexFromGit(cwd); | 387 | const vcsIndexed = yield* buildWorkspaceIndexFromVcs(cwd); |
| 382 | if (gitIndexed) { | 388 | if (vcsIndexed) { |
| 383 | return gitIndexed; | 389 | return vcsIndexed; |
| 384 | } | 390 | } |
| 385 | return yield* buildWorkspaceIndexFromFilesystem(cwd); | 391 | return yield* buildWorkspaceIndexFromFilesystem(cwd); |
| 386 | }); | 392 | }); |
apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts+3-2| ... | @@ -3,7 +3,8 @@ import { it, describe, expect } from "@effect/vitest"; | ... | @@ -3,7 +3,8 @@ import { it, describe, expect } from "@effect/vitest"; |
| 3 | import { Effect, FileSystem, Layer, Path } from "effect"; | 3 | import { Effect, FileSystem, Layer, Path } from "effect"; |
| 4 | 4 | ||
| 5 | import { ServerConfig } from "../../config.ts"; | 5 | import { ServerConfig } from "../../config.ts"; |
| 6 | import { GitCoreLive } from "../../git/Layers/GitCore.ts"; | 6 | import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; |
| 7 | import * as VcsProcess from "../../vcs/VcsProcess.ts"; | ||
| 7 | import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; | 8 | import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; |
| 8 | import { WorkspaceFileSystem } from "../Services/WorkspaceFileSystem.ts"; | 9 | import { WorkspaceFileSystem } from "../Services/WorkspaceFileSystem.ts"; |
| 9 | import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; | 10 | import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; |
| ... | @@ -19,7 +20,7 @@ const TestLayer = Layer.empty.pipe( | ... | @@ -19,7 +20,7 @@ const TestLayer = Layer.empty.pipe( |
| 19 | Layer.provideMerge(ProjectLayer), | 20 | Layer.provideMerge(ProjectLayer), |
| 20 | Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), | 21 | Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), |
| 21 | Layer.provideMerge(WorkspacePathsLive), | 22 | Layer.provideMerge(WorkspacePathsLive), |
| 22 | Layer.provideMerge(GitCoreLive), | 23 | Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), |
| 23 | Layer.provide( | 24 | Layer.provide( |
| 24 | ServerConfig.layerTest(process.cwd(), { | 25 | ServerConfig.layerTest(process.cwd(), { |
| 25 | prefix: "t3-workspace-files-test-", | 26 | prefix: "t3-workspace-files-test-", |
apps/server/src/ws.ts+74-54| ... | @@ -29,9 +29,6 @@ import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; | ... | @@ -29,9 +29,6 @@ import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; |
| 29 | 29 | ||
| 30 | import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery.ts"; | 30 | import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery.ts"; |
| 31 | import { ServerConfig } from "./config.ts"; | 31 | import { ServerConfig } from "./config.ts"; |
| 32 | import { GitCore } from "./git/Services/GitCore.ts"; | ||
| 33 | import { GitManager } from "./git/Services/GitManager.ts"; | ||
| 34 | import { GitStatusBroadcaster } from "./git/Services/GitStatusBroadcaster.ts"; | ||
| 35 | import { Keybindings } from "./keybindings.ts"; | 32 | import { Keybindings } from "./keybindings.ts"; |
| 36 | import { Open, resolveAvailableEditors } from "./open.ts"; | 33 | import { Open, resolveAvailableEditors } from "./open.ts"; |
| 37 | import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; | 34 | import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; |
| ... | @@ -50,10 +47,15 @@ import { TerminalManager } from "./terminal/Services/Manager.ts"; | ... | @@ -50,10 +47,15 @@ import { TerminalManager } from "./terminal/Services/Manager.ts"; |
| 50 | import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; | 47 | import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; |
| 51 | import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; | 48 | import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; |
| 52 | import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; | 49 | import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; |
| 50 | import { VcsStatusBroadcaster } from "./vcs/VcsStatusBroadcaster.ts"; | ||
| 51 | import { VcsProvisioningService } from "./vcs/VcsProvisioningService.ts"; | ||
| 52 | import { GitWorkflowService } from "./git/GitWorkflowService.ts"; | ||
| 53 | import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner.ts"; | 53 | import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner.ts"; |
| 54 | import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver.ts"; | 54 | import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver.ts"; |
| 55 | import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; | 55 | import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; |
| 56 | import { ServerAuth } from "./auth/Services/ServerAuth.ts"; | 56 | import { ServerAuth } from "./auth/Services/ServerAuth.ts"; |
| 57 | import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts"; | ||
| 58 | import * as VcsProcess from "./vcs/VcsProcess.ts"; | ||
| 57 | import { | 59 | import { |
| 58 | BootstrapCredentialService, | 60 | BootstrapCredentialService, |
| 59 | type BootstrapCredentialChange, | 61 | type BootstrapCredentialChange, |
| ... | @@ -136,9 +138,9 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -136,9 +138,9 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 136 | const checkpointDiffQuery = yield* CheckpointDiffQuery; | 138 | const checkpointDiffQuery = yield* CheckpointDiffQuery; |
| 137 | const keybindings = yield* Keybindings; | 139 | const keybindings = yield* Keybindings; |
| 138 | const open = yield* Open; | 140 | const open = yield* Open; |
| 139 | const gitManager = yield* GitManager; | 141 | const gitWorkflow = yield* GitWorkflowService; |
| 140 | const git = yield* GitCore; | 142 | const vcsProvisioning = yield* VcsProvisioningService; |
| 141 | const gitStatusBroadcaster = yield* GitStatusBroadcaster; | 143 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; |
| 142 | const terminalManager = yield* TerminalManager; | 144 | const terminalManager = yield* TerminalManager; |
| 143 | const providerRegistry = yield* ProviderRegistry; | 145 | const providerRegistry = yield* ProviderRegistry; |
| 144 | const config = yield* ServerConfig; | 146 | const config = yield* ServerConfig; |
| ... | @@ -151,6 +153,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -151,6 +153,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 151 | const repositoryIdentityResolver = yield* RepositoryIdentityResolver; | 153 | const repositoryIdentityResolver = yield* RepositoryIdentityResolver; |
| 152 | const serverEnvironment = yield* ServerEnvironment; | 154 | const serverEnvironment = yield* ServerEnvironment; |
| 153 | const serverAuth = yield* ServerAuth; | 155 | const serverAuth = yield* ServerAuth; |
| 156 | const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery; | ||
| 154 | const bootstrapCredentials = yield* BootstrapCredentialService; | 157 | const bootstrapCredentials = yield* BootstrapCredentialService; |
| 155 | const sessions = yield* SessionCredentialService; | 158 | const sessions = yield* SessionCredentialService; |
| 156 | const serverCommandId = (tag: string) => | 159 | const serverCommandId = (tag: string) => |
| ... | @@ -453,10 +456,10 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -453,10 +456,10 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 453 | } | 456 | } |
| 454 | 457 | ||
| 455 | if (bootstrap?.prepareWorktree) { | 458 | if (bootstrap?.prepareWorktree) { |
| 456 | const worktree = yield* git.createWorktree({ | 459 | const worktree = yield* gitWorkflow.createWorktree({ |
| 457 | cwd: bootstrap.prepareWorktree.projectCwd, | 460 | cwd: bootstrap.prepareWorktree.projectCwd, |
| 458 | branch: bootstrap.prepareWorktree.baseBranch, | 461 | refName: bootstrap.prepareWorktree.baseBranch, |
| 459 | newBranch: bootstrap.prepareWorktree.branch, | 462 | newRefName: bootstrap.prepareWorktree.branch, |
| 460 | path: null, | 463 | path: null, |
| 461 | }); | 464 | }); |
| 462 | targetWorktreePath = worktree.worktree.path; | 465 | targetWorktreePath = worktree.worktree.path; |
| ... | @@ -464,7 +467,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -464,7 +467,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 464 | type: "thread.meta.update", | 467 | type: "thread.meta.update", |
| 465 | commandId: serverCommandId("bootstrap-thread-meta-update"), | 468 | commandId: serverCommandId("bootstrap-thread-meta-update"), |
| 466 | threadId: command.threadId, | 469 | threadId: command.threadId, |
| 467 | branch: worktree.worktree.branch, | 470 | branch: worktree.worktree.refName, |
| 468 | worktreePath: targetWorktreePath, | 471 | worktreePath: targetWorktreePath, |
| 469 | }); | 472 | }); |
| 470 | yield* refreshGitStatus(targetWorktreePath); | 473 | yield* refreshGitStatus(targetWorktreePath); |
| ... | @@ -540,7 +543,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -540,7 +543,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 540 | }); | 543 | }); |
| 541 | 544 | ||
| 542 | const refreshGitStatus = (cwd: string) => | 545 | const refreshGitStatus = (cwd: string) => |
| 543 | gitStatusBroadcaster | 546 | vcsStatusBroadcaster |
| 544 | .refreshStatus(cwd) | 547 | .refreshStatus(cwd) |
| 545 | .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); | 548 | .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); |
| 546 | 549 | ||
| ... | @@ -783,6 +786,14 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -783,6 +786,14 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 783 | "rpc.aggregate": "server", | 786 | "rpc.aggregate": "server", |
| 784 | }, | 787 | }, |
| 785 | ), | 788 | ), |
| 789 | [WS_METHODS.serverDiscoverSourceControl]: (_input) => | ||
| 790 | observeRpcEffect( | ||
| 791 | WS_METHODS.serverDiscoverSourceControl, | ||
| 792 | sourceControlDiscovery.discover, | ||
| 793 | { | ||
| 794 | "rpc.aggregate": "server", | ||
| 795 | }, | ||
| 796 | ), | ||
| 786 | [WS_METHODS.projectsSearchEntries]: (input) => | 797 | [WS_METHODS.projectsSearchEntries]: (input) => |
| 787 | observeRpcEffect( | 798 | observeRpcEffect( |
| 788 | WS_METHODS.projectsSearchEntries, | 799 | WS_METHODS.projectsSearchEntries, |
| ... | @@ -831,26 +842,26 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -831,26 +842,26 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 831 | ), | 842 | ), |
| 832 | { "rpc.aggregate": "workspace" }, | 843 | { "rpc.aggregate": "workspace" }, |
| 833 | ), | 844 | ), |
| 834 | [WS_METHODS.subscribeGitStatus]: (input) => | 845 | [WS_METHODS.subscribeVcsStatus]: (input) => |
| 835 | observeRpcStream( | 846 | observeRpcStream( |
| 836 | WS_METHODS.subscribeGitStatus, | 847 | WS_METHODS.subscribeVcsStatus, |
| 837 | gitStatusBroadcaster.streamStatus(input), | 848 | vcsStatusBroadcaster.streamStatus(input), |
| 838 | { | 849 | { |
| 839 | "rpc.aggregate": "git", | 850 | "rpc.aggregate": "vcs", |
| 840 | }, | 851 | }, |
| 841 | ), | 852 | ), |
| 842 | [WS_METHODS.gitRefreshStatus]: (input) => | 853 | [WS_METHODS.vcsRefreshStatus]: (input) => |
| 843 | observeRpcEffect( | 854 | observeRpcEffect( |
| 844 | WS_METHODS.gitRefreshStatus, | 855 | WS_METHODS.vcsRefreshStatus, |
| 845 | gitStatusBroadcaster.refreshStatus(input.cwd), | 856 | vcsStatusBroadcaster.refreshStatus(input.cwd), |
| 846 | { | 857 | { |
| 847 | "rpc.aggregate": "git", | 858 | "rpc.aggregate": "vcs", |
| 848 | }, | 859 | }, |
| 849 | ), | 860 | ), |
| 850 | [WS_METHODS.gitPull]: (input) => | 861 | [WS_METHODS.vcsPull]: (input) => |
| 851 | observeRpcEffect( | 862 | observeRpcEffect( |
| 852 | WS_METHODS.gitPull, | 863 | WS_METHODS.vcsPull, |
| 853 | git.pullCurrentBranch(input.cwd).pipe( | 864 | gitWorkflow.pullCurrentBranch(input.cwd).pipe( |
| 854 | Effect.matchCauseEffect({ | 865 | Effect.matchCauseEffect({ |
| 855 | onFailure: (cause) => Effect.failCause(cause), | 866 | onFailure: (cause) => Effect.failCause(cause), |
| 856 | onSuccess: (result) => | 867 | onSuccess: (result) => |
| ... | @@ -863,7 +874,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -863,7 +874,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 863 | observeRpcStream( | 874 | observeRpcStream( |
| 864 | WS_METHODS.gitRunStackedAction, | 875 | WS_METHODS.gitRunStackedAction, |
| 865 | Stream.callback<GitActionProgressEvent, GitManagerServiceError>((queue) => | 876 | Stream.callback<GitActionProgressEvent, GitManagerServiceError>((queue) => |
| 866 | gitManager | 877 | gitWorkflow |
| 867 | .runStackedAction(input, { | 878 | .runStackedAction(input, { |
| 868 | actionId: input.actionId, | 879 | actionId: input.actionId, |
| 869 | progressReporter: { | 880 | progressReporter: { |
| ... | @@ -880,55 +891,59 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => | ... | @@ -880,55 +891,59 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => |
| 880 | }), | 891 | }), |
| 881 | ), | 892 | ), |
| 882 | ), | 893 | ), |
| 883 | { "rpc.aggregate": "git" }, | 894 | { "rpc.aggregate": "vcs" }, |
| 884 | ), | 895 | ), |
| 885 | [WS_METHODS.gitResolvePullRequest]: (input) => | 896 | [WS_METHODS.gitResolvePullRequest]: (input) => |
| 886 | observeRpcEffect(WS_METHODS.gitResolvePullRequest, gitManager.resolvePullRequest(input), { | 897 | observeRpcEffect( |
| 887 | "rpc.aggregate": "git", | 898 | WS_METHODS.gitResolvePullRequest, |
| 888 | }), | 899 | gitWorkflow.resolvePullRequest(input), |
| 900 | { | ||
| 901 | "rpc.aggregate": "git", | ||
| 902 | }, | ||
| 903 | ), | ||
| 889 | [WS_METHODS.gitPreparePullRequestThread]: (input) => | 904 | [WS_METHODS.gitPreparePullRequestThread]: (input) => |
| 890 | observeRpcEffect( | 905 | observeRpcEffect( |
| 891 | WS_METHODS.gitPreparePullRequestThread, | 906 | WS_METHODS.gitPreparePullRequestThread, |
| 892 | gitManager | 907 | gitWorkflow |
| 893 | .preparePullRequestThread(input) | 908 | .preparePullRequestThread(input) |
| 894 | .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), | 909 | .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), |
| 895 | { "rpc.aggregate": "git" }, | 910 | { "rpc.aggregate": "git" }, |
| 896 | ), | 911 | ), |
| 897 | [WS_METHODS.gitListBranches]: (input) => | 912 | [WS_METHODS.vcsListRefs]: (input) => |
| 898 | observeRpcEffect(WS_METHODS.gitListBranches, git.listBranches(input), { | 913 | observeRpcEffect(WS_METHODS.vcsListRefs, gitWorkflow.listRefs(input), { |
| 899 | "rpc.aggregate": "git", | 914 | "rpc.aggregate": "vcs", |
| 900 | }), | 915 | }), |
| 901 | [WS_METHODS.gitCreateWorktree]: (input) => | 916 | [WS_METHODS.vcsCreateWorktree]: (input) => |
| 902 | observeRpcEffect( | 917 | observeRpcEffect( |
| 903 | WS_METHODS.gitCreateWorktree, | 918 | WS_METHODS.vcsCreateWorktree, |
| 904 | git.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), | 919 | gitWorkflow.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), |
| 905 | { "rpc.aggregate": "git" }, | 920 | { "rpc.aggregate": "vcs" }, |
| 906 | ), | 921 | ), |
| 907 | [WS_METHODS.gitRemoveWorktree]: (input) => | 922 | [WS_METHODS.vcsRemoveWorktree]: (input) => |
| 908 | observeRpcEffect( | 923 | observeRpcEffect( |
| 909 | WS_METHODS.gitRemoveWorktree, | 924 | WS_METHODS.vcsRemoveWorktree, |
| 910 | git.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), | 925 | gitWorkflow.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), |
| 911 | { "rpc.aggregate": "git" }, | 926 | { "rpc.aggregate": "vcs" }, |
| 912 | ), | 927 | ), |
| 913 | [WS_METHODS.gitCreateBranch]: (input) => | 928 | [WS_METHODS.vcsCreateRef]: (input) => |
| 914 | observeRpcEffect( | 929 | observeRpcEffect( |
| 915 | WS_METHODS.gitCreateBranch, | 930 | WS_METHODS.vcsCreateRef, |
| 916 | git.createBranch(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), | 931 | gitWorkflow.createRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), |
| 917 | { "rpc.aggregate": "git" }, | 932 | { "rpc.aggregate": "vcs" }, |
| 918 | ), | 933 | ), |
| 919 | [WS_METHODS.gitCheckout]: (input) => | 934 | [WS_METHODS.vcsSwitchRef]: (input) => |
| 920 | observeRpcEffect( | 935 | observeRpcEffect( |
| 921 | WS_METHODS.gitCheckout, | 936 | WS_METHODS.vcsSwitchRef, |
| 922 | Effect.scoped(git.checkoutBranch(input)).pipe( | 937 | gitWorkflow.switchRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), |
| 923 | Effect.tap(() => refreshGitStatus(input.cwd)), | 938 | { "rpc.aggregate": "vcs" }, |
| 924 | ), | ||
| 925 | { "rpc.aggregate": "git" }, | ||
| 926 | ), | 939 | ), |
| 927 | [WS_METHODS.gitInit]: (input) => | 940 | [WS_METHODS.vcsInit]: (input) => |
| 928 | observeRpcEffect( | 941 | observeRpcEffect( |
| 929 | WS_METHODS.gitInit, | 942 | WS_METHODS.vcsInit, |
| 930 | git.initRepo(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), | 943 | vcsProvisioning |
| 931 | { "rpc.aggregate": "git" }, | 944 | .initRepository(input) |
| 945 | .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), | ||
| 946 | { "rpc.aggregate": "vcs" }, | ||
| 932 | ), | 947 | ), |
| 933 | [WS_METHODS.terminalOpen]: (input) => | 948 | [WS_METHODS.terminalOpen]: (input) => |
| 934 | observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { | 949 | observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { |
| ... | @@ -1084,7 +1099,12 @@ export const websocketRpcRouteLayer = Layer.unwrap( | ... | @@ -1084,7 +1099,12 @@ export const websocketRpcRouteLayer = Layer.unwrap( |
| 1084 | }, | 1099 | }, |
| 1085 | }).pipe( | 1100 | }).pipe( |
| 1086 | Effect.provide( | 1101 | Effect.provide( |
| 1087 | makeWsRpcLayer(session.sessionId).pipe(Layer.provideMerge(RpcSerialization.layerJson)), | 1102 | makeWsRpcLayer(session.sessionId).pipe( |
| 1103 | Layer.provideMerge(RpcSerialization.layerJson), | ||
| 1104 | Layer.provide( | ||
| 1105 | SourceControlDiscoveryLayer.layer.pipe(Layer.provide(VcsProcess.layer)), | ||
| 1106 | ), | ||
| 1107 | ), | ||
| 1088 | ), | 1108 | ), |
| 1089 | ); | 1109 | ); |
| 1090 | return yield* Effect.acquireUseRelease( | 1110 | return yield* Effect.acquireUseRelease( |
apps/web/src/components/BranchToolbar.logic.test.ts+26-26| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { EnvironmentId, type GitBranch } from "@t3tools/contracts"; | 1 | import { EnvironmentId, type VcsRef } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vitest"; | 2 | import { describe, expect, it } from "vitest"; |
| 3 | import { | 3 | import { |
| 4 | dedupeRemoteBranchesWithLocalMatches, | 4 | dedupeRemoteBranchesWithLocalMatches, |
| ... | @@ -28,7 +28,7 @@ describe("resolveDraftEnvModeAfterBranchChange", () => { | ... | @@ -28,7 +28,7 @@ describe("resolveDraftEnvModeAfterBranchChange", () => { |
| 28 | ).toBe("local"); | 28 | ).toBe("local"); |
| 29 | }); | 29 | }); |
| 30 | 30 | ||
| 31 | it("keeps new-worktree mode when selecting a base branch before worktree creation", () => { | 31 | it("keeps new-worktree mode when selecting a base ref before worktree creation", () => { |
| 32 | expect( | 32 | expect( |
| 33 | resolveDraftEnvModeAfterBranchChange({ | 33 | resolveDraftEnvModeAfterBranchChange({ |
| 34 | nextWorktreePath: null, | 34 | nextWorktreePath: null, |
| ... | @@ -38,7 +38,7 @@ describe("resolveDraftEnvModeAfterBranchChange", () => { | ... | @@ -38,7 +38,7 @@ describe("resolveDraftEnvModeAfterBranchChange", () => { |
| 38 | ).toBe("worktree"); | 38 | ).toBe("worktree"); |
| 39 | }); | 39 | }); |
| 40 | 40 | ||
| 41 | it("uses worktree mode when selecting a branch already attached to a worktree", () => { | 41 | it("uses worktree mode when selecting a ref already attached to a worktree", () => { |
| 42 | expect( | 42 | expect( |
| 43 | resolveDraftEnvModeAfterBranchChange({ | 43 | resolveDraftEnvModeAfterBranchChange({ |
| 44 | nextWorktreePath: "/repo/.t3/worktrees/feature-a", | 44 | nextWorktreePath: "/repo/.t3/worktrees/feature-a", |
| ... | @@ -50,7 +50,7 @@ describe("resolveDraftEnvModeAfterBranchChange", () => { | ... | @@ -50,7 +50,7 @@ describe("resolveDraftEnvModeAfterBranchChange", () => { |
| 50 | }); | 50 | }); |
| 51 | 51 | ||
| 52 | describe("resolveBranchToolbarValue", () => { | 52 | describe("resolveBranchToolbarValue", () => { |
| 53 | it("defaults new-worktree mode to current git branch when no explicit base branch is set", () => { | 53 | it("defaults new-worktree mode to current git ref when no explicit base ref is set", () => { |
| 54 | expect( | 54 | expect( |
| 55 | resolveBranchToolbarValue({ | 55 | resolveBranchToolbarValue({ |
| 56 | envMode: "worktree", | 56 | envMode: "worktree", |
| ... | @@ -61,7 +61,7 @@ describe("resolveBranchToolbarValue", () => { | ... | @@ -61,7 +61,7 @@ describe("resolveBranchToolbarValue", () => { |
| 61 | ).toBe("main"); | 61 | ).toBe("main"); |
| 62 | }); | 62 | }); |
| 63 | 63 | ||
| 64 | it("keeps an explicitly selected worktree base branch", () => { | 64 | it("keeps an explicitly selected worktree base ref", () => { |
| 65 | expect( | 65 | expect( |
| 66 | resolveBranchToolbarValue({ | 66 | resolveBranchToolbarValue({ |
| 67 | envMode: "worktree", | 67 | envMode: "worktree", |
| ... | @@ -72,7 +72,7 @@ describe("resolveBranchToolbarValue", () => { | ... | @@ -72,7 +72,7 @@ describe("resolveBranchToolbarValue", () => { |
| 72 | ).toBe("feature/base"); | 72 | ).toBe("feature/base"); |
| 73 | }); | 73 | }); |
| 74 | 74 | ||
| 75 | it("shows the actual checked-out branch when not selecting a new worktree base", () => { | 75 | it("shows the actual checked-out ref when not selecting a new worktree base", () => { |
| 76 | expect( | 76 | expect( |
| 77 | resolveBranchToolbarValue({ | 77 | resolveBranchToolbarValue({ |
| 78 | envMode: "local", | 78 | envMode: "local", |
| ... | @@ -186,8 +186,8 @@ describe("deriveLocalBranchNameFromRemoteRef", () => { | ... | @@ -186,8 +186,8 @@ describe("deriveLocalBranchNameFromRemoteRef", () => { |
| 186 | }); | 186 | }); |
| 187 | 187 | ||
| 188 | describe("dedupeRemoteBranchesWithLocalMatches", () => { | 188 | describe("dedupeRemoteBranchesWithLocalMatches", () => { |
| 189 | it("hides remote refs when the matching local branch exists", () => { | 189 | it("hides remote refs when the matching local ref exists", () => { |
| 190 | const input: GitBranch[] = [ | 190 | const input: VcsRef[] = [ |
| 191 | { | 191 | { |
| 192 | name: "feature/demo", | 192 | name: "feature/demo", |
| 193 | current: false, | 193 | current: false, |
| ... | @@ -212,14 +212,14 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { | ... | @@ -212,14 +212,14 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { |
| 212 | }, | 212 | }, |
| 213 | ]; | 213 | ]; |
| 214 | 214 | ||
| 215 | expect(dedupeRemoteBranchesWithLocalMatches(input).map((branch) => branch.name)).toEqual([ | 215 | expect(dedupeRemoteBranchesWithLocalMatches(input).map((ref) => ref.name)).toEqual([ |
| 216 | "feature/demo", | 216 | "feature/demo", |
| 217 | "origin/feature/remote-only", | 217 | "origin/feature/remote-only", |
| 218 | ]); | 218 | ]); |
| 219 | }); | 219 | }); |
| 220 | 220 | ||
| 221 | it("keeps all entries when no local match exists for a remote ref", () => { | 221 | it("keeps all entries when no local match exists for a remote ref", () => { |
| 222 | const input: GitBranch[] = [ | 222 | const input: VcsRef[] = [ |
| 223 | { | 223 | { |
| 224 | name: "feature/local", | 224 | name: "feature/local", |
| 225 | current: false, | 225 | current: false, |
| ... | @@ -236,14 +236,14 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { | ... | @@ -236,14 +236,14 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { |
| 236 | }, | 236 | }, |
| 237 | ]; | 237 | ]; |
| 238 | 238 | ||
| 239 | expect(dedupeRemoteBranchesWithLocalMatches(input).map((branch) => branch.name)).toEqual([ | 239 | expect(dedupeRemoteBranchesWithLocalMatches(input).map((ref) => ref.name)).toEqual([ |
| 240 | "feature/local", | 240 | "feature/local", |
| 241 | "origin/feature/remote-only", | 241 | "origin/feature/remote-only", |
| 242 | ]); | 242 | ]); |
| 243 | }); | 243 | }); |
| 244 | 244 | ||
| 245 | it("keeps non-origin remote refs visible even when a matching local branch exists", () => { | 245 | it("keeps non-origin remote refs visible even when a matching local ref exists", () => { |
| 246 | const input: GitBranch[] = [ | 246 | const input: VcsRef[] = [ |
| 247 | { | 247 | { |
| 248 | name: "feature/demo", | 248 | name: "feature/demo", |
| 249 | current: false, | 249 | current: false, |
| ... | @@ -260,14 +260,14 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { | ... | @@ -260,14 +260,14 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { |
| 260 | }, | 260 | }, |
| 261 | ]; | 261 | ]; |
| 262 | 262 | ||
| 263 | expect(dedupeRemoteBranchesWithLocalMatches(input).map((branch) => branch.name)).toEqual([ | 263 | expect(dedupeRemoteBranchesWithLocalMatches(input).map((ref) => ref.name)).toEqual([ |
| 264 | "feature/demo", | 264 | "feature/demo", |
| 265 | "my-org/upstream/feature/demo", | 265 | "my-org/upstream/feature/demo", |
| 266 | ]); | 266 | ]); |
| 267 | }); | 267 | }); |
| 268 | 268 | ||
| 269 | it("keeps non-origin remote refs visible when git tracks with first-slash local naming", () => { | 269 | it("keeps non-origin remote refs visible when git tracks with first-slash local naming", () => { |
| 270 | const input: GitBranch[] = [ | 270 | const input: VcsRef[] = [ |
| 271 | { | 271 | { |
| 272 | name: "upstream/feature", | 272 | name: "upstream/feature", |
| 273 | current: false, | 273 | current: false, |
| ... | @@ -284,7 +284,7 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { | ... | @@ -284,7 +284,7 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { |
| 284 | }, | 284 | }, |
| 285 | ]; | 285 | ]; |
| 286 | 286 | ||
| 287 | expect(dedupeRemoteBranchesWithLocalMatches(input).map((branch) => branch.name)).toEqual([ | 287 | expect(dedupeRemoteBranchesWithLocalMatches(input).map((ref) => ref.name)).toEqual([ |
| 288 | "upstream/feature", | 288 | "upstream/feature", |
| 289 | "my-org/upstream/feature", | 289 | "my-org/upstream/feature", |
| 290 | ]); | 290 | ]); |
| ... | @@ -292,12 +292,12 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { | ... | @@ -292,12 +292,12 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { |
| 292 | }); | 292 | }); |
| 293 | 293 | ||
| 294 | describe("resolveBranchSelectionTarget", () => { | 294 | describe("resolveBranchSelectionTarget", () => { |
| 295 | it("reuses an existing secondary worktree for the selected branch", () => { | 295 | it("reuses an existing secondary worktree for the selected ref", () => { |
| 296 | expect( | 296 | expect( |
| 297 | resolveBranchSelectionTarget({ | 297 | resolveBranchSelectionTarget({ |
| 298 | activeProjectCwd: "/repo", | 298 | activeProjectCwd: "/repo", |
| 299 | activeWorktreePath: "/repo/.t3/worktrees/feature-a", | 299 | activeWorktreePath: "/repo/.t3/worktrees/feature-a", |
| 300 | branch: { | 300 | refName: { |
| 301 | isDefault: false, | 301 | isDefault: false, |
| 302 | worktreePath: "/repo/.t3/worktrees/feature-b", | 302 | worktreePath: "/repo/.t3/worktrees/feature-b", |
| 303 | }, | 303 | }, |
| ... | @@ -309,12 +309,12 @@ describe("resolveBranchSelectionTarget", () => { | ... | @@ -309,12 +309,12 @@ describe("resolveBranchSelectionTarget", () => { |
| 309 | }); | 309 | }); |
| 310 | }); | 310 | }); |
| 311 | 311 | ||
| 312 | it("switches back to the main repo when the branch already lives there", () => { | 312 | it("switches back to the main repo when the ref already lives there", () => { |
| 313 | expect( | 313 | expect( |
| 314 | resolveBranchSelectionTarget({ | 314 | resolveBranchSelectionTarget({ |
| 315 | activeProjectCwd: "/repo", | 315 | activeProjectCwd: "/repo", |
| 316 | activeWorktreePath: "/repo/.t3/worktrees/feature-a", | 316 | activeWorktreePath: "/repo/.t3/worktrees/feature-a", |
| 317 | branch: { | 317 | refName: { |
| 318 | isDefault: true, | 318 | isDefault: true, |
| 319 | worktreePath: "/repo", | 319 | worktreePath: "/repo", |
| 320 | }, | 320 | }, |
| ... | @@ -326,12 +326,12 @@ describe("resolveBranchSelectionTarget", () => { | ... | @@ -326,12 +326,12 @@ describe("resolveBranchSelectionTarget", () => { |
| 326 | }); | 326 | }); |
| 327 | }); | 327 | }); |
| 328 | 328 | ||
| 329 | it("checks out the default branch in the main repo when leaving a secondary worktree", () => { | 329 | it("checks out the default ref in the main repo when leaving a secondary worktree", () => { |
| 330 | expect( | 330 | expect( |
| 331 | resolveBranchSelectionTarget({ | 331 | resolveBranchSelectionTarget({ |
| 332 | activeProjectCwd: "/repo", | 332 | activeProjectCwd: "/repo", |
| 333 | activeWorktreePath: "/repo/.t3/worktrees/feature-a", | 333 | activeWorktreePath: "/repo/.t3/worktrees/feature-a", |
| 334 | branch: { | 334 | refName: { |
| 335 | isDefault: true, | 335 | isDefault: true, |
| 336 | worktreePath: null, | 336 | worktreePath: null, |
| 337 | }, | 337 | }, |
| ... | @@ -343,12 +343,12 @@ describe("resolveBranchSelectionTarget", () => { | ... | @@ -343,12 +343,12 @@ describe("resolveBranchSelectionTarget", () => { |
| 343 | }); | 343 | }); |
| 344 | }); | 344 | }); |
| 345 | 345 | ||
| 346 | it("keeps checkout in the current worktree for non-default branches", () => { | 346 | it("keeps checkout in the current worktree for non-default refs", () => { |
| 347 | expect( | 347 | expect( |
| 348 | resolveBranchSelectionTarget({ | 348 | resolveBranchSelectionTarget({ |
| 349 | activeProjectCwd: "/repo", | 349 | activeProjectCwd: "/repo", |
| 350 | activeWorktreePath: "/repo/.t3/worktrees/feature-a", | 350 | activeWorktreePath: "/repo/.t3/worktrees/feature-a", |
| 351 | branch: { | 351 | refName: { |
| 352 | isDefault: false, | 352 | isDefault: false, |
| 353 | worktreePath: null, | 353 | worktreePath: null, |
| 354 | }, | 354 | }, |
| ... | @@ -373,7 +373,7 @@ describe("shouldIncludeBranchPickerItem", () => { | ... | @@ -373,7 +373,7 @@ describe("shouldIncludeBranchPickerItem", () => { |
| 373 | ).toBe(true); | 373 | ).toBe(true); |
| 374 | }); | 374 | }); |
| 375 | 375 | ||
| 376 | it("keeps the synthetic create-branch item visible for arbitrary branch input", () => { | 376 | it("keeps the synthetic create-ref item visible for arbitrary ref input", () => { |
| 377 | expect( | 377 | expect( |
| 378 | shouldIncludeBranchPickerItem({ | 378 | shouldIncludeBranchPickerItem({ |
| 379 | itemValue: "__create_new_branch__:feature/demo", | 379 | itemValue: "__create_new_branch__:feature/demo", |
| ... | @@ -384,7 +384,7 @@ describe("shouldIncludeBranchPickerItem", () => { | ... | @@ -384,7 +384,7 @@ describe("shouldIncludeBranchPickerItem", () => { |
| 384 | ).toBe(true); | 384 | ).toBe(true); |
| 385 | }); | 385 | }); |
| 386 | 386 | ||
| 387 | it("still filters ordinary branch items by query text", () => { | 387 | it("still filters ordinary ref items by query text", () => { |
| 388 | expect( | 388 | expect( |
| 389 | shouldIncludeBranchPickerItem({ | 389 | shouldIncludeBranchPickerItem({ |
| 390 | itemValue: "main", | 390 | itemValue: "main", |
apps/web/src/components/BranchToolbar.logic.ts+7-7| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { EnvironmentId, GitBranch, ProjectId } from "@t3tools/contracts"; | 1 | import type { EnvironmentId, VcsRef, ProjectId } from "@t3tools/contracts"; |
| 2 | import { Schema } from "effect"; | 2 | import { Schema } from "effect"; |
| 3 | export { | 3 | export { |
| 4 | dedupeRemoteBranchesWithLocalMatches, | 4 | dedupeRemoteBranchesWithLocalMatches, |
| ... | @@ -100,24 +100,24 @@ export function resolveBranchToolbarValue(input: { | ... | @@ -100,24 +100,24 @@ export function resolveBranchToolbarValue(input: { |
| 100 | export function resolveBranchSelectionTarget(input: { | 100 | export function resolveBranchSelectionTarget(input: { |
| 101 | activeProjectCwd: string; | 101 | activeProjectCwd: string; |
| 102 | activeWorktreePath: string | null; | 102 | activeWorktreePath: string | null; |
| 103 | branch: Pick<GitBranch, "isDefault" | "worktreePath">; | 103 | refName: Pick<VcsRef, "isDefault" | "worktreePath">; |
| 104 | }): { | 104 | }): { |
| 105 | checkoutCwd: string; | 105 | checkoutCwd: string; |
| 106 | nextWorktreePath: string | null; | 106 | nextWorktreePath: string | null; |
| 107 | reuseExistingWorktree: boolean; | 107 | reuseExistingWorktree: boolean; |
| 108 | } { | 108 | } { |
| 109 | const { activeProjectCwd, activeWorktreePath, branch } = input; | 109 | const { activeProjectCwd, activeWorktreePath, refName } = input; |
| 110 | 110 | ||
| 111 | if (branch.worktreePath) { | 111 | if (refName.worktreePath) { |
| 112 | return { | 112 | return { |
| 113 | checkoutCwd: branch.worktreePath, | 113 | checkoutCwd: refName.worktreePath, |
| 114 | nextWorktreePath: branch.worktreePath === activeProjectCwd ? null : branch.worktreePath, | 114 | nextWorktreePath: refName.worktreePath === activeProjectCwd ? null : refName.worktreePath, |
| 115 | reuseExistingWorktree: true, | 115 | reuseExistingWorktree: true, |
| 116 | }; | 116 | }; |
| 117 | } | 117 | } |
| 118 | 118 | ||
| 119 | const nextWorktreePath = | 119 | const nextWorktreePath = |
| 120 | activeWorktreePath !== null && branch.isDefault ? null : activeWorktreePath; | 120 | activeWorktreePath !== null && refName.isDefault ? null : activeWorktreePath; |
| 121 | 121 | ||
| 122 | return { | 122 | return { |
| 123 | checkoutCwd: nextWorktreePath ?? activeProjectCwd, | 123 | checkoutCwd: nextWorktreePath ?? activeProjectCwd, |
apps/web/src/components/BranchToolbarBranchSelector.tsx+61-50| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; | 1 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; |
| 2 | import type { EnvironmentId, GitBranch, ThreadId } from "@t3tools/contracts"; | 2 | import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; |
| 3 | import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; | 3 | import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; |
| 4 | import { LegendList, type LegendListRef } from "@legendapp/list/react"; | 4 | import { LegendList, type LegendListRef } from "@legendapp/list/react"; |
| 5 | import { ChevronDownIcon } from "lucide-react"; | 5 | import { ChevronDownIcon } from "lucide-react"; |
| ... | @@ -21,6 +21,7 @@ import { useGitStatus } from "../lib/gitStatusState"; | ... | @@ -21,6 +21,7 @@ import { useGitStatus } from "../lib/gitStatusState"; |
| 21 | import { newCommandId } from "../lib/utils"; | 21 | import { newCommandId } from "../lib/utils"; |
| 22 | import { cn } from "../lib/utils"; | 22 | import { cn } from "../lib/utils"; |
| 23 | import { parsePullRequestReference } from "../pullRequestReference"; | 23 | import { parsePullRequestReference } from "../pullRequestReference"; |
| 24 | import { getSourceControlPresentation } from "../sourceControlPresentation"; | ||
| 24 | import { useStore } from "../store"; | 25 | import { useStore } from "../store"; |
| 25 | import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; | 26 | import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; |
| 26 | import { | 27 | import { |
| ... | @@ -53,7 +54,7 @@ interface BranchToolbarBranchSelectorProps { | ... | @@ -53,7 +54,7 @@ interface BranchToolbarBranchSelectorProps { |
| 53 | envLocked: boolean; | 54 | envLocked: boolean; |
| 54 | effectiveEnvModeOverride?: "local" | "worktree"; | 55 | effectiveEnvModeOverride?: "local" | "worktree"; |
| 55 | activeThreadBranchOverride?: string | null; | 56 | activeThreadBranchOverride?: string | null; |
| 56 | onActiveThreadBranchOverrideChange?: (branch: string | null) => void; | 57 | onActiveThreadBranchOverrideChange?: (refName: string | null) => void; |
| 57 | onCheckoutPullRequestRequest?: (reference: string) => void; | 58 | onCheckoutPullRequestRequest?: (reference: string) => void; |
| 58 | onComposerFocusRequest?: () => void; | 59 | onComposerFocusRequest?: () => void; |
| 59 | } | 60 | } |
| ... | @@ -69,7 +70,7 @@ function getBranchTriggerLabel(input: { | ... | @@ -69,7 +70,7 @@ function getBranchTriggerLabel(input: { |
| 69 | }): string { | 70 | }): string { |
| 70 | const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch } = input; | 71 | const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch } = input; |
| 71 | if (!resolvedActiveBranch) { | 72 | if (!resolvedActiveBranch) { |
| 72 | return "Select branch"; | 73 | return "Select ref"; |
| 73 | } | 74 | } |
| 74 | if (effectiveEnvMode === "worktree" && !activeWorktreePath) { | 75 | if (effectiveEnvMode === "worktree" && !activeWorktreePath) { |
| 75 | return `From ${resolvedActiveBranch}`; | 76 | return `From ${resolvedActiveBranch}`; |
| ... | @@ -193,7 +194,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -193,7 +194,7 @@ export function BranchToolbarBranchSelector({ |
| 193 | ); | 194 | ); |
| 194 | 195 | ||
| 195 | // --------------------------------------------------------------------------- | 196 | // --------------------------------------------------------------------------- |
| 196 | // Git branch queries | 197 | // Git ref queries |
| 197 | // --------------------------------------------------------------------------- | 198 | // --------------------------------------------------------------------------- |
| 198 | const queryClient = useQueryClient(); | 199 | const queryClient = useQueryClient(); |
| 199 | const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); | 200 | const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); |
| ... | @@ -224,22 +225,27 @@ export function BranchToolbarBranchSelector({ | ... | @@ -224,22 +225,27 @@ export function BranchToolbarBranchSelector({ |
| 224 | query: deferredTrimmedBranchQuery, | 225 | query: deferredTrimmedBranchQuery, |
| 225 | }), | 226 | }), |
| 226 | ); | 227 | ); |
| 227 | const branches = useMemo( | 228 | const refs = useMemo( |
| 228 | () => branchesSearchData?.pages.flatMap((page) => page.branches) ?? [], | 229 | () => branchesSearchData?.pages.flatMap((page) => page.refs) ?? [], |
| 229 | [branchesSearchData?.pages], | 230 | [branchesSearchData?.pages], |
| 230 | ); | 231 | ); |
| 231 | const currentGitBranch = | 232 | const currentGitBranch = |
| 232 | branchStatusQuery.data?.branch ?? branches.find((branch) => branch.current)?.name ?? null; | 233 | branchStatusQuery.data?.refName ?? refs.find((refName) => refName.current)?.name ?? null; |
| 234 | const sourceControlPresentation = useMemo( | ||
| 235 | () => getSourceControlPresentation(branchStatusQuery.data?.sourceControlProvider), | ||
| 236 | [branchStatusQuery.data?.sourceControlProvider], | ||
| 237 | ); | ||
| 238 | const SourceControlIcon = sourceControlPresentation.Icon; | ||
| 233 | const canonicalActiveBranch = resolveBranchToolbarValue({ | 239 | const canonicalActiveBranch = resolveBranchToolbarValue({ |
| 234 | envMode: effectiveEnvMode, | 240 | envMode: effectiveEnvMode, |
| 235 | activeWorktreePath, | 241 | activeWorktreePath, |
| 236 | activeThreadBranch, | 242 | activeThreadBranch, |
| 237 | currentGitBranch, | 243 | currentGitBranch, |
| 238 | }); | 244 | }); |
| 239 | const branchNames = useMemo(() => branches.map((branch) => branch.name), [branches]); | 245 | const branchNames = useMemo(() => refs.map((refName) => refName.name), [refs]); |
| 240 | const branchByName = useMemo( | 246 | const branchByName = useMemo( |
| 241 | () => new Map(branches.map((branch) => [branch.name, branch] as const)), | 247 | () => new Map(refs.map((refName) => [refName.name, refName] as const)), |
| 242 | [branches], | 248 | [refs], |
| 243 | ); | 249 | ); |
| 244 | const normalizedDeferredBranchQuery = deferredTrimmedBranchQuery.toLowerCase(); | 250 | const normalizedDeferredBranchQuery = deferredTrimmedBranchQuery.toLowerCase(); |
| 245 | const prReference = parsePullRequestReference(trimmedBranchQuery); | 251 | const prReference = parsePullRequestReference(trimmedBranchQuery); |
| ... | @@ -289,11 +295,11 @@ export function BranchToolbarBranchSelector({ | ... | @@ -289,11 +295,11 @@ export function BranchToolbarBranchSelector({ |
| 289 | const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40; | 295 | const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40; |
| 290 | const totalBranchCount = branchesSearchData?.pages[0]?.totalCount ?? 0; | 296 | const totalBranchCount = branchesSearchData?.pages[0]?.totalCount ?? 0; |
| 291 | const branchStatusText = isBranchesSearchPending | 297 | const branchStatusText = isBranchesSearchPending |
| 292 | ? "Loading branches..." | 298 | ? "Loading refs..." |
| 293 | : isFetchingNextPage | 299 | : isFetchingNextPage |
| 294 | ? "Loading more branches..." | 300 | ? "Loading more refs..." |
| 295 | : hasNextPage | 301 | : hasNextPage |
| 296 | ? `Showing ${branches.length} of ${totalBranchCount} branches` | 302 | ? `Showing ${refs.length} of ${totalBranchCount} refs` |
| 297 | : null; | 303 | : null; |
| 298 | 304 | ||
| 299 | // --------------------------------------------------------------------------- | 305 | // --------------------------------------------------------------------------- |
| ... | @@ -303,17 +309,17 @@ export function BranchToolbarBranchSelector({ | ... | @@ -303,17 +309,17 @@ export function BranchToolbarBranchSelector({ |
| 303 | startBranchActionTransition(async () => { | 309 | startBranchActionTransition(async () => { |
| 304 | await action().catch(() => undefined); | 310 | await action().catch(() => undefined); |
| 305 | await queryClient | 311 | await queryClient |
| 306 | .invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, branchCwd) }) | 312 | .invalidateQueries({ queryKey: gitQueryKeys.refs(environmentId, branchCwd) }) |
| 307 | .catch(() => undefined); | 313 | .catch(() => undefined); |
| 308 | }); | 314 | }); |
| 309 | }; | 315 | }; |
| 310 | 316 | ||
| 311 | const selectBranch = (branch: GitBranch) => { | 317 | const selectBranch = (refName: VcsRef) => { |
| 312 | const api = readEnvironmentApi(environmentId); | 318 | const api = readEnvironmentApi(environmentId); |
| 313 | if (!api || !branchCwd || !activeProjectCwd || isBranchActionPending) return; | 319 | if (!api || !branchCwd || !activeProjectCwd || isBranchActionPending) return; |
| 314 | 320 | ||
| 315 | if (isSelectingWorktreeBase) { | 321 | if (isSelectingWorktreeBase) { |
| 316 | setThreadBranch(branch.name, null); | 322 | setThreadBranch(refName.name, null); |
| 317 | setIsBranchMenuOpen(false); | 323 | setIsBranchMenuOpen(false); |
| 318 | onComposerFocusRequest?.(); | 324 | onComposerFocusRequest?.(); |
| 319 | return; | 325 | return; |
| ... | @@ -322,19 +328,19 @@ export function BranchToolbarBranchSelector({ | ... | @@ -322,19 +328,19 @@ export function BranchToolbarBranchSelector({ |
| 322 | const selectionTarget = resolveBranchSelectionTarget({ | 328 | const selectionTarget = resolveBranchSelectionTarget({ |
| 323 | activeProjectCwd, | 329 | activeProjectCwd, |
| 324 | activeWorktreePath, | 330 | activeWorktreePath, |
| 325 | branch, | 331 | refName, |
| 326 | }); | 332 | }); |
| 327 | 333 | ||
| 328 | if (selectionTarget.reuseExistingWorktree) { | 334 | if (selectionTarget.reuseExistingWorktree) { |
| 329 | setThreadBranch(branch.name, selectionTarget.nextWorktreePath); | 335 | setThreadBranch(refName.name, selectionTarget.nextWorktreePath); |
| 330 | setIsBranchMenuOpen(false); | 336 | setIsBranchMenuOpen(false); |
| 331 | onComposerFocusRequest?.(); | 337 | onComposerFocusRequest?.(); |
| 332 | return; | 338 | return; |
| 333 | } | 339 | } |
| 334 | 340 | ||
| 335 | const selectedBranchName = branch.isRemote | 341 | const selectedBranchName = refName.isRemote |
| 336 | ? deriveLocalBranchNameFromRemoteRef(branch.name) | 342 | ? deriveLocalBranchNameFromRemoteRef(refName.name) |
| 337 | : branch.name; | 343 | : refName.name; |
| 338 | 344 | ||
| 339 | setIsBranchMenuOpen(false); | 345 | setIsBranchMenuOpen(false); |
| 340 | onComposerFocusRequest?.(); | 346 | onComposerFocusRequest?.(); |
| ... | @@ -343,12 +349,12 @@ export function BranchToolbarBranchSelector({ | ... | @@ -343,12 +349,12 @@ export function BranchToolbarBranchSelector({ |
| 343 | const previousBranch = resolvedActiveBranch; | 349 | const previousBranch = resolvedActiveBranch; |
| 344 | setOptimisticBranch(selectedBranchName); | 350 | setOptimisticBranch(selectedBranchName); |
| 345 | try { | 351 | try { |
| 346 | const checkoutResult = await api.git.checkout({ | 352 | const checkoutResult = await api.vcs.switchRef({ |
| 347 | cwd: selectionTarget.checkoutCwd, | 353 | cwd: selectionTarget.checkoutCwd, |
| 348 | branch: branch.name, | 354 | refName: refName.name, |
| 349 | }); | 355 | }); |
| 350 | const nextBranchName = branch.isRemote | 356 | const nextBranchName = refName.isRemote |
| 351 | ? (checkoutResult.branch ?? selectedBranchName) | 357 | ? (checkoutResult.refName ?? selectedBranchName) |
| 352 | : selectedBranchName; | 358 | : selectedBranchName; |
| 353 | setOptimisticBranch(nextBranchName); | 359 | setOptimisticBranch(nextBranchName); |
| 354 | setThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); | 360 | setThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); |
| ... | @@ -357,7 +363,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -357,7 +363,7 @@ export function BranchToolbarBranchSelector({ |
| 357 | toastManager.add( | 363 | toastManager.add( |
| 358 | stackedThreadToast({ | 364 | stackedThreadToast({ |
| 359 | type: "error", | 365 | type: "error", |
| 360 | title: "Failed to checkout branch.", | 366 | title: "Failed to switch ref.", |
| 361 | description: toBranchActionErrorMessage(error), | 367 | description: toBranchActionErrorMessage(error), |
| 362 | }), | 368 | }), |
| 363 | ); | 369 | ); |
| ... | @@ -365,7 +371,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -365,7 +371,7 @@ export function BranchToolbarBranchSelector({ |
| 365 | }); | 371 | }); |
| 366 | }; | 372 | }; |
| 367 | 373 | ||
| 368 | const createBranch = (rawName: string) => { | 374 | const createRef = (rawName: string) => { |
| 369 | const name = rawName.trim(); | 375 | const name = rawName.trim(); |
| 370 | const api = readEnvironmentApi(environmentId); | 376 | const api = readEnvironmentApi(environmentId); |
| 371 | if (!api || !branchCwd || !name || isBranchActionPending) return; | 377 | if (!api || !branchCwd || !name || isBranchActionPending) return; |
| ... | @@ -377,19 +383,19 @@ export function BranchToolbarBranchSelector({ | ... | @@ -377,19 +383,19 @@ export function BranchToolbarBranchSelector({ |
| 377 | const previousBranch = resolvedActiveBranch; | 383 | const previousBranch = resolvedActiveBranch; |
| 378 | setOptimisticBranch(name); | 384 | setOptimisticBranch(name); |
| 379 | try { | 385 | try { |
| 380 | const createBranchResult = await api.git.createBranch({ | 386 | const createBranchResult = await api.vcs.createRef({ |
| 381 | cwd: branchCwd, | 387 | cwd: branchCwd, |
| 382 | branch: name, | 388 | refName: name, |
| 383 | checkout: true, | 389 | switchRef: true, |
| 384 | }); | 390 | }); |
| 385 | setOptimisticBranch(createBranchResult.branch); | 391 | setOptimisticBranch(createBranchResult.refName); |
| 386 | setThreadBranch(createBranchResult.branch, activeWorktreePath); | 392 | setThreadBranch(createBranchResult.refName, activeWorktreePath); |
| 387 | } catch (error) { | 393 | } catch (error) { |
| 388 | setOptimisticBranch(previousBranch); | 394 | setOptimisticBranch(previousBranch); |
| 389 | toastManager.add( | 395 | toastManager.add( |
| 390 | stackedThreadToast({ | 396 | stackedThreadToast({ |
| 391 | type: "error", | 397 | type: "error", |
| 392 | title: "Failed to create and checkout branch.", | 398 | title: "Failed to create and switch ref.", |
| 393 | description: toBranchActionErrorMessage(error), | 399 | description: toBranchActionErrorMessage(error), |
| 394 | }), | 400 | }), |
| 395 | ); | 401 | ); |
| ... | @@ -420,7 +426,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -420,7 +426,7 @@ export function BranchToolbarBranchSelector({ |
| 420 | return; | 426 | return; |
| 421 | } | 427 | } |
| 422 | void queryClient.invalidateQueries({ | 428 | void queryClient.invalidateQueries({ |
| 423 | queryKey: gitQueryKeys.branches(environmentId, branchCwd), | 429 | queryKey: gitQueryKeys.refs(environmentId, branchCwd), |
| 424 | }); | 430 | }); |
| 425 | }, | 431 | }, |
| 426 | [branchCwd, environmentId, queryClient], | 432 | [branchCwd, environmentId, queryClient], |
| ... | @@ -482,7 +488,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -482,7 +488,7 @@ export function BranchToolbarBranchSelector({ |
| 482 | useEffect(() => { | 488 | useEffect(() => { |
| 483 | if (shouldVirtualizeBranchList) return; | 489 | if (shouldVirtualizeBranchList) return; |
| 484 | maybeFetchNextBranchPage(); | 490 | maybeFetchNextBranchPage(); |
| 485 | }, [branches.length, maybeFetchNextBranchPage, shouldVirtualizeBranchList]); | 491 | }, [refs.length, maybeFetchNextBranchPage, shouldVirtualizeBranchList]); |
| 486 | 492 | ||
| 487 | const triggerLabel = getBranchTriggerLabel({ | 493 | const triggerLabel = getBranchTriggerLabel({ |
| 488 | activeWorktreePath, | 494 | activeWorktreePath, |
| ... | @@ -508,9 +514,14 @@ export function BranchToolbarBranchSelector({ | ... | @@ -508,9 +514,14 @@ export function BranchToolbarBranchSelector({ |
| 508 | onCheckoutPullRequestRequest(prReference); | 514 | onCheckoutPullRequestRequest(prReference); |
| 509 | }} | 515 | }} |
| 510 | > | 516 | > |
| 511 | <div className="flex min-w-0 flex-col items-start py-1"> | 517 | <div className="flex min-w-0 items-center gap-2 py-1"> |
| 512 | <span className="truncate font-medium">Checkout Pull Request</span> | 518 | <SourceControlIcon className="size-3.5 shrink-0 text-muted-foreground" /> |
| 513 | <span className="truncate text-muted-foreground text-xs">{prReference}</span> | 519 | <span className="flex min-w-0 flex-col items-start"> |
| 520 | <span className="truncate font-medium"> | ||
| 521 | Checkout {sourceControlPresentation.terminology.singular} | ||
| 522 | </span> | ||
| 523 | <span className="truncate text-muted-foreground text-xs">{prReference}</span> | ||
| 524 | </span> | ||
| 514 | </div> | 525 | </div> |
| 515 | </ComboboxItem> | 526 | </ComboboxItem> |
| 516 | ); | 527 | ); |
| ... | @@ -522,25 +533,25 @@ export function BranchToolbarBranchSelector({ | ... | @@ -522,25 +533,25 @@ export function BranchToolbarBranchSelector({ |
| 522 | key={itemValue} | 533 | key={itemValue} |
| 523 | index={index} | 534 | index={index} |
| 524 | value={itemValue} | 535 | value={itemValue} |
| 525 | onClick={() => createBranch(trimmedBranchQuery)} | 536 | onClick={() => createRef(trimmedBranchQuery)} |
| 526 | > | 537 | > |
| 527 | <span className="truncate">Create new branch &quot;{trimmedBranchQuery}&quot;</span> | 538 | <span className="truncate">Create new ref &quot;{trimmedBranchQuery}&quot;</span> |
| 528 | </ComboboxItem> | 539 | </ComboboxItem> |
| 529 | ); | 540 | ); |
| 530 | } | 541 | } |
| 531 | 542 | ||
| 532 | const branch = branchByName.get(itemValue); | 543 | const refName = branchByName.get(itemValue); |
| 533 | if (!branch) return null; | 544 | if (!refName) return null; |
| 534 | 545 | ||
| 535 | const hasSecondaryWorktree = | 546 | const hasSecondaryWorktree = |
| 536 | branch.worktreePath && activeProjectCwd && branch.worktreePath !== activeProjectCwd; | 547 | refName.worktreePath && activeProjectCwd && refName.worktreePath !== activeProjectCwd; |
| 537 | const badge = branch.current | 548 | const badge = refName.current |
| 538 | ? "current" | 549 | ? "current" |
| 539 | : hasSecondaryWorktree | 550 | : hasSecondaryWorktree |
| 540 | ? "worktree" | 551 | ? "worktree" |
| 541 | : branch.isRemote | 552 | : refName.isRemote |
| 542 | ? "remote" | 553 | ? "remote" |
| 543 | : branch.isDefault | 554 | : refName.isDefault |
| 544 | ? "default" | 555 | ? "default" |
| 545 | : null; | 556 | : null; |
| 546 | return ( | 557 | return ( |
| ... | @@ -549,7 +560,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -549,7 +560,7 @@ export function BranchToolbarBranchSelector({ |
| 549 | key={itemValue} | 560 | key={itemValue} |
| 550 | index={index} | 561 | index={index} |
| 551 | value={itemValue} | 562 | value={itemValue} |
| 552 | onClick={() => selectBranch(branch)} | 563 | onClick={() => selectBranch(refName)} |
| 553 | > | 564 | > |
| 554 | <div className="flex w-full items-center justify-between gap-2"> | 565 | <div className="flex w-full items-center justify-between gap-2"> |
| 555 | <span className="truncate">{itemValue}</span> | 566 | <span className="truncate">{itemValue}</span> |
| ... | @@ -581,7 +592,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -581,7 +592,7 @@ export function BranchToolbarBranchSelector({ |
| 581 | <ComboboxTrigger | 592 | <ComboboxTrigger |
| 582 | render={<Button variant="ghost" size="xs" />} | 593 | render={<Button variant="ghost" size="xs" />} |
| 583 | className={cn("min-w-0 text-muted-foreground/70 hover:text-foreground/80", className)} | 594 | className={cn("min-w-0 text-muted-foreground/70 hover:text-foreground/80", className)} |
| 584 | disabled={(isBranchesSearchPending && branches.length === 0) || isBranchActionPending} | 595 | disabled={(isBranchesSearchPending && refs.length === 0) || isBranchActionPending} |
| 585 | > | 596 | > |
| 586 | <span className="min-w-0 max-w-[240px] truncate">{triggerLabel}</span> | 597 | <span className="min-w-0 max-w-[240px] truncate">{triggerLabel}</span> |
| 587 | <ChevronDownIcon className="shrink-0" /> | 598 | <ChevronDownIcon className="shrink-0" /> |
| ... | @@ -591,14 +602,14 @@ export function BranchToolbarBranchSelector({ | ... | @@ -591,14 +602,14 @@ export function BranchToolbarBranchSelector({ |
| 591 | <ComboboxInput | 602 | <ComboboxInput |
| 592 | className="[&_input]:font-sans rounded-md" | 603 | className="[&_input]:font-sans rounded-md" |
| 593 | inputClassName="ring-0" | 604 | inputClassName="ring-0" |
| 594 | placeholder="Search branches..." | 605 | placeholder="Search refs..." |
| 595 | showTrigger={false} | 606 | showTrigger={false} |
| 596 | size="sm" | 607 | size="sm" |
| 597 | value={branchQuery} | 608 | value={branchQuery} |
| 598 | onChange={(event) => setBranchQuery(event.target.value)} | 609 | onChange={(event) => setBranchQuery(event.target.value)} |
| 599 | /> | 610 | /> |
| 600 | </div> | 611 | </div> |
| 601 | <ComboboxEmpty>No branches found.</ComboboxEmpty> | 612 | <ComboboxEmpty>No refs found.</ComboboxEmpty> |
| 602 | 613 | ||
| 603 | {shouldVirtualizeBranchList ? ( | 614 | {shouldVirtualizeBranchList ? ( |
| 604 | <ComboboxListVirtualized> | 615 | <ComboboxListVirtualized> |
apps/web/src/components/ChatView.browser.tsx+26-25| ... | @@ -206,6 +206,7 @@ function createMockEnvironmentApi(input: { | ... | @@ -206,6 +206,7 @@ function createMockEnvironmentApi(input: { |
| 206 | filesystem: { | 206 | filesystem: { |
| 207 | browse: input.browse, | 207 | browse: input.browse, |
| 208 | }, | 208 | }, |
| 209 | vcs: {} as EnvironmentApi["vcs"], | ||
| 209 | git: {} as EnvironmentApi["git"], | 210 | git: {} as EnvironmentApi["git"], |
| 210 | orchestration: { | 211 | orchestration: { |
| 211 | dispatchCommand: input.dispatchCommand, | 212 | dispatchCommand: input.dispatchCommand, |
| ... | @@ -951,13 +952,13 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { | ... | @@ -951,13 +952,13 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { |
| 951 | if (tag === WS_METHODS.serverGetConfig) { | 952 | if (tag === WS_METHODS.serverGetConfig) { |
| 952 | return fixture.serverConfig; | 953 | return fixture.serverConfig; |
| 953 | } | 954 | } |
| 954 | if (tag === WS_METHODS.gitListBranches) { | 955 | if (tag === WS_METHODS.vcsListRefs) { |
| 955 | return { | 956 | return { |
| 956 | isRepo: true, | 957 | isRepo: true, |
| 957 | hasOriginRemote: true, | 958 | hasPrimaryRemote: true, |
| 958 | nextCursor: null, | 959 | nextCursor: null, |
| 959 | totalCount: 1, | 960 | totalCount: 1, |
| 960 | branches: [ | 961 | refs: [ |
| 961 | { | 962 | { |
| 962 | name: "main", | 963 | name: "main", |
| 963 | current: true, | 964 | current: true, |
| ... | @@ -2287,16 +2288,16 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -2287,16 +2288,16 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2287 | branchButton.click(); | 2288 | branchButton.click(); |
| 2288 | 2289 | ||
| 2289 | const branchInput = await waitForElement( | 2290 | const branchInput = await waitForElement( |
| 2290 | () => document.querySelector<HTMLInputElement>('input[placeholder="Search branches..."]'), | 2291 | () => document.querySelector<HTMLInputElement>('input[placeholder="Search refs..."]'), |
| 2291 | "Unable to find branch search input.", | 2292 | "Unable to find ref search input.", |
| 2292 | ); | 2293 | ); |
| 2293 | branchInput.focus(); | 2294 | branchInput.focus(); |
| 2294 | await page.getByPlaceholder("Search branches...").fill("1359"); | 2295 | await page.getByPlaceholder("Search refs...").fill("1359"); |
| 2295 | 2296 | ||
| 2296 | const checkoutItem = await waitForElement( | 2297 | const checkoutItem = await waitForElement( |
| 2297 | () => | 2298 | () => |
| 2298 | Array.from(document.querySelectorAll("span")).find( | 2299 | Array.from(document.querySelectorAll("span")).find( |
| 2299 | (element) => element.textContent?.trim() === "Checkout Pull Request", | 2300 | (element) => element.textContent?.trim() === "Checkout pull request", |
| 2300 | ) as HTMLSpanElement | null, | 2301 | ) as HTMLSpanElement | null, |
| 2301 | "Unable to find checkout pull request option.", | 2302 | "Unable to find checkout pull request option.", |
| 2302 | ); | 2303 | ); |
| ... | @@ -2425,7 +2426,7 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -2425,7 +2426,7 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2425 | { timeout: 8_000, interval: 16 }, | 2426 | { timeout: 8_000, interval: 16 }, |
| 2426 | ); | 2427 | ); |
| 2427 | 2428 | ||
| 2428 | expect(wsRequests.some((request) => request._tag === WS_METHODS.gitCreateWorktree)).toBe( | 2429 | expect(wsRequests.some((request) => request._tag === WS_METHODS.vcsCreateWorktree)).toBe( |
| 2429 | false, | 2430 | false, |
| 2430 | ); | 2431 | ); |
| 2431 | expect( | 2432 | expect( |
| ... | @@ -2572,13 +2573,13 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -2572,13 +2573,13 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2572 | ), | 2573 | ), |
| 2573 | }, | 2574 | }, |
| 2574 | resolveRpc: (body) => { | 2575 | resolveRpc: (body) => { |
| 2575 | if (body._tag === WS_METHODS.gitListBranches) { | 2576 | if (body._tag === WS_METHODS.vcsListRefs) { |
| 2576 | return { | 2577 | return { |
| 2577 | isRepo: true, | 2578 | isRepo: true, |
| 2578 | hasOriginRemote: true, | 2579 | hasPrimaryRemote: true, |
| 2579 | nextCursor: null, | 2580 | nextCursor: null, |
| 2580 | totalCount: 1, | 2581 | totalCount: 1, |
| 2581 | branches: [ | 2582 | refs: [ |
| 2582 | { | 2583 | { |
| 2583 | name: "main", | 2584 | name: "main", |
| 2584 | current: true, | 2585 | current: true, |
| ... | @@ -2665,13 +2666,13 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -2665,13 +2666,13 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2665 | ), | 2666 | ), |
| 2666 | }, | 2667 | }, |
| 2667 | resolveRpc: (body) => { | 2668 | resolveRpc: (body) => { |
| 2668 | if (body._tag === WS_METHODS.gitListBranches) { | 2669 | if (body._tag === WS_METHODS.vcsListRefs) { |
| 2669 | return { | 2670 | return { |
| 2670 | isRepo: true, | 2671 | isRepo: true, |
| 2671 | hasOriginRemote: true, | 2672 | hasPrimaryRemote: true, |
| 2672 | nextCursor: null, | 2673 | nextCursor: null, |
| 2673 | totalCount: 2, | 2674 | totalCount: 2, |
| 2674 | branches: [ | 2675 | refs: [ |
| 2675 | { | 2676 | { |
| 2676 | name: "main", | 2677 | name: "main", |
| 2677 | current: true, | 2678 | current: true, |
| ... | @@ -2761,13 +2762,13 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -2761,13 +2762,13 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 2761 | viewport: DEFAULT_VIEWPORT, | 2762 | viewport: DEFAULT_VIEWPORT, |
| 2762 | snapshot: snapshotWithTwoThreads, | 2763 | snapshot: snapshotWithTwoThreads, |
| 2763 | resolveRpc: (body) => { | 2764 | resolveRpc: (body) => { |
| 2764 | if (body._tag === WS_METHODS.gitListBranches) { | 2765 | if (body._tag === WS_METHODS.vcsListRefs) { |
| 2765 | return { | 2766 | return { |
| 2766 | isRepo: true, | 2767 | isRepo: true, |
| 2767 | hasOriginRemote: true, | 2768 | hasPrimaryRemote: true, |
| 2768 | nextCursor: null, | 2769 | nextCursor: null, |
| 2769 | totalCount: 2, | 2770 | totalCount: 2, |
| 2770 | branches: [ | 2771 | refs: [ |
| 2771 | { | 2772 | { |
| 2772 | name: "main", | 2773 | name: "main", |
| 2773 | current: true, | 2774 | current: true, |
| ... | @@ -3021,13 +3022,13 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -3021,13 +3022,13 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 3021 | snapshot: createDraftOnlySnapshot(), | 3022 | snapshot: createDraftOnlySnapshot(), |
| 3022 | initialPath: `/draft/${activeDraftId}`, | 3023 | initialPath: `/draft/${activeDraftId}`, |
| 3023 | resolveRpc: (body) => { | 3024 | resolveRpc: (body) => { |
| 3024 | if (body._tag === WS_METHODS.gitListBranches) { | 3025 | if (body._tag === WS_METHODS.vcsListRefs) { |
| 3025 | return { | 3026 | return { |
| 3026 | isRepo: true, | 3027 | isRepo: true, |
| 3027 | hasOriginRemote: true, | 3028 | hasPrimaryRemote: true, |
| 3028 | nextCursor: null, | 3029 | nextCursor: null, |
| 3029 | totalCount: 2, | 3030 | totalCount: 2, |
| 3030 | branches: [ | 3031 | refs: [ |
| 3031 | { | 3032 | { |
| 3032 | name: "main", | 3033 | name: "main", |
| 3033 | current: true, | 3034 | current: true, |
| ... | @@ -3146,13 +3147,13 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -3146,13 +3147,13 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 3146 | snapshot: createDraftOnlySnapshot(), | 3147 | snapshot: createDraftOnlySnapshot(), |
| 3147 | initialPath: `/draft/${draftId}`, | 3148 | initialPath: `/draft/${draftId}`, |
| 3148 | resolveRpc: (body) => { | 3149 | resolveRpc: (body) => { |
| 3149 | if (body._tag === WS_METHODS.gitListBranches) { | 3150 | if (body._tag === WS_METHODS.vcsListRefs) { |
| 3150 | return { | 3151 | return { |
| 3151 | isRepo: true, | 3152 | isRepo: true, |
| 3152 | hasOriginRemote: true, | 3153 | hasPrimaryRemote: true, |
| 3153 | nextCursor: null, | 3154 | nextCursor: null, |
| 3154 | totalCount: branches.length, | 3155 | totalCount: branches.length, |
| 3155 | branches, | 3156 | refs: branches, |
| 3156 | }; | 3157 | }; |
| 3157 | } | 3158 | } |
| 3158 | return undefined; | 3159 | return undefined; |
| ... | @@ -3170,8 +3171,8 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -3170,8 +3171,8 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 3170 | branchButton.click(); | 3171 | branchButton.click(); |
| 3171 | 3172 | ||
| 3172 | await waitForElement( | 3173 | await waitForElement( |
| 3173 | () => document.querySelector<HTMLInputElement>('input[placeholder="Search branches..."]'), | 3174 | () => document.querySelector<HTMLInputElement>('input[placeholder="Search refs..."]'), |
| 3174 | "Unable to find branch search input.", | 3175 | "Unable to find ref search input.", |
| 3175 | ); | 3176 | ); |
| 3176 | 3177 | ||
| 3177 | const popup = await waitForElement( | 3178 | const popup = await waitForElement( |
apps/web/src/components/GitActionsControl.browser.tsx+9-1| ... | @@ -113,7 +113,15 @@ vi.mock("~/lib/gitStatusState", () => ({ | ... | @@ -113,7 +113,15 @@ vi.mock("~/lib/gitStatusState", () => ({ |
| 113 | resetGitStatusStateForTests: () => undefined, | 113 | resetGitStatusStateForTests: () => undefined, |
| 114 | useGitStatus: vi.fn(() => ({ | 114 | useGitStatus: vi.fn(() => ({ |
| 115 | data: { | 115 | data: { |
| 116 | branch: BRANCH_NAME, | 116 | isRepo: true, |
| 117 | sourceControlProvider: { | ||
| 118 | kind: "github", | ||
| 119 | name: "GitHub", | ||
| 120 | baseUrl: "https://github.com", | ||
| 121 | }, | ||
| 122 | hasPrimaryRemote: true, | ||
| 123 | isDefaultRef: false, | ||
| 124 | refName: BRANCH_NAME, | ||
| 117 | hasWorkingTreeChanges: false, | 125 | hasWorkingTreeChanges: false, |
| 118 | workingTree: { files: [], insertions: 0, deletions: 0 }, | 126 | workingTree: { files: [], insertions: 0, deletions: 0 }, |
| 119 | hasUpstream: true, | 127 | hasUpstream: true, |
apps/web/src/components/GitActionsControl.logic.test.ts+101-75| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { GitStatusResult } from "@t3tools/contracts"; | 1 | import type { VcsStatusResult } from "@t3tools/contracts"; |
| 2 | import { assert, describe, it } from "vitest"; | 2 | import { assert, describe, it } from "vitest"; |
| 3 | import { | 3 | import { |
| 4 | buildGitActionProgressStages, | 4 | buildGitActionProgressStages, |
| ... | @@ -11,12 +11,12 @@ import { | ... | @@ -11,12 +11,12 @@ import { |
| 11 | resolveThreadBranchUpdate, | 11 | resolveThreadBranchUpdate, |
| 12 | } from "./GitActionsControl.logic"; | 12 | } from "./GitActionsControl.logic"; |
| 13 | 13 | ||
| 14 | function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult { | 14 | function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult { |
| 15 | return { | 15 | return { |
| 16 | isRepo: true, | 16 | isRepo: true, |
| 17 | hasOriginRemote: true, | 17 | hasPrimaryRemote: true, |
| 18 | isDefaultBranch: false, | 18 | isDefaultRef: false, |
| 19 | branch: "feature/test", | 19 | refName: "feature/test", |
| 20 | hasWorkingTreeChanges: false, | 20 | hasWorkingTreeChanges: false, |
| 21 | workingTree: { | 21 | workingTree: { |
| 22 | files: [], | 22 | files: [], |
| ... | @@ -31,7 +31,7 @@ function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult { | ... | @@ -31,7 +31,7 @@ function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult { |
| 31 | }; | 31 | }; |
| 32 | } | 32 | } |
| 33 | 33 | ||
| 34 | describe("when: branch is clean and has an open PR", () => { | 34 | describe("when: ref is clean and has an open PR", () => { |
| 35 | it("resolveQuickAction opens the existing PR", () => { | 35 | it("resolveQuickAction opens the existing PR", () => { |
| 36 | const quick = resolveQuickAction( | 36 | const quick = resolveQuickAction( |
| 37 | status({ | 37 | status({ |
| ... | @@ -39,8 +39,8 @@ describe("when: branch is clean and has an open PR", () => { | ... | @@ -39,8 +39,8 @@ describe("when: branch is clean and has an open PR", () => { |
| 39 | number: 10, | 39 | number: 10, |
| 40 | title: "Open PR", | 40 | title: "Open PR", |
| 41 | url: "https://example.com/pr/10", | 41 | url: "https://example.com/pr/10", |
| 42 | baseBranch: "main", | 42 | baseRef: "main", |
| 43 | headBranch: "feature/test", | 43 | headRef: "feature/test", |
| 44 | state: "open", | 44 | state: "open", |
| 45 | }, | 45 | }, |
| 46 | }), | 46 | }), |
| ... | @@ -56,8 +56,8 @@ describe("when: branch is clean and has an open PR", () => { | ... | @@ -56,8 +56,8 @@ describe("when: branch is clean and has an open PR", () => { |
| 56 | number: 11, | 56 | number: 11, |
| 57 | title: "Existing PR", | 57 | title: "Existing PR", |
| 58 | url: "https://example.com/pr/11", | 58 | url: "https://example.com/pr/11", |
| 59 | baseBranch: "main", | 59 | baseRef: "main", |
| 60 | headBranch: "feature/test", | 60 | headRef: "feature/test", |
| 61 | state: "open", | 61 | state: "open", |
| 62 | }, | 62 | }, |
| 63 | }), | 63 | }), |
| ... | @@ -150,7 +150,7 @@ describe("when: git status is unavailable", () => { | ... | @@ -150,7 +150,7 @@ describe("when: git status is unavailable", () => { |
| 150 | }); | 150 | }); |
| 151 | }); | 151 | }); |
| 152 | 152 | ||
| 153 | describe("when: branch is clean, ahead, and has an open PR", () => { | 153 | describe("when: ref is clean, ahead, and has an open PR", () => { |
| 154 | it("resolveQuickAction prefers push", () => { | 154 | it("resolveQuickAction prefers push", () => { |
| 155 | const quick = resolveQuickAction( | 155 | const quick = resolveQuickAction( |
| 156 | status({ | 156 | status({ |
| ... | @@ -159,8 +159,8 @@ describe("when: branch is clean, ahead, and has an open PR", () => { | ... | @@ -159,8 +159,8 @@ describe("when: branch is clean, ahead, and has an open PR", () => { |
| 159 | number: 13, | 159 | number: 13, |
| 160 | title: "Open PR", | 160 | title: "Open PR", |
| 161 | url: "https://example.com/pr/13", | 161 | url: "https://example.com/pr/13", |
| 162 | baseBranch: "main", | 162 | baseRef: "main", |
| 163 | headBranch: "feature/test", | 163 | headRef: "feature/test", |
| 164 | state: "open", | 164 | state: "open", |
| 165 | }, | 165 | }, |
| 166 | }), | 166 | }), |
| ... | @@ -177,8 +177,8 @@ describe("when: branch is clean, ahead, and has an open PR", () => { | ... | @@ -177,8 +177,8 @@ describe("when: branch is clean, ahead, and has an open PR", () => { |
| 177 | number: 12, | 177 | number: 12, |
| 178 | title: "Existing PR", | 178 | title: "Existing PR", |
| 179 | url: "https://example.com/pr/12", | 179 | url: "https://example.com/pr/12", |
| 180 | baseBranch: "main", | 180 | baseRef: "main", |
| 181 | headBranch: "feature/test", | 181 | headRef: "feature/test", |
| 182 | state: "open", | 182 | state: "open", |
| 183 | }, | 183 | }, |
| 184 | }), | 184 | }), |
| ... | @@ -212,7 +212,7 @@ describe("when: branch is clean, ahead, and has an open PR", () => { | ... | @@ -212,7 +212,7 @@ describe("when: branch is clean, ahead, and has an open PR", () => { |
| 212 | }); | 212 | }); |
| 213 | }); | 213 | }); |
| 214 | 214 | ||
| 215 | describe("when: branch is clean, ahead, and has no open PR", () => { | 215 | describe("when: ref is clean, ahead, and has no open PR", () => { |
| 216 | it("resolveQuickAction pushes and creates a PR", () => { | 216 | it("resolveQuickAction pushes and creates a PR", () => { |
| 217 | const quick = resolveQuickAction(status({ aheadCount: 2, pr: null }), false); | 217 | const quick = resolveQuickAction(status({ aheadCount: 2, pr: null }), false); |
| 218 | assert.deepInclude(quick, { | 218 | assert.deepInclude(quick, { |
| ... | @@ -253,7 +253,33 @@ describe("when: branch is clean, ahead, and has no open PR", () => { | ... | @@ -253,7 +253,33 @@ describe("when: branch is clean, ahead, and has no open PR", () => { |
| 253 | }); | 253 | }); |
| 254 | }); | 254 | }); |
| 255 | 255 | ||
| 256 | describe("when: branch is clean, up to date, and has no open PR", () => { | 256 | describe("when: source control provider uses merge requests", () => { |
| 257 | it("uses GitLab MR terminology in quick actions and menu items", () => { | ||
| 258 | const gitlabStatus = status({ | ||
| 259 | aheadCount: 2, | ||
| 260 | sourceControlProvider: { | ||
| 261 | kind: "gitlab", | ||
| 262 | name: "GitLab", | ||
| 263 | baseUrl: "https://gitlab.com", | ||
| 264 | }, | ||
| 265 | }); | ||
| 266 | |||
| 267 | const quick = resolveQuickAction(gitlabStatus, false); | ||
| 268 | const items = buildMenuItems(gitlabStatus, false); | ||
| 269 | |||
| 270 | assert.deepInclude(quick, { | ||
| 271 | kind: "run_action", | ||
| 272 | action: "create_pr", | ||
| 273 | label: "Push & create MR", | ||
| 274 | }); | ||
| 275 | assert.deepInclude(items[2], { | ||
| 276 | id: "pr", | ||
| 277 | label: "Create MR", | ||
| 278 | }); | ||
| 279 | }); | ||
| 280 | }); | ||
| 281 | |||
| 282 | describe("when: ref is clean, up to date, and has no open PR", () => { | ||
| 257 | it("resolveQuickAction returns disabled no-action state", () => { | 283 | it("resolveQuickAction returns disabled no-action state", () => { |
| 258 | const quick = resolveQuickAction( | 284 | const quick = resolveQuickAction( |
| 259 | status({ aheadCount: 0, behindCount: 0, hasWorkingTreeChanges: false, pr: null }), | 285 | 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", () => { | ... | @@ -293,7 +319,7 @@ describe("when: branch is clean, up to date, and has no open PR", () => { |
| 293 | }); | 319 | }); |
| 294 | }); | 320 | }); |
| 295 | 321 | ||
| 296 | describe("when: branch is behind upstream", () => { | 322 | describe("when: ref is behind upstream", () => { |
| 297 | it("resolveQuickAction returns pull", () => { | 323 | it("resolveQuickAction returns pull", () => { |
| 298 | const quick = resolveQuickAction(status({ behindCount: 2 }), false); | 324 | const quick = resolveQuickAction(status({ behindCount: 2 }), false); |
| 299 | assert.deepInclude(quick, { kind: "run_pull", label: "Pull", disabled: false }); | 325 | assert.deepInclude(quick, { kind: "run_pull", label: "Pull", disabled: false }); |
| ... | @@ -330,11 +356,11 @@ describe("when: branch is behind upstream", () => { | ... | @@ -330,11 +356,11 @@ describe("when: branch is behind upstream", () => { |
| 330 | }); | 356 | }); |
| 331 | }); | 357 | }); |
| 332 | 358 | ||
| 333 | describe("when: branch has diverged from upstream", () => { | 359 | describe("when: ref has diverged from upstream", () => { |
| 334 | it("resolveQuickAction returns a disabled sync hint", () => { | 360 | it("resolveQuickAction returns a disabled sync hint", () => { |
| 335 | const quick = resolveQuickAction(status({ aheadCount: 2, behindCount: 1 }), false); | 361 | const quick = resolveQuickAction(status({ aheadCount: 2, behindCount: 1 }), false); |
| 336 | assert.deepEqual(quick, { | 362 | assert.deepEqual(quick, { |
| 337 | label: "Sync branch", | 363 | label: "Sync ref", |
| 338 | disabled: true, | 364 | disabled: true, |
| 339 | kind: "show_hint", | 365 | kind: "show_hint", |
| 340 | hint: "Branch has diverged from upstream. Rebase/merge first.", | 366 | hint: "Branch has diverged from upstream. Rebase/merge first.", |
| ... | @@ -375,8 +401,8 @@ describe("when: working tree has local changes", () => { | ... | @@ -375,8 +401,8 @@ describe("when: working tree has local changes", () => { |
| 375 | number: 16, | 401 | number: 16, |
| 376 | title: "Existing PR", | 402 | title: "Existing PR", |
| 377 | url: "https://example.com/pr/16", | 403 | url: "https://example.com/pr/16", |
| 378 | baseBranch: "main", | 404 | baseRef: "main", |
| 379 | headBranch: "feature/test", | 405 | headRef: "feature/test", |
| 380 | state: "open", | 406 | state: "open", |
| 381 | }, | 407 | }, |
| 382 | }), | 408 | }), |
| ... | @@ -420,10 +446,10 @@ describe("when: working tree has local changes", () => { | ... | @@ -420,10 +446,10 @@ describe("when: working tree has local changes", () => { |
| 420 | }); | 446 | }); |
| 421 | }); | 447 | }); |
| 422 | 448 | ||
| 423 | describe("when: on default branch without open PR", () => { | 449 | describe("when: on default ref without open PR", () => { |
| 424 | it("resolveQuickAction returns commit and push when local changes exist", () => { | 450 | it("resolveQuickAction returns commit and push when local changes exist", () => { |
| 425 | const quick = resolveQuickAction( | 451 | const quick = resolveQuickAction( |
| 426 | status({ branch: "main", hasWorkingTreeChanges: true }), | 452 | status({ refName: "main", hasWorkingTreeChanges: true }), |
| 427 | false, | 453 | false, |
| 428 | true, | 454 | true, |
| 429 | ); | 455 | ); |
| ... | @@ -435,9 +461,9 @@ describe("when: on default branch without open PR", () => { | ... | @@ -435,9 +461,9 @@ describe("when: on default branch without open PR", () => { |
| 435 | }); | 461 | }); |
| 436 | }); | 462 | }); |
| 437 | 463 | ||
| 438 | it("resolveQuickAction returns push when branch is ahead", () => { | 464 | it("resolveQuickAction returns push when ref is ahead", () => { |
| 439 | const quick = resolveQuickAction( | 465 | const quick = resolveQuickAction( |
| 440 | status({ branch: "main", aheadCount: 2, pr: null }), | 466 | status({ refName: "main", aheadCount: 2, pr: null }), |
| 441 | false, | 467 | false, |
| 442 | true, | 468 | true, |
| 443 | ); | 469 | ); |
| ... | @@ -450,7 +476,7 @@ describe("when: on default branch without open PR", () => { | ... | @@ -450,7 +476,7 @@ describe("when: on default branch without open PR", () => { |
| 450 | }); | 476 | }); |
| 451 | }); | 477 | }); |
| 452 | 478 | ||
| 453 | describe("when: working tree has local changes and branch is behind upstream", () => { | 479 | describe("when: working tree has local changes and ref is behind upstream", () => { |
| 454 | it("resolveQuickAction still prefers commit, push, and create PR", () => { | 480 | it("resolveQuickAction still prefers commit, push, and create PR", () => { |
| 455 | const quick = resolveQuickAction( | 481 | const quick = resolveQuickAction( |
| 456 | status({ hasWorkingTreeChanges: true, behindCount: 1 }), | 482 | status({ hasWorkingTreeChanges: true, behindCount: 1 }), |
| ... | @@ -497,14 +523,14 @@ describe("when: working tree has local changes and branch is behind upstream", ( | ... | @@ -497,14 +523,14 @@ describe("when: working tree has local changes and branch is behind upstream", ( |
| 497 | describe("when: HEAD is detached and there are no local changes", () => { | 523 | describe("when: HEAD is detached and there are no local changes", () => { |
| 498 | it("resolveQuickAction shows detached head hint", () => { | 524 | it("resolveQuickAction shows detached head hint", () => { |
| 499 | const quick = resolveQuickAction( | 525 | const quick = resolveQuickAction( |
| 500 | status({ branch: null, hasWorkingTreeChanges: false, hasUpstream: false }), | 526 | status({ refName: null, hasWorkingTreeChanges: false, hasUpstream: false }), |
| 501 | false, | 527 | false, |
| 502 | ); | 528 | ); |
| 503 | assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true }); | 529 | assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true }); |
| 504 | }); | 530 | }); |
| 505 | 531 | ||
| 506 | it("buildMenuItems keeps commit, push, and PR disabled", () => { | 532 | it("buildMenuItems keeps commit, push, and PR disabled", () => { |
| 507 | const items = buildMenuItems(status({ branch: null, hasWorkingTreeChanges: false }), false); | 533 | const items = buildMenuItems(status({ refName: null, hasWorkingTreeChanges: false }), false); |
| 508 | assert.deepEqual(items, [ | 534 | assert.deepEqual(items, [ |
| 509 | { | 535 | { |
| 510 | id: "commit", | 536 | id: "commit", |
| ... | @@ -534,7 +560,7 @@ describe("when: HEAD is detached and there are no local changes", () => { | ... | @@ -534,7 +560,7 @@ describe("when: HEAD is detached and there are no local changes", () => { |
| 534 | }); | 560 | }); |
| 535 | }); | 561 | }); |
| 536 | 562 | ||
| 537 | describe("when: branch has no upstream configured", () => { | 563 | describe("when: ref has no upstream configured", () => { |
| 538 | it("resolveQuickAction is disabled when clean, no upstream, and no local commits are ahead", () => { | 564 | it("resolveQuickAction is disabled when clean, no upstream, and no local commits are ahead", () => { |
| 539 | const quick = resolveQuickAction( | 565 | const quick = resolveQuickAction( |
| 540 | status({ hasUpstream: false, pr: null, aheadCount: 0 }), | 566 | status({ hasUpstream: false, pr: null, aheadCount: 0 }), |
| ... | @@ -557,8 +583,8 @@ describe("when: branch has no upstream configured", () => { | ... | @@ -557,8 +583,8 @@ describe("when: branch has no upstream configured", () => { |
| 557 | number: 14, | 583 | number: 14, |
| 558 | title: "Existing PR", | 584 | title: "Existing PR", |
| 559 | url: "https://example.com/pr/14", | 585 | url: "https://example.com/pr/14", |
| 560 | baseBranch: "main", | 586 | baseRef: "main", |
| 561 | headBranch: "feature/test", | 587 | headRef: "feature/test", |
| 562 | state: "open", | 588 | state: "open", |
| 563 | }, | 589 | }, |
| 564 | }), | 590 | }), |
| ... | @@ -580,8 +606,8 @@ describe("when: branch has no upstream configured", () => { | ... | @@ -580,8 +606,8 @@ describe("when: branch has no upstream configured", () => { |
| 580 | number: 15, | 606 | number: 15, |
| 581 | title: "Existing PR", | 607 | title: "Existing PR", |
| 582 | url: "https://example.com/pr/15", | 608 | url: "https://example.com/pr/15", |
| 583 | baseBranch: "main", | 609 | baseRef: "main", |
| 584 | headBranch: "feature/test", | 610 | headRef: "feature/test", |
| 585 | state: "open", | 611 | state: "open", |
| 586 | }, | 612 | }, |
| 587 | }), | 613 | }), |
| ... | @@ -656,7 +682,7 @@ describe("when: branch has no upstream configured", () => { | ... | @@ -656,7 +682,7 @@ describe("when: branch has no upstream configured", () => { |
| 656 | assert.deepEqual(quick, { | 682 | assert.deepEqual(quick, { |
| 657 | kind: "show_hint", | 683 | kind: "show_hint", |
| 658 | label: "Push", | 684 | label: "Push", |
| 659 | hint: 'Add an "origin" remote before pushing or creating a PR.', | 685 | hint: 'Add an "origin" remote before pushing or creating a pull request.', |
| 660 | disabled: true, | 686 | disabled: true, |
| 661 | }); | 687 | }); |
| 662 | }); | 688 | }); |
| ... | @@ -725,10 +751,10 @@ describe("when: branch has no upstream configured", () => { | ... | @@ -725,10 +751,10 @@ describe("when: branch has no upstream configured", () => { |
| 725 | ]); | 751 | ]); |
| 726 | }); | 752 | }); |
| 727 | 753 | ||
| 728 | it("resolveQuickAction is disabled on default branch when no upstream exists and no commits are ahead", () => { | 754 | it("resolveQuickAction is disabled on default ref when no upstream exists and no commits are ahead", () => { |
| 729 | const quick = resolveQuickAction( | 755 | const quick = resolveQuickAction( |
| 730 | status({ | 756 | status({ |
| 731 | branch: "main", | 757 | refName: "main", |
| 732 | hasUpstream: false, | 758 | hasUpstream: false, |
| 733 | aheadCount: 0, | 759 | aheadCount: 0, |
| 734 | pr: null, | 760 | pr: null, |
| ... | @@ -744,10 +770,10 @@ describe("when: branch has no upstream configured", () => { | ... | @@ -744,10 +770,10 @@ describe("when: branch has no upstream configured", () => { |
| 744 | }); | 770 | }); |
| 745 | }); | 771 | }); |
| 746 | 772 | ||
| 747 | it("resolveQuickAction uses push-only on default branch when no upstream exists and commits are ahead", () => { | 773 | it("resolveQuickAction uses push-only on default ref when no upstream exists and commits are ahead", () => { |
| 748 | const quick = resolveQuickAction( | 774 | const quick = resolveQuickAction( |
| 749 | status({ | 775 | status({ |
| 750 | branch: "main", | 776 | refName: "main", |
| 751 | hasUpstream: false, | 777 | hasUpstream: false, |
| 752 | aheadCount: 1, | 778 | aheadCount: 1, |
| 753 | pr: null, | 779 | pr: null, |
| ... | @@ -763,7 +789,7 @@ describe("when: branch has no upstream configured", () => { | ... | @@ -763,7 +789,7 @@ describe("when: branch has no upstream configured", () => { |
| 763 | }); | 789 | }); |
| 764 | }); | 790 | }); |
| 765 | 791 | ||
| 766 | it("buildMenuItems still disables push and create PR when branch is behind", () => { | 792 | it("buildMenuItems still disables push and create PR when ref is behind", () => { |
| 767 | const items = buildMenuItems( | 793 | const items = buildMenuItems( |
| 768 | status({ | 794 | status({ |
| 769 | hasUpstream: false, | 795 | hasUpstream: false, |
| ... | @@ -803,7 +829,7 @@ describe("when: branch has no upstream configured", () => { | ... | @@ -803,7 +829,7 @@ describe("when: branch has no upstream configured", () => { |
| 803 | }); | 829 | }); |
| 804 | 830 | ||
| 805 | describe("requiresDefaultBranchConfirmation", () => { | 831 | describe("requiresDefaultBranchConfirmation", () => { |
| 806 | it("requires confirmation for push actions on default branch", () => { | 832 | it("requires confirmation for push actions on default ref", () => { |
| 807 | assert.isFalse(requiresDefaultBranchConfirmation("commit", true)); | 833 | assert.isFalse(requiresDefaultBranchConfirmation("commit", true)); |
| 808 | assert.isTrue(requiresDefaultBranchConfirmation("push", true)); | 834 | assert.isTrue(requiresDefaultBranchConfirmation("push", true)); |
| 809 | assert.isTrue(requiresDefaultBranchConfirmation("create_pr", true)); | 835 | assert.isTrue(requiresDefaultBranchConfirmation("create_pr", true)); |
| ... | @@ -823,9 +849,9 @@ describe("resolveDefaultBranchActionDialogCopy", () => { | ... | @@ -823,9 +849,9 @@ describe("resolveDefaultBranchActionDialogCopy", () => { |
| 823 | }); | 849 | }); |
| 824 | 850 | ||
| 825 | assert.deepEqual(copy, { | 851 | assert.deepEqual(copy, { |
| 826 | title: "Push to default branch?", | 852 | title: "Push to default ref?", |
| 827 | description: | 853 | description: |
| 828 | 'This action will push local commits on "main". You can continue on this branch or create a feature branch and run the same action there.', | 854 | 'This action will push local commits on "main". You can continue on this ref or create a feature ref and run the same action there.', |
| 829 | continueLabel: "Push to main", | 855 | continueLabel: "Push to main", |
| 830 | }); | 856 | }); |
| 831 | }); | 857 | }); |
| ... | @@ -838,9 +864,9 @@ describe("resolveDefaultBranchActionDialogCopy", () => { | ... | @@ -838,9 +864,9 @@ describe("resolveDefaultBranchActionDialogCopy", () => { |
| 838 | }); | 864 | }); |
| 839 | 865 | ||
| 840 | assert.deepEqual(copy, { | 866 | assert.deepEqual(copy, { |
| 841 | title: "Push & create PR from default branch?", | 867 | title: "Push & create PR from default ref?", |
| 842 | description: | 868 | description: |
| 843 | 'This action will push local commits and create a PR on "main". You can continue on this branch or create a feature branch and run the same action there.', | 869 | 'This action will push local commits and create a pull request on "main". You can continue on this ref or create a feature ref and run the same action there.', |
| 844 | continueLabel: "Push & create PR", | 870 | continueLabel: "Push & create PR", |
| 845 | }); | 871 | }); |
| 846 | }); | 872 | }); |
| ... | @@ -853,9 +879,9 @@ describe("resolveDefaultBranchActionDialogCopy", () => { | ... | @@ -853,9 +879,9 @@ describe("resolveDefaultBranchActionDialogCopy", () => { |
| 853 | }); | 879 | }); |
| 854 | 880 | ||
| 855 | assert.deepEqual(copy, { | 881 | assert.deepEqual(copy, { |
| 856 | title: "Commit, push & create PR from default branch?", | 882 | title: "Commit, push & create PR from default ref?", |
| 857 | description: | 883 | description: |
| 858 | 'This action will commit, push, and create a PR on "main". You can continue on this branch or create a feature branch and run the same action there.', | 884 | 'This action will commit, push, and create a pull request on "main". You can continue on this ref or create a feature ref and run the same action there.', |
| 859 | continueLabel: "Commit, push & create PR", | 885 | continueLabel: "Commit, push & create PR", |
| 860 | }); | 886 | }); |
| 861 | }); | 887 | }); |
| ... | @@ -884,7 +910,7 @@ describe("buildGitActionProgressStages", () => { | ... | @@ -884,7 +910,7 @@ describe("buildGitActionProgressStages", () => { |
| 884 | "Pushing to origin/feature/test...", | 910 | "Pushing to origin/feature/test...", |
| 885 | "Preparing PR...", | 911 | "Preparing PR...", |
| 886 | "Generating PR content...", | 912 | "Generating PR content...", |
| 887 | "Creating GitHub pull request...", | 913 | "Creating pull request...", |
| 888 | ]); | 914 | ]); |
| 889 | }); | 915 | }); |
| 890 | 916 | ||
| ... | @@ -898,7 +924,7 @@ describe("buildGitActionProgressStages", () => { | ... | @@ -898,7 +924,7 @@ describe("buildGitActionProgressStages", () => { |
| 898 | assert.deepEqual(stages, [ | 924 | assert.deepEqual(stages, [ |
| 899 | "Preparing PR...", | 925 | "Preparing PR...", |
| 900 | "Generating PR content...", | 926 | "Generating PR content...", |
| 901 | "Creating GitHub pull request...", | 927 | "Creating pull request...", |
| 902 | ]); | 928 | ]); |
| 903 | }); | 929 | }); |
| 904 | 930 | ||
| ... | @@ -928,7 +954,7 @@ describe("buildGitActionProgressStages", () => { | ... | @@ -928,7 +954,7 @@ describe("buildGitActionProgressStages", () => { |
| 928 | "Pushing to origin/feature/test...", | 954 | "Pushing to origin/feature/test...", |
| 929 | "Preparing PR...", | 955 | "Preparing PR...", |
| 930 | "Generating PR content...", | 956 | "Generating PR content...", |
| 931 | "Creating GitHub pull request...", | 957 | "Creating pull request...", |
| 932 | ]); | 958 | ]); |
| 933 | }); | 959 | }); |
| 934 | }); | 960 | }); |
| ... | @@ -944,7 +970,7 @@ describe("resolveThreadBranchUpdate", () => { | ... | @@ -944,7 +970,7 @@ describe("resolveThreadBranchUpdate", () => { |
| 944 | commit: { | 970 | commit: { |
| 945 | status: "created", | 971 | status: "created", |
| 946 | commitSha: "89abcdef01234567", | 972 | commitSha: "89abcdef01234567", |
| 947 | subject: "feat: add branch sync", | 973 | subject: "feat: add ref sync", |
| 948 | }, | 974 | }, |
| 949 | push: { status: "pushed", branch: "feature/fix-toast-copy" }, | 975 | push: { status: "pushed", branch: "feature/fix-toast-copy" }, |
| 950 | pr: { status: "skipped_not_requested" }, | 976 | pr: { status: "skipped_not_requested" }, |
| ... | @@ -968,7 +994,7 @@ describe("resolveThreadBranchUpdate", () => { | ... | @@ -968,7 +994,7 @@ describe("resolveThreadBranchUpdate", () => { |
| 968 | commit: { | 994 | commit: { |
| 969 | status: "created", | 995 | status: "created", |
| 970 | commitSha: "89abcdef01234567", | 996 | commitSha: "89abcdef01234567", |
| 971 | subject: "feat: add branch sync", | 997 | subject: "feat: add ref sync", |
| 972 | }, | 998 | }, |
| 973 | push: { status: "pushed", branch: "feature/fix-toast-copy" }, | 999 | push: { status: "pushed", branch: "feature/fix-toast-copy" }, |
| 974 | pr: { status: "skipped_not_requested" }, | 1000 | pr: { status: "skipped_not_requested" }, |
| ... | @@ -985,8 +1011,8 @@ describe("resolveThreadBranchUpdate", () => { | ... | @@ -985,8 +1011,8 @@ describe("resolveThreadBranchUpdate", () => { |
| 985 | describe("resolveLiveThreadBranchUpdate", () => { | 1011 | describe("resolveLiveThreadBranchUpdate", () => { |
| 986 | it("returns a branch update when live git status differs from stored thread metadata", () => { | 1012 | it("returns a branch update when live git status differs from stored thread metadata", () => { |
| 987 | const update = resolveLiveThreadBranchUpdate({ | 1013 | const update = resolveLiveThreadBranchUpdate({ |
| 988 | threadBranch: "feature/old-branch", | 1014 | threadBranch: "feature/old-ref", |
| 989 | gitStatus: status({ branch: "effect-atom" }), | 1015 | gitStatus: status({ refName: "effect-atom" }), |
| 990 | }); | 1016 | }); |
| 991 | 1017 | ||
| 992 | assert.deepEqual(update, { | 1018 | assert.deepEqual(update, { |
| ... | @@ -996,35 +1022,35 @@ describe("resolveLiveThreadBranchUpdate", () => { | ... | @@ -996,35 +1022,35 @@ describe("resolveLiveThreadBranchUpdate", () => { |
| 996 | 1022 | ||
| 997 | it("returns null when live git status is unavailable", () => { | 1023 | it("returns null when live git status is unavailable", () => { |
| 998 | const update = resolveLiveThreadBranchUpdate({ | 1024 | const update = resolveLiveThreadBranchUpdate({ |
| 999 | threadBranch: "feature/old-branch", | 1025 | threadBranch: "feature/old-ref", |
| 1000 | gitStatus: null, | 1026 | gitStatus: null, |
| 1001 | }); | 1027 | }); |
| 1002 | 1028 | ||
| 1003 | assert.equal(update, null); | 1029 | assert.equal(update, null); |
| 1004 | }); | 1030 | }); |
| 1005 | 1031 | ||
| 1006 | it("returns null when the stored thread branch already matches git status", () => { | 1032 | it("returns null when the stored thread ref already matches git status", () => { |
| 1007 | const update = resolveLiveThreadBranchUpdate({ | 1033 | const update = resolveLiveThreadBranchUpdate({ |
| 1008 | threadBranch: "effect-atom", | 1034 | threadBranch: "effect-atom", |
| 1009 | gitStatus: status({ branch: "effect-atom" }), | 1035 | gitStatus: status({ refName: "effect-atom" }), |
| 1010 | }); | 1036 | }); |
| 1011 | 1037 | ||
| 1012 | assert.equal(update, null); | 1038 | assert.equal(update, null); |
| 1013 | }); | 1039 | }); |
| 1014 | 1040 | ||
| 1015 | it("returns null when git status is detached HEAD but the thread already has a branch", () => { | 1041 | it("returns null when git status is detached HEAD but the thread already has a ref", () => { |
| 1016 | const update = resolveLiveThreadBranchUpdate({ | 1042 | const update = resolveLiveThreadBranchUpdate({ |
| 1017 | threadBranch: "effect-atom", | 1043 | threadBranch: "effect-atom", |
| 1018 | gitStatus: status({ branch: null }), | 1044 | gitStatus: status({ refName: null }), |
| 1019 | }); | 1045 | }); |
| 1020 | 1046 | ||
| 1021 | assert.equal(update, null); | 1047 | assert.equal(update, null); |
| 1022 | }); | 1048 | }); |
| 1023 | 1049 | ||
| 1024 | it("does not regress a semantic thread branch back to a temporary worktree branch", () => { | 1050 | it("does not regress a semantic thread ref back to a temporary worktree ref", () => { |
| 1025 | const update = resolveLiveThreadBranchUpdate({ | 1051 | const update = resolveLiveThreadBranchUpdate({ |
| 1026 | threadBranch: "t3code/github-query-rate-limit", | 1052 | threadBranch: "t3code/github-query-rate-limit", |
| 1027 | gitStatus: status({ branch: "t3code/bda76797" }), | 1053 | gitStatus: status({ refName: "t3code/bda76797" }), |
| 1028 | }); | 1054 | }); |
| 1029 | 1055 | ||
| 1030 | assert.equal(update, null); | 1056 | assert.equal(update, null); |
| ... | @@ -1032,31 +1058,31 @@ describe("resolveLiveThreadBranchUpdate", () => { | ... | @@ -1032,31 +1058,31 @@ describe("resolveLiveThreadBranchUpdate", () => { |
| 1032 | }); | 1058 | }); |
| 1033 | 1059 | ||
| 1034 | describe("resolveAutoFeatureBranchName", () => { | 1060 | describe("resolveAutoFeatureBranchName", () => { |
| 1035 | it("uses semantic preferred branch names when available", () => { | 1061 | it("uses semantic preferred ref names when available", () => { |
| 1036 | const branch = resolveAutoFeatureBranchName(["main", "feature/other"], "fix toast copy"); | 1062 | const ref = resolveAutoFeatureBranchName(["main", "feature/other"], "fix toast copy"); |
| 1037 | assert.equal(branch, "feature/fix-toast-copy"); | 1063 | assert.equal(ref, "feature/fix-toast-copy"); |
| 1038 | }); | 1064 | }); |
| 1039 | 1065 | ||
| 1040 | it("normalizes preferred names that already include a branch namespace", () => { | 1066 | it("normalizes preferred names that already include a ref namespace", () => { |
| 1041 | const branch = resolveAutoFeatureBranchName(["main"], "feature/refine-toolbar-actions"); | 1067 | const ref = resolveAutoFeatureBranchName(["main"], "feature/refine-toolbar-actions"); |
| 1042 | assert.equal(branch, "feature/refine-toolbar-actions"); | 1068 | assert.equal(ref, "feature/refine-toolbar-actions"); |
| 1043 | }); | 1069 | }); |
| 1044 | 1070 | ||
| 1045 | it("increments suffix when the preferred branch name already exists", () => { | 1071 | it("increments suffix when the preferred ref name already exists", () => { |
| 1046 | const branch = resolveAutoFeatureBranchName( | 1072 | const ref = resolveAutoFeatureBranchName( |
| 1047 | ["main", "feature/fix-toast-copy", "feature/fix-toast-copy-2"], | 1073 | ["main", "feature/fix-toast-copy", "feature/fix-toast-copy-2"], |
| 1048 | "fix toast copy", | 1074 | "fix toast copy", |
| 1049 | ); | 1075 | ); |
| 1050 | assert.equal(branch, "feature/fix-toast-copy-3"); | 1076 | assert.equal(ref, "feature/fix-toast-copy-3"); |
| 1051 | }); | 1077 | }); |
| 1052 | 1078 | ||
| 1053 | it("treats existing branch names as case-insensitive for collision checks", () => { | 1079 | it("treats existing ref names as case-insensitive for collision checks", () => { |
| 1054 | const branch = resolveAutoFeatureBranchName(["Feature/Ticket-1"], "feature/ticket-1"); | 1080 | const ref = resolveAutoFeatureBranchName(["Feature/Ticket-1"], "feature/ticket-1"); |
| 1055 | assert.equal(branch, "feature/ticket-1-2"); | 1081 | assert.equal(ref, "feature/ticket-1-2"); |
| 1056 | }); | 1082 | }); |
| 1057 | 1083 | ||
| 1058 | it("falls back to feature/update when no preferred name is provided", () => { | 1084 | it("falls back to feature/update when no preferred name is provided", () => { |
| 1059 | const branch = resolveAutoFeatureBranchName(["main"]); | 1085 | const ref = resolveAutoFeatureBranchName(["main"]); |
| 1060 | assert.equal(branch, "feature/update"); | 1086 | assert.equal(ref, "feature/update"); |
| 1061 | }); | 1087 | }); |
| 1062 | }); | 1088 | }); |
apps/web/src/components/GitActionsControl.logic.ts+67-48| ... | @@ -1,9 +1,14 @@ | ... | @@ -1,9 +1,14 @@ |
| 1 | import type { | 1 | import type { |
| 2 | GitRunStackedActionResult, | 2 | GitRunStackedActionResult, |
| 3 | GitStackedAction, | 3 | GitStackedAction, |
| 4 | GitStatusResult, | 4 | VcsStatusResult, |
| 5 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 6 | import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; | 6 | import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; |
| 7 | import { | ||
| 8 | DEFAULT_CHANGE_REQUEST_TERMINOLOGY, | ||
| 9 | getChangeRequestTerminology, | ||
| 10 | type ChangeRequestTerminology, | ||
| 11 | } from "../sourceControlPresentation"; | ||
| 7 | 12 | ||
| 8 | export type GitActionIconName = "commit" | "push" | "pr"; | 13 | export type GitActionIconName = "commit" | "push" | "pr"; |
| 9 | 14 | ||
| ... | @@ -38,6 +43,14 @@ export type DefaultBranchConfirmableAction = | ... | @@ -38,6 +43,14 @@ export type DefaultBranchConfirmableAction = |
| 38 | | "commit_push" | 43 | | "commit_push" |
| 39 | | "commit_push_pr"; | 44 | | "commit_push_pr"; |
| 40 | 45 | ||
| 46 | function resolveChangeRequestTerminology( | ||
| 47 | gitStatus: VcsStatusResult | null, | ||
| 48 | ): ChangeRequestTerminology { | ||
| 49 | return gitStatus?.sourceControlProvider | ||
| 50 | ? getChangeRequestTerminology(gitStatus.sourceControlProvider) | ||
| 51 | : DEFAULT_CHANGE_REQUEST_TERMINOLOGY; | ||
| 52 | } | ||
| 53 | |||
| 41 | export function buildGitActionProgressStages(input: { | 54 | export function buildGitActionProgressStages(input: { |
| 42 | action: GitStackedAction; | 55 | action: GitStackedAction; |
| 43 | hasCustomCommitMessage: boolean; | 56 | hasCustomCommitMessage: boolean; |
| ... | @@ -45,13 +58,15 @@ export function buildGitActionProgressStages(input: { | ... | @@ -45,13 +58,15 @@ export function buildGitActionProgressStages(input: { |
| 45 | pushTarget?: string; | 58 | pushTarget?: string; |
| 46 | featureBranch?: boolean; | 59 | featureBranch?: boolean; |
| 47 | shouldPushBeforePr?: boolean; | 60 | shouldPushBeforePr?: boolean; |
| 61 | terminology?: ChangeRequestTerminology; | ||
| 48 | }): string[] { | 62 | }): string[] { |
| 49 | const branchStages = input.featureBranch ? ["Preparing feature branch..."] : []; | 63 | const terminology = input.terminology ?? DEFAULT_CHANGE_REQUEST_TERMINOLOGY; |
| 64 | const branchStages = input.featureBranch ? ["Preparing feature ref..."] : []; | ||
| 50 | const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing..."; | 65 | const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing..."; |
| 51 | const prStages = [ | 66 | const prStages = [ |
| 52 | "Preparing PR...", | 67 | `Preparing ${terminology.shortLabel}...`, |
| 53 | "Generating PR content...", | 68 | `Generating ${terminology.shortLabel} content...`, |
| 54 | "Creating GitHub pull request...", | 69 | `Creating ${terminology.singular}...`, |
| 55 | ]; | 70 | ]; |
| 56 | 71 | ||
| 57 | if (input.action === "push") { | 72 | if (input.action === "push") { |
| ... | @@ -77,17 +92,18 @@ export function buildGitActionProgressStages(input: { | ... | @@ -77,17 +92,18 @@ export function buildGitActionProgressStages(input: { |
| 77 | } | 92 | } |
| 78 | 93 | ||
| 79 | export function buildMenuItems( | 94 | export function buildMenuItems( |
| 80 | gitStatus: GitStatusResult | null, | 95 | gitStatus: VcsStatusResult | null, |
| 81 | isBusy: boolean, | 96 | isBusy: boolean, |
| 82 | hasOriginRemote = true, | 97 | hasPrimaryRemote = true, |
| 83 | ): GitActionMenuItem[] { | 98 | ): GitActionMenuItem[] { |
| 84 | if (!gitStatus) return []; | 99 | if (!gitStatus) return []; |
| 100 | const terminology = resolveChangeRequestTerminology(gitStatus); | ||
| 85 | 101 | ||
| 86 | const hasBranch = gitStatus.branch !== null; | 102 | const hasBranch = gitStatus.refName !== null; |
| 87 | const hasChanges = gitStatus.hasWorkingTreeChanges; | 103 | const hasChanges = gitStatus.hasWorkingTreeChanges; |
| 88 | const hasOpenPr = gitStatus.pr?.state === "open"; | 104 | const hasOpenPr = gitStatus.pr?.state === "open"; |
| 89 | const isBehind = gitStatus.behindCount > 0; | 105 | const isBehind = gitStatus.behindCount > 0; |
| 90 | const canPushWithoutUpstream = hasOriginRemote && !gitStatus.hasUpstream; | 106 | const canPushWithoutUpstream = hasPrimaryRemote && !gitStatus.hasUpstream; |
| 91 | const canCommit = !isBusy && hasChanges; | 107 | const canCommit = !isBusy && hasChanges; |
| 92 | const canPush = | 108 | const canPush = |
| 93 | !isBusy && | 109 | !isBusy && |
| ... | @@ -126,14 +142,14 @@ export function buildMenuItems( | ... | @@ -126,14 +142,14 @@ export function buildMenuItems( |
| 126 | hasOpenPr | 142 | hasOpenPr |
| 127 | ? { | 143 | ? { |
| 128 | id: "pr", | 144 | id: "pr", |
| 129 | label: "View PR", | 145 | label: `View ${terminology.shortLabel}`, |
| 130 | disabled: !canOpenPr, | 146 | disabled: !canOpenPr, |
| 131 | icon: "pr", | 147 | icon: "pr", |
| 132 | kind: "open_pr", | 148 | kind: "open_pr", |
| 133 | } | 149 | } |
| 134 | : { | 150 | : { |
| 135 | id: "pr", | 151 | id: "pr", |
| 136 | label: "Create PR", | 152 | label: `Create ${terminology.shortLabel}`, |
| 137 | disabled: !canCreatePr, | 153 | disabled: !canCreatePr, |
| 138 | icon: "pr", | 154 | icon: "pr", |
| 139 | kind: "open_dialog", | 155 | kind: "open_dialog", |
| ... | @@ -143,10 +159,10 @@ export function buildMenuItems( | ... | @@ -143,10 +159,10 @@ export function buildMenuItems( |
| 143 | } | 159 | } |
| 144 | 160 | ||
| 145 | export function resolveQuickAction( | 161 | export function resolveQuickAction( |
| 146 | gitStatus: GitStatusResult | null, | 162 | gitStatus: VcsStatusResult | null, |
| 147 | isBusy: boolean, | 163 | isBusy: boolean, |
| 148 | isDefaultBranch = false, | 164 | isDefaultRef = false, |
| 149 | hasOriginRemote = true, | 165 | hasPrimaryRemote = true, |
| 150 | ): GitQuickAction { | 166 | ): GitQuickAction { |
| 151 | if (isBusy) { | 167 | if (isBusy) { |
| 152 | return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." }; | 168 | return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." }; |
| ... | @@ -161,31 +177,32 @@ export function resolveQuickAction( | ... | @@ -161,31 +177,32 @@ export function resolveQuickAction( |
| 161 | }; | 177 | }; |
| 162 | } | 178 | } |
| 163 | 179 | ||
| 164 | const hasBranch = gitStatus.branch !== null; | 180 | const hasBranch = gitStatus.refName !== null; |
| 165 | const hasChanges = gitStatus.hasWorkingTreeChanges; | 181 | const hasChanges = gitStatus.hasWorkingTreeChanges; |
| 166 | const hasOpenPr = gitStatus.pr?.state === "open"; | 182 | const hasOpenPr = gitStatus.pr?.state === "open"; |
| 167 | const isAhead = gitStatus.aheadCount > 0; | 183 | const isAhead = gitStatus.aheadCount > 0; |
| 168 | const isBehind = gitStatus.behindCount > 0; | 184 | const isBehind = gitStatus.behindCount > 0; |
| 169 | const isDiverged = isAhead && isBehind; | 185 | const isDiverged = isAhead && isBehind; |
| 186 | const terminology = resolveChangeRequestTerminology(gitStatus); | ||
| 170 | 187 | ||
| 171 | if (!hasBranch) { | 188 | if (!hasBranch) { |
| 172 | return { | 189 | return { |
| 173 | label: "Commit", | 190 | label: "Commit", |
| 174 | disabled: true, | 191 | disabled: true, |
| 175 | kind: "show_hint", | 192 | kind: "show_hint", |
| 176 | hint: "Create and checkout a branch before pushing or opening a PR.", | 193 | hint: `Create and checkout a ref before pushing or opening a ${terminology.singular}.`, |
| 177 | }; | 194 | }; |
| 178 | } | 195 | } |
| 179 | 196 | ||
| 180 | if (hasChanges) { | 197 | if (hasChanges) { |
| 181 | if (!gitStatus.hasUpstream && !hasOriginRemote) { | 198 | if (!gitStatus.hasUpstream && !hasPrimaryRemote) { |
| 182 | return { label: "Commit", disabled: false, kind: "run_action", action: "commit" }; | 199 | return { label: "Commit", disabled: false, kind: "run_action", action: "commit" }; |
| 183 | } | 200 | } |
| 184 | if (hasOpenPr || isDefaultBranch) { | 201 | if (hasOpenPr || isDefaultRef) { |
| 185 | return { label: "Commit & push", disabled: false, kind: "run_action", action: "commit_push" }; | 202 | return { label: "Commit & push", disabled: false, kind: "run_action", action: "commit_push" }; |
| 186 | } | 203 | } |
| 187 | return { | 204 | return { |
| 188 | label: "Commit, push & PR", | 205 | label: `Commit, push & ${terminology.shortLabel}`, |
| 189 | disabled: false, | 206 | disabled: false, |
| 190 | kind: "run_action", | 207 | kind: "run_action", |
| 191 | action: "commit_push_pr", | 208 | action: "commit_push_pr", |
| ... | @@ -193,20 +210,20 @@ export function resolveQuickAction( | ... | @@ -193,20 +210,20 @@ export function resolveQuickAction( |
| 193 | } | 210 | } |
| 194 | 211 | ||
| 195 | if (!gitStatus.hasUpstream) { | 212 | if (!gitStatus.hasUpstream) { |
| 196 | if (!hasOriginRemote) { | 213 | if (!hasPrimaryRemote) { |
| 197 | if (hasOpenPr && !isAhead) { | 214 | if (hasOpenPr && !isAhead) { |
| 198 | return { label: "View PR", disabled: false, kind: "open_pr" }; | 215 | return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" }; |
| 199 | } | 216 | } |
| 200 | return { | 217 | return { |
| 201 | label: "Push", | 218 | label: "Push", |
| 202 | disabled: true, | 219 | disabled: true, |
| 203 | kind: "show_hint", | 220 | kind: "show_hint", |
| 204 | hint: 'Add an "origin" remote before pushing or creating a PR.', | 221 | hint: `Add an "origin" remote before pushing or creating a ${terminology.singular}.`, |
| 205 | }; | 222 | }; |
| 206 | } | 223 | } |
| 207 | if (!isAhead) { | 224 | if (!isAhead) { |
| 208 | if (hasOpenPr) { | 225 | if (hasOpenPr) { |
| 209 | return { label: "View PR", disabled: false, kind: "open_pr" }; | 226 | return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" }; |
| 210 | } | 227 | } |
| 211 | return { | 228 | return { |
| 212 | label: "Push", | 229 | label: "Push", |
| ... | @@ -215,16 +232,16 @@ export function resolveQuickAction( | ... | @@ -215,16 +232,16 @@ export function resolveQuickAction( |
| 215 | hint: "No local commits to push.", | 232 | hint: "No local commits to push.", |
| 216 | }; | 233 | }; |
| 217 | } | 234 | } |
| 218 | if (hasOpenPr || isDefaultBranch) { | 235 | if (hasOpenPr || isDefaultRef) { |
| 219 | return { | 236 | return { |
| 220 | label: "Push", | 237 | label: "Push", |
| 221 | disabled: false, | 238 | disabled: false, |
| 222 | kind: "run_action", | 239 | kind: "run_action", |
| 223 | action: isDefaultBranch ? "commit_push" : "push", | 240 | action: isDefaultRef ? "commit_push" : "push", |
| 224 | }; | 241 | }; |
| 225 | } | 242 | } |
| 226 | return { | 243 | return { |
| 227 | label: "Push & create PR", | 244 | label: `Push & create ${terminology.shortLabel}`, |
| 228 | disabled: false, | 245 | disabled: false, |
| 229 | kind: "run_action", | 246 | kind: "run_action", |
| 230 | action: "create_pr", | 247 | action: "create_pr", |
| ... | @@ -233,7 +250,7 @@ export function resolveQuickAction( | ... | @@ -233,7 +250,7 @@ export function resolveQuickAction( |
| 233 | 250 | ||
| 234 | if (isDiverged) { | 251 | if (isDiverged) { |
| 235 | return { | 252 | return { |
| 236 | label: "Sync branch", | 253 | label: "Sync ref", |
| 237 | disabled: true, | 254 | disabled: true, |
| 238 | kind: "show_hint", | 255 | kind: "show_hint", |
| 239 | hint: "Branch has diverged from upstream. Rebase/merge first.", | 256 | hint: "Branch has diverged from upstream. Rebase/merge first.", |
| ... | @@ -249,16 +266,16 @@ export function resolveQuickAction( | ... | @@ -249,16 +266,16 @@ export function resolveQuickAction( |
| 249 | } | 266 | } |
| 250 | 267 | ||
| 251 | if (isAhead) { | 268 | if (isAhead) { |
| 252 | if (hasOpenPr || isDefaultBranch) { | 269 | if (hasOpenPr || isDefaultRef) { |
| 253 | return { | 270 | return { |
| 254 | label: "Push", | 271 | label: "Push", |
| 255 | disabled: false, | 272 | disabled: false, |
| 256 | kind: "run_action", | 273 | kind: "run_action", |
| 257 | action: isDefaultBranch ? "commit_push" : "push", | 274 | action: isDefaultRef ? "commit_push" : "push", |
| 258 | }; | 275 | }; |
| 259 | } | 276 | } |
| 260 | return { | 277 | return { |
| 261 | label: "Push & create PR", | 278 | label: `Push & create ${terminology.shortLabel}`, |
| 262 | disabled: false, | 279 | disabled: false, |
| 263 | kind: "run_action", | 280 | kind: "run_action", |
| 264 | action: "create_pr", | 281 | action: "create_pr", |
| ... | @@ -266,7 +283,7 @@ export function resolveQuickAction( | ... | @@ -266,7 +283,7 @@ export function resolveQuickAction( |
| 266 | } | 283 | } |
| 267 | 284 | ||
| 268 | if (hasOpenPr && gitStatus.hasUpstream) { | 285 | if (hasOpenPr && gitStatus.hasUpstream) { |
| 269 | return { label: "View PR", disabled: false, kind: "open_pr" }; | 286 | return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" }; |
| 270 | } | 287 | } |
| 271 | 288 | ||
| 272 | return { | 289 | return { |
| ... | @@ -279,9 +296,9 @@ export function resolveQuickAction( | ... | @@ -279,9 +296,9 @@ export function resolveQuickAction( |
| 279 | 296 | ||
| 280 | export function requiresDefaultBranchConfirmation( | 297 | export function requiresDefaultBranchConfirmation( |
| 281 | action: GitStackedAction, | 298 | action: GitStackedAction, |
| 282 | isDefaultBranch: boolean, | 299 | isDefaultRef: boolean, |
| 283 | ): boolean { | 300 | ): boolean { |
| 284 | if (!isDefaultBranch) return false; | 301 | if (!isDefaultRef) return false; |
| 285 | return ( | 302 | return ( |
| 286 | action === "push" || | 303 | action === "push" || |
| 287 | action === "create_pr" || | 304 | action === "create_pr" || |
| ... | @@ -294,20 +311,22 @@ export function resolveDefaultBranchActionDialogCopy(input: { | ... | @@ -294,20 +311,22 @@ export function resolveDefaultBranchActionDialogCopy(input: { |
| 294 | action: DefaultBranchConfirmableAction; | 311 | action: DefaultBranchConfirmableAction; |
| 295 | branchName: string; | 312 | branchName: string; |
| 296 | includesCommit: boolean; | 313 | includesCommit: boolean; |
| 314 | terminology?: ChangeRequestTerminology; | ||
| 297 | }): DefaultBranchActionDialogCopy { | 315 | }): DefaultBranchActionDialogCopy { |
| 298 | const branchLabel = input.branchName; | 316 | const branchLabel = input.branchName; |
| 299 | const suffix = ` on "${branchLabel}". You can continue on this branch or create a feature branch and run the same action there.`; | 317 | const suffix = ` on "${branchLabel}". You can continue on this ref or create a feature ref and run the same action there.`; |
| 318 | const terminology = input.terminology ?? DEFAULT_CHANGE_REQUEST_TERMINOLOGY; | ||
| 300 | 319 | ||
| 301 | if (input.action === "push" || input.action === "commit_push") { | 320 | if (input.action === "push" || input.action === "commit_push") { |
| 302 | if (input.includesCommit) { | 321 | if (input.includesCommit) { |
| 303 | return { | 322 | return { |
| 304 | title: "Commit & push to default branch?", | 323 | title: "Commit & push to default ref?", |
| 305 | description: `This action will commit and push changes${suffix}`, | 324 | description: `This action will commit and push changes${suffix}`, |
| 306 | continueLabel: `Commit & push to ${branchLabel}`, | 325 | continueLabel: `Commit & push to ${branchLabel}`, |
| 307 | }; | 326 | }; |
| 308 | } | 327 | } |
| 309 | return { | 328 | return { |
| 310 | title: "Push to default branch?", | 329 | title: "Push to default ref?", |
| 311 | description: `This action will push local commits${suffix}`, | 330 | description: `This action will push local commits${suffix}`, |
| 312 | continueLabel: `Push to ${branchLabel}`, | 331 | continueLabel: `Push to ${branchLabel}`, |
| 313 | }; | 332 | }; |
| ... | @@ -315,15 +334,15 @@ export function resolveDefaultBranchActionDialogCopy(input: { | ... | @@ -315,15 +334,15 @@ export function resolveDefaultBranchActionDialogCopy(input: { |
| 315 | 334 | ||
| 316 | if (input.includesCommit) { | 335 | if (input.includesCommit) { |
| 317 | return { | 336 | return { |
| 318 | title: "Commit, push & create PR from default branch?", | 337 | title: `Commit, push & create ${terminology.shortLabel} from default ref?`, |
| 319 | description: `This action will commit, push, and create a PR${suffix}`, | 338 | description: `This action will commit, push, and create a ${terminology.singular}${suffix}`, |
| 320 | continueLabel: `Commit, push & create PR`, | 339 | continueLabel: `Commit, push & create ${terminology.shortLabel}`, |
| 321 | }; | 340 | }; |
| 322 | } | 341 | } |
| 323 | return { | 342 | return { |
| 324 | title: "Push & create PR from default branch?", | 343 | title: `Push & create ${terminology.shortLabel} from default ref?`, |
| 325 | description: `This action will push local commits and create a PR${suffix}`, | 344 | description: `This action will push local commits and create a ${terminology.singular}${suffix}`, |
| 326 | continueLabel: "Push & create PR", | 345 | continueLabel: `Push & create ${terminology.shortLabel}`, |
| 327 | }; | 346 | }; |
| 328 | } | 347 | } |
| 329 | 348 | ||
| ... | @@ -341,31 +360,31 @@ export function resolveThreadBranchUpdate( | ... | @@ -341,31 +360,31 @@ export function resolveThreadBranchUpdate( |
| 341 | 360 | ||
| 342 | export function resolveLiveThreadBranchUpdate(input: { | 361 | export function resolveLiveThreadBranchUpdate(input: { |
| 343 | threadBranch: string | null; | 362 | threadBranch: string | null; |
| 344 | gitStatus: GitStatusResult | null; | 363 | gitStatus: VcsStatusResult | null; |
| 345 | }): { branch: string | null } | null { | 364 | }): { branch: string | null } | null { |
| 346 | if (!input.gitStatus) { | 365 | if (!input.gitStatus) { |
| 347 | return null; | 366 | return null; |
| 348 | } | 367 | } |
| 349 | 368 | ||
| 350 | if (input.gitStatus.branch === null && input.threadBranch !== null) { | 369 | if (input.gitStatus.refName === null && input.threadBranch !== null) { |
| 351 | return null; | 370 | return null; |
| 352 | } | 371 | } |
| 353 | 372 | ||
| 354 | if (input.threadBranch === input.gitStatus.branch) { | 373 | if (input.threadBranch === input.gitStatus.refName) { |
| 355 | return null; | 374 | return null; |
| 356 | } | 375 | } |
| 357 | 376 | ||
| 358 | if ( | 377 | if ( |
| 359 | input.threadBranch !== null && | 378 | input.threadBranch !== null && |
| 360 | input.gitStatus.branch !== null && | 379 | input.gitStatus.refName !== null && |
| 361 | !isTemporaryWorktreeBranch(input.threadBranch) && | 380 | !isTemporaryWorktreeBranch(input.threadBranch) && |
| 362 | isTemporaryWorktreeBranch(input.gitStatus.branch) | 381 | isTemporaryWorktreeBranch(input.gitStatus.refName) |
| 363 | ) { | 382 | ) { |
| 364 | return null; | 383 | return null; |
| 365 | } | 384 | } |
| 366 | 385 | ||
| 367 | return { | 386 | return { |
| 368 | branch: input.gitStatus.branch, | 387 | branch: input.gitStatus.refName, |
| 369 | }; | 388 | }; |
| 370 | } | 389 | } |
| 371 | 390 |
apps/web/src/components/GitActionsControl.tsx+79-49| ... | @@ -3,12 +3,11 @@ import type { | ... | @@ -3,12 +3,11 @@ import type { |
| 3 | GitActionProgressEvent, | 3 | GitActionProgressEvent, |
| 4 | GitRunStackedActionResult, | 4 | GitRunStackedActionResult, |
| 5 | GitStackedAction, | 5 | GitStackedAction, |
| 6 | GitStatusResult, | 6 | VcsStatusResult, |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; | 8 | import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; |
| 9 | import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; | 9 | import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; |
| 10 | import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; | 10 | import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; |
| 11 | import { GitHubIcon } from "./Icons"; | ||
| 12 | import { | 11 | import { |
| 13 | buildGitActionProgressStages, | 12 | buildGitActionProgressStages, |
| 14 | buildMenuItems, | 13 | buildMenuItems, |
| ... | @@ -52,6 +51,7 @@ import { resolvePathLinkTarget } from "~/terminal-links"; | ... | @@ -52,6 +51,7 @@ import { resolvePathLinkTarget } from "~/terminal-links"; |
| 52 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; | 51 | import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; |
| 53 | import { readEnvironmentApi } from "~/environmentApi"; | 52 | import { readEnvironmentApi } from "~/environmentApi"; |
| 54 | import { readLocalApi } from "~/localApi"; | 53 | import { readLocalApi } from "~/localApi"; |
| 54 | import { getSourceControlPresentation } from "~/sourceControlPresentation"; | ||
| 55 | import { useStore } from "~/store"; | 55 | import { useStore } from "~/store"; |
| 56 | import { createThreadSelectorByRef } from "~/storeSelectors"; | 56 | import { createThreadSelectorByRef } from "~/storeSelectors"; |
| 57 | 57 | ||
| ... | @@ -89,7 +89,7 @@ interface RunGitActionWithToastInput { | ... | @@ -89,7 +89,7 @@ interface RunGitActionWithToastInput { |
| 89 | commitMessage?: string; | 89 | commitMessage?: string; |
| 90 | onConfirmed?: () => void; | 90 | onConfirmed?: () => void; |
| 91 | skipDefaultBranchPrompt?: boolean; | 91 | skipDefaultBranchPrompt?: boolean; |
| 92 | statusOverride?: GitStatusResult | null; | 92 | statusOverride?: VcsStatusResult | null; |
| 93 | featureBranch?: boolean; | 93 | featureBranch?: boolean; |
| 94 | progressToastId?: GitActionToastId; | 94 | progressToastId?: GitActionToastId; |
| 95 | filePaths?: string[]; | 95 | filePaths?: string[]; |
| ... | @@ -121,22 +121,23 @@ function getMenuActionDisabledReason({ | ... | @@ -121,22 +121,23 @@ function getMenuActionDisabledReason({ |
| 121 | item, | 121 | item, |
| 122 | gitStatus, | 122 | gitStatus, |
| 123 | isBusy, | 123 | isBusy, |
| 124 | hasOriginRemote, | 124 | hasPrimaryRemote, |
| 125 | }: { | 125 | }: { |
| 126 | item: GitActionMenuItem; | 126 | item: GitActionMenuItem; |
| 127 | gitStatus: GitStatusResult | null; | 127 | gitStatus: VcsStatusResult | null; |
| 128 | isBusy: boolean; | 128 | isBusy: boolean; |
| 129 | hasOriginRemote: boolean; | 129 | hasPrimaryRemote: boolean; |
| 130 | }): string | null { | 130 | }): string | null { |
| 131 | if (!item.disabled) return null; | 131 | if (!item.disabled) return null; |
| 132 | if (isBusy) return "Git action in progress."; | 132 | if (isBusy) return "Git action in progress."; |
| 133 | if (!gitStatus) return "Git status is unavailable."; | 133 | if (!gitStatus) return "Git status is unavailable."; |
| 134 | 134 | ||
| 135 | const hasBranch = gitStatus.branch !== null; | 135 | const hasBranch = gitStatus.refName !== null; |
| 136 | const hasChanges = gitStatus.hasWorkingTreeChanges; | 136 | const hasChanges = gitStatus.hasWorkingTreeChanges; |
| 137 | const hasOpenPr = gitStatus.pr?.state === "open"; | 137 | const hasOpenPr = gitStatus.pr?.state === "open"; |
| 138 | const isAhead = gitStatus.aheadCount > 0; | 138 | const isAhead = gitStatus.aheadCount > 0; |
| 139 | const isBehind = gitStatus.behindCount > 0; | 139 | const isBehind = gitStatus.behindCount > 0; |
| 140 | const terminology = getSourceControlPresentation(gitStatus.sourceControlProvider).terminology; | ||
| 140 | 141 | ||
| 141 | if (item.id === "commit") { | 142 | if (item.id === "commit") { |
| 142 | if (!hasChanges) { | 143 | if (!hasChanges) { |
| ... | @@ -147,7 +148,7 @@ function getMenuActionDisabledReason({ | ... | @@ -147,7 +148,7 @@ function getMenuActionDisabledReason({ |
| 147 | 148 | ||
| 148 | if (item.id === "push") { | 149 | if (item.id === "push") { |
| 149 | if (!hasBranch) { | 150 | if (!hasBranch) { |
| 150 | return "Detached HEAD: checkout a branch before pushing."; | 151 | return "Detached HEAD: checkout a refName before pushing."; |
| 151 | } | 152 | } |
| 152 | if (hasChanges) { | 153 | if (hasChanges) { |
| 153 | return "Commit or stash local changes before pushing."; | 154 | return "Commit or stash local changes before pushing."; |
| ... | @@ -155,7 +156,7 @@ function getMenuActionDisabledReason({ | ... | @@ -155,7 +156,7 @@ function getMenuActionDisabledReason({ |
| 155 | if (isBehind) { | 156 | if (isBehind) { |
| 156 | return "Branch is behind upstream. Pull/rebase before pushing."; | 157 | return "Branch is behind upstream. Pull/rebase before pushing."; |
| 157 | } | 158 | } |
| 158 | if (!gitStatus.hasUpstream && !hasOriginRemote) { | 159 | if (!gitStatus.hasUpstream && !hasPrimaryRemote) { |
| 159 | return 'Add an "origin" remote before pushing.'; | 160 | return 'Add an "origin" remote before pushing.'; |
| 160 | } | 161 | } |
| 161 | if (!isAhead) { | 162 | if (!isAhead) { |
| ... | @@ -165,46 +166,58 @@ function getMenuActionDisabledReason({ | ... | @@ -165,46 +166,58 @@ function getMenuActionDisabledReason({ |
| 165 | } | 166 | } |
| 166 | 167 | ||
| 167 | if (hasOpenPr) { | 168 | if (hasOpenPr) { |
| 168 | return "View PR is currently unavailable."; | 169 | return `View ${terminology.singular} is currently unavailable.`; |
| 169 | } | 170 | } |
| 170 | if (!hasBranch) { | 171 | if (!hasBranch) { |
| 171 | return "Detached HEAD: checkout a branch before creating a PR."; | 172 | return `Detached HEAD: checkout a refName before creating a ${terminology.singular}.`; |
| 172 | } | 173 | } |
| 173 | if (hasChanges) { | 174 | if (hasChanges) { |
| 174 | return "Commit local changes before creating a PR."; | 175 | return `Commit local changes before creating a ${terminology.singular}.`; |
| 175 | } | 176 | } |
| 176 | if (!gitStatus.hasUpstream && !hasOriginRemote) { | 177 | if (!gitStatus.hasUpstream && !hasPrimaryRemote) { |
| 177 | return 'Add an "origin" remote before creating a PR.'; | 178 | return `Add an "origin" remote before creating a ${terminology.singular}.`; |
| 178 | } | 179 | } |
| 179 | if (!isAhead) { | 180 | if (!isAhead) { |
| 180 | return "No local commits to include in a PR."; | 181 | return `No local commits to include in a ${terminology.singular}.`; |
| 181 | } | 182 | } |
| 182 | if (isBehind) { | 183 | if (isBehind) { |
| 183 | return "Branch is behind upstream. Pull/rebase before creating a PR."; | 184 | return `Branch is behind upstream. Pull/rebase before creating a ${terminology.singular}.`; |
| 184 | } | 185 | } |
| 185 | return "Create PR is currently unavailable."; | 186 | return `Create ${terminology.singular} is currently unavailable.`; |
| 186 | } | 187 | } |
| 187 | 188 | ||
| 188 | const COMMIT_DIALOG_TITLE = "Commit changes"; | 189 | const COMMIT_DIALOG_TITLE = "Commit changes"; |
| 189 | const COMMIT_DIALOG_DESCRIPTION = | 190 | const COMMIT_DIALOG_DESCRIPTION = |
| 190 | "Review and confirm your commit. Leave the message blank to auto-generate one."; | 191 | "Review and confirm your commit. Leave the message blank to auto-generate one."; |
| 191 | 192 | ||
| 192 | function GitActionItemIcon({ icon }: { icon: GitActionIconName }) { | 193 | function GitActionItemIcon({ |
| 194 | icon, | ||
| 195 | SourceControlIcon, | ||
| 196 | }: { | ||
| 197 | icon: GitActionIconName; | ||
| 198 | SourceControlIcon: ReturnType<typeof getSourceControlPresentation>["Icon"]; | ||
| 199 | }) { | ||
| 193 | if (icon === "commit") return <GitCommitIcon />; | 200 | if (icon === "commit") return <GitCommitIcon />; |
| 194 | if (icon === "push") return <CloudUploadIcon />; | 201 | if (icon === "push") return <CloudUploadIcon />; |
| 195 | return <GitHubIcon />; | 202 | return <SourceControlIcon />; |
| 196 | } | 203 | } |
| 197 | 204 | ||
| 198 | function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { | 205 | function GitQuickActionIcon({ |
| 206 | quickAction, | ||
| 207 | SourceControlIcon, | ||
| 208 | }: { | ||
| 209 | quickAction: GitQuickAction; | ||
| 210 | SourceControlIcon: ReturnType<typeof getSourceControlPresentation>["Icon"]; | ||
| 211 | }) { | ||
| 199 | const iconClassName = "size-3.5"; | 212 | const iconClassName = "size-3.5"; |
| 200 | if (quickAction.kind === "open_pr") return <GitHubIcon className={iconClassName} />; | 213 | if (quickAction.kind === "open_pr") return <SourceControlIcon className={iconClassName} />; |
| 201 | if (quickAction.kind === "run_pull") return <InfoIcon className={iconClassName} />; | 214 | if (quickAction.kind === "run_pull") return <InfoIcon className={iconClassName} />; |
| 202 | if (quickAction.kind === "run_action") { | 215 | if (quickAction.kind === "run_action") { |
| 203 | if (quickAction.action === "commit") return <GitCommitIcon className={iconClassName} />; | 216 | if (quickAction.action === "commit") return <GitCommitIcon className={iconClassName} />; |
| 204 | if (quickAction.action === "push" || quickAction.action === "commit_push") { | 217 | if (quickAction.action === "push" || quickAction.action === "commit_push") { |
| 205 | return <CloudUploadIcon className={iconClassName} />; | 218 | return <CloudUploadIcon className={iconClassName} />; |
| 206 | } | 219 | } |
| 207 | return <GitHubIcon className={iconClassName} />; | 220 | return <SourceControlIcon className={iconClassName} />; |
| 208 | } | 221 | } |
| 209 | if (quickAction.label === "Commit") return <GitCommitIcon className={iconClassName} />; | 222 | if (quickAction.label === "Commit") return <GitCommitIcon className={iconClassName} />; |
| 210 | return <InfoIcon className={iconClassName} />; | 223 | return <InfoIcon className={iconClassName} />; |
| ... | @@ -322,9 +335,15 @@ export default function GitActionsControl({ | ... | @@ -322,9 +335,15 @@ export default function GitActionsControl({ |
| 322 | environmentId: activeEnvironmentId, | 335 | environmentId: activeEnvironmentId, |
| 323 | cwd: gitCwd, | 336 | cwd: gitCwd, |
| 324 | }); | 337 | }); |
| 338 | const sourceControlPresentation = useMemo( | ||
| 339 | () => getSourceControlPresentation(gitStatus?.sourceControlProvider), | ||
| 340 | [gitStatus?.sourceControlProvider], | ||
| 341 | ); | ||
| 342 | const changeRequestTerminology = sourceControlPresentation.terminology; | ||
| 343 | const SourceControlIcon = sourceControlPresentation.Icon; | ||
| 325 | // Default to true while loading so we don't flash init controls. | 344 | // Default to true while loading so we don't flash init controls. |
| 326 | const isRepo = gitStatus?.isRepo ?? true; | 345 | const isRepo = gitStatus?.isRepo ?? true; |
| 327 | const hasOriginRemote = gitStatus?.hasOriginRemote ?? false; | 346 | const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false; |
| 328 | const gitStatusForActions = gitStatus; | 347 | const gitStatusForActions = gitStatus; |
| 329 | 348 | ||
| 330 | const allFiles = gitStatusForActions?.workingTree.files ?? []; | 349 | const allFiles = gitStatusForActions?.workingTree.files ?? []; |
| ... | @@ -382,18 +401,18 @@ export default function GitActionsControl({ | ... | @@ -382,18 +401,18 @@ export default function GitActionsControl({ |
| 382 | persistThreadBranchSync, | 401 | persistThreadBranchSync, |
| 383 | ]); | 402 | ]); |
| 384 | 403 | ||
| 385 | const isDefaultBranch = useMemo(() => { | 404 | const isDefaultRef = useMemo(() => { |
| 386 | return gitStatusForActions?.isDefaultBranch ?? false; | 405 | return gitStatusForActions?.isDefaultRef ?? false; |
| 387 | }, [gitStatusForActions?.isDefaultBranch]); | 406 | }, [gitStatusForActions?.isDefaultRef]); |
| 388 | 407 | ||
| 389 | const gitActionMenuItems = useMemo( | 408 | const gitActionMenuItems = useMemo( |
| 390 | () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasOriginRemote), | 409 | () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasPrimaryRemote), |
| 391 | [gitStatusForActions, hasOriginRemote, isGitActionRunning], | 410 | [gitStatusForActions, hasPrimaryRemote, isGitActionRunning], |
| 392 | ); | 411 | ); |
| 393 | const quickAction = useMemo( | 412 | const quickAction = useMemo( |
| 394 | () => | 413 | () => |
| 395 | resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultBranch, hasOriginRemote), | 414 | resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultRef, hasPrimaryRemote), |
| 396 | [gitStatusForActions, hasOriginRemote, isDefaultBranch, isGitActionRunning], | 415 | [gitStatusForActions, hasPrimaryRemote, isDefaultRef, isGitActionRunning], |
| 397 | ); | 416 | ); |
| 398 | const quickActionDisabledReason = quickAction.disabled | 417 | const quickActionDisabledReason = quickAction.disabled |
| 399 | ? (quickAction.hint ?? "This action is currently unavailable.") | 418 | ? (quickAction.hint ?? "This action is currently unavailable.") |
| ... | @@ -403,6 +422,7 @@ export default function GitActionsControl({ | ... | @@ -403,6 +422,7 @@ export default function GitActionsControl({ |
| 403 | action: pendingDefaultBranchAction.action, | 422 | action: pendingDefaultBranchAction.action, |
| 404 | branchName: pendingDefaultBranchAction.branchName, | 423 | branchName: pendingDefaultBranchAction.branchName, |
| 405 | includesCommit: pendingDefaultBranchAction.includesCommit, | 424 | includesCommit: pendingDefaultBranchAction.includesCommit, |
| 425 | terminology: changeRequestTerminology, | ||
| 406 | }) | 426 | }) |
| 407 | : null; | 427 | : null; |
| 408 | 428 | ||
| ... | @@ -468,7 +488,7 @@ export default function GitActionsControl({ | ... | @@ -468,7 +488,7 @@ export default function GitActionsControl({ |
| 468 | if (!prUrl) { | 488 | if (!prUrl) { |
| 469 | toastManager.add({ | 489 | toastManager.add({ |
| 470 | type: "error", | 490 | type: "error", |
| 471 | title: "No open PR found.", | 491 | title: "No open pull request found.", |
| 472 | data: threadToastData, | 492 | data: threadToastData, |
| 473 | }); | 493 | }); |
| 474 | return; | 494 | return; |
| ... | @@ -477,7 +497,7 @@ export default function GitActionsControl({ | ... | @@ -477,7 +497,7 @@ export default function GitActionsControl({ |
| 477 | toastManager.add( | 497 | toastManager.add( |
| 478 | stackedThreadToast({ | 498 | stackedThreadToast({ |
| 479 | type: "error", | 499 | type: "error", |
| 480 | title: "Unable to open PR link", | 500 | title: "Unable to open pull request link", |
| 481 | description: err instanceof Error ? err.message : "An error occurred.", | 501 | description: err instanceof Error ? err.message : "An error occurred.", |
| 482 | ...(threadToastData !== undefined ? { data: threadToastData } : {}), | 502 | ...(threadToastData !== undefined ? { data: threadToastData } : {}), |
| 483 | }), | 503 | }), |
| ... | @@ -497,8 +517,8 @@ export default function GitActionsControl({ | ... | @@ -497,8 +517,8 @@ export default function GitActionsControl({ |
| 497 | filePaths, | 517 | filePaths, |
| 498 | }: RunGitActionWithToastInput) => { | 518 | }: RunGitActionWithToastInput) => { |
| 499 | const actionStatus = statusOverride ?? gitStatusForActions; | 519 | const actionStatus = statusOverride ?? gitStatusForActions; |
| 500 | const actionBranch = actionStatus?.branch ?? null; | 520 | const actionBranch = actionStatus?.refName ?? null; |
| 501 | const actionIsDefaultBranch = featureBranch ? false : isDefaultBranch; | 521 | const actionIsDefaultBranch = featureBranch ? false : isDefaultRef; |
| 502 | const actionCanCommit = | 522 | const actionCanCommit = |
| 503 | action === "commit" || action === "commit_push" || action === "commit_push_pr"; | 523 | action === "commit" || action === "commit_push" || action === "commit_push_pr"; |
| 504 | const includesCommit = | 524 | const includesCommit = |
| ... | @@ -534,6 +554,7 @@ export default function GitActionsControl({ | ... | @@ -534,6 +554,7 @@ export default function GitActionsControl({ |
| 534 | hasCustomCommitMessage: !!commitMessage?.trim(), | 554 | hasCustomCommitMessage: !!commitMessage?.trim(), |
| 535 | hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges, | 555 | hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges, |
| 536 | featureBranch, | 556 | featureBranch, |
| 557 | terminology: changeRequestTerminology, | ||
| 537 | shouldPushBeforePr: | 558 | shouldPushBeforePr: |
| 538 | action === "create_pr" && | 559 | action === "create_pr" && |
| 539 | (!actionStatus?.hasUpstream || (actionStatus?.aheadCount ?? 0) > 0), | 560 | (!actionStatus?.hasUpstream || (actionStatus?.aheadCount ?? 0) > 0), |
| ... | @@ -775,8 +796,8 @@ export default function GitActionsControl({ | ... | @@ -775,8 +796,8 @@ export default function GitActionsControl({ |
| 775 | title: result.status === "pulled" ? "Pulled" : "Already up to date", | 796 | title: result.status === "pulled" ? "Pulled" : "Already up to date", |
| 776 | description: | 797 | description: |
| 777 | result.status === "pulled" | 798 | result.status === "pulled" |
| 778 | ? `Updated ${result.branch} from ${result.upstreamBranch ?? "upstream"}` | 799 | ? `Updated ${result.refName} from ${result.upstreamRef ?? "upstream"}` |
| 779 | : `${result.branch} is already synchronized.`, | 800 | : `${result.refName} is already synchronized.`, |
| 780 | data: threadToastData, | 801 | data: threadToastData, |
| 781 | }), | 802 | }), |
| 782 | error: (err) => ({ | 803 | error: (err) => ({ |
| ... | @@ -889,7 +910,10 @@ export default function GitActionsControl({ | ... | @@ -889,7 +910,10 @@ export default function GitActionsControl({ |
| 889 | /> | 910 | /> |
| 890 | } | 911 | } |
| 891 | > | 912 | > |
| 892 | <GitQuickActionIcon quickAction={quickAction} /> | 913 | <GitQuickActionIcon |
| 914 | quickAction={quickAction} | ||
| 915 | SourceControlIcon={SourceControlIcon} | ||
| 916 | /> | ||
| 893 | <span className="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5"> | 917 | <span className="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5"> |
| 894 | {quickAction.label} | 918 | {quickAction.label} |
| 895 | </span> | 919 | </span> |
| ... | @@ -905,7 +929,7 @@ export default function GitActionsControl({ | ... | @@ -905,7 +929,7 @@ export default function GitActionsControl({ |
| 905 | disabled={isGitActionRunning || quickAction.disabled} | 929 | disabled={isGitActionRunning || quickAction.disabled} |
| 906 | onClick={runQuickAction} | 930 | onClick={runQuickAction} |
| 907 | > | 931 | > |
| 908 | <GitQuickActionIcon quickAction={quickAction} /> | 932 | <GitQuickActionIcon quickAction={quickAction} SourceControlIcon={SourceControlIcon} /> |
| 909 | <span className="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5"> | 933 | <span className="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5"> |
| 910 | {quickAction.label} | 934 | {quickAction.label} |
| 911 | </span> | 935 | </span> |
| ... | @@ -934,7 +958,7 @@ export default function GitActionsControl({ | ... | @@ -934,7 +958,7 @@ export default function GitActionsControl({ |
| 934 | item, | 958 | item, |
| 935 | gitStatus: gitStatusForActions, | 959 | gitStatus: gitStatusForActions, |
| 936 | isBusy: isGitActionRunning, | 960 | isBusy: isGitActionRunning, |
| 937 | hasOriginRemote, | 961 | hasPrimaryRemote, |
| 938 | }); | 962 | }); |
| 939 | if (item.disabled && disabledReason) { | 963 | if (item.disabled && disabledReason) { |
| 940 | return ( | 964 | return ( |
| ... | @@ -945,7 +969,10 @@ export default function GitActionsControl({ | ... | @@ -945,7 +969,10 @@ export default function GitActionsControl({ |
| 945 | render={<span className="block w-max cursor-not-allowed" />} | 969 | render={<span className="block w-max cursor-not-allowed" />} |
| 946 | > | 970 | > |
| 947 | <MenuItem className="w-full" disabled> | 971 | <MenuItem className="w-full" disabled> |
| 948 | <GitActionItemIcon icon={item.icon} /> | 972 | <GitActionItemIcon |
| 973 | icon={item.icon} | ||
| 974 | SourceControlIcon={SourceControlIcon} | ||
| 975 | /> | ||
| 949 | {item.label} | 976 | {item.label} |
| 950 | </MenuItem> | 977 | </MenuItem> |
| 951 | </PopoverTrigger> | 978 | </PopoverTrigger> |
| ... | @@ -964,18 +991,19 @@ export default function GitActionsControl({ | ... | @@ -964,18 +991,19 @@ export default function GitActionsControl({ |
| 964 | openDialogForMenuItem(item); | 991 | openDialogForMenuItem(item); |
| 965 | }} | 992 | }} |
| 966 | > | 993 | > |
| 967 | <GitActionItemIcon icon={item.icon} /> | 994 | <GitActionItemIcon icon={item.icon} SourceControlIcon={SourceControlIcon} /> |
| 968 | {item.label} | 995 | {item.label} |
| 969 | </MenuItem> | 996 | </MenuItem> |
| 970 | ); | 997 | ); |
| 971 | })} | 998 | })} |
| 972 | {gitStatusForActions?.branch === null && ( | 999 | {gitStatusForActions?.refName === null && ( |
| 973 | <p className="px-2 py-1.5 text-xs text-warning"> | 1000 | <p className="px-2 py-1.5 text-xs text-warning"> |
| 974 | Detached HEAD: create and checkout a branch to enable push and PR actions. | 1001 | Detached HEAD: create and checkout a refName to enable push and pull request |
| 1002 | actions. | ||
| 975 | </p> | 1003 | </p> |
| 976 | )} | 1004 | )} |
| 977 | {gitStatusForActions && | 1005 | {gitStatusForActions && |
| 978 | gitStatusForActions.branch !== null && | 1006 | gitStatusForActions.refName !== null && |
| 979 | !gitStatusForActions.hasWorkingTreeChanges && | 1007 | !gitStatusForActions.hasWorkingTreeChanges && |
| 980 | gitStatusForActions.behindCount > 0 && | 1008 | gitStatusForActions.behindCount > 0 && |
| 981 | gitStatusForActions.aheadCount === 0 && ( | 1009 | gitStatusForActions.aheadCount === 0 && ( |
| ... | @@ -1013,10 +1041,12 @@ export default function GitActionsControl({ | ... | @@ -1013,10 +1041,12 @@ export default function GitActionsControl({ |
| 1013 | <span className="text-muted-foreground">Branch</span> | 1041 | <span className="text-muted-foreground">Branch</span> |
| 1014 | <span className="flex items-center justify-between gap-2"> | 1042 | <span className="flex items-center justify-between gap-2"> |
| 1015 | <span className="font-medium"> | 1043 | <span className="font-medium"> |
| 1016 | {gitStatusForActions?.branch ?? "(detached HEAD)"} | 1044 | {gitStatusForActions?.refName ?? "(detached HEAD)"} |
| 1017 | </span> | 1045 | </span> |
| 1018 | {isDefaultBranch && ( | 1046 | {isDefaultRef && ( |
| 1019 | <span className="text-right text-warning text-xs">Warning: default branch</span> | 1047 | <span className="text-right text-warning text-xs"> |
| 1048 | Warning: default refName | ||
| 1049 | </span> | ||
| 1020 | )} | 1050 | )} |
| 1021 | </span> | 1051 | </span> |
| 1022 | </div> | 1052 | </div> |
| ... | @@ -1149,7 +1179,7 @@ export default function GitActionsControl({ | ... | @@ -1149,7 +1179,7 @@ export default function GitActionsControl({ |
| 1149 | disabled={noneSelected} | 1179 | disabled={noneSelected} |
| 1150 | onClick={runDialogActionOnNewBranch} | 1180 | onClick={runDialogActionOnNewBranch} |
| 1151 | > | 1181 | > |
| 1152 | Commit on new branch | 1182 | Commit on new refName |
| 1153 | </Button> | 1183 | </Button> |
| 1154 | <Button size="sm" disabled={noneSelected} onClick={runDialogAction}> | 1184 | <Button size="sm" disabled={noneSelected} onClick={runDialogAction}> |
| 1155 | Commit | 1185 | Commit |
| ... | @@ -1169,7 +1199,7 @@ export default function GitActionsControl({ | ... | @@ -1169,7 +1199,7 @@ export default function GitActionsControl({ |
| 1169 | <DialogPopup className="max-w-xl"> | 1199 | <DialogPopup className="max-w-xl"> |
| 1170 | <DialogHeader> | 1200 | <DialogHeader> |
| 1171 | <DialogTitle> | 1201 | <DialogTitle> |
| 1172 | {pendingDefaultBranchActionCopy?.title ?? "Run action on default branch?"} | 1202 | {pendingDefaultBranchActionCopy?.title ?? "Run action on default refName?"} |
| 1173 | </DialogTitle> | 1203 | </DialogTitle> |
| 1174 | <DialogDescription>{pendingDefaultBranchActionCopy?.description}</DialogDescription> | 1204 | <DialogDescription>{pendingDefaultBranchActionCopy?.description}</DialogDescription> |
| 1175 | </DialogHeader> | 1205 | </DialogHeader> |
apps/web/src/components/Icons.tsx+25| ... | @@ -15,6 +15,31 @@ export const GitHubIcon: Icon = (props) => ( | ... | @@ -15,6 +15,31 @@ export const GitHubIcon: Icon = (props) => ( |
| 15 | </svg> | 15 | </svg> |
| 16 | ); | 16 | ); |
| 17 | 17 | ||
| 18 | export const GitLabIcon: Icon = (props) => ( | ||
| 19 | <svg {...props} viewBox="0 0 24 24" fill="currentColor"> | ||
| 20 | <path d="M22.6 9.3 20.9 4c-.2-.7-1.2-.7-1.5-.1l-2 5.4H6.6l-2-5.4c-.2-.6-1.2-.6-1.5.1L1.4 9.3c-.2.6 0 1.2.5 1.6L12 18.7l10.1-7.8c.5-.4.7-1 .5-1.6Z" /> | ||
| 21 | <path d="m12 18.7 4.1-9.4H7.9L12 18.7Z" opacity=".72" /> | ||
| 22 | <path d="M7.9 9.3H1.5L12 18.7 7.9 9.3ZM16.1 9.3 12 18.7l10.5-9.4h-6.4Z" opacity=".88" /> | ||
| 23 | </svg> | ||
| 24 | ); | ||
| 25 | |||
| 26 | export const AzureDevOpsIcon: Icon = (props) => ( | ||
| 27 | <svg {...props} viewBox="0 0 24 24" fill="currentColor"> | ||
| 28 | <path d="M21 5.2v12.7L16 22l-8-2.8V22l-4.9-6.4 4.9.4V4.8L16 2l5 3.2ZM16 6.4l-5 1.4v8.4l5 1.4V6.4Z" /> | ||
| 29 | <path d="m8 8.4-5 .8v5.6l5 .8V8.4Z" opacity=".72" /> | ||
| 30 | </svg> | ||
| 31 | ); | ||
| 32 | |||
| 33 | export const BitbucketIcon: Icon = (props) => ( | ||
| 34 | <svg {...props} viewBox="0 0 24 24" fill="currentColor"> | ||
| 35 | <path d="M2.4 3.5c-.4 0-.7.3-.6.8L4.3 20c.1.4.4.7.8.7h13.7c.4 0 .7-.3.8-.7l2.6-15.7c.1-.5-.2-.8-.7-.8H2.4Zm12.5 11.1H9.4L8.5 8.7h7.4l-1 5.9Z" /> | ||
| 36 | <path | ||
| 37 | d="M21 8.7h-5.1l-1 5.9H9.4l-4.2 5c.2.6.5 1.1 1.2 1.1h12.4c.4 0 .7-.3.8-.7L21 8.7Z" | ||
| 38 | opacity=".72" | ||
| 39 | /> | ||
| 40 | </svg> | ||
| 41 | ); | ||
| 42 | |||
| 18 | export const CursorIcon: Icon = ({ className, ...props }) => ( | 43 | export const CursorIcon: Icon = ({ className, ...props }) => ( |
| 19 | <svg | 44 | <svg |
| 20 | {...props} | 45 | {...props} |
apps/web/src/components/KeybindingsToast.browser.tsx+3-3| ... | @@ -256,13 +256,13 @@ function resolveWsRpc(tag: string): unknown { | ... | @@ -256,13 +256,13 @@ function resolveWsRpc(tag: string): unknown { |
| 256 | if (tag === WS_METHODS.serverGetConfig) { | 256 | if (tag === WS_METHODS.serverGetConfig) { |
| 257 | return fixture.serverConfig; | 257 | return fixture.serverConfig; |
| 258 | } | 258 | } |
| 259 | if (tag === WS_METHODS.gitListBranches) { | 259 | if (tag === WS_METHODS.vcsListRefs) { |
| 260 | return { | 260 | return { |
| 261 | isRepo: true, | 261 | isRepo: true, |
| 262 | hasOriginRemote: true, | 262 | hasPrimaryRemote: true, |
| 263 | nextCursor: null, | 263 | nextCursor: null, |
| 264 | totalCount: 1, | 264 | totalCount: 1, |
| 265 | branches: [{ name: "main", current: true, isDefault: true, worktreePath: null }], | 265 | refs: [{ name: "main", current: true, isDefault: true, worktreePath: null }], |
| 266 | }; | 266 | }; |
| 267 | } | 267 | } |
| 268 | if (tag === WS_METHODS.projectsSearchEntries) { | 268 | if (tag === WS_METHODS.projectsSearchEntries) { |
apps/web/src/components/PullRequestThreadDialog.tsx+24-10| ... | @@ -7,8 +7,10 @@ import { | ... | @@ -7,8 +7,10 @@ import { |
| 7 | gitPreparePullRequestThreadMutationOptions, | 7 | gitPreparePullRequestThreadMutationOptions, |
| 8 | gitResolvePullRequestQueryOptions, | 8 | gitResolvePullRequestQueryOptions, |
| 9 | } from "~/lib/gitReactQuery"; | 9 | } from "~/lib/gitReactQuery"; |
| 10 | import { useGitStatus } from "~/lib/gitStatusState"; | ||
| 10 | import { cn } from "~/lib/utils"; | 11 | import { cn } from "~/lib/utils"; |
| 11 | import { parsePullRequestReference } from "~/pullRequestReference"; | 12 | import { parsePullRequestReference } from "~/pullRequestReference"; |
| 13 | import { getSourceControlPresentation } from "~/sourceControlPresentation"; | ||
| 12 | import { Button } from "./ui/button"; | 14 | import { Button } from "./ui/button"; |
| 13 | import { | 15 | import { |
| 14 | Dialog, | 16 | Dialog, |
| ... | @@ -51,6 +53,13 @@ export function PullRequestThreadDialog({ | ... | @@ -51,6 +53,13 @@ export function PullRequestThreadDialog({ |
| 51 | { wait: 450 }, | 53 | { wait: 450 }, |
| 52 | (debouncerState) => ({ isPending: debouncerState.isPending }), | 54 | (debouncerState) => ({ isPending: debouncerState.isPending }), |
| 53 | ); | 55 | ); |
| 56 | const { data: gitStatus = null } = useGitStatus({ environmentId, cwd }); | ||
| 57 | const sourceControlPresentation = useMemo( | ||
| 58 | () => getSourceControlPresentation(gitStatus?.sourceControlProvider), | ||
| 59 | [gitStatus?.sourceControlProvider], | ||
| 60 | ); | ||
| 61 | const terminology = sourceControlPresentation.terminology; | ||
| 62 | const SourceControlIcon = sourceControlPresentation.Icon; | ||
| 54 | 63 | ||
| 55 | useEffect(() => { | 64 | useEffect(() => { |
| 56 | if (!open) return; | 65 | if (!open) return; |
| ... | @@ -161,20 +170,20 @@ export function PullRequestThreadDialog({ | ... | @@ -161,20 +170,20 @@ export function PullRequestThreadDialog({ |
| 161 | const validationMessage = !referenceDirty | 170 | const validationMessage = !referenceDirty |
| 162 | ? null | 171 | ? null |
| 163 | : reference.trim().length === 0 | 172 | : reference.trim().length === 0 |
| 164 | ? "Paste a GitHub pull request URL, `gh pr checkout 123`, or enter 123 / #123." | 173 | ? `Paste a ${terminology.singular} URL, checkout command, or enter 123 / #123.` |
| 165 | : parsedReference === null | 174 | : parsedReference === null |
| 166 | ? "Use a GitHub pull request URL, `gh pr checkout 123`, 123, or #123." | 175 | ? `Use a ${terminology.singular} URL, checkout command, 123, or #123.` |
| 167 | : null; | 176 | : null; |
| 168 | const errorMessage = | 177 | const errorMessage = |
| 169 | validationMessage ?? | 178 | validationMessage ?? |
| 170 | (resolvedPullRequest === null && resolvePullRequestQuery.isError | 179 | (resolvedPullRequest === null && resolvePullRequestQuery.isError |
| 171 | ? resolvePullRequestQuery.error instanceof Error | 180 | ? resolvePullRequestQuery.error instanceof Error |
| 172 | ? resolvePullRequestQuery.error.message | 181 | ? resolvePullRequestQuery.error.message |
| 173 | : "Failed to resolve pull request." | 182 | : `Failed to resolve ${terminology.singular}.` |
| 174 | : preparePullRequestThreadMutation.error instanceof Error | 183 | : preparePullRequestThreadMutation.error instanceof Error |
| 175 | ? preparePullRequestThreadMutation.error.message | 184 | ? preparePullRequestThreadMutation.error.message |
| 176 | : preparePullRequestThreadMutation.error | 185 | : preparePullRequestThreadMutation.error |
| 177 | ? "Failed to prepare pull request thread." | 186 | ? `Failed to prepare ${terminology.singular} thread.` |
| 178 | : null); | 187 | : null); |
| 179 | 188 | ||
| 180 | return ( | 189 | return ( |
| ... | @@ -188,18 +197,23 @@ export function PullRequestThreadDialog({ | ... | @@ -188,18 +197,23 @@ export function PullRequestThreadDialog({ |
| 188 | > | 197 | > |
| 189 | <DialogPopup className="max-w-xl"> | 198 | <DialogPopup className="max-w-xl"> |
| 190 | <DialogHeader> | 199 | <DialogHeader> |
| 191 | <DialogTitle>Checkout Pull Request</DialogTitle> | 200 | <DialogTitle className="flex items-center gap-2"> |
| 201 | <SourceControlIcon className="size-4" /> | ||
| 202 | Checkout {terminology.singular} | ||
| 203 | </DialogTitle> | ||
| 192 | <DialogDescription> | 204 | <DialogDescription> |
| 193 | Resolve a GitHub pull request, then create the draft thread in the main repo or in a | 205 | Resolve a {sourceControlPresentation.providerName} {terminology.singular}, then create |
| 194 | dedicated worktree. | 206 | the draft thread in the main repo or in a dedicated worktree. |
| 195 | </DialogDescription> | 207 | </DialogDescription> |
| 196 | </DialogHeader> | 208 | </DialogHeader> |
| 197 | <DialogPanel className="space-y-4"> | 209 | <DialogPanel className="space-y-4"> |
| 198 | <label className="grid gap-1.5"> | 210 | <label className="grid gap-1.5"> |
| 199 | <span className="text-xs font-medium text-foreground">Pull request</span> | 211 | <span className="text-xs font-medium text-foreground capitalize"> |
| 212 | {terminology.singular} | ||
| 213 | </span> | ||
| 200 | <Input | 214 | <Input |
| 201 | ref={referenceInputRef} | 215 | ref={referenceInputRef} |
| 202 | placeholder="https://github.com/owner/repo/pull/42, gh pr checkout 42, or #42" | 216 | placeholder={`${terminology.shortLabel} URL, checkout command, or #42`} |
| 203 | value={reference} | 217 | value={reference} |
| 204 | onChange={(event) => { | 218 | onChange={(event) => { |
| 205 | setReferenceDirty(true); | 219 | setReferenceDirty(true); |
| ... | @@ -237,7 +251,7 @@ export function PullRequestThreadDialog({ | ... | @@ -237,7 +251,7 @@ export function PullRequestThreadDialog({ |
| 237 | {isResolving ? ( | 251 | {isResolving ? ( |
| 238 | <div className="flex items-center gap-2 text-muted-foreground text-xs"> | 252 | <div className="flex items-center gap-2 text-muted-foreground text-xs"> |
| 239 | <Spinner className="size-3.5" /> | 253 | <Spinner className="size-3.5" /> |
| 240 | Resolving pull request... | 254 | Resolving {terminology.singular}... |
| 241 | </div> | 255 | </div> |
| 242 | ) : null} | 256 | ) : null} |
| 243 | 257 |
apps/web/src/components/Sidebar.tsx+4-4| ... | @@ -3,7 +3,6 @@ import { | ... | @@ -3,7 +3,6 @@ import { |
| 3 | ArrowUpDownIcon, | 3 | ArrowUpDownIcon, |
| 4 | ChevronRightIcon, | 4 | ChevronRightIcon, |
| 5 | CloudIcon, | 5 | CloudIcon, |
| 6 | GitPullRequestIcon, | ||
| 7 | FolderPlusIcon, | 6 | FolderPlusIcon, |
| 8 | SearchIcon, | 7 | SearchIcon, |
| 9 | SettingsIcon, | 8 | SettingsIcon, |
| ... | @@ -12,6 +11,7 @@ import { | ... | @@ -12,6 +11,7 @@ import { |
| 12 | TriangleAlertIcon, | 11 | TriangleAlertIcon, |
| 13 | } from "lucide-react"; | 12 | } from "lucide-react"; |
| 14 | import { | 13 | import { |
| 14 | ChangeRequestStatusIcon, | ||
| 15 | prStatusIndicator, | 15 | prStatusIndicator, |
| 16 | resolveThreadPr, | 16 | resolveThreadPr, |
| 17 | terminalStatusFromRunningIds, | 17 | terminalStatusFromRunningIds, |
| ... | @@ -367,7 +367,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP | ... | @@ -367,7 +367,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 367 | }, | 367 | }, |
| 368 | }); | 368 | }); |
| 369 | const pr = resolveThreadPr(thread.branch, gitStatus.data); | 369 | const pr = resolveThreadPr(thread.branch, gitStatus.data); |
| 370 | const prStatus = prStatusIndicator(pr); | 370 | const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); |
| 371 | const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); | 371 | const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); |
| 372 | const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; | 372 | const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; |
| 373 | const threadMetaClassName = isConfirmingArchive | 373 | const threadMetaClassName = isConfirmingArchive |
| ... | @@ -558,7 +558,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP | ... | @@ -558,7 +558,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP |
| 558 | className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`} | 558 | className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`} |
| 559 | onClick={handlePrClick} | 559 | onClick={handlePrClick} |
| 560 | > | 560 | > |
| 561 | <GitPullRequestIcon className="size-3" /> | 561 | <ChangeRequestStatusIcon className="size-3" /> |
| 562 | </button> | 562 | </button> |
| 563 | } | 563 | } |
| 564 | /> | 564 | /> |
| ... | @@ -1002,7 +1002,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1002,7 +1002,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1002 | toastManager.add( | 1002 | toastManager.add( |
| 1003 | stackedThreadToast({ | 1003 | stackedThreadToast({ |
| 1004 | type: "error", | 1004 | type: "error", |
| 1005 | title: "Unable to open PR link", | 1005 | title: "Unable to open pull request link", |
| 1006 | description: error instanceof Error ? error.message : "An error occurred.", | 1006 | description: error instanceof Error ? error.message : "An error occurred.", |
| 1007 | }), | 1007 | }), |
| 1008 | ); | 1008 | ); |
apps/web/src/components/ThreadStatusIndicators.tsx+24-15| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { scopeProjectRef, scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; | 1 | import { scopeProjectRef, scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; |
| 2 | import type { GitStatusResult } from "@t3tools/contracts"; | 2 | import type { VcsStatusResult } from "@t3tools/contracts"; |
| 3 | import { CloudIcon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; | 3 | import { CloudIcon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; |
| 4 | import { useMemo } from "react"; | 4 | import { useMemo } from "react"; |
| 5 | import { usePrimaryEnvironmentId } from "../environments/primary"; | 5 | import { usePrimaryEnvironmentId } from "../environments/primary"; |
| ... | @@ -11,12 +11,13 @@ import { useGitStatus } from "../lib/gitStatusState"; | ... | @@ -11,12 +11,13 @@ import { useGitStatus } from "../lib/gitStatusState"; |
| 11 | import { type AppState, selectProjectByRef, useStore } from "../store"; | 11 | import { type AppState, selectProjectByRef, useStore } from "../store"; |
| 12 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; | 12 | import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; |
| 13 | import { useUiStateStore } from "../uiStateStore"; | 13 | import { useUiStateStore } from "../uiStateStore"; |
| 14 | import { resolveChangeRequestPresentation } from "../sourceControlPresentation"; | ||
| 14 | import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic"; | 15 | import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic"; |
| 15 | import type { SidebarThreadSummary } from "../types"; | 16 | import type { SidebarThreadSummary } from "../types"; |
| 16 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; | 17 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 17 | 18 | ||
| 18 | export interface PrStatusIndicator { | 19 | export interface PrStatusIndicator { |
| 19 | label: "PR open" | "PR closed" | "PR merged"; | 20 | label: string; |
| 20 | colorClass: string; | 21 | colorClass: string; |
| 21 | tooltip: string; | 22 | tooltip: string; |
| 22 | url: string; | 23 | url: string; |
| ... | @@ -28,43 +29,51 @@ export interface TerminalStatusIndicator { | ... | @@ -28,43 +29,51 @@ export interface TerminalStatusIndicator { |
| 28 | pulse: boolean; | 29 | pulse: boolean; |
| 29 | } | 30 | } |
| 30 | 31 | ||
| 31 | export type ThreadPr = GitStatusResult["pr"]; | 32 | export type ThreadPr = VcsStatusResult["pr"]; |
| 32 | 33 | ||
| 33 | export function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null { | 34 | export function prStatusIndicator( |
| 35 | pr: ThreadPr, | ||
| 36 | provider: VcsStatusResult["sourceControlProvider"] | null | undefined, | ||
| 37 | ): PrStatusIndicator | null { | ||
| 34 | if (!pr) return null; | 38 | if (!pr) return null; |
| 39 | const presentation = resolveChangeRequestPresentation(provider); | ||
| 35 | 40 | ||
| 36 | if (pr.state === "open") { | 41 | if (pr.state === "open") { |
| 37 | return { | 42 | return { |
| 38 | label: "PR open", | 43 | label: `${presentation.shortName} open`, |
| 39 | colorClass: "text-emerald-600 dark:text-emerald-300/90", | 44 | colorClass: "text-emerald-600 dark:text-emerald-300/90", |
| 40 | tooltip: `#${pr.number} PR open: ${pr.title}`, | 45 | tooltip: `#${pr.number} ${presentation.shortName} open: ${pr.title}`, |
| 41 | url: pr.url, | 46 | url: pr.url, |
| 42 | }; | 47 | }; |
| 43 | } | 48 | } |
| 44 | if (pr.state === "closed") { | 49 | if (pr.state === "closed") { |
| 45 | return { | 50 | return { |
| 46 | label: "PR closed", | 51 | label: `${presentation.shortName} closed`, |
| 47 | colorClass: "text-zinc-500 dark:text-zinc-400/80", | 52 | colorClass: "text-zinc-500 dark:text-zinc-400/80", |
| 48 | tooltip: `#${pr.number} PR closed: ${pr.title}`, | 53 | tooltip: `#${pr.number} ${presentation.shortName} closed: ${pr.title}`, |
| 49 | url: pr.url, | 54 | url: pr.url, |
| 50 | }; | 55 | }; |
| 51 | } | 56 | } |
| 52 | if (pr.state === "merged") { | 57 | if (pr.state === "merged") { |
| 53 | return { | 58 | return { |
| 54 | label: "PR merged", | 59 | label: `${presentation.shortName} merged`, |
| 55 | colorClass: "text-violet-600 dark:text-violet-300/90", | 60 | colorClass: "text-violet-600 dark:text-violet-300/90", |
| 56 | tooltip: `#${pr.number} PR merged: ${pr.title}`, | 61 | tooltip: `#${pr.number} ${presentation.shortName} merged: ${pr.title}`, |
| 57 | url: pr.url, | 62 | url: pr.url, |
| 58 | }; | 63 | }; |
| 59 | } | 64 | } |
| 60 | return null; | 65 | return null; |
| 61 | } | 66 | } |
| 62 | 67 | ||
| 68 | export function ChangeRequestStatusIcon({ className }: { className?: string }) { | ||
| 69 | return <GitPullRequestIcon className={className} />; | ||
| 70 | } | ||
| 71 | |||
| 63 | export function resolveThreadPr( | 72 | export function resolveThreadPr( |
| 64 | threadBranch: string | null, | 73 | threadBranch: string | null, |
| 65 | gitStatus: GitStatusResult | null, | 74 | gitStatus: VcsStatusResult | null, |
| 66 | ): ThreadPr | null { | 75 | ): ThreadPr | null { |
| 67 | if (threadBranch === null || gitStatus === null || gitStatus.branch !== threadBranch) { | 76 | if (threadBranch === null || gitStatus === null || gitStatus.refName !== threadBranch) { |
| 68 | return null; | 77 | return null; |
| 69 | } | 78 | } |
| 70 | 79 | ||
| ... | @@ -124,7 +133,7 @@ export function ThreadStatusLabel({ | ... | @@ -124,7 +133,7 @@ export function ThreadStatusLabel({ |
| 124 | 133 | ||
| 125 | /** | 134 | /** |
| 126 | * Non-interactive leading status icons for a thread row in compact contexts | 135 | * Non-interactive leading status icons for a thread row in compact contexts |
| 127 | * like the command palette. Shows the PR state icon (if present) and the | 136 | * like the command palette. Shows the change request state icon (if present) and the |
| 128 | * thread status dot, matching the sidebar's leading indicators. | 137 | * thread status dot, matching the sidebar's leading indicators. |
| 129 | */ | 138 | */ |
| 130 | export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummary }) { | 139 | export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummary }) { |
| ... | @@ -146,7 +155,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar | ... | @@ -146,7 +155,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar |
| 146 | cwd: thread.branch != null ? gitCwd : null, | 155 | cwd: thread.branch != null ? gitCwd : null, |
| 147 | }); | 156 | }); |
| 148 | const pr = resolveThreadPr(thread.branch, gitStatus.data); | 157 | const pr = resolveThreadPr(thread.branch, gitStatus.data); |
| 149 | const prStatus = prStatusIndicator(pr); | 158 | const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); |
| 150 | const threadStatus = resolveThreadStatusPill({ | 159 | const threadStatus = resolveThreadStatusPill({ |
| 151 | thread: { | 160 | thread: { |
| 152 | ...thread, | 161 | ...thread, |
| ... | @@ -170,7 +179,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar | ... | @@ -170,7 +179,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar |
| 170 | /> | 179 | /> |
| 171 | } | 180 | } |
| 172 | > | 181 | > |
| 173 | <GitPullRequestIcon className="size-3" /> | 182 | <ChangeRequestStatusIcon className="size-3" /> |
| 174 | </TooltipTrigger> | 183 | </TooltipTrigger> |
| 175 | <TooltipPopup side="top">{prStatus.tooltip}</TooltipPopup> | 184 | <TooltipPopup side="top">{prStatus.tooltip}</TooltipPopup> |
| 176 | </Tooltip> | 185 | </Tooltip> |
apps/web/src/components/settings/ProviderInstanceCard.tsx+8-44| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { ChevronDownIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react"; | 3 | import { ChevronDownIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react"; |
| 4 | import { useEffect, useMemo, useState, type ReactNode } from "react"; | 4 | import { useEffect, useState, type ReactNode } from "react"; |
| 5 | import { | 5 | import { |
| 6 | isProviderDriverKind, | 6 | isProviderDriverKind, |
| 7 | type ProviderInstanceConfig, | 7 | type ProviderInstanceConfig, |
| ... | @@ -24,6 +24,7 @@ import type { DriverOption } from "./providerDriverMeta"; | ... | @@ -24,6 +24,7 @@ import type { DriverOption } from "./providerDriverMeta"; |
| 24 | import { ProviderSettingsForm } from "./ProviderSettingsForm"; | 24 | import { ProviderSettingsForm } from "./ProviderSettingsForm"; |
| 25 | import { ProviderModelsSection } from "./ProviderModelsSection"; | 25 | import { ProviderModelsSection } from "./ProviderModelsSection"; |
| 26 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; | 26 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; |
| 27 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; | ||
| 27 | import { | 28 | import { |
| 28 | PROVIDER_STATUS_STYLES, | 29 | PROVIDER_STATUS_STYLES, |
| 29 | getProviderSummary, | 30 | getProviderSummary, |
| ... | @@ -42,8 +43,6 @@ const PROVIDER_ACCENT_SWATCHES = [ | ... | @@ -42,8 +43,6 @@ const PROVIDER_ACCENT_SWATCHES = [ |
| 42 | 43 | ||
| 43 | const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; | 44 | const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; |
| 44 | 45 | ||
| 45 | const REDACTED_EMAIL_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"; | ||
| 46 | |||
| 47 | let environmentVariableDraftId = 0; | 46 | let environmentVariableDraftId = 0; |
| 48 | const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`; | 47 | const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`; |
| 49 | 48 | ||
| ... | @@ -68,25 +67,6 @@ function makeEnvironmentDraftRow( | ... | @@ -68,25 +67,6 @@ function makeEnvironmentDraftRow( |
| 68 | }; | 67 | }; |
| 69 | } | 68 | } |
| 70 | 69 | ||
| 71 | function redactedEmailPlaceholder(email: string): string { | ||
| 72 | let state = 0x811c9dc5; | ||
| 73 | for (let index = 0; index < email.length; index += 1) { | ||
| 74 | state ^= email.charCodeAt(index); | ||
| 75 | state = Math.imul(state, 0x01000193); | ||
| 76 | } | ||
| 77 | |||
| 78 | const nextChar = () => { | ||
| 79 | state = Math.imul(state ^ (state >>> 13), 0x85ebca6b); | ||
| 80 | state = Math.imul(state ^ (state >>> 16), 0xc2b2ae35); | ||
| 81 | return REDACTED_EMAIL_ALPHABET[Math.abs(state) % REDACTED_EMAIL_ALPHABET.length] ?? "x"; | ||
| 82 | }; | ||
| 83 | |||
| 84 | return Array.from(email, (char) => { | ||
| 85 | if (char === "@" || char === "." || char === "-" || char === "_") return char; | ||
| 86 | return nextChar(); | ||
| 87 | }).join(""); | ||
| 88 | } | ||
| 89 | |||
| 90 | /** | 70 | /** |
| 91 | * Read a string[] at `key` from the opaque config blob, filtering out | 71 | * Read a string[] at `key` from the opaque config blob, filtering out |
| 92 | * non-string entries. Used for `customModels`, which is always typed as | 72 | * non-string entries. Used for `customModels`, which is always typed as |
| ... | @@ -146,35 +126,19 @@ function ProviderAuthEmail(props: { | ... | @@ -146,35 +126,19 @@ function ProviderAuthEmail(props: { |
| 146 | readonly prefix?: string; | 126 | readonly prefix?: string; |
| 147 | readonly separator?: boolean; | 127 | readonly separator?: boolean; |
| 148 | }) { | 128 | }) { |
| 149 | const [revealed, setRevealed] = useState(false); | ||
| 150 | const trimmed = props.email?.trim(); | 129 | const trimmed = props.email?.trim(); |
| 151 | const redacted = useMemo(() => (trimmed ? redactedEmailPlaceholder(trimmed) : ""), [trimmed]); | ||
| 152 | if (!trimmed) return null; | 130 | if (!trimmed) return null; |
| 153 | 131 | ||
| 154 | return ( | 132 | return ( |
| 155 | <span className="inline-flex min-w-0 items-center gap-1.5"> | 133 | <span className="inline-flex min-w-0 items-center gap-1.5"> |
| 156 | {props.separator ? <span aria-hidden>·</span> : null} | 134 | {props.separator ? <span aria-hidden>·</span> : null} |
| 157 | {props.prefix ? <span className="text-muted-foreground/80">{props.prefix}</span> : null} | 135 | {props.prefix ? <span className="text-muted-foreground/80">{props.prefix}</span> : null} |
| 158 | <Tooltip> | 136 | <RedactedSensitiveText |
| 159 | <TooltipTrigger | 137 | value={trimmed} |
| 160 | render={ | 138 | ariaLabel="Toggle account email visibility" |
| 161 | <button | 139 | revealTooltip="Click to reveal email" |
| 162 | type="button" | 140 | hideTooltip="Click to hide email" |
| 163 | className={cn( | 141 | /> |
| 164 | "min-w-0 cursor-pointer rounded-sm font-mono text-[11px] leading-none transition hover:text-foreground", | ||
| 165 | revealed ? "text-muted-foreground" : "select-none text-muted-foreground blur-[2px]", | ||
| 166 | )} | ||
| 167 | onClick={() => setRevealed((value) => !value)} | ||
| 168 | aria-label={revealed ? "Hide account email" : "Reveal account email"} | ||
| 169 | > | ||
| 170 | {revealed ? trimmed : redacted} | ||
| 171 | </button> | ||
| 172 | } | ||
| 173 | /> | ||
| 174 | <TooltipPopup side="top"> | ||
| 175 | {revealed ? "Click to hide email" : "Click to reveal email"} | ||
| 176 | </TooltipPopup> | ||
| 177 | </Tooltip> | ||
| 178 | </span> | 142 | </span> |
| 179 | ); | 143 | ); |
| 180 | } | 144 | } |
apps/web/src/components/settings/RedactedSensitiveText.tsx created+61| ... | @@ -0,0 +1,61 @@ | ||
| 1 | import { useMemo, useState } from "react"; | ||
| 2 | |||
| 3 | import { cn } from "../../lib/utils"; | ||
| 4 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ||
| 5 | |||
| 6 | const REDACTED_TEXT_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; | ||
| 7 | |||
| 8 | function redactedPlaceholder(value: string): string { | ||
| 9 | let state = 0x811c9dc5; | ||
| 10 | for (let index = 0; index < value.length; index += 1) { | ||
| 11 | state ^= value.charCodeAt(index); | ||
| 12 | state = Math.imul(state, 0x01000193); | ||
| 13 | } | ||
| 14 | |||
| 15 | const nextChar = () => { | ||
| 16 | state = Math.imul(state ^ (state >>> 13), 0x85ebca6b); | ||
| 17 | state = Math.imul(state ^ (state >>> 16), 0xc2b2ae35); | ||
| 18 | return REDACTED_TEXT_ALPHABET[Math.abs(state) % REDACTED_TEXT_ALPHABET.length] ?? "x"; | ||
| 19 | }; | ||
| 20 | |||
| 21 | return Array.from(value, (char) => { | ||
| 22 | if (char === "@" || char === "." || char === "-" || char === "_") return char; | ||
| 23 | return nextChar(); | ||
| 24 | }).join(""); | ||
| 25 | } | ||
| 26 | |||
| 27 | export function RedactedSensitiveText(props: { | ||
| 28 | readonly value: string | null | undefined; | ||
| 29 | readonly ariaLabel: string; | ||
| 30 | readonly revealTooltip: string; | ||
| 31 | readonly hideTooltip: string; | ||
| 32 | readonly className?: string; | ||
| 33 | }) { | ||
| 34 | const [revealed, setRevealed] = useState(false); | ||
| 35 | const value = props.value?.trim(); | ||
| 36 | const redacted = useMemo(() => (value ? redactedPlaceholder(value) : ""), [value]); | ||
| 37 | |||
| 38 | if (!value) return null; | ||
| 39 | |||
| 40 | return ( | ||
| 41 | <Tooltip> | ||
| 42 | <TooltipTrigger | ||
| 43 | render={ | ||
| 44 | <button | ||
| 45 | type="button" | ||
| 46 | className={cn( | ||
| 47 | "min-w-0 cursor-pointer rounded-sm font-mono text-[11px] leading-none transition hover:text-foreground", | ||
| 48 | revealed ? "text-muted-foreground" : "select-none text-muted-foreground blur-[2px]", | ||
| 49 | props.className, | ||
| 50 | )} | ||
| 51 | onClick={() => setRevealed((current) => !current)} | ||
| 52 | aria-label={props.ariaLabel} | ||
| 53 | > | ||
| 54 | {revealed ? value : redacted} | ||
| 55 | </button> | ||
| 56 | } | ||
| 57 | /> | ||
| 58 | <TooltipPopup side="top">{revealed ? props.hideTooltip : props.revealTooltip}</TooltipPopup> | ||
| 59 | </Tooltip> | ||
| 60 | ); | ||
| 61 | } | ||
apps/web/src/components/settings/SettingsPanels.browser.tsx+149-2| ... | @@ -11,17 +11,19 @@ import { | ... | @@ -11,17 +11,19 @@ import { |
| 11 | type DesktopUpdateState, | 11 | type DesktopUpdateState, |
| 12 | type LocalApi, | 12 | type LocalApi, |
| 13 | type ServerConfig, | 13 | type ServerConfig, |
| 14 | type SourceControlDiscoveryResult, | ||
| 14 | } from "@t3tools/contracts"; | 15 | } from "@t3tools/contracts"; |
| 15 | import { DateTime } from "effect"; | 16 | import { DateTime, Option } from "effect"; |
| 16 | import { page } from "vitest/browser"; | 17 | import { page } from "vitest/browser"; |
| 17 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | 18 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 18 | import { render } from "vitest-browser-react"; | 19 | import { render } from "vitest-browser-react"; |
| 19 | 20 | ||
| 20 | import { __resetLocalApiForTests } from "../../localApi"; | 21 | import { __resetLocalApiForTests } from "../../localApi"; |
| 21 | import { AppAtomRegistryProvider } from "../../rpc/atomRegistry"; | 22 | import { AppAtomRegistryProvider, resetAppAtomRegistryForTests } from "../../rpc/atomRegistry"; |
| 22 | import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState"; | 23 | import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState"; |
| 23 | import { ConnectionsSettings } from "./ConnectionsSettings"; | 24 | import { ConnectionsSettings } from "./ConnectionsSettings"; |
| 24 | import { GeneralSettingsPanel } from "./SettingsPanels"; | 25 | import { GeneralSettingsPanel } from "./SettingsPanels"; |
| 26 | import { SourceControlSettingsPanel } from "./SourceControlSettings"; | ||
| 25 | 27 | ||
| 26 | const authAccessHarness = vi.hoisted(() => { | 28 | const authAccessHarness = vi.hoisted(() => { |
| 27 | type Snapshot = AuthAccessSnapshot; | 29 | type Snapshot = AuthAccessSnapshot; |
| ... | @@ -723,3 +725,148 @@ describe("GeneralSettingsPanel observability", () => { | ... | @@ -723,3 +725,148 @@ describe("GeneralSettingsPanel observability", () => { |
| 723 | await expect.element(page.getByPlaceholder("Optional")).toBeInTheDocument(); | 725 | await expect.element(page.getByPlaceholder("Optional")).toBeInTheDocument(); |
| 724 | }); | 726 | }); |
| 725 | }); | 727 | }); |
| 728 | |||
| 729 | describe("SourceControlSettingsPanel discovery states", () => { | ||
| 730 | let mounted: | ||
| 731 | | (Awaited<ReturnType<typeof render>> & { | ||
| 732 | cleanup?: () => Promise<void>; | ||
| 733 | unmount?: () => Promise<void>; | ||
| 734 | }) | ||
| 735 | | null = null; | ||
| 736 | |||
| 737 | beforeEach(async () => { | ||
| 738 | resetAppAtomRegistryForTests(); | ||
| 739 | await __resetLocalApiForTests(); | ||
| 740 | document.body.innerHTML = ""; | ||
| 741 | }); | ||
| 742 | |||
| 743 | afterEach(async () => { | ||
| 744 | if (mounted) { | ||
| 745 | const teardown = mounted.cleanup ?? mounted.unmount; | ||
| 746 | await teardown?.call(mounted).catch(() => {}); | ||
| 747 | } | ||
| 748 | mounted = null; | ||
| 749 | Reflect.deleteProperty(window, "nativeApi"); | ||
| 750 | document.body.innerHTML = ""; | ||
| 751 | await __resetLocalApiForTests(); | ||
| 752 | resetAppAtomRegistryForTests(); | ||
| 753 | }); | ||
| 754 | |||
| 755 | function setSourceControlDiscoveryStub( | ||
| 756 | discoverSourceControl: () => Promise<SourceControlDiscoveryResult>, | ||
| 757 | ) { | ||
| 758 | window.nativeApi = { | ||
| 759 | server: { | ||
| 760 | discoverSourceControl, | ||
| 761 | }, | ||
| 762 | } as LocalApi; | ||
| 763 | } | ||
| 764 | |||
| 765 | it("shows skeleton sections while the first source control scan is pending", async () => { | ||
| 766 | setSourceControlDiscoveryStub(() => new Promise(() => {})); | ||
| 767 | |||
| 768 | mounted = await render( | ||
| 769 | <AppAtomRegistryProvider> | ||
| 770 | <SourceControlSettingsPanel /> | ||
| 771 | </AppAtomRegistryProvider>, | ||
| 772 | ); | ||
| 773 | |||
| 774 | await expect.element(page.getByText("Version Control")).toBeInTheDocument(); | ||
| 775 | await expect.element(page.getByText("Source Control Providers")).toBeInTheDocument(); | ||
| 776 | await expect | ||
| 777 | .element(page.getByRole("button", { name: "Scan source control tools" })) | ||
| 778 | .toBeDisabled(); | ||
| 779 | await expect.element(page.getByText("No source control tools found")).not.toBeInTheDocument(); | ||
| 780 | }); | ||
| 781 | |||
| 782 | it("uses the shared empty state when discovery completes without tools", async () => { | ||
| 783 | setSourceControlDiscoveryStub(async () => ({ | ||
| 784 | versionControlSystems: [], | ||
| 785 | sourceControlProviders: [], | ||
| 786 | })); | ||
| 787 | |||
| 788 | mounted = await render( | ||
| 789 | <AppAtomRegistryProvider> | ||
| 790 | <SourceControlSettingsPanel /> | ||
| 791 | </AppAtomRegistryProvider>, | ||
| 792 | ); | ||
| 793 | |||
| 794 | await expect.element(page.getByText("No source control tools found")).toBeInTheDocument(); | ||
| 795 | await expect | ||
| 796 | .element(page.getByText("Install a supported Git or pull request CLI, then scan again.")) | ||
| 797 | .toBeInTheDocument(); | ||
| 798 | await expect.element(page.getByRole("button", { name: "Scan" })).toBeInTheDocument(); | ||
| 799 | }); | ||
| 800 | |||
| 801 | it("keeps discovered rows instead of showing the empty state", async () => { | ||
| 802 | setSourceControlDiscoveryStub(async () => ({ | ||
| 803 | versionControlSystems: [ | ||
| 804 | { | ||
| 805 | kind: "git", | ||
| 806 | label: "Git", | ||
| 807 | executable: "git", | ||
| 808 | implemented: true, | ||
| 809 | status: "available", | ||
| 810 | version: Option.some("git version 2.50.0"), | ||
| 811 | installHint: "Install Git.", | ||
| 812 | detail: Option.none(), | ||
| 813 | }, | ||
| 814 | ], | ||
| 815 | sourceControlProviders: [], | ||
| 816 | })); | ||
| 817 | |||
| 818 | mounted = await render( | ||
| 819 | <AppAtomRegistryProvider> | ||
| 820 | <SourceControlSettingsPanel /> | ||
| 821 | </AppAtomRegistryProvider>, | ||
| 822 | ); | ||
| 823 | |||
| 824 | await expect.element(page.getByRole("heading", { name: "Git" })).toBeInTheDocument(); | ||
| 825 | await expect.element(page.getByText("No source control tools found")).not.toBeInTheDocument(); | ||
| 826 | }); | ||
| 827 | |||
| 828 | it("does not rescan on remount while the discovery atom is fresh", async () => { | ||
| 829 | let calls = 0; | ||
| 830 | setSourceControlDiscoveryStub(async () => { | ||
| 831 | calls += 1; | ||
| 832 | return { | ||
| 833 | versionControlSystems: [ | ||
| 834 | { | ||
| 835 | kind: "git", | ||
| 836 | label: "Git", | ||
| 837 | executable: "git", | ||
| 838 | implemented: true, | ||
| 839 | status: "available", | ||
| 840 | version: Option.some("git version 2.50.0"), | ||
| 841 | installHint: "Install Git.", | ||
| 842 | detail: Option.none(), | ||
| 843 | }, | ||
| 844 | ], | ||
| 845 | sourceControlProviders: [], | ||
| 846 | }; | ||
| 847 | }); | ||
| 848 | |||
| 849 | mounted = await render( | ||
| 850 | <AppAtomRegistryProvider> | ||
| 851 | <SourceControlSettingsPanel /> | ||
| 852 | </AppAtomRegistryProvider>, | ||
| 853 | ); | ||
| 854 | |||
| 855 | await expect.element(page.getByRole("heading", { name: "Git" })).toBeInTheDocument(); | ||
| 856 | expect(calls).toBe(1); | ||
| 857 | |||
| 858 | const teardown = mounted.cleanup ?? mounted.unmount; | ||
| 859 | await teardown?.call(mounted).catch(() => {}); | ||
| 860 | mounted = null; | ||
| 861 | document.body.innerHTML = ""; | ||
| 862 | |||
| 863 | mounted = await render( | ||
| 864 | <AppAtomRegistryProvider> | ||
| 865 | <SourceControlSettingsPanel /> | ||
| 866 | </AppAtomRegistryProvider>, | ||
| 867 | ); | ||
| 868 | |||
| 869 | await expect.element(page.getByRole("heading", { name: "Git" })).toBeInTheDocument(); | ||
| 870 | expect(calls).toBe(1); | ||
| 871 | }); | ||
| 872 | }); |
apps/web/src/components/settings/SettingsSidebarNav.tsx+3-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { useCallback, type ComponentType } from "react"; | 1 | import { useCallback, type ComponentType } from "react"; |
| 2 | import { ArchiveIcon, ArrowLeftIcon, Link2Icon, Settings2Icon } from "lucide-react"; | 2 | import { ArchiveIcon, ArrowLeftIcon, GitBranchIcon, Link2Icon, Settings2Icon } from "lucide-react"; |
| 3 | import { useCanGoBack, useNavigate } from "@tanstack/react-router"; | 3 | import { useCanGoBack, useNavigate } from "@tanstack/react-router"; |
| 4 | 4 | ||
| 5 | import { | 5 | import { |
| ... | @@ -15,6 +15,7 @@ import { | ... | @@ -15,6 +15,7 @@ import { |
| 15 | 15 | ||
| 16 | export type SettingsSectionPath = | 16 | export type SettingsSectionPath = |
| 17 | | "/settings/general" | 17 | | "/settings/general" |
| 18 | | "/settings/source-control" | ||
| 18 | | "/settings/connections" | 19 | | "/settings/connections" |
| 19 | | "/settings/archived"; | 20 | | "/settings/archived"; |
| 20 | 21 | ||
| ... | @@ -24,6 +25,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ | ... | @@ -24,6 +25,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ |
| 24 | icon: ComponentType<{ className?: string }>; | 25 | icon: ComponentType<{ className?: string }>; |
| 25 | }> = [ | 26 | }> = [ |
| 26 | { label: "General", to: "/settings/general", icon: Settings2Icon }, | 27 | { label: "General", to: "/settings/general", icon: Settings2Icon }, |
| 28 | { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon }, | ||
| 27 | { label: "Connections", to: "/settings/connections", icon: Link2Icon }, | 29 | { label: "Connections", to: "/settings/connections", icon: Link2Icon }, |
| 28 | { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, | 30 | { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, |
| 29 | ]; | 31 | ]; |
apps/web/src/components/settings/SourceControlSettings.tsx created+471| ... | @@ -0,0 +1,471 @@ | ||
| 1 | import { GitPullRequestIcon, RefreshCwIcon } from "lucide-react"; | ||
| 2 | import { Option } from "effect"; | ||
| 3 | import { type ReactNode, useId } from "react"; | ||
| 4 | import type { | ||
| 5 | SourceControlProviderKind, | ||
| 6 | SourceControlDiscoveryResult, | ||
| 7 | SourceControlProviderAuth, | ||
| 8 | SourceControlProviderDiscoveryItem, | ||
| 9 | VcsDiscoveryItem, | ||
| 10 | } from "@t3tools/contracts"; | ||
| 11 | |||
| 12 | import { cn } from "../../lib/utils"; | ||
| 13 | import { | ||
| 14 | refreshSourceControlDiscovery, | ||
| 15 | useSourceControlDiscovery, | ||
| 16 | } from "../../lib/sourceControlDiscoveryState"; | ||
| 17 | import { Badge } from "../ui/badge"; | ||
| 18 | import { Button } from "../ui/button"; | ||
| 19 | import { | ||
| 20 | Empty, | ||
| 21 | EmptyContent, | ||
| 22 | EmptyDescription, | ||
| 23 | EmptyHeader, | ||
| 24 | EmptyMedia, | ||
| 25 | EmptyTitle, | ||
| 26 | } from "../ui/empty"; | ||
| 27 | import { Skeleton } from "../ui/skeleton"; | ||
| 28 | import { Switch } from "../ui/switch"; | ||
| 29 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ||
| 30 | import { GitHubIcon, type Icon } from "../Icons"; | ||
| 31 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; | ||
| 32 | import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; | ||
| 33 | |||
| 34 | const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { | ||
| 35 | versionControlSystems: [], | ||
| 36 | sourceControlProviders: [], | ||
| 37 | }; | ||
| 38 | |||
| 39 | const GitLabIcon: Icon = (props) => ( | ||
| 40 | <svg {...props} viewBox="0 0 32 32" fill="none"> | ||
| 41 | <path | ||
| 42 | d="m31.46 12.78-.04-.12-4.35-11.35A1.14 1.14 0 0 0 25.94.6c-.24 0-.47.1-.66.24-.19.15-.33.36-.39.6l-2.94 9h-11.9l-2.94-9A1.14 1.14 0 0 0 6.07.58a1.15 1.15 0 0 0-1.14.72L.58 12.68l-.05.11a8.1 8.1 0 0 0 2.68 9.34l.02.01.04.03 6.63 4.97 3.28 2.48 2 1.52a1.35 1.35 0 0 0 1.62 0l2-1.52 3.28-2.48 6.67-5h.02a8.09 8.09 0 0 0 2.7-9.36Z" | ||
| 43 | fill="#E24329" | ||
| 44 | /> | ||
| 45 | <path | ||
| 46 | d="m31.46 12.78-.04-.12a14.75 14.75 0 0 0-5.86 2.64l-9.55 7.24 6.09 4.6 6.67-5h.02a8.09 8.09 0 0 0 2.67-9.36Z" | ||
| 47 | fill="#FC6D26" | ||
| 48 | /> | ||
| 49 | <path | ||
| 50 | d="m9.9 27.14 3.28 2.48 2 1.52a1.35 1.35 0 0 0 1.62 0l2-1.52 3.28-2.48-6.1-4.6-6.07 4.6Z" | ||
| 51 | fill="#FCA326" | ||
| 52 | /> | ||
| 53 | <path | ||
| 54 | d="M6.44 15.3a14.71 14.71 0 0 0-5.86-2.63l-.05.12a8.1 8.1 0 0 0 2.68 9.34l.02.01.04.03 6.63 4.97 6.1-4.6-9.56-7.24Z" | ||
| 55 | fill="#FC6D26" | ||
| 56 | /> | ||
| 57 | </svg> | ||
| 58 | ); | ||
| 59 | |||
| 60 | const AzureDevOpsIcon: Icon = (props) => { | ||
| 61 | const id = useId().replaceAll(":", ""); | ||
| 62 | const gradientA = `${id}-azure-a`; | ||
| 63 | const gradientB = `${id}-azure-b`; | ||
| 64 | const gradientC = `${id}-azure-c`; | ||
| 65 | |||
| 66 | return ( | ||
| 67 | <svg {...props} viewBox="0 0 96 96"> | ||
| 68 | <defs> | ||
| 69 | <linearGradient | ||
| 70 | id={gradientA} | ||
| 71 | x1="-1032.17" | ||
| 72 | x2="-1059.21" | ||
| 73 | y1="145.31" | ||
| 74 | y2="65.43" | ||
| 75 | gradientTransform="matrix(1 0 0 -1 1075 158)" | ||
| 76 | gradientUnits="userSpaceOnUse" | ||
| 77 | > | ||
| 78 | <stop offset="0" stopColor="#114a8b" /> | ||
| 79 | <stop offset="1" stopColor="#0669bc" /> | ||
| 80 | </linearGradient> | ||
| 81 | <linearGradient | ||
| 82 | id={gradientB} | ||
| 83 | x1="-1023.73" | ||
| 84 | x2="-1029.98" | ||
| 85 | y1="108.08" | ||
| 86 | y2="105.97" | ||
| 87 | gradientTransform="matrix(1 0 0 -1 1075 158)" | ||
| 88 | gradientUnits="userSpaceOnUse" | ||
| 89 | > | ||
| 90 | <stop offset="0" stopOpacity=".3" /> | ||
| 91 | <stop offset=".07" stopOpacity=".2" /> | ||
| 92 | <stop offset=".32" stopOpacity=".1" /> | ||
| 93 | <stop offset=".62" stopOpacity=".05" /> | ||
| 94 | <stop offset="1" stopOpacity="0" /> | ||
| 95 | </linearGradient> | ||
| 96 | <linearGradient | ||
| 97 | id={gradientC} | ||
| 98 | x1="-1027.16" | ||
| 99 | x2="-997.48" | ||
| 100 | y1="147.64" | ||
| 101 | y2="68.56" | ||
| 102 | gradientTransform="matrix(1 0 0 -1 1075 158)" | ||
| 103 | gradientUnits="userSpaceOnUse" | ||
| 104 | > | ||
| 105 | <stop offset="0" stopColor="#3ccbf4" /> | ||
| 106 | <stop offset="1" stopColor="#2892df" /> | ||
| 107 | </linearGradient> | ||
| 108 | </defs> | ||
| 109 | <path | ||
| 110 | fill={`url(#${gradientA})`} | ||
| 111 | d="M33.34 6.54h26.04l-27.03 80.1a4.15 4.15 0 0 1-3.94 2.81H8.15a4.14 4.14 0 0 1-3.93-5.47L29.4 9.38a4.15 4.15 0 0 1 3.94-2.83z" | ||
| 112 | /> | ||
| 113 | <path | ||
| 114 | fill="#0078d4" | ||
| 115 | d="M71.17 60.26H29.88a1.91 1.91 0 0 0-1.3 3.31l26.53 24.76a4.17 4.17 0 0 0 2.85 1.13h23.38z" | ||
| 116 | /> | ||
| 117 | <path | ||
| 118 | fill={`url(#${gradientB})`} | ||
| 119 | d="M33.34 6.54a4.12 4.12 0 0 0-3.95 2.88L4.25 83.92a4.14 4.14 0 0 0 3.91 5.54h20.79a4.44 4.44 0 0 0 3.4-2.9l5.02-14.78 17.91 16.7a4.24 4.24 0 0 0 2.67.97h23.29L71.02 60.26H41.24L59.47 6.55z" | ||
| 120 | /> | ||
| 121 | <path | ||
| 122 | fill={`url(#${gradientC})`} | ||
| 123 | d="M66.6 9.36a4.14 4.14 0 0 0-3.93-2.82H33.65a4.15 4.15 0 0 1 3.93 2.82l25.18 74.62a4.15 4.15 0 0 1-3.93 5.48h29.02a4.15 4.15 0 0 0 3.93-5.48z" | ||
| 124 | /> | ||
| 125 | </svg> | ||
| 126 | ); | ||
| 127 | }; | ||
| 128 | |||
| 129 | const BitbucketIcon: Icon = (props) => { | ||
| 130 | const id = useId().replaceAll(":", ""); | ||
| 131 | const gradientId = `${id}-bitbucket-a`; | ||
| 132 | |||
| 133 | return ( | ||
| 134 | <svg {...props} viewBox="8.4 14.39 2481.29 2231.21"> | ||
| 135 | <path fill="none" d="M989.97,1493.09h518.05l125.04-730.04H852.22L989.97,1493.09z" /> | ||
| 136 | <path | ||
| 137 | fill="#2684FF" | ||
| 138 | d="M88.92,14.4C45.02,13.83,8.97,48.96,8.41,92.86c-0.06,4.61,0.28,9.22,1.02,13.77l337.48,2048.72 c8.68,51.75,53.26,89.8,105.74,90.24h1619.03c39.38,0.5,73.19-27.9,79.49-66.78l337.49-2071.78c7.03-43.34-22.41-84.17-65.75-91.2 c-4.55-0.74-9.15-1.08-13.76-1.02L88.92,14.4z M1509.99,1495.09H993.24l-139.92-731h781.89L1509.99,1495.09z" | ||
| 139 | /> | ||
| 140 | <linearGradient | ||
| 141 | id={gradientId} | ||
| 142 | gradientUnits="userSpaceOnUse" | ||
| 143 | x1="945.1094" | ||
| 144 | y1="1524.8389" | ||
| 145 | x2="944.4923" | ||
| 146 | y2="1524.1893" | ||
| 147 | gradientTransform="matrix(1996.6343 0 0 -1480.3047 -1884485.625 2258195)" | ||
| 148 | > | ||
| 149 | <stop offset="0.18" stopColor="#0052CC" /> | ||
| 150 | <stop offset="1" stopColor="#2684FF" /> | ||
| 151 | </linearGradient> | ||
| 152 | <path | ||
| 153 | fill={`url(#${gradientId})`} | ||
| 154 | d="M2379.27,763.06h-745.5l-125.12,730.42H992.31l-609.67,723.67c19.32,16.71,43.96,26,69.5,26.21h1618.13 c39.35,0.51,73.14-27.88,79.44-66.72L2379.27,763.06z" | ||
| 155 | /> | ||
| 156 | </svg> | ||
| 157 | ); | ||
| 158 | }; | ||
| 159 | |||
| 160 | const SOURCE_CONTROL_PROVIDER_ICONS: Partial<Record<SourceControlProviderKind, Icon>> = { | ||
| 161 | github: GitHubIcon, | ||
| 162 | gitlab: GitLabIcon, | ||
| 163 | "azure-devops": AzureDevOpsIcon, | ||
| 164 | bitbucket: BitbucketIcon, | ||
| 165 | }; | ||
| 166 | |||
| 167 | function optionLabel(value: Option.Option<string>): string | null { | ||
| 168 | return Option.getOrNull(value); | ||
| 169 | } | ||
| 170 | |||
| 171 | function isProviderDiscoveryItem( | ||
| 172 | item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem, | ||
| 173 | ): item is SourceControlProviderDiscoveryItem { | ||
| 174 | return "auth" in item; | ||
| 175 | } | ||
| 176 | |||
| 177 | function authPresentation(auth: SourceControlProviderAuth): { | ||
| 178 | readonly label: string; | ||
| 179 | readonly badge: "warning" | null; | ||
| 180 | } { | ||
| 181 | if (auth.status === "authenticated") { | ||
| 182 | return { label: "Signed in", badge: null }; | ||
| 183 | } | ||
| 184 | if (auth.status === "unauthenticated") { | ||
| 185 | return { label: "Sign in", badge: "warning" }; | ||
| 186 | } | ||
| 187 | return { label: "Sign in", badge: null }; | ||
| 188 | } | ||
| 189 | |||
| 190 | function RedactedAccount(props: { readonly account: string | null }) { | ||
| 191 | return ( | ||
| 192 | <RedactedSensitiveText | ||
| 193 | value={props.account} | ||
| 194 | ariaLabel="Toggle source control account visibility" | ||
| 195 | revealTooltip="Click to reveal account" | ||
| 196 | hideTooltip="Click to hide account" | ||
| 197 | /> | ||
| 198 | ); | ||
| 199 | } | ||
| 200 | |||
| 201 | function itemStatusDot(item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem): string { | ||
| 202 | if (!item.implemented) return "bg-muted-foreground/35"; | ||
| 203 | if (item.status !== "available") return "bg-warning"; | ||
| 204 | if (isProviderDiscoveryItem(item) && item.auth.status !== "authenticated") return "bg-warning"; | ||
| 205 | return "bg-success"; | ||
| 206 | } | ||
| 207 | |||
| 208 | function SourceControlItemMark({ | ||
| 209 | item, | ||
| 210 | }: { | ||
| 211 | readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem; | ||
| 212 | }) { | ||
| 213 | const dotClassName = itemStatusDot(item); | ||
| 214 | const Icon = isProviderDiscoveryItem(item) ? SOURCE_CONTROL_PROVIDER_ICONS[item.kind] : null; | ||
| 215 | |||
| 216 | if (!Icon) { | ||
| 217 | return <span className={cn("size-2 shrink-0 rounded-full", dotClassName)} aria-hidden />; | ||
| 218 | } | ||
| 219 | |||
| 220 | return ( | ||
| 221 | <span className="relative inline-flex size-5 shrink-0 items-center justify-center"> | ||
| 222 | <Icon className="size-4.5 text-foreground/80" aria-hidden /> | ||
| 223 | <span | ||
| 224 | className={cn( | ||
| 225 | "pointer-events-none absolute -left-0.5 -top-0.5 size-2 rounded-full ring-2 ring-background", | ||
| 226 | dotClassName, | ||
| 227 | )} | ||
| 228 | aria-hidden | ||
| 229 | /> | ||
| 230 | </span> | ||
| 231 | ); | ||
| 232 | } | ||
| 233 | |||
| 234 | function itemSummary({ | ||
| 235 | item, | ||
| 236 | auth, | ||
| 237 | authAccount, | ||
| 238 | }: { | ||
| 239 | readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem; | ||
| 240 | readonly auth: SourceControlProviderAuth | null; | ||
| 241 | readonly authAccount: string | null; | ||
| 242 | }) { | ||
| 243 | if (!item.implemented) { | ||
| 244 | return <span>Support for {item.label} is coming soon.</span>; | ||
| 245 | } | ||
| 246 | |||
| 247 | if (item.status !== "available") { | ||
| 248 | return <span>Not found - {item.installHint}</span>; | ||
| 249 | } | ||
| 250 | |||
| 251 | if (auth) { | ||
| 252 | if (auth.status === "authenticated") { | ||
| 253 | return ( | ||
| 254 | <> | ||
| 255 | <span>Authenticated</span> | ||
| 256 | {authAccount ? ( | ||
| 257 | <> | ||
| 258 | <span aria-hidden>as</span> | ||
| 259 | <RedactedAccount account={authAccount} /> | ||
| 260 | </> | ||
| 261 | ) : null} | ||
| 262 | </> | ||
| 263 | ); | ||
| 264 | } | ||
| 265 | if (auth.status === "unauthenticated") { | ||
| 266 | return <span>Sign in with the {item.executable} CLI to enable pull request actions.</span>; | ||
| 267 | } | ||
| 268 | return ( | ||
| 269 | <span> | ||
| 270 | Install and sign in with the {item.executable} CLI to enable pull request actions. | ||
| 271 | </span> | ||
| 272 | ); | ||
| 273 | } | ||
| 274 | |||
| 275 | return <span>Available</span>; | ||
| 276 | } | ||
| 277 | |||
| 278 | function DiscoveryItemRow({ | ||
| 279 | item, | ||
| 280 | }: { | ||
| 281 | readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem; | ||
| 282 | }) { | ||
| 283 | const version = optionLabel(item.version); | ||
| 284 | const enabled = item.implemented && item.status === "available"; | ||
| 285 | const auth = isProviderDiscoveryItem(item) ? item.auth : null; | ||
| 286 | const authStatus = auth ? authPresentation(auth) : null; | ||
| 287 | const authAccount = auth ? optionLabel(auth.account) : null; | ||
| 288 | |||
| 289 | return ( | ||
| 290 | <div | ||
| 291 | className={cn( | ||
| 292 | "border-t border-border/60 first:border-t-0", | ||
| 293 | !item.implemented && "opacity-80", | ||
| 294 | )} | ||
| 295 | > | ||
| 296 | <div className="px-4 py-3.5 sm:px-5"> | ||
| 297 | <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> | ||
| 298 | <div className="min-w-0 flex-1 space-y-1"> | ||
| 299 | <div className="flex min-w-0 flex-wrap items-center gap-2"> | ||
| 300 | <SourceControlItemMark item={item} /> | ||
| 301 | <h3 className="truncate text-[13px] font-semibold tracking-[-0.01em] text-foreground"> | ||
| 302 | {item.label} | ||
| 303 | </h3> | ||
| 304 | {version ? <code className="text-xs text-muted-foreground">{version}</code> : null} | ||
| 305 | {!item.implemented ? ( | ||
| 306 | <Badge variant="warning" size="sm"> | ||
| 307 | Coming Soon | ||
| 308 | </Badge> | ||
| 309 | ) : null} | ||
| 310 | {authStatus?.badge ? ( | ||
| 311 | <Badge variant={authStatus.badge} size="sm"> | ||
| 312 | {authStatus.label} | ||
| 313 | </Badge> | ||
| 314 | ) : null} | ||
| 315 | </div> | ||
| 316 | <p className="flex min-w-0 flex-wrap items-center gap-x-1 text-xs text-muted-foreground"> | ||
| 317 | {itemSummary({ item, auth, authAccount })} | ||
| 318 | </p> | ||
| 319 | </div> | ||
| 320 | <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end"> | ||
| 321 | {item.implemented ? ( | ||
| 322 | <Switch checked={enabled} disabled aria-label={`${item.label} availability`} /> | ||
| 323 | ) : null} | ||
| 324 | </div> | ||
| 325 | </div> | ||
| 326 | </div> | ||
| 327 | </div> | ||
| 328 | ); | ||
| 329 | } | ||
| 330 | |||
| 331 | function SourceControlSectionSkeleton({ | ||
| 332 | title, | ||
| 333 | headerAction, | ||
| 334 | }: { | ||
| 335 | readonly title: string; | ||
| 336 | readonly headerAction?: ReactNode; | ||
| 337 | }) { | ||
| 338 | return ( | ||
| 339 | <SettingsSection title={title} headerAction={headerAction}> | ||
| 340 | {Array.from({ length: 2 }, (_, index) => ( | ||
| 341 | <div key={index} className="border-t border-border/60 px-4 py-3.5 first:border-t-0 sm:px-5"> | ||
| 342 | <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> | ||
| 343 | <div className="min-w-0 flex-1 space-y-2"> | ||
| 344 | <div className="flex items-center gap-2"> | ||
| 345 | <Skeleton className="size-2 rounded-full" /> | ||
| 346 | <Skeleton className="h-4 w-28 rounded-full" /> | ||
| 347 | <Skeleton className="h-5 w-14 rounded-full" /> | ||
| 348 | </div> | ||
| 349 | <Skeleton className="h-3 w-full max-w-xs rounded-full" /> | ||
| 350 | </div> | ||
| 351 | <div className="flex shrink-0 items-center gap-2"> | ||
| 352 | <Skeleton className="size-7 rounded-md" /> | ||
| 353 | <Skeleton className="h-5 w-9 rounded-full" /> | ||
| 354 | </div> | ||
| 355 | </div> | ||
| 356 | </div> | ||
| 357 | ))} | ||
| 358 | </SettingsSection> | ||
| 359 | ); | ||
| 360 | } | ||
| 361 | |||
| 362 | function EmptySourceControlDiscovery({ | ||
| 363 | error, | ||
| 364 | isPending, | ||
| 365 | onScan, | ||
| 366 | }: { | ||
| 367 | readonly error: string | null; | ||
| 368 | readonly isPending: boolean; | ||
| 369 | readonly onScan: () => void; | ||
| 370 | }) { | ||
| 371 | const hasError = error !== null; | ||
| 372 | |||
| 373 | return ( | ||
| 374 | <SettingsSection title="Detected tools"> | ||
| 375 | <Empty className="min-h-88"> | ||
| 376 | <EmptyMedia variant="icon"> | ||
| 377 | <GitPullRequestIcon /> | ||
| 378 | </EmptyMedia> | ||
| 379 | <EmptyHeader> | ||
| 380 | <EmptyTitle> | ||
| 381 | {hasError ? "Could not scan source control" : "No source control tools found"} | ||
| 382 | </EmptyTitle> | ||
| 383 | <EmptyDescription> | ||
| 384 | {hasError ? error : "Install a supported Git or pull request CLI, then scan again."} | ||
| 385 | </EmptyDescription> | ||
| 386 | </EmptyHeader> | ||
| 387 | <EmptyContent> | ||
| 388 | <Button | ||
| 389 | size="sm" | ||
| 390 | variant="outline" | ||
| 391 | className="h-8 gap-1.5 px-3 text-xs" | ||
| 392 | onClick={onScan} | ||
| 393 | disabled={isPending} | ||
| 394 | > | ||
| 395 | <RefreshCwIcon className={cn("size-3.5", isPending && "animate-spin")} /> | ||
| 396 | Scan | ||
| 397 | </Button> | ||
| 398 | </EmptyContent> | ||
| 399 | </Empty> | ||
| 400 | </SettingsSection> | ||
| 401 | ); | ||
| 402 | } | ||
| 403 | |||
| 404 | export function SourceControlSettingsPanel() { | ||
| 405 | const discovery = useSourceControlDiscovery(); | ||
| 406 | |||
| 407 | const result = discovery.data ?? EMPTY_DISCOVERY_RESULT; | ||
| 408 | const hasDiscoveryItems = | ||
| 409 | result.versionControlSystems.length > 0 || result.sourceControlProviders.length > 0; | ||
| 410 | const isInitialScanPending = discovery.isPending && discovery.data === null; | ||
| 411 | const handleScan = () => { | ||
| 412 | void refreshSourceControlDiscovery(); | ||
| 413 | }; | ||
| 414 | const scanButton = ( | ||
| 415 | <Tooltip> | ||
| 416 | <TooltipTrigger | ||
| 417 | render={ | ||
| 418 | <Button | ||
| 419 | size="icon-xs" | ||
| 420 | variant="ghost" | ||
| 421 | className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground" | ||
| 422 | onClick={handleScan} | ||
| 423 | disabled={discovery.isPending} | ||
| 424 | aria-label="Scan source control tools" | ||
| 425 | > | ||
| 426 | <RefreshCwIcon className={cn("size-3", discovery.isPending && "animate-spin")} /> | ||
| 427 | </Button> | ||
| 428 | } | ||
| 429 | /> | ||
| 430 | <TooltipPopup side="top">Scan source control tools</TooltipPopup> | ||
| 431 | </Tooltip> | ||
| 432 | ); | ||
| 433 | |||
| 434 | return ( | ||
| 435 | <SettingsPageContainer> | ||
| 436 | {isInitialScanPending ? ( | ||
| 437 | <> | ||
| 438 | <SourceControlSectionSkeleton title="Version Control" headerAction={scanButton} /> | ||
| 439 | <SourceControlSectionSkeleton title="Source Control Providers" /> | ||
| 440 | </> | ||
| 441 | ) : hasDiscoveryItems ? ( | ||
| 442 | <> | ||
| 443 | {result.versionControlSystems.length > 0 ? ( | ||
| 444 | <SettingsSection title="Version Control" headerAction={scanButton}> | ||
| 445 | {result.versionControlSystems.map((item) => ( | ||
| 446 | <DiscoveryItemRow key={`vcs:${item.kind}`} item={item} /> | ||
| 447 | ))} | ||
| 448 | </SettingsSection> | ||
| 449 | ) : null} | ||
| 450 | |||
| 451 | {result.sourceControlProviders.length > 0 ? ( | ||
| 452 | <SettingsSection | ||
| 453 | title="Source Control Providers" | ||
| 454 | headerAction={result.versionControlSystems.length === 0 ? scanButton : null} | ||
| 455 | > | ||
| 456 | {result.sourceControlProviders.map((item) => ( | ||
| 457 | <DiscoveryItemRow key={`provider:${item.kind}`} item={item} /> | ||
| 458 | ))} | ||
| 459 | </SettingsSection> | ||
| 460 | ) : null} | ||
| 461 | </> | ||
| 462 | ) : ( | ||
| 463 | <EmptySourceControlDiscovery | ||
| 464 | error={discovery.error} | ||
| 465 | isPending={discovery.isPending} | ||
| 466 | onScan={handleScan} | ||
| 467 | /> | ||
| 468 | )} | ||
| 469 | </SettingsPageContainer> | ||
| 470 | ); | ||
| 471 | } | ||
apps/web/src/components/settings/settingsLayout.tsx+2-2| ... | @@ -28,13 +28,13 @@ export function SettingsSection({ | ... | @@ -28,13 +28,13 @@ export function SettingsSection({ |
| 28 | }) { | 28 | }) { |
| 29 | return ( | 29 | return ( |
| 30 | <section className="space-y-2.5"> | 30 | <section className="space-y-2.5"> |
| 31 | <div className="flex items-center justify-between px-1"> | 31 | <div className="flex h-5 items-center justify-between px-1"> |
| 32 | <h2 className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-foreground/50"> | 32 | <h2 className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-foreground/50"> |
| 33 | <span className="inline-block h-px w-3 bg-border" aria-hidden /> | 33 | <span className="inline-block h-px w-3 bg-border" aria-hidden /> |
| 34 | {icon} | 34 | {icon} |
| 35 | {title} | 35 | {title} |
| 36 | </h2> | 36 | </h2> |
| 37 | {headerAction} | 37 | <div className="flex h-5 min-w-5 items-center justify-end">{headerAction}</div> |
| 38 | </div> | 38 | </div> |
| 39 | <div className="relative overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-sm/4 not-dark:bg-clip-padding before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:shadow-none dark:before:shadow-[0_-1px_--theme(--color-white/6%)]"> | 39 | <div className="relative overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-sm/4 not-dark:bg-clip-padding before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:shadow-none dark:before:shadow-[0_-1px_--theme(--color-white/6%)]"> |
| 40 | {children} | 40 | {children} |
apps/web/src/environmentApi.ts+11-9| ... | @@ -23,16 +23,18 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { | ... | @@ -23,16 +23,18 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { |
| 23 | filesystem: { | 23 | filesystem: { |
| 24 | browse: rpcClient.filesystem.browse, | 24 | browse: rpcClient.filesystem.browse, |
| 25 | }, | 25 | }, |
| 26 | vcs: { | ||
| 27 | pull: rpcClient.vcs.pull, | ||
| 28 | refreshStatus: rpcClient.vcs.refreshStatus, | ||
| 29 | onStatus: (input, callback, options) => rpcClient.vcs.onStatus(input, callback, options), | ||
| 30 | listRefs: rpcClient.vcs.listRefs, | ||
| 31 | createWorktree: rpcClient.vcs.createWorktree, | ||
| 32 | removeWorktree: rpcClient.vcs.removeWorktree, | ||
| 33 | createRef: rpcClient.vcs.createRef, | ||
| 34 | switchRef: rpcClient.vcs.switchRef, | ||
| 35 | init: rpcClient.vcs.init, | ||
| 36 | }, | ||
| 26 | git: { | 37 | git: { |
| 27 | pull: rpcClient.git.pull, | ||
| 28 | refreshStatus: rpcClient.git.refreshStatus, | ||
| 29 | onStatus: (input, callback, options) => rpcClient.git.onStatus(input, callback, options), | ||
| 30 | listBranches: rpcClient.git.listBranches, | ||
| 31 | createWorktree: rpcClient.git.createWorktree, | ||
| 32 | removeWorktree: rpcClient.git.removeWorktree, | ||
| 33 | createBranch: rpcClient.git.createBranch, | ||
| 34 | checkout: rpcClient.git.checkout, | ||
| 35 | init: rpcClient.git.init, | ||
| 36 | resolvePullRequest: rpcClient.git.resolvePullRequest, | 38 | resolvePullRequest: rpcClient.git.resolvePullRequest, |
| 37 | preparePullRequestThread: rpcClient.git.preparePullRequestThread, | 39 | preparePullRequestThread: rpcClient.git.preparePullRequestThread, |
| 38 | }, | 40 | }, |
apps/web/src/environments/runtime/connection.test.ts-9| ... | @@ -85,16 +85,7 @@ function createTestClient() { | ... | @@ -85,16 +85,7 @@ function createTestClient() { |
| 85 | openInEditor: vi.fn(async () => undefined), | 85 | openInEditor: vi.fn(async () => undefined), |
| 86 | }, | 86 | }, |
| 87 | git: { | 87 | git: { |
| 88 | pull: vi.fn(async () => undefined), | ||
| 89 | refreshStatus: vi.fn(async () => undefined), | ||
| 90 | onStatus: vi.fn(() => () => undefined), | ||
| 91 | runStackedAction: vi.fn(async () => ({}) as any), | 88 | runStackedAction: vi.fn(async () => ({}) as any), |
| 92 | listBranches: vi.fn(async () => []), | ||
| 93 | createWorktree: vi.fn(async () => undefined), | ||
| 94 | removeWorktree: vi.fn(async () => undefined), | ||
| 95 | createBranch: vi.fn(async () => undefined), | ||
| 96 | checkout: vi.fn(async () => undefined), | ||
| 97 | init: vi.fn(async () => undefined), | ||
| 98 | resolvePullRequest: vi.fn(async () => undefined), | 89 | resolvePullRequest: vi.fn(async () => undefined), |
| 99 | preparePullRequestThread: vi.fn(async () => undefined), | 90 | preparePullRequestThread: vi.fn(async () => undefined), |
| 100 | }, | 91 | }, |
apps/web/src/hooks/useThreadActions.ts+1-1| ... | @@ -209,7 +209,7 @@ export function useThreadActions() { | ... | @@ -209,7 +209,7 @@ export function useThreadActions() { |
| 209 | } | 209 | } |
| 210 | 210 | ||
| 211 | try { | 211 | try { |
| 212 | await ensureEnvironmentApi(threadRef.environmentId).git.removeWorktree({ | 212 | await ensureEnvironmentApi(threadRef.environmentId).vcs.removeWorktree({ |
| 213 | cwd: threadProject.cwd, | 213 | cwd: threadProject.cwd, |
| 214 | path: orphanedWorktreePath, | 214 | path: orphanedWorktreePath, |
| 215 | force: true, | 215 | force: true, |
apps/web/src/lib/gitReactQuery.test.ts+5-5| ... | @@ -11,7 +11,7 @@ vi.mock("../wsRpcClient", () => ({ | ... | @@ -11,7 +11,7 @@ vi.mock("../wsRpcClient", () => ({ |
| 11 | })); | 11 | })); |
| 12 | 12 | ||
| 13 | import type { InfiniteData } from "@tanstack/react-query"; | 13 | import type { InfiniteData } from "@tanstack/react-query"; |
| 14 | import { EnvironmentId, type GitListBranchesResult } from "@t3tools/contracts"; | 14 | import { EnvironmentId, type VcsListRefsResult } from "@t3tools/contracts"; |
| 15 | 15 | ||
| 16 | import { | 16 | import { |
| 17 | gitBranchSearchInfiniteQueryOptions, | 17 | gitBranchSearchInfiniteQueryOptions, |
| ... | @@ -22,15 +22,15 @@ import { | ... | @@ -22,15 +22,15 @@ import { |
| 22 | invalidateGitQueries, | 22 | invalidateGitQueries, |
| 23 | } from "./gitReactQuery"; | 23 | } from "./gitReactQuery"; |
| 24 | 24 | ||
| 25 | const BRANCH_QUERY_RESULT: GitListBranchesResult = { | 25 | const BRANCH_QUERY_RESULT: VcsListRefsResult = { |
| 26 | branches: [], | 26 | refs: [], |
| 27 | isRepo: true, | 27 | isRepo: true, |
| 28 | hasOriginRemote: true, | 28 | hasPrimaryRemote: true, |
| 29 | nextCursor: null, | 29 | nextCursor: null, |
| 30 | totalCount: 0, | 30 | totalCount: 0, |
| 31 | }; | 31 | }; |
| 32 | 32 | ||
| 33 | const BRANCH_SEARCH_RESULT: InfiniteData<GitListBranchesResult, number> = { | 33 | const BRANCH_SEARCH_RESULT: InfiniteData<VcsListRefsResult, number> = { |
| 34 | pages: [BRANCH_QUERY_RESULT], | 34 | pages: [BRANCH_QUERY_RESULT], |
| 35 | pageParams: [0], | 35 | pageParams: [0], |
| 36 | }; | 36 | }; |
apps/web/src/lib/gitReactQuery.ts+38-20| ... | @@ -19,17 +19,17 @@ const GIT_BRANCHES_PAGE_SIZE = 100; | ... | @@ -19,17 +19,17 @@ const GIT_BRANCHES_PAGE_SIZE = 100; |
| 19 | 19 | ||
| 20 | export const gitQueryKeys = { | 20 | export const gitQueryKeys = { |
| 21 | all: ["git"] as const, | 21 | all: ["git"] as const, |
| 22 | branches: (environmentId: EnvironmentId | null, cwd: string | null) => | 22 | refs: (environmentId: EnvironmentId | null, cwd: string | null) => |
| 23 | ["git", "branches", environmentId ?? null, cwd] as const, | 23 | ["git", "refs", environmentId ?? null, cwd] as const, |
| 24 | branchSearch: (environmentId: EnvironmentId | null, cwd: string | null, query: string) => | 24 | branchSearch: (environmentId: EnvironmentId | null, cwd: string | null, query: string) => |
| 25 | ["git", "branches", environmentId ?? null, cwd, "search", query] as const, | 25 | ["git", "refs", environmentId ?? null, cwd, "search", query] as const, |
| 26 | }; | 26 | }; |
| 27 | 27 | ||
| 28 | export const gitMutationKeys = { | 28 | export const gitMutationKeys = { |
| 29 | init: (environmentId: EnvironmentId | null, cwd: string | null) => | 29 | init: (environmentId: EnvironmentId | null, cwd: string | null) => |
| 30 | ["git", "mutation", "init", environmentId ?? null, cwd] as const, | 30 | ["git", "mutation", "init", environmentId ?? null, cwd] as const, |
| 31 | checkout: (environmentId: EnvironmentId | null, cwd: string | null) => | 31 | switchRef: (environmentId: EnvironmentId | null, cwd: string | null) => |
| 32 | ["git", "mutation", "checkout", environmentId ?? null, cwd] as const, | 32 | ["git", "mutation", "switchRef", environmentId ?? null, cwd] as const, |
| 33 | runStackedAction: (environmentId: EnvironmentId | null, cwd: string | null) => | 33 | runStackedAction: (environmentId: EnvironmentId | null, cwd: string | null) => |
| 34 | ["git", "mutation", "run-stacked-action", environmentId ?? null, cwd] as const, | 34 | ["git", "mutation", "run-stacked-action", environmentId ?? null, cwd] as const, |
| 35 | pull: (environmentId: EnvironmentId | null, cwd: string | null) => | 35 | pull: (environmentId: EnvironmentId | null, cwd: string | null) => |
| ... | @@ -45,7 +45,7 @@ export function invalidateGitQueries( | ... | @@ -45,7 +45,7 @@ export function invalidateGitQueries( |
| 45 | const environmentId = input?.environmentId ?? null; | 45 | const environmentId = input?.environmentId ?? null; |
| 46 | const cwd = input?.cwd ?? null; | 46 | const cwd = input?.cwd ?? null; |
| 47 | if (cwd !== null) { | 47 | if (cwd !== null) { |
| 48 | return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, cwd) }); | 48 | return queryClient.invalidateQueries({ queryKey: gitQueryKeys.refs(environmentId, cwd) }); |
| 49 | } | 49 | } |
| 50 | 50 | ||
| 51 | return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all }); | 51 | return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all }); |
| ... | @@ -60,9 +60,12 @@ function invalidateGitBranchQueries( | ... | @@ -60,9 +60,12 @@ function invalidateGitBranchQueries( |
| 60 | return Promise.resolve(); | 60 | return Promise.resolve(); |
| 61 | } | 61 | } |
| 62 | 62 | ||
| 63 | return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, cwd) }); | 63 | return queryClient.invalidateQueries({ queryKey: gitQueryKeys.refs(environmentId, cwd) }); |
| 64 | } | 64 | } |
| 65 | 65 | ||
| 66 | /** | ||
| 67 | * @deprecated Use a VCS-named query helper once the UI naming migration lands. | ||
| 68 | */ | ||
| 66 | export function gitBranchSearchInfiniteQueryOptions(input: { | 69 | export function gitBranchSearchInfiniteQueryOptions(input: { |
| 67 | environmentId: EnvironmentId | null; | 70 | environmentId: EnvironmentId | null; |
| 68 | cwd: string | null; | 71 | cwd: string | null; |
| ... | @@ -75,10 +78,10 @@ export function gitBranchSearchInfiniteQueryOptions(input: { | ... | @@ -75,10 +78,10 @@ export function gitBranchSearchInfiniteQueryOptions(input: { |
| 75 | queryKey: gitQueryKeys.branchSearch(input.environmentId, input.cwd, normalizedQuery), | 78 | queryKey: gitQueryKeys.branchSearch(input.environmentId, input.cwd, normalizedQuery), |
| 76 | initialPageParam: 0, | 79 | initialPageParam: 0, |
| 77 | queryFn: async ({ pageParam }) => { | 80 | queryFn: async ({ pageParam }) => { |
| 78 | if (!input.cwd) throw new Error("Git branches are unavailable."); | 81 | if (!input.cwd) throw new Error("Git refs are unavailable."); |
| 79 | if (!input.environmentId) throw new Error("Git branches are unavailable."); | 82 | if (!input.environmentId) throw new Error("Git refs are unavailable."); |
| 80 | const api = ensureEnvironmentApi(input.environmentId); | 83 | const api = ensureEnvironmentApi(input.environmentId); |
| 81 | return api.git.listBranches({ | 84 | return api.vcs.listRefs({ |
| 82 | cwd: input.cwd, | 85 | cwd: input.cwd, |
| 83 | ...(normalizedQuery.length > 0 ? { query: normalizedQuery } : {}), | 86 | ...(normalizedQuery.length > 0 ? { query: normalizedQuery } : {}), |
| 84 | cursor: pageParam, | 87 | cursor: pageParam, |
| ... | @@ -121,6 +124,9 @@ export function gitResolvePullRequestQueryOptions(input: { | ... | @@ -121,6 +124,9 @@ export function gitResolvePullRequestQueryOptions(input: { |
| 121 | }); | 124 | }); |
| 122 | } | 125 | } |
| 123 | 126 | ||
| 127 | /** | ||
| 128 | * @deprecated Use a VCS-named mutation helper once the UI naming migration lands. | ||
| 129 | */ | ||
| 124 | export function gitInitMutationOptions(input: { | 130 | export function gitInitMutationOptions(input: { |
| 125 | environmentId: EnvironmentId | null; | 131 | environmentId: EnvironmentId | null; |
| 126 | cwd: string | null; | 132 | cwd: string | null; |
| ... | @@ -131,7 +137,7 @@ export function gitInitMutationOptions(input: { | ... | @@ -131,7 +137,7 @@ export function gitInitMutationOptions(input: { |
| 131 | mutationFn: async () => { | 137 | mutationFn: async () => { |
| 132 | if (!input.cwd || !input.environmentId) throw new Error("Git init is unavailable."); | 138 | if (!input.cwd || !input.environmentId) throw new Error("Git init is unavailable."); |
| 133 | const api = ensureEnvironmentApi(input.environmentId); | 139 | const api = ensureEnvironmentApi(input.environmentId); |
| 134 | return api.git.init({ cwd: input.cwd }); | 140 | return api.vcs.init({ cwd: input.cwd }); |
| 135 | }, | 141 | }, |
| 136 | onSettled: async () => { | 142 | onSettled: async () => { |
| 137 | await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); | 143 | await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); |
| ... | @@ -139,17 +145,20 @@ export function gitInitMutationOptions(input: { | ... | @@ -139,17 +145,20 @@ export function gitInitMutationOptions(input: { |
| 139 | }); | 145 | }); |
| 140 | } | 146 | } |
| 141 | 147 | ||
| 148 | /** | ||
| 149 | * @deprecated Use a VCS-named mutation helper once the UI naming migration lands. | ||
| 150 | */ | ||
| 142 | export function gitCheckoutMutationOptions(input: { | 151 | export function gitCheckoutMutationOptions(input: { |
| 143 | environmentId: EnvironmentId | null; | 152 | environmentId: EnvironmentId | null; |
| 144 | cwd: string | null; | 153 | cwd: string | null; |
| 145 | queryClient: QueryClient; | 154 | queryClient: QueryClient; |
| 146 | }) { | 155 | }) { |
| 147 | return mutationOptions({ | 156 | return mutationOptions({ |
| 148 | mutationKey: gitMutationKeys.checkout(input.environmentId, input.cwd), | 157 | mutationKey: gitMutationKeys.switchRef(input.environmentId, input.cwd), |
| 149 | mutationFn: async (branch: string) => { | 158 | mutationFn: async (refName: string) => { |
| 150 | if (!input.cwd || !input.environmentId) throw new Error("Git checkout is unavailable."); | 159 | if (!input.cwd || !input.environmentId) throw new Error("Git switchRef is unavailable."); |
| 151 | const api = ensureEnvironmentApi(input.environmentId); | 160 | const api = ensureEnvironmentApi(input.environmentId); |
| 152 | return api.git.checkout({ cwd: input.cwd, branch }); | 161 | return api.vcs.switchRef({ cwd: input.cwd, refName }); |
| 153 | }, | 162 | }, |
| 154 | onSettled: async () => { | 163 | onSettled: async () => { |
| 155 | await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); | 164 | await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); |
| ... | @@ -198,6 +207,9 @@ export function gitRunStackedActionMutationOptions(input: { | ... | @@ -198,6 +207,9 @@ export function gitRunStackedActionMutationOptions(input: { |
| 198 | }); | 207 | }); |
| 199 | } | 208 | } |
| 200 | 209 | ||
| 210 | /** | ||
| 211 | * @deprecated Use a VCS-named mutation helper once the UI naming migration lands. | ||
| 212 | */ | ||
| 201 | export function gitPullMutationOptions(input: { | 213 | export function gitPullMutationOptions(input: { |
| 202 | environmentId: EnvironmentId | null; | 214 | environmentId: EnvironmentId | null; |
| 203 | cwd: string | null; | 215 | cwd: string | null; |
| ... | @@ -208,7 +220,7 @@ export function gitPullMutationOptions(input: { | ... | @@ -208,7 +220,7 @@ export function gitPullMutationOptions(input: { |
| 208 | mutationFn: async () => { | 220 | mutationFn: async () => { |
| 209 | if (!input.cwd || !input.environmentId) throw new Error("Git pull is unavailable."); | 221 | if (!input.cwd || !input.environmentId) throw new Error("Git pull is unavailable."); |
| 210 | const api = ensureEnvironmentApi(input.environmentId); | 222 | const api = ensureEnvironmentApi(input.environmentId); |
| 211 | return api.git.pull({ cwd: input.cwd }); | 223 | return api.vcs.pull({ cwd: input.cwd }); |
| 212 | }, | 224 | }, |
| 213 | onSuccess: async () => { | 225 | onSuccess: async () => { |
| 214 | await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); | 226 | await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); |
| ... | @@ -216,6 +228,9 @@ export function gitPullMutationOptions(input: { | ... | @@ -216,6 +228,9 @@ export function gitPullMutationOptions(input: { |
| 216 | }); | 228 | }); |
| 217 | } | 229 | } |
| 218 | 230 | ||
| 231 | /** | ||
| 232 | * @deprecated Use a VCS-named mutation helper once the UI naming migration lands. | ||
| 233 | */ | ||
| 219 | export function gitCreateWorktreeMutationOptions(input: { | 234 | export function gitCreateWorktreeMutationOptions(input: { |
| 220 | environmentId: EnvironmentId | null; | 235 | environmentId: EnvironmentId | null; |
| 221 | queryClient: QueryClient; | 236 | queryClient: QueryClient; |
| ... | @@ -223,12 +238,12 @@ export function gitCreateWorktreeMutationOptions(input: { | ... | @@ -223,12 +238,12 @@ export function gitCreateWorktreeMutationOptions(input: { |
| 223 | return mutationOptions({ | 238 | return mutationOptions({ |
| 224 | mutationKey: ["git", "mutation", "create-worktree", input.environmentId ?? null] as const, | 239 | mutationKey: ["git", "mutation", "create-worktree", input.environmentId ?? null] as const, |
| 225 | mutationFn: ( | 240 | mutationFn: ( |
| 226 | args: Parameters<ReturnType<typeof ensureEnvironmentApi>["git"]["createWorktree"]>[0], | 241 | args: Parameters<ReturnType<typeof ensureEnvironmentApi>["vcs"]["createWorktree"]>[0], |
| 227 | ) => { | 242 | ) => { |
| 228 | if (!input.environmentId) { | 243 | if (!input.environmentId) { |
| 229 | throw new Error("Worktree creation is unavailable."); | 244 | throw new Error("Worktree creation is unavailable."); |
| 230 | } | 245 | } |
| 231 | return ensureEnvironmentApi(input.environmentId).git.createWorktree(args); | 246 | return ensureEnvironmentApi(input.environmentId).vcs.createWorktree(args); |
| 232 | }, | 247 | }, |
| 233 | onSuccess: async () => { | 248 | onSuccess: async () => { |
| 234 | await invalidateGitQueries(input.queryClient, { environmentId: input.environmentId }); | 249 | await invalidateGitQueries(input.queryClient, { environmentId: input.environmentId }); |
| ... | @@ -236,6 +251,9 @@ export function gitCreateWorktreeMutationOptions(input: { | ... | @@ -236,6 +251,9 @@ export function gitCreateWorktreeMutationOptions(input: { |
| 236 | }); | 251 | }); |
| 237 | } | 252 | } |
| 238 | 253 | ||
| 254 | /** | ||
| 255 | * @deprecated Use a VCS-named mutation helper once the UI naming migration lands. | ||
| 256 | */ | ||
| 239 | export function gitRemoveWorktreeMutationOptions(input: { | 257 | export function gitRemoveWorktreeMutationOptions(input: { |
| 240 | environmentId: EnvironmentId | null; | 258 | environmentId: EnvironmentId | null; |
| 241 | queryClient: QueryClient; | 259 | queryClient: QueryClient; |
| ... | @@ -243,12 +261,12 @@ export function gitRemoveWorktreeMutationOptions(input: { | ... | @@ -243,12 +261,12 @@ export function gitRemoveWorktreeMutationOptions(input: { |
| 243 | return mutationOptions({ | 261 | return mutationOptions({ |
| 244 | mutationKey: ["git", "mutation", "remove-worktree", input.environmentId ?? null] as const, | 262 | mutationKey: ["git", "mutation", "remove-worktree", input.environmentId ?? null] as const, |
| 245 | mutationFn: ( | 263 | mutationFn: ( |
| 246 | args: Parameters<ReturnType<typeof ensureEnvironmentApi>["git"]["removeWorktree"]>[0], | 264 | args: Parameters<ReturnType<typeof ensureEnvironmentApi>["vcs"]["removeWorktree"]>[0], |
| 247 | ) => { | 265 | ) => { |
| 248 | if (!input.environmentId) { | 266 | if (!input.environmentId) { |
| 249 | throw new Error("Worktree removal is unavailable."); | 267 | throw new Error("Worktree removal is unavailable."); |
| 250 | } | 268 | } |
| 251 | return ensureEnvironmentApi(input.environmentId).git.removeWorktree(args); | 269 | return ensureEnvironmentApi(input.environmentId).vcs.removeWorktree(args); |
| 252 | }, | 270 | }, |
| 253 | onSuccess: async () => { | 271 | onSuccess: async () => { |
| 254 | await invalidateGitQueries(input.queryClient, { environmentId: input.environmentId }); | 272 | await invalidateGitQueries(input.queryClient, { environmentId: input.environmentId }); |
apps/web/src/lib/gitStatusState.test.ts+31-29| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { EnvironmentId, type GitStatusResult } from "@t3tools/contracts"; | 1 | import { EnvironmentId, type VcsStatusResult } from "@t3tools/contracts"; |
| 2 | import { afterEach, describe, expect, it, vi } from "vitest"; | 2 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| 3 | 3 | ||
| 4 | import type { WsRpcClient } from "../rpc/wsRpcClient"; | 4 | import type { WsRpcClient } from "../rpc/wsRpcClient"; |
| ... | @@ -33,17 +33,17 @@ function registerListener<T>(listeners: Set<(event: T) => void>, listener: (even | ... | @@ -33,17 +33,17 @@ function registerListener<T>(listeners: Set<(event: T) => void>, listener: (even |
| 33 | }; | 33 | }; |
| 34 | } | 34 | } |
| 35 | 35 | ||
| 36 | const gitStatusListeners = new Set<(event: GitStatusResult) => void>(); | 36 | const gitStatusListeners = new Set<(event: VcsStatusResult) => void>(); |
| 37 | const ENVIRONMENT_ID = EnvironmentId.make("environment-local"); | 37 | const ENVIRONMENT_ID = EnvironmentId.make("environment-local"); |
| 38 | const OTHER_ENVIRONMENT_ID = EnvironmentId.make("environment-remote"); | 38 | const OTHER_ENVIRONMENT_ID = EnvironmentId.make("environment-remote"); |
| 39 | const TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/repo" } as const; | 39 | const TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/repo" } as const; |
| 40 | const FRESH_TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/fresh" } as const; | 40 | const FRESH_TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/fresh" } as const; |
| 41 | 41 | ||
| 42 | const BASE_STATUS: GitStatusResult = { | 42 | const BASE_STATUS: VcsStatusResult = { |
| 43 | isRepo: true, | 43 | isRepo: true, |
| 44 | hasOriginRemote: true, | 44 | hasPrimaryRemote: true, |
| 45 | isDefaultBranch: false, | 45 | isDefaultRef: false, |
| 46 | branch: "feature/push-status", | 46 | refName: "feature/push-status", |
| 47 | hasWorkingTreeChanges: false, | 47 | hasWorkingTreeChanges: false, |
| 48 | workingTree: { files: [], insertions: 0, deletions: 0 }, | 48 | workingTree: { files: [], insertions: 0, deletions: 0 }, |
| 49 | hasUpstream: true, | 49 | hasUpstream: true, |
| ... | @@ -55,21 +55,21 @@ const BASE_STATUS: GitStatusResult = { | ... | @@ -55,21 +55,21 @@ const BASE_STATUS: GitStatusResult = { |
| 55 | const gitClient = { | 55 | const gitClient = { |
| 56 | refreshStatus: vi.fn(async (input: { cwd: string }) => ({ | 56 | refreshStatus: vi.fn(async (input: { cwd: string }) => ({ |
| 57 | ...BASE_STATUS, | 57 | ...BASE_STATUS, |
| 58 | branch: `${input.cwd}-refreshed`, | 58 | refName: `${input.cwd}-refreshed`, |
| 59 | })), | 59 | })), |
| 60 | onStatus: vi.fn((input: { cwd: string }, listener: (event: GitStatusResult) => void) => | 60 | onStatus: vi.fn((input: { cwd: string }, listener: (event: VcsStatusResult) => void) => |
| 61 | registerListener(gitStatusListeners, listener), | 61 | registerListener(gitStatusListeners, listener), |
| 62 | ), | 62 | ), |
| 63 | }; | 63 | }; |
| 64 | 64 | ||
| 65 | function emitGitStatus(event: GitStatusResult) { | 65 | function emitGitStatus(event: VcsStatusResult) { |
| 66 | for (const listener of gitStatusListeners) { | 66 | for (const listener of gitStatusListeners) { |
| 67 | listener(event); | 67 | listener(event); |
| 68 | } | 68 | } |
| 69 | } | 69 | } |
| 70 | 70 | ||
| 71 | function createRegisteredGitStatusClient(environmentId: EnvironmentId) { | 71 | function createRegisteredGitStatusClient(environmentId: EnvironmentId) { |
| 72 | const listeners = new Set<(event: GitStatusResult) => void>(); | 72 | const listeners = new Set<(event: VcsStatusResult) => void>(); |
| 73 | const client = { | 73 | const client = { |
| 74 | dispose: vi.fn(async () => undefined), | 74 | dispose: vi.fn(async () => undefined), |
| 75 | reconnect: vi.fn(async () => undefined), | 75 | reconnect: vi.fn(async () => undefined), |
| ... | @@ -89,22 +89,24 @@ function createRegisteredGitStatusClient(environmentId: EnvironmentId) { | ... | @@ -89,22 +89,24 @@ function createRegisteredGitStatusClient(environmentId: EnvironmentId) { |
| 89 | shell: { | 89 | shell: { |
| 90 | openInEditor: vi.fn(async () => undefined), | 90 | openInEditor: vi.fn(async () => undefined), |
| 91 | }, | 91 | }, |
| 92 | git: { | 92 | vcs: { |
| 93 | pull: vi.fn(async () => undefined), | 93 | pull: vi.fn(async () => undefined), |
| 94 | refreshStatus: vi.fn(async (input: { cwd: string }) => ({ | 94 | refreshStatus: vi.fn(async (input: { cwd: string }) => ({ |
| 95 | ...BASE_STATUS, | 95 | ...BASE_STATUS, |
| 96 | branch: `${input.cwd}-refreshed`, | 96 | refName: `${input.cwd}-refreshed`, |
| 97 | })), | 97 | })), |
| 98 | onStatus: vi.fn((_: { cwd: string }, listener: (event: GitStatusResult) => void) => | 98 | onStatus: vi.fn((_: { cwd: string }, listener: (event: VcsStatusResult) => void) => |
| 99 | registerListener(listeners, listener), | 99 | registerListener(listeners, listener), |
| 100 | ), | 100 | ), |
| 101 | runStackedAction: vi.fn(async () => ({}) as any), | 101 | listRefs: vi.fn(async () => []), |
| 102 | listBranches: vi.fn(async () => []), | ||
| 103 | createWorktree: vi.fn(async () => undefined), | 102 | createWorktree: vi.fn(async () => undefined), |
| 104 | removeWorktree: vi.fn(async () => undefined), | 103 | removeWorktree: vi.fn(async () => undefined), |
| 105 | createBranch: vi.fn(async () => undefined), | 104 | createRef: vi.fn(async () => undefined), |
| 106 | checkout: vi.fn(async () => undefined), | 105 | switchRef: vi.fn(async () => undefined), |
| 107 | init: vi.fn(async () => undefined), | 106 | init: vi.fn(async () => undefined), |
| 107 | }, | ||
| 108 | git: { | ||
| 109 | runStackedAction: vi.fn(async () => ({}) as any), | ||
| 108 | resolvePullRequest: vi.fn(async () => undefined), | 110 | resolvePullRequest: vi.fn(async () => undefined), |
| 109 | preparePullRequestThread: vi.fn(async () => undefined), | 111 | preparePullRequestThread: vi.fn(async () => undefined), |
| 110 | }, | 112 | }, |
| ... | @@ -155,7 +157,7 @@ function createRegisteredGitStatusClient(environmentId: EnvironmentId) { | ... | @@ -155,7 +157,7 @@ function createRegisteredGitStatusClient(environmentId: EnvironmentId) { |
| 155 | 157 | ||
| 156 | return { | 158 | return { |
| 157 | client, | 159 | client, |
| 158 | emit: (event: GitStatusResult) => { | 160 | emit: (event: VcsStatusResult) => { |
| 159 | for (const listener of listeners) { | 161 | for (const listener of listeners) { |
| 160 | listener(event); | 162 | listener(event); |
| 161 | } | 163 | } |
| ... | @@ -219,7 +221,7 @@ describe("gitStatusState", () => { | ... | @@ -219,7 +221,7 @@ describe("gitStatusState", () => { |
| 219 | 221 | ||
| 220 | expect(gitClient.onStatus).toHaveBeenCalledOnce(); | 222 | expect(gitClient.onStatus).toHaveBeenCalledOnce(); |
| 221 | expect(gitClient.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); | 223 | expect(gitClient.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); |
| 222 | expect(refreshed).toEqual({ ...BASE_STATUS, branch: "/repo-refreshed" }); | 224 | expect(refreshed).toEqual({ ...BASE_STATUS, refName: "/repo-refreshed" }); |
| 223 | expect(getGitStatusSnapshot(TARGET)).toEqual({ | 225 | expect(getGitStatusSnapshot(TARGET)).toEqual({ |
| 224 | data: BASE_STATUS, | 226 | data: BASE_STATUS, |
| 225 | error: null, | 227 | error: null, |
| ... | @@ -231,17 +233,17 @@ describe("gitStatusState", () => { | ... | @@ -231,17 +233,17 @@ describe("gitStatusState", () => { |
| 231 | }); | 233 | }); |
| 232 | 234 | ||
| 233 | it("keeps git status subscriptions isolated by environment when cwds match", () => { | 235 | it("keeps git status subscriptions isolated by environment when cwds match", () => { |
| 234 | const localListeners = new Set<(event: GitStatusResult) => void>(); | 236 | const localListeners = new Set<(event: VcsStatusResult) => void>(); |
| 235 | const remoteListeners = new Set<(event: GitStatusResult) => void>(); | 237 | const remoteListeners = new Set<(event: VcsStatusResult) => void>(); |
| 236 | const localClient = { | 238 | const localClient = { |
| 237 | refreshStatus: vi.fn(), | 239 | refreshStatus: vi.fn(), |
| 238 | onStatus: vi.fn((_: { cwd: string }, listener: (event: GitStatusResult) => void) => | 240 | onStatus: vi.fn((_: { cwd: string }, listener: (event: VcsStatusResult) => void) => |
| 239 | registerListener(localListeners, listener), | 241 | registerListener(localListeners, listener), |
| 240 | ), | 242 | ), |
| 241 | }; | 243 | }; |
| 242 | const remoteClient = { | 244 | const remoteClient = { |
| 243 | refreshStatus: vi.fn(), | 245 | refreshStatus: vi.fn(), |
| 244 | onStatus: vi.fn((_: { cwd: string }, listener: (event: GitStatusResult) => void) => | 246 | onStatus: vi.fn((_: { cwd: string }, listener: (event: VcsStatusResult) => void) => |
| 245 | registerListener(remoteListeners, listener), | 247 | registerListener(remoteListeners, listener), |
| 246 | ), | 248 | ), |
| 247 | }; | 249 | }; |
| ... | @@ -254,11 +256,11 @@ describe("gitStatusState", () => { | ... | @@ -254,11 +256,11 @@ describe("gitStatusState", () => { |
| 254 | listener(BASE_STATUS); | 256 | listener(BASE_STATUS); |
| 255 | } | 257 | } |
| 256 | for (const listener of remoteListeners) { | 258 | for (const listener of remoteListeners) { |
| 257 | listener({ ...BASE_STATUS, branch: "remote-branch" }); | 259 | listener({ ...BASE_STATUS, refName: "remote-refName" }); |
| 258 | } | 260 | } |
| 259 | 261 | ||
| 260 | expect(getGitStatusSnapshot(TARGET).data?.branch).toBe("feature/push-status"); | 262 | expect(getGitStatusSnapshot(TARGET).data?.refName).toBe("feature/push-status"); |
| 261 | expect(getGitStatusSnapshot(remoteTarget).data?.branch).toBe("remote-branch"); | 263 | expect(getGitStatusSnapshot(remoteTarget).data?.refName).toBe("remote-refName"); |
| 262 | 264 | ||
| 263 | releaseLocal(); | 265 | releaseLocal(); |
| 264 | releaseRemote(); | 266 | releaseRemote(); |
| ... | @@ -292,7 +294,7 @@ describe("gitStatusState", () => { | ... | @@ -292,7 +294,7 @@ describe("gitStatusState", () => { |
| 292 | const release = watchGitStatus(TARGET); | 294 | const release = watchGitStatus(TARGET); |
| 293 | 295 | ||
| 294 | firstClient.emit(BASE_STATUS); | 296 | firstClient.emit(BASE_STATUS); |
| 295 | expect(getGitStatusSnapshot(TARGET).data?.branch).toBe("feature/push-status"); | 297 | expect(getGitStatusSnapshot(TARGET).data?.refName).toBe("feature/push-status"); |
| 296 | 298 | ||
| 297 | serviceHarness.connections.delete(ENVIRONMENT_ID); | 299 | serviceHarness.connections.delete(ENVIRONMENT_ID); |
| 298 | for (const listener of serviceHarness.listeners) { | 300 | for (const listener of serviceHarness.listeners) { |
| ... | @@ -307,10 +309,10 @@ describe("gitStatusState", () => { | ... | @@ -307,10 +309,10 @@ describe("gitStatusState", () => { |
| 307 | }); | 309 | }); |
| 308 | 310 | ||
| 309 | const secondClient = createRegisteredGitStatusClient(ENVIRONMENT_ID); | 311 | const secondClient = createRegisteredGitStatusClient(ENVIRONMENT_ID); |
| 310 | secondClient.emit({ ...BASE_STATUS, branch: "reconnected-branch" }); | 312 | secondClient.emit({ ...BASE_STATUS, refName: "reconnected-refName" }); |
| 311 | 313 | ||
| 312 | expect(getGitStatusSnapshot(TARGET)).toEqual({ | 314 | expect(getGitStatusSnapshot(TARGET)).toEqual({ |
| 313 | data: { ...BASE_STATUS, branch: "reconnected-branch" }, | 315 | data: { ...BASE_STATUS, refName: "reconnected-refName" }, |
| 314 | error: null, | 316 | error: null, |
| 315 | cause: null, | 317 | cause: null, |
| 316 | isPending: false, | 318 | isPending: false, |
apps/web/src/lib/gitStatusState.ts+7-7| ... | @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; | ... | @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { | 2 | import { |
| 3 | type EnvironmentId, | 3 | type EnvironmentId, |
| 4 | type GitManagerServiceError, | 4 | type GitManagerServiceError, |
| 5 | type GitStatusResult, | 5 | type VcsStatusResult, |
| 6 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 7 | import { Cause } from "effect"; | 7 | import { Cause } from "effect"; |
| 8 | import { Atom } from "effect/unstable/reactivity"; | 8 | import { Atom } from "effect/unstable/reactivity"; |
| ... | @@ -16,13 +16,13 @@ import { | ... | @@ -16,13 +16,13 @@ import { |
| 16 | import type { WsRpcClient } from "~/rpc/wsRpcClient"; | 16 | import type { WsRpcClient } from "~/rpc/wsRpcClient"; |
| 17 | 17 | ||
| 18 | interface GitStatusState { | 18 | interface GitStatusState { |
| 19 | readonly data: GitStatusResult | null; | 19 | readonly data: VcsStatusResult | null; |
| 20 | readonly error: GitManagerServiceError | null; | 20 | readonly error: GitManagerServiceError | null; |
| 21 | readonly cause: Cause.Cause<GitManagerServiceError> | null; | 21 | readonly cause: Cause.Cause<GitManagerServiceError> | null; |
| 22 | readonly isPending: boolean; | 22 | readonly isPending: boolean; |
| 23 | } | 23 | } |
| 24 | 24 | ||
| 25 | type GitStatusClient = Pick<WsRpcClient["git"], "onStatus" | "refreshStatus">; | 25 | type GitStatusClient = Pick<WsRpcClient["vcs"], "onStatus" | "refreshStatus">; |
| 26 | interface ResolvedGitStatusClient { | 26 | interface ResolvedGitStatusClient { |
| 27 | readonly clientIdentity: string; | 27 | readonly clientIdentity: string; |
| 28 | readonly client: GitStatusClient; | 28 | readonly client: GitStatusClient; |
| ... | @@ -56,7 +56,7 @@ const EMPTY_GIT_STATUS_ATOM = Atom.make(EMPTY_GIT_STATUS_STATE).pipe( | ... | @@ -56,7 +56,7 @@ const EMPTY_GIT_STATUS_ATOM = Atom.make(EMPTY_GIT_STATUS_STATE).pipe( |
| 56 | const NOOP: () => void = () => undefined; | 56 | const NOOP: () => void = () => undefined; |
| 57 | const watchedGitStatuses = new Map<string, WatchedGitStatus>(); | 57 | const watchedGitStatuses = new Map<string, WatchedGitStatus>(); |
| 58 | const knownGitStatusKeys = new Set<string>(); | 58 | const knownGitStatusKeys = new Set<string>(); |
| 59 | const gitStatusRefreshInFlight = new Map<string, Promise<GitStatusResult>>(); | 59 | const gitStatusRefreshInFlight = new Map<string, Promise<VcsStatusResult>>(); |
| 60 | const gitStatusLastRefreshAtByKey = new Map<string, number>(); | 60 | const gitStatusLastRefreshAtByKey = new Map<string, number>(); |
| 61 | 61 | ||
| 62 | const GIT_STATUS_REFRESH_DEBOUNCE_MS = 1_000; | 62 | const GIT_STATUS_REFRESH_DEBOUNCE_MS = 1_000; |
| ... | @@ -83,7 +83,7 @@ function readResolvedGitStatusClient(target: GitStatusTarget): ResolvedGitStatus | ... | @@ -83,7 +83,7 @@ function readResolvedGitStatusClient(target: GitStatusTarget): ResolvedGitStatus |
| 83 | } | 83 | } |
| 84 | const connection = readEnvironmentConnection(target.environmentId); | 84 | const connection = readEnvironmentConnection(target.environmentId); |
| 85 | return connection | 85 | return connection |
| 86 | ? { clientIdentity: connection.environmentId, client: connection.client.git } | 86 | ? { clientIdentity: connection.environmentId, client: connection.client.vcs } |
| 87 | : null; | 87 | : null; |
| 88 | } | 88 | } |
| 89 | 89 | ||
| ... | @@ -119,7 +119,7 @@ export function watchGitStatus(target: GitStatusTarget, client?: GitStatusClient | ... | @@ -119,7 +119,7 @@ export function watchGitStatus(target: GitStatusTarget, client?: GitStatusClient |
| 119 | export function refreshGitStatus( | 119 | export function refreshGitStatus( |
| 120 | target: GitStatusTarget, | 120 | target: GitStatusTarget, |
| 121 | client?: GitStatusClient, | 121 | client?: GitStatusClient, |
| 122 | ): Promise<GitStatusResult | null> { | 122 | ): Promise<VcsStatusResult | null> { |
| 123 | const targetKey = getGitStatusTargetKey(target); | 123 | const targetKey = getGitStatusTargetKey(target); |
| 124 | if (targetKey === null || target.cwd === null) { | 124 | if (targetKey === null || target.cwd === null) { |
| 125 | return Promise.resolve(null); | 125 | return Promise.resolve(null); |
| ... | @@ -245,7 +245,7 @@ function subscribeToGitStatus(targetKey: string, cwd: string, client: GitStatusC | ... | @@ -245,7 +245,7 @@ function subscribeToGitStatus(targetKey: string, cwd: string, client: GitStatusC |
| 245 | markGitStatusPending(targetKey); | 245 | markGitStatusPending(targetKey); |
| 246 | return client.onStatus( | 246 | return client.onStatus( |
| 247 | { cwd }, | 247 | { cwd }, |
| 248 | (status: GitStatusResult) => { | 248 | (status: VcsStatusResult) => { |
| 249 | appAtomRegistry.set(gitStatusStateAtom(targetKey), { | 249 | appAtomRegistry.set(gitStatusStateAtom(targetKey), { |
| 250 | data: status, | 250 | data: status, |
| 251 | error: null, | 251 | error: null, |
apps/web/src/lib/sourceControlDiscoveryState.ts created+56| ... | @@ -0,0 +1,56 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { | ||
| 3 | EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM, | ||
| 4 | EMPTY_SOURCE_CONTROL_DISCOVERY_STATE, | ||
| 5 | type SourceControlDiscoveryState, | ||
| 6 | createSourceControlDiscoveryManager, | ||
| 7 | getSourceControlDiscoveryTargetKey, | ||
| 8 | sourceControlDiscoveryStateAtom, | ||
| 9 | } from "@t3tools/client-runtime"; | ||
| 10 | import type { SourceControlDiscoveryResult } from "@t3tools/contracts"; | ||
| 11 | import { Effect } from "effect"; | ||
| 12 | import { Atom } from "effect/unstable/reactivity"; | ||
| 13 | |||
| 14 | import { readLocalApi } from "../localApi"; | ||
| 15 | import { appAtomRegistry } from "../rpc/atomRegistry"; | ||
| 16 | |||
| 17 | const SOURCE_CONTROL_DISCOVERY_TARGET = { key: "primary" } as const; | ||
| 18 | const SOURCE_CONTROL_DISCOVERY_STALE_TIME_MS = 30_000; | ||
| 19 | const SOURCE_CONTROL_DISCOVERY_IDLE_TTL_MS = 5 * 60_000; | ||
| 20 | |||
| 21 | export const sourceControlDiscoveryManager = createSourceControlDiscoveryManager({ | ||
| 22 | getRegistry: () => appAtomRegistry, | ||
| 23 | getClient: () => readLocalApi()?.server ?? null, | ||
| 24 | }); | ||
| 25 | |||
| 26 | const sourceControlDiscoveryAutoRefreshAtom = Atom.make(() => | ||
| 27 | Effect.promise(() => refreshSourceControlDiscovery()), | ||
| 28 | ).pipe( | ||
| 29 | Atom.swr({ | ||
| 30 | staleTime: SOURCE_CONTROL_DISCOVERY_STALE_TIME_MS, | ||
| 31 | revalidateOnMount: true, | ||
| 32 | }), | ||
| 33 | Atom.setIdleTTL(SOURCE_CONTROL_DISCOVERY_IDLE_TTL_MS), | ||
| 34 | Atom.withLabel("source-control-discovery:auto-refresh"), | ||
| 35 | ); | ||
| 36 | |||
| 37 | export function refreshSourceControlDiscovery(): Promise<SourceControlDiscoveryResult | null> { | ||
| 38 | return sourceControlDiscoveryManager.refresh(SOURCE_CONTROL_DISCOVERY_TARGET); | ||
| 39 | } | ||
| 40 | |||
| 41 | export function resetSourceControlDiscoveryStateForTests(): void { | ||
| 42 | sourceControlDiscoveryManager.reset(); | ||
| 43 | } | ||
| 44 | |||
| 45 | export function useSourceControlDiscovery(): SourceControlDiscoveryState { | ||
| 46 | const targetKey = getSourceControlDiscoveryTargetKey(SOURCE_CONTROL_DISCOVERY_TARGET); | ||
| 47 | |||
| 48 | useAtomValue(sourceControlDiscoveryAutoRefreshAtom); | ||
| 49 | |||
| 50 | const state = useAtomValue( | ||
| 51 | targetKey !== null | ||
| 52 | ? sourceControlDiscoveryStateAtom(targetKey) | ||
| 53 | : EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM, | ||
| 54 | ); | ||
| 55 | return targetKey === null ? EMPTY_SOURCE_CONTROL_DISCOVERY_STATE : state; | ||
| 56 | } | ||
apps/web/src/localApi.test.ts+19-17| ... | @@ -3,7 +3,7 @@ import { | ... | @@ -3,7 +3,7 @@ import { |
| 3 | DEFAULT_SERVER_SETTINGS, | 3 | DEFAULT_SERVER_SETTINGS, |
| 4 | type DesktopBridge, | 4 | type DesktopBridge, |
| 5 | EnvironmentId, | 5 | EnvironmentId, |
| 6 | type GitStatusResult, | 6 | type VcsStatusResult, |
| 7 | ProjectId, | 7 | ProjectId, |
| 8 | type OrchestrationShellStreamItem, | 8 | type OrchestrationShellStreamItem, |
| 9 | ProviderDriverKind, | 9 | ProviderDriverKind, |
| ... | @@ -34,7 +34,7 @@ function registerListener<T>(listeners: Set<(event: T) => void>, listener: (even | ... | @@ -34,7 +34,7 @@ function registerListener<T>(listeners: Set<(event: T) => void>, listener: (even |
| 34 | 34 | ||
| 35 | const terminalEventListeners = new Set<(event: TerminalEvent) => void>(); | 35 | const terminalEventListeners = new Set<(event: TerminalEvent) => void>(); |
| 36 | const shellStreamListeners = new Set<(event: OrchestrationShellStreamItem) => void>(); | 36 | const shellStreamListeners = new Set<(event: OrchestrationShellStreamItem) => void>(); |
| 37 | const gitStatusListeners = new Set<(event: GitStatusResult) => void>(); | 37 | const gitStatusListeners = new Set<(event: VcsStatusResult) => void>(); |
| 38 | 38 | ||
| 39 | const rpcClientMock = { | 39 | const rpcClientMock = { |
| 40 | dispose: vi.fn(), | 40 | dispose: vi.fn(), |
| ... | @@ -59,19 +59,21 @@ const rpcClientMock = { | ... | @@ -59,19 +59,21 @@ const rpcClientMock = { |
| 59 | shell: { | 59 | shell: { |
| 60 | openInEditor: vi.fn(), | 60 | openInEditor: vi.fn(), |
| 61 | }, | 61 | }, |
| 62 | git: { | 62 | vcs: { |
| 63 | pull: vi.fn(), | 63 | pull: vi.fn(), |
| 64 | refreshStatus: vi.fn(), | 64 | refreshStatus: vi.fn(), |
| 65 | onStatus: vi.fn((input: { cwd: string }, listener: (event: GitStatusResult) => void) => | 65 | onStatus: vi.fn((input: { cwd: string }, listener: (event: VcsStatusResult) => void) => |
| 66 | registerListener(gitStatusListeners, listener), | 66 | registerListener(gitStatusListeners, listener), |
| 67 | ), | 67 | ), |
| 68 | runStackedAction: vi.fn(), | 68 | listRefs: vi.fn(), |
| 69 | listBranches: vi.fn(), | ||
| 70 | createWorktree: vi.fn(), | 69 | createWorktree: vi.fn(), |
| 71 | removeWorktree: vi.fn(), | 70 | removeWorktree: vi.fn(), |
| 72 | createBranch: vi.fn(), | 71 | createRef: vi.fn(), |
| 73 | checkout: vi.fn(), | 72 | switchRef: vi.fn(), |
| 74 | init: vi.fn(), | 73 | init: vi.fn(), |
| 74 | }, | ||
| 75 | git: { | ||
| 76 | runStackedAction: vi.fn(), | ||
| 75 | resolvePullRequest: vi.fn(), | 77 | resolvePullRequest: vi.fn(), |
| 76 | preparePullRequestThread: vi.fn(), | 78 | preparePullRequestThread: vi.fn(), |
| 77 | }, | 79 | }, |
| ... | @@ -259,11 +261,11 @@ const baseServerConfig: ServerConfig = { | ... | @@ -259,11 +261,11 @@ const baseServerConfig: ServerConfig = { |
| 259 | settings: DEFAULT_SERVER_SETTINGS, | 261 | settings: DEFAULT_SERVER_SETTINGS, |
| 260 | }; | 262 | }; |
| 261 | 263 | ||
| 262 | const baseGitStatus: GitStatusResult = { | 264 | const baseGitStatus: VcsStatusResult = { |
| 263 | isRepo: true, | 265 | isRepo: true, |
| 264 | hasOriginRemote: true, | 266 | hasPrimaryRemote: true, |
| 265 | isDefaultBranch: false, | 267 | isDefaultRef: false, |
| 266 | branch: "feature/streamed", | 268 | refName: "feature/streamed", |
| 267 | hasWorkingTreeChanges: false, | 269 | hasWorkingTreeChanges: false, |
| 268 | workingTree: { files: [], insertions: 0, deletions: 0 }, | 270 | workingTree: { files: [], insertions: 0, deletions: 0 }, |
| 269 | hasUpstream: true, | 271 | hasUpstream: true, |
| ... | @@ -351,24 +353,24 @@ describe("wsApi", () => { | ... | @@ -351,24 +353,24 @@ describe("wsApi", () => { |
| 351 | const api = createEnvironmentApi(rpcClientMock as never); | 353 | const api = createEnvironmentApi(rpcClientMock as never); |
| 352 | const onStatus = vi.fn(); | 354 | const onStatus = vi.fn(); |
| 353 | 355 | ||
| 354 | api.git.onStatus({ cwd: "/repo" }, onStatus); | 356 | api.vcs.onStatus({ cwd: "/repo" }, onStatus); |
| 355 | 357 | ||
| 356 | const gitStatus = baseGitStatus; | 358 | const gitStatus = baseGitStatus; |
| 357 | emitEvent(gitStatusListeners, gitStatus); | 359 | emitEvent(gitStatusListeners, gitStatus); |
| 358 | 360 | ||
| 359 | expect(rpcClientMock.git.onStatus).toHaveBeenCalledWith({ cwd: "/repo" }, onStatus, undefined); | 361 | expect(rpcClientMock.vcs.onStatus).toHaveBeenCalledWith({ cwd: "/repo" }, onStatus, undefined); |
| 360 | expect(onStatus).toHaveBeenCalledWith(gitStatus); | 362 | expect(onStatus).toHaveBeenCalledWith(gitStatus); |
| 361 | }); | 363 | }); |
| 362 | 364 | ||
| 363 | it("forwards git status refreshes directly to the RPC client", async () => { | 365 | it("forwards git status refreshes directly to the RPC client", async () => { |
| 364 | rpcClientMock.git.refreshStatus.mockResolvedValue(baseGitStatus); | 366 | rpcClientMock.vcs.refreshStatus.mockResolvedValue(baseGitStatus); |
| 365 | const { createEnvironmentApi } = await import("./environmentApi"); | 367 | const { createEnvironmentApi } = await import("./environmentApi"); |
| 366 | 368 | ||
| 367 | const api = createEnvironmentApi(rpcClientMock as never); | 369 | const api = createEnvironmentApi(rpcClientMock as never); |
| 368 | 370 | ||
| 369 | await api.git.refreshStatus({ cwd: "/repo" }); | 371 | await api.vcs.refreshStatus({ cwd: "/repo" }); |
| 370 | 372 | ||
| 371 | expect(rpcClientMock.git.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); | 373 | expect(rpcClientMock.vcs.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); |
| 372 | }); | 374 | }); |
| 373 | 375 | ||
| 374 | it("forwards shell stream subscription options to the RPC client", async () => { | 376 | it("forwards shell stream subscription options to the RPC client", async () => { |
apps/web/src/localApi.ts+3| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import type { ContextMenuItem, LocalApi } from "@t3tools/contracts"; | 1 | import type { ContextMenuItem, LocalApi } from "@t3tools/contracts"; |
| 2 | 2 | ||
| 3 | import { resetGitStatusStateForTests } from "./lib/gitStatusState"; | 3 | import { resetGitStatusStateForTests } from "./lib/gitStatusState"; |
| 4 | import { resetSourceControlDiscoveryStateForTests } from "./lib/sourceControlDiscoveryState"; | ||
| 4 | import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; | 5 | import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; |
| 5 | import { resetServerStateForTests } from "./rpc/serverState"; | 6 | import { resetServerStateForTests } from "./rpc/serverState"; |
| 6 | import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState"; | 7 | import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState"; |
| ... | @@ -115,6 +116,7 @@ export function createLocalApi(rpcClient: WsRpcClient): LocalApi { | ... | @@ -115,6 +116,7 @@ export function createLocalApi(rpcClient: WsRpcClient): LocalApi { |
| 115 | upsertKeybinding: rpcClient.server.upsertKeybinding, | 116 | upsertKeybinding: rpcClient.server.upsertKeybinding, |
| 116 | getSettings: rpcClient.server.getSettings, | 117 | getSettings: rpcClient.server.getSettings, |
| 117 | updateSettings: rpcClient.server.updateSettings, | 118 | updateSettings: rpcClient.server.updateSettings, |
| 119 | discoverSourceControl: rpcClient.server.discoverSourceControl, | ||
| 118 | }, | 120 | }, |
| 119 | }; | 121 | }; |
| 120 | } | 122 | } |
| ... | @@ -146,6 +148,7 @@ export async function __resetLocalApiForTests() { | ... | @@ -146,6 +148,7 @@ export async function __resetLocalApiForTests() { |
| 146 | __resetClientSettingsPersistenceForTests(); | 148 | __resetClientSettingsPersistenceForTests(); |
| 147 | await resetEnvironmentServiceForTests(); | 149 | await resetEnvironmentServiceForTests(); |
| 148 | resetGitStatusStateForTests(); | 150 | resetGitStatusStateForTests(); |
| 151 | resetSourceControlDiscoveryStateForTests(); | ||
| 149 | resetRequestLatencyStateForTests(); | 152 | resetRequestLatencyStateForTests(); |
| 150 | resetSavedEnvironmentRegistryStoreForTests(); | 153 | resetSavedEnvironmentRegistryStoreForTests(); |
| 151 | resetSavedEnvironmentRuntimeStoreForTests(); | 154 | resetSavedEnvironmentRuntimeStoreForTests(); |
apps/web/src/pullRequestReference.test.ts+32| ... | @@ -9,6 +9,24 @@ describe("parsePullRequestReference", () => { | ... | @@ -9,6 +9,24 @@ describe("parsePullRequestReference", () => { |
| 9 | ); | 9 | ); |
| 10 | }); | 10 | }); |
| 11 | 11 | ||
| 12 | it("accepts Azure DevOps pull request URLs", () => { | ||
| 13 | expect( | ||
| 14 | parsePullRequestReference("https://dev.azure.com/acme/project/_git/t3code/pullrequest/42"), | ||
| 15 | ).toBe("https://dev.azure.com/acme/project/_git/t3code/pullrequest/42"); | ||
| 16 | }); | ||
| 17 | |||
| 18 | it("accepts GitLab merge request URLs", () => { | ||
| 19 | expect(parsePullRequestReference("https://gitlab.com/group/project/-/merge_requests/42")).toBe( | ||
| 20 | "https://gitlab.com/group/project/-/merge_requests/42", | ||
| 21 | ); | ||
| 22 | }); | ||
| 23 | |||
| 24 | it("accepts legacy Azure DevOps pull request URLs", () => { | ||
| 25 | expect( | ||
| 26 | parsePullRequestReference("https://acme.visualstudio.com/project/_git/t3code/pullrequest/42"), | ||
| 27 | ).toBe("https://acme.visualstudio.com/project/_git/t3code/pullrequest/42"); | ||
| 28 | }); | ||
| 29 | |||
| 12 | it("accepts raw numbers", () => { | 30 | it("accepts raw numbers", () => { |
| 13 | expect(parsePullRequestReference("42")).toBe("42"); | 31 | expect(parsePullRequestReference("42")).toBe("42"); |
| 14 | }); | 32 | }); |
| ... | @@ -31,6 +49,20 @@ describe("parsePullRequestReference", () => { | ... | @@ -31,6 +49,20 @@ describe("parsePullRequestReference", () => { |
| 31 | ).toBe("https://github.com/pingdotgg/t3code/pull/42"); | 49 | ).toBe("https://github.com/pingdotgg/t3code/pull/42"); |
| 32 | }); | 50 | }); |
| 33 | 51 | ||
| 52 | it("accepts glab mr checkout commands with raw numbers", () => { | ||
| 53 | expect(parsePullRequestReference("glab mr checkout 42")).toBe("42"); | ||
| 54 | }); | ||
| 55 | |||
| 56 | it("accepts az repos pr checkout commands with raw numbers", () => { | ||
| 57 | expect(parsePullRequestReference("az repos pr checkout --id 42")).toBe("42"); | ||
| 58 | }); | ||
| 59 | |||
| 60 | it("accepts az repos pr checkout commands with extra flags", () => { | ||
| 61 | expect(parsePullRequestReference("az repos pr checkout --id 42 --remote-name origin")).toBe( | ||
| 62 | "42", | ||
| 63 | ); | ||
| 64 | }); | ||
| 65 | |||
| 34 | it("rejects non-pull-request input", () => { | 66 | it("rejects non-pull-request input", () => { |
| 35 | expect(parsePullRequestReference("feature/my-branch")).toBeNull(); | 67 | expect(parsePullRequestReference("feature/my-branch")).toBeNull(); |
| 36 | }); | 68 | }); |
apps/web/src/pullRequestReference.ts+28-2| ... | @@ -1,7 +1,22 @@ | ... | @@ -1,7 +1,22 @@ |
| 1 | const GITHUB_PULL_REQUEST_URL_PATTERN = | 1 | const GITHUB_PULL_REQUEST_URL_PATTERN = |
| 2 | /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; | 2 | /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; |
| 3 | const GITLAB_MERGE_REQUEST_URL_PATTERN = | ||
| 4 | /^https:\/\/[^/\s]*gitlab[^/\s]*\/.+\/-\/merge_requests\/(\d+)(?:[/?#].*)?$/i; | ||
| 5 | const AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN = | ||
| 6 | /^https:\/\/(?:dev\.azure\.com\/[^/\s]+\/[^/\s]+|[^/\s]+\.visualstudio\.com\/[^/\s]+)\/_git\/[^/\s]+\/pullrequest\/(\d+)(?:[/?#].*)?$/i; | ||
| 3 | const PULL_REQUEST_NUMBER_PATTERN = /^#?(\d+)$/; | 7 | const PULL_REQUEST_NUMBER_PATTERN = /^#?(\d+)$/; |
| 4 | const GITHUB_CLI_PR_CHECKOUT_PATTERN = /^gh\s+pr\s+checkout\s+(.+)$/i; | 8 | const GITHUB_CLI_PR_CHECKOUT_PATTERN = /^gh\s+pr\s+checkout\s+(.+)$/i; |
| 9 | const GITLAB_CLI_MR_CHECKOUT_PATTERN = /^glab\s+mr\s+checkout\s+(.+)$/i; | ||
| 10 | const AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN = /^az\s+repos\s+pr\s+checkout\s+(.+)$/i; | ||
| 11 | |||
| 12 | function parseAzureDevOpsCheckoutReference(args: string): string | null { | ||
| 13 | const parts = args.trim().split(/\s+/).filter(Boolean); | ||
| 14 | const idFlagIndex = parts.findIndex((part) => part === "--id" || part === "-i"); | ||
| 15 | if (idFlagIndex >= 0) { | ||
| 16 | return parts[idFlagIndex + 1] ?? null; | ||
| 17 | } | ||
| 18 | return parts.find((part) => !part.startsWith("-")) ?? null; | ||
| 19 | } | ||
| 5 | 20 | ||
| 6 | export function parsePullRequestReference(input: string): string | null { | 21 | export function parsePullRequestReference(input: string): string | null { |
| 7 | const trimmed = input.trim(); | 22 | const trimmed = input.trim(); |
| ... | @@ -10,12 +25,23 @@ export function parsePullRequestReference(input: string): string | null { | ... | @@ -10,12 +25,23 @@ export function parsePullRequestReference(input: string): string | null { |
| 10 | } | 25 | } |
| 11 | 26 | ||
| 12 | const ghCliCheckoutMatch = GITHUB_CLI_PR_CHECKOUT_PATTERN.exec(trimmed); | 27 | const ghCliCheckoutMatch = GITHUB_CLI_PR_CHECKOUT_PATTERN.exec(trimmed); |
| 13 | const normalizedInput = ghCliCheckoutMatch?.[1]?.trim() ?? trimmed; | 28 | const glabCliCheckoutMatch = GITLAB_CLI_MR_CHECKOUT_PATTERN.exec(trimmed); |
| 29 | const azureDevOpsCliCheckoutMatch = AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN.exec(trimmed); | ||
| 30 | const normalizedInput = | ||
| 31 | ghCliCheckoutMatch?.[1]?.trim() ?? | ||
| 32 | glabCliCheckoutMatch?.[1]?.trim() ?? | ||
| 33 | (azureDevOpsCliCheckoutMatch?.[1] | ||
| 34 | ? parseAzureDevOpsCheckoutReference(azureDevOpsCliCheckoutMatch[1]) | ||
| 35 | : null) ?? | ||
| 36 | trimmed; | ||
| 14 | if (normalizedInput.length === 0) { | 37 | if (normalizedInput.length === 0) { |
| 15 | return null; | 38 | return null; |
| 16 | } | 39 | } |
| 17 | 40 | ||
| 18 | const urlMatch = GITHUB_PULL_REQUEST_URL_PATTERN.exec(normalizedInput); | 41 | const urlMatch = |
| 42 | GITHUB_PULL_REQUEST_URL_PATTERN.exec(normalizedInput) ?? | ||
| 43 | GITLAB_MERGE_REQUEST_URL_PATTERN.exec(normalizedInput) ?? | ||
| 44 | AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN.exec(normalizedInput); | ||
| 19 | if (urlMatch?.[1]) { | 45 | if (urlMatch?.[1]) { |
| 20 | return normalizedInput; | 46 | return normalizedInput; |
| 21 | } | 47 | } |
apps/web/src/routeTree.gen.ts+21| ... | @@ -13,6 +13,7 @@ import { Route as SettingsRouteImport } from './routes/settings' | ... | @@ -13,6 +13,7 @@ import { Route as SettingsRouteImport } from './routes/settings' |
| 13 | import { Route as PairRouteImport } from './routes/pair' | 13 | import { Route as PairRouteImport } from './routes/pair' |
| 14 | import { Route as ChatRouteImport } from './routes/_chat' | 14 | import { Route as ChatRouteImport } from './routes/_chat' |
| 15 | import { Route as ChatIndexRouteImport } from './routes/_chat.index' | 15 | import { Route as ChatIndexRouteImport } from './routes/_chat.index' |
| 16 | import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' | ||
| 16 | import { Route as SettingsGeneralRouteImport } from './routes/settings.general' | 17 | import { Route as SettingsGeneralRouteImport } from './routes/settings.general' |
| 17 | import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' | 18 | import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' |
| 18 | import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' | 19 | import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' |
| ... | @@ -38,6 +39,11 @@ const ChatIndexRoute = ChatIndexRouteImport.update({ | ... | @@ -38,6 +39,11 @@ const ChatIndexRoute = ChatIndexRouteImport.update({ |
| 38 | path: '/', | 39 | path: '/', |
| 39 | getParentRoute: () => ChatRoute, | 40 | getParentRoute: () => ChatRoute, |
| 40 | } as any) | 41 | } as any) |
| 42 | const SettingsSourceControlRoute = SettingsSourceControlRouteImport.update({ | ||
| 43 | id: '/source-control', | ||
| 44 | path: '/source-control', | ||
| 45 | getParentRoute: () => SettingsRoute, | ||
| 46 | } as any) | ||
| 41 | const SettingsGeneralRoute = SettingsGeneralRouteImport.update({ | 47 | const SettingsGeneralRoute = SettingsGeneralRouteImport.update({ |
| 42 | id: '/general', | 48 | id: '/general', |
| 43 | path: '/general', | 49 | path: '/general', |
| ... | @@ -72,6 +78,7 @@ export interface FileRoutesByFullPath { | ... | @@ -72,6 +78,7 @@ export interface FileRoutesByFullPath { |
| 72 | '/settings/archived': typeof SettingsArchivedRoute | 78 | '/settings/archived': typeof SettingsArchivedRoute |
| 73 | '/settings/connections': typeof SettingsConnectionsRoute | 79 | '/settings/connections': typeof SettingsConnectionsRoute |
| 74 | '/settings/general': typeof SettingsGeneralRoute | 80 | '/settings/general': typeof SettingsGeneralRoute |
| 81 | '/settings/source-control': typeof SettingsSourceControlRoute | ||
| 75 | '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute | 82 | '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute |
| 76 | '/draft/$draftId': typeof ChatDraftDraftIdRoute | 83 | '/draft/$draftId': typeof ChatDraftDraftIdRoute |
| 77 | } | 84 | } |
| ... | @@ -81,6 +88,7 @@ export interface FileRoutesByTo { | ... | @@ -81,6 +88,7 @@ export interface FileRoutesByTo { |
| 81 | '/settings/archived': typeof SettingsArchivedRoute | 88 | '/settings/archived': typeof SettingsArchivedRoute |
| 82 | '/settings/connections': typeof SettingsConnectionsRoute | 89 | '/settings/connections': typeof SettingsConnectionsRoute |
| 83 | '/settings/general': typeof SettingsGeneralRoute | 90 | '/settings/general': typeof SettingsGeneralRoute |
| 91 | '/settings/source-control': typeof SettingsSourceControlRoute | ||
| 84 | '/': typeof ChatIndexRoute | 92 | '/': typeof ChatIndexRoute |
| 85 | '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute | 93 | '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute |
| 86 | '/draft/$draftId': typeof ChatDraftDraftIdRoute | 94 | '/draft/$draftId': typeof ChatDraftDraftIdRoute |
| ... | @@ -93,6 +101,7 @@ export interface FileRoutesById { | ... | @@ -93,6 +101,7 @@ export interface FileRoutesById { |
| 93 | '/settings/archived': typeof SettingsArchivedRoute | 101 | '/settings/archived': typeof SettingsArchivedRoute |
| 94 | '/settings/connections': typeof SettingsConnectionsRoute | 102 | '/settings/connections': typeof SettingsConnectionsRoute |
| 95 | '/settings/general': typeof SettingsGeneralRoute | 103 | '/settings/general': typeof SettingsGeneralRoute |
| 104 | '/settings/source-control': typeof SettingsSourceControlRoute | ||
| 96 | '/_chat/': typeof ChatIndexRoute | 105 | '/_chat/': typeof ChatIndexRoute |
| 97 | '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute | 106 | '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute |
| 98 | '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute | 107 | '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute |
| ... | @@ -106,6 +115,7 @@ export interface FileRouteTypes { | ... | @@ -106,6 +115,7 @@ export interface FileRouteTypes { |
| 106 | | '/settings/archived' | 115 | | '/settings/archived' |
| 107 | | '/settings/connections' | 116 | | '/settings/connections' |
| 108 | | '/settings/general' | 117 | | '/settings/general' |
| 118 | | '/settings/source-control' | ||
| 109 | | '/$environmentId/$threadId' | 119 | | '/$environmentId/$threadId' |
| 110 | | '/draft/$draftId' | 120 | | '/draft/$draftId' |
| 111 | fileRoutesByTo: FileRoutesByTo | 121 | fileRoutesByTo: FileRoutesByTo |
| ... | @@ -115,6 +125,7 @@ export interface FileRouteTypes { | ... | @@ -115,6 +125,7 @@ export interface FileRouteTypes { |
| 115 | | '/settings/archived' | 125 | | '/settings/archived' |
| 116 | | '/settings/connections' | 126 | | '/settings/connections' |
| 117 | | '/settings/general' | 127 | | '/settings/general' |
| 128 | | '/settings/source-control' | ||
| 118 | | '/' | 129 | | '/' |
| 119 | | '/$environmentId/$threadId' | 130 | | '/$environmentId/$threadId' |
| 120 | | '/draft/$draftId' | 131 | | '/draft/$draftId' |
| ... | @@ -126,6 +137,7 @@ export interface FileRouteTypes { | ... | @@ -126,6 +137,7 @@ export interface FileRouteTypes { |
| 126 | | '/settings/archived' | 137 | | '/settings/archived' |
| 127 | | '/settings/connections' | 138 | | '/settings/connections' |
| 128 | | '/settings/general' | 139 | | '/settings/general' |
| 140 | | '/settings/source-control' | ||
| 129 | | '/_chat/' | 141 | | '/_chat/' |
| 130 | | '/_chat/$environmentId/$threadId' | 142 | | '/_chat/$environmentId/$threadId' |
| 131 | | '/_chat/draft/$draftId' | 143 | | '/_chat/draft/$draftId' |
| ... | @@ -167,6 +179,13 @@ declare module '@tanstack/react-router' { | ... | @@ -167,6 +179,13 @@ declare module '@tanstack/react-router' { |
| 167 | preLoaderRoute: typeof ChatIndexRouteImport | 179 | preLoaderRoute: typeof ChatIndexRouteImport |
| 168 | parentRoute: typeof ChatRoute | 180 | parentRoute: typeof ChatRoute |
| 169 | } | 181 | } |
| 182 | '/settings/source-control': { | ||
| 183 | id: '/settings/source-control' | ||
| 184 | path: '/source-control' | ||
| 185 | fullPath: '/settings/source-control' | ||
| 186 | preLoaderRoute: typeof SettingsSourceControlRouteImport | ||
| 187 | parentRoute: typeof SettingsRoute | ||
| 188 | } | ||
| 170 | '/settings/general': { | 189 | '/settings/general': { |
| 171 | id: '/settings/general' | 190 | id: '/settings/general' |
| 172 | path: '/general' | 191 | path: '/general' |
| ... | @@ -223,12 +242,14 @@ interface SettingsRouteChildren { | ... | @@ -223,12 +242,14 @@ interface SettingsRouteChildren { |
| 223 | SettingsArchivedRoute: typeof SettingsArchivedRoute | 242 | SettingsArchivedRoute: typeof SettingsArchivedRoute |
| 224 | SettingsConnectionsRoute: typeof SettingsConnectionsRoute | 243 | SettingsConnectionsRoute: typeof SettingsConnectionsRoute |
| 225 | SettingsGeneralRoute: typeof SettingsGeneralRoute | 244 | SettingsGeneralRoute: typeof SettingsGeneralRoute |
| 245 | SettingsSourceControlRoute: typeof SettingsSourceControlRoute | ||
| 226 | } | 246 | } |
| 227 | 247 | ||
| 228 | const SettingsRouteChildren: SettingsRouteChildren = { | 248 | const SettingsRouteChildren: SettingsRouteChildren = { |
| 229 | SettingsArchivedRoute: SettingsArchivedRoute, | 249 | SettingsArchivedRoute: SettingsArchivedRoute, |
| 230 | SettingsConnectionsRoute: SettingsConnectionsRoute, | 250 | SettingsConnectionsRoute: SettingsConnectionsRoute, |
| 231 | SettingsGeneralRoute: SettingsGeneralRoute, | 251 | SettingsGeneralRoute: SettingsGeneralRoute, |
| 252 | SettingsSourceControlRoute: SettingsSourceControlRoute, | ||
| 232 | } | 253 | } |
| 233 | 254 | ||
| 234 | const SettingsRouteWithChildren = SettingsRoute._addFileChildren( | 255 | const SettingsRouteWithChildren = SettingsRoute._addFileChildren( |
apps/web/src/routes/settings.source-control.tsx created+7| ... | @@ -0,0 +1,7 @@ | ||
| 1 | import { createFileRoute } from "@tanstack/react-router"; | ||
| 2 | |||
| 3 | import { SourceControlSettingsPanel } from "../components/settings/SourceControlSettings"; | ||
| 4 | |||
| 5 | export const Route = createFileRoute("/settings/source-control")({ | ||
| 6 | component: SourceControlSettingsPanel, | ||
| 7 | }); | ||
apps/web/src/rpc/wsRpcClient.test.ts+11-11| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import type { | 1 | import type { |
| 2 | GitStatusLocalResult, | 2 | VcsStatusLocalResult, |
| 3 | GitStatusRemoteResult, | 3 | VcsStatusRemoteResult, |
| 4 | GitStatusStreamEvent, | 4 | VcsStatusStreamEvent, |
| 5 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| 6 | import { describe, expect, it, vi } from "vitest"; | 6 | import { describe, expect, it, vi } from "vitest"; |
| 7 | 7 | ||
| ... | @@ -18,16 +18,16 @@ vi.mock("./wsTransport", () => ({ | ... | @@ -18,16 +18,16 @@ vi.mock("./wsTransport", () => ({ |
| 18 | import { createWsRpcClient } from "./wsRpcClient"; | 18 | import { createWsRpcClient } from "./wsRpcClient"; |
| 19 | import { type WsTransport } from "./wsTransport"; | 19 | import { type WsTransport } from "./wsTransport"; |
| 20 | 20 | ||
| 21 | const baseLocalStatus: GitStatusLocalResult = { | 21 | const baseLocalStatus: VcsStatusLocalResult = { |
| 22 | isRepo: true, | 22 | isRepo: true, |
| 23 | hasOriginRemote: true, | 23 | hasPrimaryRemote: true, |
| 24 | isDefaultBranch: false, | 24 | isDefaultRef: false, |
| 25 | branch: "feature/demo", | 25 | refName: "feature/demo", |
| 26 | hasWorkingTreeChanges: false, | 26 | hasWorkingTreeChanges: false, |
| 27 | workingTree: { files: [], insertions: 0, deletions: 0 }, | 27 | workingTree: { files: [], insertions: 0, deletions: 0 }, |
| 28 | }; | 28 | }; |
| 29 | 29 | ||
| 30 | const baseRemoteStatus: GitStatusRemoteResult = { | 30 | const baseRemoteStatus: VcsStatusRemoteResult = { |
| 31 | hasUpstream: true, | 31 | hasUpstream: true, |
| 32 | aheadCount: 0, | 32 | aheadCount: 0, |
| 33 | behindCount: 0, | 33 | behindCount: 0, |
| ... | @@ -35,7 +35,7 @@ const baseRemoteStatus: GitStatusRemoteResult = { | ... | @@ -35,7 +35,7 @@ const baseRemoteStatus: GitStatusRemoteResult = { |
| 35 | }; | 35 | }; |
| 36 | 36 | ||
| 37 | describe("wsRpcClient", () => { | 37 | describe("wsRpcClient", () => { |
| 38 | it("reduces git status stream events into flat status snapshots", () => { | 38 | it("reduces vcs status stream events into flat status snapshots", () => { |
| 39 | const subscribe = vi.fn(<TValue>(_connect: unknown, listener: (value: TValue) => void) => { | 39 | const subscribe = vi.fn(<TValue>(_connect: unknown, listener: (value: TValue) => void) => { |
| 40 | for (const event of [ | 40 | for (const event of [ |
| 41 | { | 41 | { |
| ... | @@ -54,7 +54,7 @@ describe("wsRpcClient", () => { | ... | @@ -54,7 +54,7 @@ describe("wsRpcClient", () => { |
| 54 | hasWorkingTreeChanges: true, | 54 | hasWorkingTreeChanges: true, |
| 55 | }, | 55 | }, |
| 56 | }, | 56 | }, |
| 57 | ] satisfies GitStatusStreamEvent[]) { | 57 | ] satisfies VcsStatusStreamEvent[]) { |
| 58 | listener(event as TValue); | 58 | listener(event as TValue); |
| 59 | } | 59 | } |
| 60 | return () => undefined; | 60 | return () => undefined; |
| ... | @@ -74,7 +74,7 @@ describe("wsRpcClient", () => { | ... | @@ -74,7 +74,7 @@ describe("wsRpcClient", () => { |
| 74 | const client = createWsRpcClient(transport as unknown as WsTransport); | 74 | const client = createWsRpcClient(transport as unknown as WsTransport); |
| 75 | const listener = vi.fn(); | 75 | const listener = vi.fn(); |
| 76 | 76 | ||
| 77 | client.git.onStatus({ cwd: "/repo" }, listener); | 77 | client.vcs.onStatus({ cwd: "/repo" }, listener); |
| 78 | 78 | ||
| 79 | expect(listener.mock.calls).toEqual([ | 79 | expect(listener.mock.calls).toEqual([ |
| 80 | [ | 80 | [ |
apps/web/src/rpc/wsRpcClient.ts+39-29| ... | @@ -2,8 +2,8 @@ import { | ... | @@ -2,8 +2,8 @@ import { |
| 2 | type GitActionProgressEvent, | 2 | type GitActionProgressEvent, |
| 3 | type GitRunStackedActionInput, | 3 | type GitRunStackedActionInput, |
| 4 | type GitRunStackedActionResult, | 4 | type GitRunStackedActionResult, |
| 5 | type GitStatusResult, | 5 | type VcsStatusResult, |
| 6 | type GitStatusStreamEvent, | 6 | type VcsStatusStreamEvent, |
| 7 | type LocalApi, | 7 | type LocalApi, |
| 8 | ORCHESTRATION_WS_METHODS, | 8 | ORCHESTRATION_WS_METHODS, |
| 9 | type ServerSettingsPatch, | 9 | type ServerSettingsPatch, |
| ... | @@ -77,24 +77,29 @@ export interface WsRpcClient { | ... | @@ -77,24 +77,29 @@ export interface WsRpcClient { |
| 77 | readonly editor: Parameters<LocalApi["shell"]["openInEditor"]>[1]; | 77 | readonly editor: Parameters<LocalApi["shell"]["openInEditor"]>[1]; |
| 78 | }) => ReturnType<LocalApi["shell"]["openInEditor"]>; | 78 | }) => ReturnType<LocalApi["shell"]["openInEditor"]>; |
| 79 | }; | 79 | }; |
| 80 | readonly git: { | 80 | readonly vcs: { |
| 81 | readonly pull: RpcUnaryMethod<typeof WS_METHODS.gitPull>; | 81 | readonly pull: RpcUnaryMethod<typeof WS_METHODS.vcsPull>; |
| 82 | readonly refreshStatus: RpcUnaryMethod<typeof WS_METHODS.gitRefreshStatus>; | 82 | readonly refreshStatus: RpcUnaryMethod<typeof WS_METHODS.vcsRefreshStatus>; |
| 83 | readonly onStatus: ( | 83 | readonly onStatus: ( |
| 84 | input: RpcInput<typeof WS_METHODS.subscribeGitStatus>, | 84 | input: RpcInput<typeof WS_METHODS.subscribeVcsStatus>, |
| 85 | listener: (status: GitStatusResult) => void, | 85 | listener: (status: VcsStatusResult) => void, |
| 86 | options?: StreamSubscriptionOptions, | 86 | options?: StreamSubscriptionOptions, |
| 87 | ) => () => void; | 87 | ) => () => void; |
| 88 | readonly listRefs: RpcUnaryMethod<typeof WS_METHODS.vcsListRefs>; | ||
| 89 | readonly createWorktree: RpcUnaryMethod<typeof WS_METHODS.vcsCreateWorktree>; | ||
| 90 | readonly removeWorktree: RpcUnaryMethod<typeof WS_METHODS.vcsRemoveWorktree>; | ||
| 91 | readonly createRef: RpcUnaryMethod<typeof WS_METHODS.vcsCreateRef>; | ||
| 92 | readonly switchRef: RpcUnaryMethod<typeof WS_METHODS.vcsSwitchRef>; | ||
| 93 | readonly init: RpcUnaryMethod<typeof WS_METHODS.vcsInit>; | ||
| 94 | }; | ||
| 95 | /** | ||
| 96 | * Git-specific workflows. Local repository mechanics live under `vcs`. | ||
| 97 | */ | ||
| 98 | readonly git: { | ||
| 88 | readonly runStackedAction: ( | 99 | readonly runStackedAction: ( |
| 89 | input: GitRunStackedActionInput, | 100 | input: GitRunStackedActionInput, |
| 90 | options?: GitRunStackedActionOptions, | 101 | options?: GitRunStackedActionOptions, |
| 91 | ) => Promise<GitRunStackedActionResult>; | 102 | ) => Promise<GitRunStackedActionResult>; |
| 92 | readonly listBranches: RpcUnaryMethod<typeof WS_METHODS.gitListBranches>; | ||
| 93 | readonly createWorktree: RpcUnaryMethod<typeof WS_METHODS.gitCreateWorktree>; | ||
| 94 | readonly removeWorktree: RpcUnaryMethod<typeof WS_METHODS.gitRemoveWorktree>; | ||
| 95 | readonly createBranch: RpcUnaryMethod<typeof WS_METHODS.gitCreateBranch>; | ||
| 96 | readonly checkout: RpcUnaryMethod<typeof WS_METHODS.gitCheckout>; | ||
| 97 | readonly init: RpcUnaryMethod<typeof WS_METHODS.gitInit>; | ||
| 98 | readonly resolvePullRequest: RpcUnaryMethod<typeof WS_METHODS.gitResolvePullRequest>; | 103 | readonly resolvePullRequest: RpcUnaryMethod<typeof WS_METHODS.gitResolvePullRequest>; |
| 99 | readonly preparePullRequestThread: RpcUnaryMethod< | 104 | readonly preparePullRequestThread: RpcUnaryMethod< |
| 100 | typeof WS_METHODS.gitPreparePullRequestThread | 105 | typeof WS_METHODS.gitPreparePullRequestThread |
| ... | @@ -114,6 +119,9 @@ export interface WsRpcClient { | ... | @@ -114,6 +119,9 @@ export interface WsRpcClient { |
| 114 | readonly updateSettings: ( | 119 | readonly updateSettings: ( |
| 115 | patch: ServerSettingsPatch, | 120 | patch: ServerSettingsPatch, |
| 116 | ) => ReturnType<RpcUnaryMethod<typeof WS_METHODS.serverUpdateSettings>>; | 121 | ) => ReturnType<RpcUnaryMethod<typeof WS_METHODS.serverUpdateSettings>>; |
| 122 | readonly discoverSourceControl: RpcUnaryNoArgMethod< | ||
| 123 | typeof WS_METHODS.serverDiscoverSourceControl | ||
| 124 | >; | ||
| 117 | readonly subscribeConfig: RpcStreamMethod<typeof WS_METHODS.subscribeServerConfig>; | 125 | readonly subscribeConfig: RpcStreamMethod<typeof WS_METHODS.subscribeServerConfig>; |
| 118 | readonly subscribeLifecycle: RpcStreamMethod<typeof WS_METHODS.subscribeServerLifecycle>; | 126 | readonly subscribeLifecycle: RpcStreamMethod<typeof WS_METHODS.subscribeServerLifecycle>; |
| 119 | readonly subscribeAuthAccess: RpcStreamMethod<typeof WS_METHODS.subscribeAuthAccess>; | 127 | readonly subscribeAuthAccess: RpcStreamMethod<typeof WS_METHODS.subscribeAuthAccess>; |
| ... | @@ -161,21 +169,31 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { | ... | @@ -161,21 +169,31 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { |
| 161 | openInEditor: (input) => | 169 | openInEditor: (input) => |
| 162 | transport.request((client) => client[WS_METHODS.shellOpenInEditor](input)), | 170 | transport.request((client) => client[WS_METHODS.shellOpenInEditor](input)), |
| 163 | }, | 171 | }, |
| 164 | git: { | 172 | vcs: { |
| 165 | pull: (input) => transport.request((client) => client[WS_METHODS.gitPull](input)), | 173 | pull: (input) => transport.request((client) => client[WS_METHODS.vcsPull](input)), |
| 166 | refreshStatus: (input) => | 174 | refreshStatus: (input) => |
| 167 | transport.request((client) => client[WS_METHODS.gitRefreshStatus](input)), | 175 | transport.request((client) => client[WS_METHODS.vcsRefreshStatus](input)), |
| 168 | onStatus: (input, listener, options) => { | 176 | onStatus: (input, listener, options) => { |
| 169 | let current: GitStatusResult | null = null; | 177 | let current: VcsStatusResult | null = null; |
| 170 | return transport.subscribe( | 178 | return transport.subscribe( |
| 171 | (client) => client[WS_METHODS.subscribeGitStatus](input), | 179 | (client) => client[WS_METHODS.subscribeVcsStatus](input), |
| 172 | (event: GitStatusStreamEvent) => { | 180 | (event: VcsStatusStreamEvent) => { |
| 173 | current = applyGitStatusStreamEvent(current, event); | 181 | current = applyGitStatusStreamEvent(current, event); |
| 174 | listener(current); | 182 | listener(current); |
| 175 | }, | 183 | }, |
| 176 | options, | 184 | options, |
| 177 | ); | 185 | ); |
| 178 | }, | 186 | }, |
| 187 | listRefs: (input) => transport.request((client) => client[WS_METHODS.vcsListRefs](input)), | ||
| 188 | createWorktree: (input) => | ||
| 189 | transport.request((client) => client[WS_METHODS.vcsCreateWorktree](input)), | ||
| 190 | removeWorktree: (input) => | ||
| 191 | transport.request((client) => client[WS_METHODS.vcsRemoveWorktree](input)), | ||
| 192 | createRef: (input) => transport.request((client) => client[WS_METHODS.vcsCreateRef](input)), | ||
| 193 | switchRef: (input) => transport.request((client) => client[WS_METHODS.vcsSwitchRef](input)), | ||
| 194 | init: (input) => transport.request((client) => client[WS_METHODS.vcsInit](input)), | ||
| 195 | }, | ||
| 196 | git: { | ||
| 179 | runStackedAction: async (input, options) => { | 197 | runStackedAction: async (input, options) => { |
| 180 | let result: GitRunStackedActionResult | null = null; | 198 | let result: GitRunStackedActionResult | null = null; |
| 181 | 199 | ||
| ... | @@ -195,16 +213,6 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { | ... | @@ -195,16 +213,6 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { |
| 195 | 213 | ||
| 196 | throw new Error("Git action stream completed without a final result."); | 214 | throw new Error("Git action stream completed without a final result."); |
| 197 | }, | 215 | }, |
| 198 | listBranches: (input) => | ||
| 199 | transport.request((client) => client[WS_METHODS.gitListBranches](input)), | ||
| 200 | createWorktree: (input) => | ||
| 201 | transport.request((client) => client[WS_METHODS.gitCreateWorktree](input)), | ||
| 202 | removeWorktree: (input) => | ||
| 203 | transport.request((client) => client[WS_METHODS.gitRemoveWorktree](input)), | ||
| 204 | createBranch: (input) => | ||
| 205 | transport.request((client) => client[WS_METHODS.gitCreateBranch](input)), | ||
| 206 | checkout: (input) => transport.request((client) => client[WS_METHODS.gitCheckout](input)), | ||
| 207 | init: (input) => transport.request((client) => client[WS_METHODS.gitInit](input)), | ||
| 208 | resolvePullRequest: (input) => | 216 | resolvePullRequest: (input) => |
| 209 | transport.request((client) => client[WS_METHODS.gitResolvePullRequest](input)), | 217 | transport.request((client) => client[WS_METHODS.gitResolvePullRequest](input)), |
| 210 | preparePullRequestThread: (input) => | 218 | preparePullRequestThread: (input) => |
| ... | @@ -219,6 +227,8 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { | ... | @@ -219,6 +227,8 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { |
| 219 | getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})), | 227 | getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})), |
| 220 | updateSettings: (patch) => | 228 | updateSettings: (patch) => |
| 221 | transport.request((client) => client[WS_METHODS.serverUpdateSettings]({ patch })), | 229 | transport.request((client) => client[WS_METHODS.serverUpdateSettings]({ patch })), |
| 230 | discoverSourceControl: () => | ||
| 231 | transport.request((client) => client[WS_METHODS.serverDiscoverSourceControl]({})), | ||
| 222 | subscribeConfig: (listener, options) => | 232 | subscribeConfig: (listener, options) => |
| 223 | transport.subscribe( | 233 | transport.subscribe( |
| 224 | (client) => client[WS_METHODS.subscribeServerConfig]({}), | 234 | (client) => client[WS_METHODS.subscribeServerConfig]({}), |
apps/web/src/sourceControlPresentation.ts created+62| ... | @@ -0,0 +1,62 @@ | ||
| 1 | import { GitPullRequestIcon } from "lucide-react"; | ||
| 2 | import type { ElementType } from "react"; | ||
| 3 | import type { SourceControlProviderInfo } from "@t3tools/contracts"; | ||
| 4 | export { | ||
| 5 | DEFAULT_CHANGE_REQUEST_TERMINOLOGY, | ||
| 6 | formatChangeRequestAction, | ||
| 7 | formatCreateChangeRequestPhrase, | ||
| 8 | getChangeRequestTerminology, | ||
| 9 | resolveChangeRequestPresentation, | ||
| 10 | type ChangeRequestPresentation, | ||
| 11 | type ChangeRequestTerminology, | ||
| 12 | } from "@t3tools/shared/sourceControl"; | ||
| 13 | import { | ||
| 14 | getChangeRequestTerminology, | ||
| 15 | resolveChangeRequestPresentation, | ||
| 16 | type ChangeRequestTerminology, | ||
| 17 | } from "@t3tools/shared/sourceControl"; | ||
| 18 | import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./components/Icons"; | ||
| 19 | |||
| 20 | export interface SourceControlPresentation { | ||
| 21 | readonly providerName: string; | ||
| 22 | readonly terminology: ChangeRequestTerminology; | ||
| 23 | readonly Icon: ElementType<{ className?: string }>; | ||
| 24 | } | ||
| 25 | |||
| 26 | export function getSourceControlPresentation( | ||
| 27 | provider: SourceControlProviderInfo | null | undefined, | ||
| 28 | ): SourceControlPresentation { | ||
| 29 | const presentation = resolveChangeRequestPresentation(provider); | ||
| 30 | switch (presentation.icon) { | ||
| 31 | case "github": | ||
| 32 | return { | ||
| 33 | providerName: provider?.name || presentation.providerName, | ||
| 34 | terminology: getChangeRequestTerminology(provider), | ||
| 35 | Icon: GitHubIcon, | ||
| 36 | }; | ||
| 37 | case "gitlab": | ||
| 38 | return { | ||
| 39 | providerName: provider?.name || presentation.providerName, | ||
| 40 | terminology: getChangeRequestTerminology(provider), | ||
| 41 | Icon: GitLabIcon, | ||
| 42 | }; | ||
| 43 | case "azure-devops": | ||
| 44 | return { | ||
| 45 | providerName: provider?.name || presentation.providerName, | ||
| 46 | terminology: getChangeRequestTerminology(provider), | ||
| 47 | Icon: AzureDevOpsIcon, | ||
| 48 | }; | ||
| 49 | case "bitbucket": | ||
| 50 | return { | ||
| 51 | providerName: provider?.name || presentation.providerName, | ||
| 52 | terminology: getChangeRequestTerminology(provider), | ||
| 53 | Icon: BitbucketIcon, | ||
| 54 | }; | ||
| 55 | case "change-request": | ||
| 56 | return { | ||
| 57 | providerName: provider?.name || presentation.providerName, | ||
| 58 | terminology: getChangeRequestTerminology(provider), | ||
| 59 | Icon: GitPullRequestIcon, | ||
| 60 | }; | ||
| 61 | } | ||
| 62 | } | ||
apps/web/test/wsRpcHarness.ts+1-1| ... | @@ -26,7 +26,7 @@ const STREAM_METHODS = new Set<string>([ | ... | @@ -26,7 +26,7 @@ const STREAM_METHODS = new Set<string>([ |
| 26 | ORCHESTRATION_WS_METHODS.subscribeShell, | 26 | ORCHESTRATION_WS_METHODS.subscribeShell, |
| 27 | ORCHESTRATION_WS_METHODS.subscribeThread, | 27 | ORCHESTRATION_WS_METHODS.subscribeThread, |
| 28 | WS_METHODS.gitRunStackedAction, | 28 | WS_METHODS.gitRunStackedAction, |
| 29 | WS_METHODS.subscribeGitStatus, | 29 | WS_METHODS.subscribeVcsStatus, |
| 30 | WS_METHODS.subscribeTerminalEvents, | 30 | WS_METHODS.subscribeTerminalEvents, |
| 31 | WS_METHODS.subscribeServerConfig, | 31 | WS_METHODS.subscribeServerConfig, |
| 32 | WS_METHODS.subscribeServerLifecycle, | 32 | WS_METHODS.subscribeServerLifecycle, |
bun.lock+1| ... | @@ -131,6 +131,7 @@ | ... | @@ -131,6 +131,7 @@ |
| 131 | "version": "0.0.0-alpha.1", | 131 | "version": "0.0.0-alpha.1", |
| 132 | "dependencies": { | 132 | "dependencies": { |
| 133 | "@t3tools/contracts": "workspace:*", | 133 | "@t3tools/contracts": "workspace:*", |
| 134 | "effect": "catalog:", | ||
| 134 | }, | 135 | }, |
| 135 | "devDependencies": { | 136 | "devDependencies": { |
| 136 | "@effect/language-service": "catalog:", | 137 | "@effect/language-service": "catalog:", |
packages/client-runtime/package.json+2-1| ... | @@ -15,7 +15,8 @@ | ... | @@ -15,7 +15,8 @@ |
| 15 | "test": "vitest run" | 15 | "test": "vitest run" |
| 16 | }, | 16 | }, |
| 17 | "dependencies": { | 17 | "dependencies": { |
| 18 | "@t3tools/contracts": "workspace:*" | 18 | "@t3tools/contracts": "workspace:*", |
| 19 | "effect": "catalog:" | ||
| 19 | }, | 20 | }, |
| 20 | "devDependencies": { | 21 | "devDependencies": { |
| 21 | "@effect/language-service": "catalog:", | 22 | "@effect/language-service": "catalog:", |
packages/client-runtime/src/index.ts+1| ... | @@ -1,2 +1,3 @@ | ... | @@ -1,2 +1,3 @@ |
| 1 | export * from "./knownEnvironment.ts"; | 1 | export * from "./knownEnvironment.ts"; |
| 2 | export * from "./scoped.ts"; | 2 | export * from "./scoped.ts"; |
| 3 | export * from "./sourceControlDiscoveryState.ts"; |
packages/client-runtime/src/sourceControlDiscoveryState.test.ts created+107| ... | @@ -0,0 +1,107 @@ | ||
| 1 | import { assert, beforeEach, it } from "vitest"; | ||
| 2 | import type { SourceControlDiscoveryResult } from "@t3tools/contracts"; | ||
| 3 | import { AtomRegistry } from "effect/unstable/reactivity"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | EMPTY_SOURCE_CONTROL_DISCOVERY_STATE, | ||
| 7 | createSourceControlDiscoveryManager, | ||
| 8 | } from "./sourceControlDiscoveryState.ts"; | ||
| 9 | |||
| 10 | const EMPTY_RESULT: SourceControlDiscoveryResult = { | ||
| 11 | versionControlSystems: [], | ||
| 12 | sourceControlProviders: [], | ||
| 13 | }; | ||
| 14 | |||
| 15 | function unresolvedDiscovery() { | ||
| 16 | throw new Error("Discovery resolver was not initialized."); | ||
| 17 | } | ||
| 18 | |||
| 19 | let registry = AtomRegistry.make(); | ||
| 20 | |||
| 21 | beforeEach(() => { | ||
| 22 | registry.dispose(); | ||
| 23 | registry = AtomRegistry.make(); | ||
| 24 | }); | ||
| 25 | |||
| 26 | it("stores refreshed discovery data in an atom snapshot", async () => { | ||
| 27 | const manager = createSourceControlDiscoveryManager({ | ||
| 28 | getRegistry: () => registry, | ||
| 29 | getClient: () => ({ | ||
| 30 | discoverSourceControl: async () => EMPTY_RESULT, | ||
| 31 | }), | ||
| 32 | }); | ||
| 33 | |||
| 34 | assert.deepStrictEqual(manager.getSnapshot({ key: null }), EMPTY_SOURCE_CONTROL_DISCOVERY_STATE); | ||
| 35 | |||
| 36 | const result = await manager.refresh({ key: "primary" }); | ||
| 37 | |||
| 38 | assert.strictEqual(result, EMPTY_RESULT); | ||
| 39 | assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { | ||
| 40 | data: EMPTY_RESULT, | ||
| 41 | error: null, | ||
| 42 | isPending: false, | ||
| 43 | }); | ||
| 44 | }); | ||
| 45 | |||
| 46 | it("deduplicates in-flight discovery refreshes by target key", async () => { | ||
| 47 | let resolveDiscovery: (result: SourceControlDiscoveryResult) => void = unresolvedDiscovery; | ||
| 48 | let calls = 0; | ||
| 49 | const manager = createSourceControlDiscoveryManager({ | ||
| 50 | getRegistry: () => registry, | ||
| 51 | getClient: () => ({ | ||
| 52 | discoverSourceControl: () => { | ||
| 53 | calls += 1; | ||
| 54 | return new Promise<SourceControlDiscoveryResult>((resolve) => { | ||
| 55 | resolveDiscovery = resolve; | ||
| 56 | }); | ||
| 57 | }, | ||
| 58 | }), | ||
| 59 | }); | ||
| 60 | |||
| 61 | const first = manager.refresh({ key: "primary" }); | ||
| 62 | const second = manager.refresh({ key: "primary" }); | ||
| 63 | |||
| 64 | assert.strictEqual(first, second); | ||
| 65 | assert.strictEqual(calls, 1); | ||
| 66 | assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { | ||
| 67 | data: null, | ||
| 68 | error: null, | ||
| 69 | isPending: true, | ||
| 70 | }); | ||
| 71 | |||
| 72 | resolveDiscovery(EMPTY_RESULT); | ||
| 73 | await first; | ||
| 74 | |||
| 75 | assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { | ||
| 76 | data: EMPTY_RESULT, | ||
| 77 | error: null, | ||
| 78 | isPending: false, | ||
| 79 | }); | ||
| 80 | }); | ||
| 81 | |||
| 82 | it("keeps the previous snapshot when refresh fails", async () => { | ||
| 83 | let shouldFail = false; | ||
| 84 | const manager = createSourceControlDiscoveryManager({ | ||
| 85 | getRegistry: () => registry, | ||
| 86 | getClient: () => ({ | ||
| 87 | discoverSourceControl: async () => { | ||
| 88 | if (shouldFail) { | ||
| 89 | throw new Error("probe failed"); | ||
| 90 | } | ||
| 91 | return EMPTY_RESULT; | ||
| 92 | }, | ||
| 93 | }), | ||
| 94 | }); | ||
| 95 | |||
| 96 | await manager.refresh({ key: "primary" }); | ||
| 97 | shouldFail = true; | ||
| 98 | |||
| 99 | const result = await manager.refresh({ key: "primary" }); | ||
| 100 | |||
| 101 | assert.strictEqual(result, EMPTY_RESULT); | ||
| 102 | assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { | ||
| 103 | data: EMPTY_RESULT, | ||
| 104 | error: "probe failed", | ||
| 105 | isPending: false, | ||
| 106 | }); | ||
| 107 | }); | ||
packages/client-runtime/src/sourceControlDiscoveryState.ts created+206| ... | @@ -0,0 +1,206 @@ | ||
| 1 | import type { SourceControlDiscoveryResult } from "@t3tools/contracts"; | ||
| 2 | import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; | ||
| 3 | |||
| 4 | /* --- Types ---------------------------------------------------------- */ | ||
| 5 | |||
| 6 | export interface SourceControlDiscoveryState { | ||
| 7 | readonly data: SourceControlDiscoveryResult | null; | ||
| 8 | readonly error: string | null; | ||
| 9 | readonly isPending: boolean; | ||
| 10 | } | ||
| 11 | |||
| 12 | export interface SourceControlDiscoveryTarget { | ||
| 13 | readonly key: string | null; | ||
| 14 | } | ||
| 15 | |||
| 16 | export interface SourceControlDiscoveryClient { | ||
| 17 | readonly discoverSourceControl: () => Promise<SourceControlDiscoveryResult>; | ||
| 18 | } | ||
| 19 | |||
| 20 | /* --- Constants ------------------------------------------------------ */ | ||
| 21 | |||
| 22 | export const EMPTY_SOURCE_CONTROL_DISCOVERY_STATE = Object.freeze<SourceControlDiscoveryState>({ | ||
| 23 | data: null, | ||
| 24 | error: null, | ||
| 25 | isPending: false, | ||
| 26 | }); | ||
| 27 | |||
| 28 | const INITIAL_SOURCE_CONTROL_DISCOVERY_STATE = Object.freeze<SourceControlDiscoveryState>({ | ||
| 29 | data: null, | ||
| 30 | error: null, | ||
| 31 | isPending: true, | ||
| 32 | }); | ||
| 33 | |||
| 34 | /* --- Atoms ---------------------------------------------------------- */ | ||
| 35 | |||
| 36 | const knownSourceControlDiscoveryKeys = new Set<string>(); | ||
| 37 | |||
| 38 | export const sourceControlDiscoveryStateAtom = Atom.family((key: string) => { | ||
| 39 | knownSourceControlDiscoveryKeys.add(key); | ||
| 40 | return Atom.make(INITIAL_SOURCE_CONTROL_DISCOVERY_STATE).pipe( | ||
| 41 | Atom.keepAlive, | ||
| 42 | Atom.withLabel(`source-control-discovery:${key}`), | ||
| 43 | ); | ||
| 44 | }); | ||
| 45 | |||
| 46 | export const EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM = Atom.make( | ||
| 47 | EMPTY_SOURCE_CONTROL_DISCOVERY_STATE, | ||
| 48 | ).pipe(Atom.keepAlive, Atom.withLabel("source-control-discovery:null")); | ||
| 49 | |||
| 50 | /* --- Helpers -------------------------------------------------------- */ | ||
| 51 | |||
| 52 | export function getSourceControlDiscoveryTargetKey( | ||
| 53 | target: SourceControlDiscoveryTarget, | ||
| 54 | ): string | null { | ||
| 55 | const key = target.key?.trim(); | ||
| 56 | return key && key.length > 0 ? key : null; | ||
| 57 | } | ||
| 58 | |||
| 59 | /* --- Refresh manager ------------------------------------------------ */ | ||
| 60 | |||
| 61 | export interface SourceControlDiscoveryManagerConfig { | ||
| 62 | /** | ||
| 63 | * Get the atom registry used to read/write source-control discovery snapshots. | ||
| 64 | */ | ||
| 65 | readonly getRegistry: () => AtomRegistry.AtomRegistry; | ||
| 66 | /** | ||
| 67 | * Resolve the runtime client for a discovery target key. | ||
| 68 | * | ||
| 69 | * Web currently uses a single `"primary"` target, but keeping this keyed | ||
| 70 | * lets mobile or future multi-environment clients provide separate discovery | ||
| 71 | * clients without changing the state primitive. | ||
| 72 | */ | ||
| 73 | readonly getClient: (key: string) => SourceControlDiscoveryClient | null; | ||
| 74 | } | ||
| 75 | |||
| 76 | export function createSourceControlDiscoveryManager(config: SourceControlDiscoveryManagerConfig) { | ||
| 77 | const refreshInFlight = new Map<string, Promise<SourceControlDiscoveryResult | null>>(); | ||
| 78 | |||
| 79 | /* -- Atom helpers -------------------------------------------------- */ | ||
| 80 | |||
| 81 | function setState(targetKey: string, nextState: SourceControlDiscoveryState): void { | ||
| 82 | config.getRegistry().set(sourceControlDiscoveryStateAtom(targetKey), nextState); | ||
| 83 | } | ||
| 84 | |||
| 85 | function markPending(targetKey: string): void { | ||
| 86 | const current = config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey)); | ||
| 87 | const next: SourceControlDiscoveryState = | ||
| 88 | current.data === null | ||
| 89 | ? INITIAL_SOURCE_CONTROL_DISCOVERY_STATE | ||
| 90 | : { | ||
| 91 | data: current.data, | ||
| 92 | error: null, | ||
| 93 | isPending: true, | ||
| 94 | }; | ||
| 95 | |||
| 96 | if ( | ||
| 97 | current.data === next.data && | ||
| 98 | current.error === next.error && | ||
| 99 | current.isPending === next.isPending | ||
| 100 | ) { | ||
| 101 | return; | ||
| 102 | } | ||
| 103 | |||
| 104 | setState(targetKey, next); | ||
| 105 | } | ||
| 106 | |||
| 107 | function setData(targetKey: string, data: SourceControlDiscoveryResult): void { | ||
| 108 | setState(targetKey, { | ||
| 109 | data, | ||
| 110 | error: null, | ||
| 111 | isPending: false, | ||
| 112 | }); | ||
| 113 | } | ||
| 114 | |||
| 115 | function setError(targetKey: string, error: unknown): void { | ||
| 116 | const current = config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey)); | ||
| 117 | setState(targetKey, { | ||
| 118 | data: current.data, | ||
| 119 | error: error instanceof Error ? error.message : "Failed to discover source control tools.", | ||
| 120 | isPending: false, | ||
| 121 | }); | ||
| 122 | } | ||
| 123 | |||
| 124 | /* -- Public API ---------------------------------------------------- */ | ||
| 125 | |||
| 126 | /** | ||
| 127 | * Trigger a one-shot source-control discovery RPC for a target. | ||
| 128 | * | ||
| 129 | * Calls are deduplicated while a refresh for the same target key is in | ||
| 130 | * flight. On failure, the previous successful snapshot is kept in `data` | ||
| 131 | * and the error message is stored separately so UI can keep rendering stale | ||
| 132 | * discovery results while showing the failure. | ||
| 133 | * | ||
| 134 | * @param target The logical runtime target to refresh. | ||
| 135 | * @param client Optional pre-resolved client, useful in tests. | ||
| 136 | */ | ||
| 137 | function refresh( | ||
| 138 | target: SourceControlDiscoveryTarget, | ||
| 139 | client?: SourceControlDiscoveryClient, | ||
| 140 | ): Promise<SourceControlDiscoveryResult | null> { | ||
| 141 | const targetKey = getSourceControlDiscoveryTargetKey(target); | ||
| 142 | if (targetKey === null) { | ||
| 143 | return Promise.resolve(null); | ||
| 144 | } | ||
| 145 | |||
| 146 | const existing = refreshInFlight.get(targetKey); | ||
| 147 | if (existing) { | ||
| 148 | return existing; | ||
| 149 | } | ||
| 150 | |||
| 151 | const resolvedClient = client ?? config.getClient(targetKey); | ||
| 152 | if (!resolvedClient) { | ||
| 153 | const error = new Error("Source control discovery client is unavailable."); | ||
| 154 | setError(targetKey, error); | ||
| 155 | return Promise.resolve(getSnapshot(target).data); | ||
| 156 | } | ||
| 157 | |||
| 158 | markPending(targetKey); | ||
| 159 | const promise = resolvedClient.discoverSourceControl().then( | ||
| 160 | (result) => { | ||
| 161 | setData(targetKey, result); | ||
| 162 | return result; | ||
| 163 | }, | ||
| 164 | (error: unknown) => { | ||
| 165 | setError(targetKey, error); | ||
| 166 | return getSnapshot(target).data; | ||
| 167 | }, | ||
| 168 | ); | ||
| 169 | const tracked = promise.finally(() => refreshInFlight.delete(targetKey)); | ||
| 170 | refreshInFlight.set(targetKey, tracked); | ||
| 171 | return tracked; | ||
| 172 | } | ||
| 173 | |||
| 174 | /** | ||
| 175 | * Read the current atom snapshot for `target`. | ||
| 176 | * | ||
| 177 | * Invalid targets return the inert empty state rather than creating a new | ||
| 178 | * family atom entry. | ||
| 179 | */ | ||
| 180 | function getSnapshot(target: SourceControlDiscoveryTarget): SourceControlDiscoveryState { | ||
| 181 | const targetKey = getSourceControlDiscoveryTargetKey(target); | ||
| 182 | if (targetKey === null) { | ||
| 183 | return EMPTY_SOURCE_CONTROL_DISCOVERY_STATE; | ||
| 184 | } | ||
| 185 | |||
| 186 | return config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey)); | ||
| 187 | } | ||
| 188 | |||
| 189 | /** | ||
| 190 | * Clear in-flight refresh tracking and reset every known discovery atom. | ||
| 191 | * Primarily used by tests and runtime teardown. | ||
| 192 | */ | ||
| 193 | function reset(): void { | ||
| 194 | refreshInFlight.clear(); | ||
| 195 | for (const key of knownSourceControlDiscoveryKeys) { | ||
| 196 | setState(key, INITIAL_SOURCE_CONTROL_DISCOVERY_STATE); | ||
| 197 | } | ||
| 198 | knownSourceControlDiscoveryKeys.clear(); | ||
| 199 | } | ||
| 200 | |||
| 201 | return { | ||
| 202 | refresh, | ||
| 203 | getSnapshot, | ||
| 204 | reset, | ||
| 205 | }; | ||
| 206 | } | ||
packages/contracts/src/git.test.ts+7-7| ... | @@ -2,14 +2,14 @@ import { describe, expect, it } from "vitest"; | ... | @@ -2,14 +2,14 @@ import { describe, expect, it } from "vitest"; |
| 2 | import { Schema } from "effect"; | 2 | import { Schema } from "effect"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 5 | GitCreateWorktreeInput, | 5 | VcsCreateWorktreeInput, |
| 6 | GitPreparePullRequestThreadInput, | 6 | GitPreparePullRequestThreadInput, |
| 7 | GitRunStackedActionResult, | 7 | GitRunStackedActionResult, |
| 8 | GitRunStackedActionInput, | 8 | GitRunStackedActionInput, |
| 9 | GitResolvePullRequestResult, | 9 | GitResolvePullRequestResult, |
| 10 | } from "./git.ts"; | 10 | } from "./git.ts"; |
| 11 | 11 | ||
| 12 | const decodeCreateWorktreeInput = Schema.decodeUnknownSync(GitCreateWorktreeInput); | 12 | const decodeCreateWorktreeInput = Schema.decodeUnknownSync(VcsCreateWorktreeInput); |
| 13 | const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync( | 13 | const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync( |
| 14 | GitPreparePullRequestThreadInput, | 14 | GitPreparePullRequestThreadInput, |
| 15 | ); | 15 | ); |
| ... | @@ -17,16 +17,16 @@ const decodeRunStackedActionInput = Schema.decodeUnknownSync(GitRunStackedAction | ... | @@ -17,16 +17,16 @@ const decodeRunStackedActionInput = Schema.decodeUnknownSync(GitRunStackedAction |
| 17 | const decodeRunStackedActionResult = Schema.decodeUnknownSync(GitRunStackedActionResult); | 17 | const decodeRunStackedActionResult = Schema.decodeUnknownSync(GitRunStackedActionResult); |
| 18 | const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult); | 18 | const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult); |
| 19 | 19 | ||
| 20 | describe("GitCreateWorktreeInput", () => { | 20 | describe("VcsCreateWorktreeInput", () => { |
| 21 | it("accepts omitted newBranch for existing-branch worktrees", () => { | 21 | it("accepts omitted newRefName for existing-refName worktrees", () => { |
| 22 | const parsed = decodeCreateWorktreeInput({ | 22 | const parsed = decodeCreateWorktreeInput({ |
| 23 | cwd: "/repo", | 23 | cwd: "/repo", |
| 24 | branch: "feature/existing", | 24 | refName: "feature/existing", |
| 25 | path: "/tmp/worktree", | 25 | path: "/tmp/worktree", |
| 26 | }); | 26 | }); |
| 27 | 27 | ||
| 28 | expect(parsed.newBranch).toBeUndefined(); | 28 | expect(parsed.newRefName).toBeUndefined(); |
| 29 | expect(parsed.branch).toBe("feature/existing"); | 29 | expect(parsed.refName).toBe("feature/existing"); |
| 30 | }); | 30 | }); |
| 31 | }); | 31 | }); |
| 32 | 32 |
packages/contracts/src/git.ts+72-76| ... | @@ -1,5 +1,7 @@ | ... | @@ -1,5 +1,7 @@ |
| 1 | import { Schema } from "effect"; | 1 | import { Schema } from "effect"; |
| 2 | import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; | 2 | import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 3 | import { SourceControlProviderError, SourceControlProviderInfo } from "./sourceControl.ts"; | ||
| 4 | import { VcsDriverKind } from "./vcs.ts"; | ||
| 3 | 5 | ||
| 4 | const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; | 6 | const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; |
| 5 | const GIT_LIST_BRANCHES_MAX_LIMIT = 200; | 7 | const GIT_LIST_BRANCHES_MAX_LIMIT = 200; |
| ... | @@ -40,18 +42,10 @@ const GitPushStepStatus = Schema.Literals([ | ... | @@ -40,18 +42,10 @@ const GitPushStepStatus = Schema.Literals([ |
| 40 | ]); | 42 | ]); |
| 41 | const GitBranchStepStatus = Schema.Literals(["created", "skipped_not_requested"]); | 43 | const GitBranchStepStatus = Schema.Literals(["created", "skipped_not_requested"]); |
| 42 | const GitPrStepStatus = Schema.Literals(["created", "opened_existing", "skipped_not_requested"]); | 44 | const GitPrStepStatus = Schema.Literals(["created", "opened_existing", "skipped_not_requested"]); |
| 43 | const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]); | 45 | const VcsStatusChangeRequestState = Schema.Literals(["open", "closed", "merged"]); |
| 44 | const GitPullRequestReference = TrimmedNonEmptyStringSchema; | 46 | const GitPullRequestReference = TrimmedNonEmptyStringSchema; |
| 45 | const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]); | 47 | const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]); |
| 46 | const GitPreparePullRequestThreadMode = Schema.Literals(["local", "worktree"]); | 48 | const GitPreparePullRequestThreadMode = Schema.Literals(["local", "worktree"]); |
| 47 | export const GitHostingProviderKind = Schema.Literals(["github", "gitlab", "unknown"]); | ||
| 48 | export type GitHostingProviderKind = typeof GitHostingProviderKind.Type; | ||
| 49 | export const GitHostingProvider = Schema.Struct({ | ||
| 50 | kind: GitHostingProviderKind, | ||
| 51 | name: TrimmedNonEmptyStringSchema, | ||
| 52 | baseUrl: Schema.String, | ||
| 53 | }); | ||
| 54 | export type GitHostingProvider = typeof GitHostingProvider.Type; | ||
| 55 | export const GitRunStackedActionToastRunAction = Schema.Struct({ | 49 | export const GitRunStackedActionToastRunAction = Schema.Struct({ |
| 56 | kind: GitStackedAction, | 50 | kind: GitStackedAction, |
| 57 | }); | 51 | }); |
| ... | @@ -79,7 +73,7 @@ const GitRunStackedActionToast = Schema.Struct({ | ... | @@ -79,7 +73,7 @@ const GitRunStackedActionToast = Schema.Struct({ |
| 79 | }); | 73 | }); |
| 80 | export type GitRunStackedActionToast = typeof GitRunStackedActionToast.Type; | 74 | export type GitRunStackedActionToast = typeof GitRunStackedActionToast.Type; |
| 81 | 75 | ||
| 82 | export const GitBranch = Schema.Struct({ | 76 | export const VcsRef = Schema.Struct({ |
| 83 | name: TrimmedNonEmptyStringSchema, | 77 | name: TrimmedNonEmptyStringSchema, |
| 84 | isRemote: Schema.optional(Schema.Boolean), | 78 | isRemote: Schema.optional(Schema.Boolean), |
| 85 | remoteName: Schema.optional(TrimmedNonEmptyStringSchema), | 79 | remoteName: Schema.optional(TrimmedNonEmptyStringSchema), |
| ... | @@ -87,11 +81,11 @@ export const GitBranch = Schema.Struct({ | ... | @@ -87,11 +81,11 @@ export const GitBranch = Schema.Struct({ |
| 87 | isDefault: Schema.Boolean, | 81 | isDefault: Schema.Boolean, |
| 88 | worktreePath: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), | 82 | worktreePath: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), |
| 89 | }); | 83 | }); |
| 90 | export type GitBranch = typeof GitBranch.Type; | 84 | export type VcsRef = typeof VcsRef.Type; |
| 91 | 85 | ||
| 92 | const GitWorktree = Schema.Struct({ | 86 | const VcsWorktree = Schema.Struct({ |
| 93 | path: TrimmedNonEmptyStringSchema, | 87 | path: TrimmedNonEmptyStringSchema, |
| 94 | branch: TrimmedNonEmptyStringSchema, | 88 | refName: TrimmedNonEmptyStringSchema, |
| 95 | }); | 89 | }); |
| 96 | const GitResolvedPullRequest = Schema.Struct({ | 90 | const GitResolvedPullRequest = Schema.Struct({ |
| 97 | number: PositiveInt, | 91 | number: PositiveInt, |
| ... | @@ -105,15 +99,15 @@ export type GitResolvedPullRequest = typeof GitResolvedPullRequest.Type; | ... | @@ -105,15 +99,15 @@ export type GitResolvedPullRequest = typeof GitResolvedPullRequest.Type; |
| 105 | 99 | ||
| 106 | // RPC Inputs | 100 | // RPC Inputs |
| 107 | 101 | ||
| 108 | export const GitStatusInput = Schema.Struct({ | 102 | export const VcsStatusInput = Schema.Struct({ |
| 109 | cwd: TrimmedNonEmptyStringSchema, | 103 | cwd: TrimmedNonEmptyStringSchema, |
| 110 | }); | 104 | }); |
| 111 | export type GitStatusInput = typeof GitStatusInput.Type; | 105 | export type VcsStatusInput = typeof VcsStatusInput.Type; |
| 112 | 106 | ||
| 113 | export const GitPullInput = Schema.Struct({ | 107 | export const VcsPullInput = Schema.Struct({ |
| 114 | cwd: TrimmedNonEmptyStringSchema, | 108 | cwd: TrimmedNonEmptyStringSchema, |
| 115 | }); | 109 | }); |
| 116 | export type GitPullInput = typeof GitPullInput.Type; | 110 | export type VcsPullInput = typeof VcsPullInput.Type; |
| 117 | 111 | ||
| 118 | export const GitRunStackedActionInput = Schema.Struct({ | 112 | export const GitRunStackedActionInput = Schema.Struct({ |
| 119 | actionId: TrimmedNonEmptyStringSchema, | 113 | actionId: TrimmedNonEmptyStringSchema, |
| ... | @@ -127,7 +121,7 @@ export const GitRunStackedActionInput = Schema.Struct({ | ... | @@ -127,7 +121,7 @@ export const GitRunStackedActionInput = Schema.Struct({ |
| 127 | }); | 121 | }); |
| 128 | export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type; | 122 | export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type; |
| 129 | 123 | ||
| 130 | export const GitListBranchesInput = Schema.Struct({ | 124 | export const VcsListRefsInput = Schema.Struct({ |
| 131 | cwd: TrimmedNonEmptyStringSchema, | 125 | cwd: TrimmedNonEmptyStringSchema, |
| 132 | query: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(256))), | 126 | query: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(256))), |
| 133 | cursor: Schema.optional(NonNegativeInt), | 127 | cursor: Schema.optional(NonNegativeInt), |
| ... | @@ -135,15 +129,15 @@ export const GitListBranchesInput = Schema.Struct({ | ... | @@ -135,15 +129,15 @@ export const GitListBranchesInput = Schema.Struct({ |
| 135 | PositiveInt.check(Schema.isLessThanOrEqualTo(GIT_LIST_BRANCHES_MAX_LIMIT)), | 129 | PositiveInt.check(Schema.isLessThanOrEqualTo(GIT_LIST_BRANCHES_MAX_LIMIT)), |
| 136 | ), | 130 | ), |
| 137 | }); | 131 | }); |
| 138 | export type GitListBranchesInput = typeof GitListBranchesInput.Type; | 132 | export type VcsListRefsInput = typeof VcsListRefsInput.Type; |
| 139 | 133 | ||
| 140 | export const GitCreateWorktreeInput = Schema.Struct({ | 134 | export const VcsCreateWorktreeInput = Schema.Struct({ |
| 141 | cwd: TrimmedNonEmptyStringSchema, | 135 | cwd: TrimmedNonEmptyStringSchema, |
| 142 | branch: TrimmedNonEmptyStringSchema, | 136 | refName: TrimmedNonEmptyStringSchema, |
| 143 | newBranch: Schema.optional(TrimmedNonEmptyStringSchema), | 137 | newRefName: Schema.optional(TrimmedNonEmptyStringSchema), |
| 144 | path: Schema.NullOr(TrimmedNonEmptyStringSchema), | 138 | path: Schema.NullOr(TrimmedNonEmptyStringSchema), |
| 145 | }); | 139 | }); |
| 146 | export type GitCreateWorktreeInput = typeof GitCreateWorktreeInput.Type; | 140 | export type VcsCreateWorktreeInput = typeof VcsCreateWorktreeInput.Type; |
| 147 | 141 | ||
| 148 | export const GitPullRequestRefInput = Schema.Struct({ | 142 | export const GitPullRequestRefInput = Schema.Struct({ |
| 149 | cwd: TrimmedNonEmptyStringSchema, | 143 | cwd: TrimmedNonEmptyStringSchema, |
| ... | @@ -159,53 +153,54 @@ export const GitPreparePullRequestThreadInput = Schema.Struct({ | ... | @@ -159,53 +153,54 @@ export const GitPreparePullRequestThreadInput = Schema.Struct({ |
| 159 | }); | 153 | }); |
| 160 | export type GitPreparePullRequestThreadInput = typeof GitPreparePullRequestThreadInput.Type; | 154 | export type GitPreparePullRequestThreadInput = typeof GitPreparePullRequestThreadInput.Type; |
| 161 | 155 | ||
| 162 | export const GitRemoveWorktreeInput = Schema.Struct({ | 156 | export const VcsRemoveWorktreeInput = Schema.Struct({ |
| 163 | cwd: TrimmedNonEmptyStringSchema, | 157 | cwd: TrimmedNonEmptyStringSchema, |
| 164 | path: TrimmedNonEmptyStringSchema, | 158 | path: TrimmedNonEmptyStringSchema, |
| 165 | force: Schema.optional(Schema.Boolean), | 159 | force: Schema.optional(Schema.Boolean), |
| 166 | }); | 160 | }); |
| 167 | export type GitRemoveWorktreeInput = typeof GitRemoveWorktreeInput.Type; | 161 | export type VcsRemoveWorktreeInput = typeof VcsRemoveWorktreeInput.Type; |
| 168 | 162 | ||
| 169 | export const GitCreateBranchInput = Schema.Struct({ | 163 | export const VcsCreateRefInput = Schema.Struct({ |
| 170 | cwd: TrimmedNonEmptyStringSchema, | 164 | cwd: TrimmedNonEmptyStringSchema, |
| 171 | branch: TrimmedNonEmptyStringSchema, | 165 | refName: TrimmedNonEmptyStringSchema, |
| 172 | checkout: Schema.optional(Schema.Boolean), | 166 | switchRef: Schema.optional(Schema.Boolean), |
| 173 | }); | 167 | }); |
| 174 | export type GitCreateBranchInput = typeof GitCreateBranchInput.Type; | 168 | export type VcsCreateRefInput = typeof VcsCreateRefInput.Type; |
| 175 | 169 | ||
| 176 | export const GitCreateBranchResult = Schema.Struct({ | 170 | export const VcsCreateRefResult = Schema.Struct({ |
| 177 | branch: TrimmedNonEmptyStringSchema, | 171 | refName: TrimmedNonEmptyStringSchema, |
| 178 | }); | 172 | }); |
| 179 | export type GitCreateBranchResult = typeof GitCreateBranchResult.Type; | 173 | export type VcsCreateRefResult = typeof VcsCreateRefResult.Type; |
| 180 | 174 | ||
| 181 | export const GitCheckoutInput = Schema.Struct({ | 175 | export const VcsSwitchRefInput = Schema.Struct({ |
| 182 | cwd: TrimmedNonEmptyStringSchema, | 176 | cwd: TrimmedNonEmptyStringSchema, |
| 183 | branch: TrimmedNonEmptyStringSchema, | 177 | refName: TrimmedNonEmptyStringSchema, |
| 184 | }); | 178 | }); |
| 185 | export type GitCheckoutInput = typeof GitCheckoutInput.Type; | 179 | export type VcsSwitchRefInput = typeof VcsSwitchRefInput.Type; |
| 186 | 180 | ||
| 187 | export const GitInitInput = Schema.Struct({ | 181 | export const VcsInitInput = Schema.Struct({ |
| 188 | cwd: TrimmedNonEmptyStringSchema, | 182 | cwd: TrimmedNonEmptyStringSchema, |
| 183 | kind: Schema.optional(VcsDriverKind), | ||
| 189 | }); | 184 | }); |
| 190 | export type GitInitInput = typeof GitInitInput.Type; | 185 | export type VcsInitInput = typeof VcsInitInput.Type; |
| 191 | 186 | ||
| 192 | // RPC Results | 187 | // RPC Results |
| 193 | 188 | ||
| 194 | const GitStatusPr = Schema.Struct({ | 189 | const VcsStatusChangeRequest = Schema.Struct({ |
| 195 | number: PositiveInt, | 190 | number: PositiveInt, |
| 196 | title: TrimmedNonEmptyStringSchema, | 191 | title: TrimmedNonEmptyStringSchema, |
| 197 | url: Schema.String, | 192 | url: Schema.String, |
| 198 | baseBranch: TrimmedNonEmptyStringSchema, | 193 | baseRef: TrimmedNonEmptyStringSchema, |
| 199 | headBranch: TrimmedNonEmptyStringSchema, | 194 | headRef: TrimmedNonEmptyStringSchema, |
| 200 | state: GitStatusPrState, | 195 | state: VcsStatusChangeRequestState, |
| 201 | }); | 196 | }); |
| 202 | 197 | ||
| 203 | const GitStatusLocalShape = { | 198 | const VcsStatusLocalShape = { |
| 204 | isRepo: Schema.Boolean, | 199 | isRepo: Schema.Boolean, |
| 205 | hostingProvider: Schema.optional(GitHostingProvider), | 200 | sourceControlProvider: Schema.optional(SourceControlProviderInfo), |
| 206 | hasOriginRemote: Schema.Boolean, | 201 | hasPrimaryRemote: Schema.Boolean, |
| 207 | isDefaultBranch: Schema.Boolean, | 202 | isDefaultRef: Schema.Boolean, |
| 208 | branch: Schema.NullOr(TrimmedNonEmptyStringSchema), | 203 | refName: Schema.NullOr(TrimmedNonEmptyStringSchema), |
| 209 | hasWorkingTreeChanges: Schema.Boolean, | 204 | hasWorkingTreeChanges: Schema.Boolean, |
| 210 | workingTree: Schema.Struct({ | 205 | workingTree: Schema.Struct({ |
| 211 | files: Schema.Array( | 206 | files: Schema.Array( |
| ... | @@ -220,52 +215,52 @@ const GitStatusLocalShape = { | ... | @@ -220,52 +215,52 @@ const GitStatusLocalShape = { |
| 220 | }), | 215 | }), |
| 221 | }; | 216 | }; |
| 222 | 217 | ||
| 223 | const GitStatusRemoteShape = { | 218 | const VcsStatusRemoteShape = { |
| 224 | hasUpstream: Schema.Boolean, | 219 | hasUpstream: Schema.Boolean, |
| 225 | aheadCount: NonNegativeInt, | 220 | aheadCount: NonNegativeInt, |
| 226 | behindCount: NonNegativeInt, | 221 | behindCount: NonNegativeInt, |
| 227 | pr: Schema.NullOr(GitStatusPr), | 222 | pr: Schema.NullOr(VcsStatusChangeRequest), |
| 228 | }; | 223 | }; |
| 229 | 224 | ||
| 230 | export const GitStatusLocalResult = Schema.Struct(GitStatusLocalShape); | 225 | export const VcsStatusLocalResult = Schema.Struct(VcsStatusLocalShape); |
| 231 | export type GitStatusLocalResult = typeof GitStatusLocalResult.Type; | 226 | export type VcsStatusLocalResult = typeof VcsStatusLocalResult.Type; |
| 232 | 227 | ||
| 233 | export const GitStatusRemoteResult = Schema.Struct(GitStatusRemoteShape); | 228 | export const VcsStatusRemoteResult = Schema.Struct(VcsStatusRemoteShape); |
| 234 | export type GitStatusRemoteResult = typeof GitStatusRemoteResult.Type; | 229 | export type VcsStatusRemoteResult = typeof VcsStatusRemoteResult.Type; |
| 235 | 230 | ||
| 236 | export const GitStatusResult = Schema.Struct({ | 231 | export const VcsStatusResult = Schema.Struct({ |
| 237 | ...GitStatusLocalShape, | 232 | ...VcsStatusLocalShape, |
| 238 | ...GitStatusRemoteShape, | 233 | ...VcsStatusRemoteShape, |
| 239 | }); | 234 | }); |
| 240 | export type GitStatusResult = typeof GitStatusResult.Type; | 235 | export type VcsStatusResult = typeof VcsStatusResult.Type; |
| 241 | 236 | ||
| 242 | export const GitStatusStreamEvent = Schema.Union([ | 237 | export const VcsStatusStreamEvent = Schema.Union([ |
| 243 | Schema.TaggedStruct("snapshot", { | 238 | Schema.TaggedStruct("snapshot", { |
| 244 | local: GitStatusLocalResult, | 239 | local: VcsStatusLocalResult, |
| 245 | remote: Schema.NullOr(GitStatusRemoteResult), | 240 | remote: Schema.NullOr(VcsStatusRemoteResult), |
| 246 | }), | 241 | }), |
| 247 | Schema.TaggedStruct("localUpdated", { | 242 | Schema.TaggedStruct("localUpdated", { |
| 248 | local: GitStatusLocalResult, | 243 | local: VcsStatusLocalResult, |
| 249 | }), | 244 | }), |
| 250 | Schema.TaggedStruct("remoteUpdated", { | 245 | Schema.TaggedStruct("remoteUpdated", { |
| 251 | remote: Schema.NullOr(GitStatusRemoteResult), | 246 | remote: Schema.NullOr(VcsStatusRemoteResult), |
| 252 | }), | 247 | }), |
| 253 | ]); | 248 | ]); |
| 254 | export type GitStatusStreamEvent = typeof GitStatusStreamEvent.Type; | 249 | export type VcsStatusStreamEvent = typeof VcsStatusStreamEvent.Type; |
| 255 | 250 | ||
| 256 | export const GitListBranchesResult = Schema.Struct({ | 251 | export const VcsListRefsResult = Schema.Struct({ |
| 257 | branches: Schema.Array(GitBranch), | 252 | refs: Schema.Array(VcsRef), |
| 258 | isRepo: Schema.Boolean, | 253 | isRepo: Schema.Boolean, |
| 259 | hasOriginRemote: Schema.Boolean, | 254 | hasPrimaryRemote: Schema.Boolean, |
| 260 | nextCursor: NonNegativeInt.pipe(Schema.NullOr), | 255 | nextCursor: NonNegativeInt.pipe(Schema.NullOr), |
| 261 | totalCount: NonNegativeInt, | 256 | totalCount: NonNegativeInt, |
| 262 | }); | 257 | }); |
| 263 | export type GitListBranchesResult = typeof GitListBranchesResult.Type; | 258 | export type VcsListRefsResult = typeof VcsListRefsResult.Type; |
| 264 | 259 | ||
| 265 | export const GitCreateWorktreeResult = Schema.Struct({ | 260 | export const VcsCreateWorktreeResult = Schema.Struct({ |
| 266 | worktree: GitWorktree, | 261 | worktree: VcsWorktree, |
| 267 | }); | 262 | }); |
| 268 | export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type; | 263 | export type VcsCreateWorktreeResult = typeof VcsCreateWorktreeResult.Type; |
| 269 | 264 | ||
| 270 | export const GitResolvePullRequestResult = Schema.Struct({ | 265 | export const GitResolvePullRequestResult = Schema.Struct({ |
| 271 | pullRequest: GitResolvedPullRequest, | 266 | pullRequest: GitResolvedPullRequest, |
| ... | @@ -279,10 +274,10 @@ export const GitPreparePullRequestThreadResult = Schema.Struct({ | ... | @@ -279,10 +274,10 @@ export const GitPreparePullRequestThreadResult = Schema.Struct({ |
| 279 | }); | 274 | }); |
| 280 | export type GitPreparePullRequestThreadResult = typeof GitPreparePullRequestThreadResult.Type; | 275 | export type GitPreparePullRequestThreadResult = typeof GitPreparePullRequestThreadResult.Type; |
| 281 | 276 | ||
| 282 | export const GitCheckoutResult = Schema.Struct({ | 277 | export const VcsSwitchRefResult = Schema.Struct({ |
| 283 | branch: Schema.NullOr(TrimmedNonEmptyStringSchema), | 278 | refName: Schema.NullOr(TrimmedNonEmptyStringSchema), |
| 284 | }); | 279 | }); |
| 285 | export type GitCheckoutResult = typeof GitCheckoutResult.Type; | 280 | export type VcsSwitchRefResult = typeof VcsSwitchRefResult.Type; |
| 286 | 281 | ||
| 287 | export const GitRunStackedActionResult = Schema.Struct({ | 282 | export const GitRunStackedActionResult = Schema.Struct({ |
| 288 | action: GitStackedAction, | 283 | action: GitStackedAction, |
| ... | @@ -313,12 +308,12 @@ export const GitRunStackedActionResult = Schema.Struct({ | ... | @@ -313,12 +308,12 @@ export const GitRunStackedActionResult = Schema.Struct({ |
| 313 | }); | 308 | }); |
| 314 | export type GitRunStackedActionResult = typeof GitRunStackedActionResult.Type; | 309 | export type GitRunStackedActionResult = typeof GitRunStackedActionResult.Type; |
| 315 | 310 | ||
| 316 | export const GitPullResult = Schema.Struct({ | 311 | export const VcsPullResult = Schema.Struct({ |
| 317 | status: Schema.Literals(["pulled", "skipped_up_to_date"]), | 312 | status: Schema.Literals(["pulled", "skipped_up_to_date"]), |
| 318 | branch: TrimmedNonEmptyStringSchema, | 313 | refName: TrimmedNonEmptyStringSchema, |
| 319 | upstreamBranch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), | 314 | upstreamRef: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), |
| 320 | }); | 315 | }); |
| 321 | export type GitPullResult = typeof GitPullResult.Type; | 316 | export type VcsPullResult = typeof VcsPullResult.Type; |
| 322 | 317 | ||
| 323 | // RPC / domain errors | 318 | // RPC / domain errors |
| 324 | export class GitCommandError extends Schema.TaggedErrorClass<GitCommandError>()("GitCommandError", { | 319 | export class GitCommandError extends Schema.TaggedErrorClass<GitCommandError>()("GitCommandError", { |
| ... | @@ -370,6 +365,7 @@ export const GitManagerServiceError = Schema.Union([ | ... | @@ -370,6 +365,7 @@ export const GitManagerServiceError = Schema.Union([ |
| 370 | GitManagerError, | 365 | GitManagerError, |
| 371 | GitCommandError, | 366 | GitCommandError, |
| 372 | GitHubCliError, | 367 | GitHubCliError, |
| 368 | SourceControlProviderError, | ||
| 373 | TextGenerationError, | 369 | TextGenerationError, |
| 374 | ]); | 370 | ]); |
| 375 | export type GitManagerServiceError = typeof GitManagerServiceError.Type; | 371 | export type GitManagerServiceError = typeof GitManagerServiceError.Type; |
packages/contracts/src/index.ts+2| ... | @@ -11,6 +11,8 @@ export * from "./keybindings.ts"; | ... | @@ -11,6 +11,8 @@ export * from "./keybindings.ts"; |
| 11 | export * from "./server.ts"; | 11 | export * from "./server.ts"; |
| 12 | export * from "./settings.ts"; | 12 | export * from "./settings.ts"; |
| 13 | export * from "./git.ts"; | 13 | export * from "./git.ts"; |
| 14 | export * from "./vcs.ts"; | ||
| 15 | export * from "./sourceControl.ts"; | ||
| 14 | export * from "./orchestration.ts"; | 16 | export * from "./orchestration.ts"; |
| 15 | export * from "./editor.ts"; | 17 | export * from "./editor.ts"; |
| 16 | export * from "./project.ts"; | 18 | export * from "./project.ts"; |
packages/contracts/src/ipc.ts+34-30| ... | @@ -1,22 +1,22 @@ | ... | @@ -1,22 +1,22 @@ |
| 1 | import type { | 1 | import type { |
| 2 | GitCheckoutInput, | 2 | VcsSwitchRefInput, |
| 3 | GitCheckoutResult, | 3 | VcsSwitchRefResult, |
| 4 | GitCreateBranchInput, | 4 | VcsCreateRefInput, |
| 5 | GitPreparePullRequestThreadInput, | 5 | GitPreparePullRequestThreadInput, |
| 6 | GitPreparePullRequestThreadResult, | 6 | GitPreparePullRequestThreadResult, |
| 7 | GitPullRequestRefInput, | 7 | GitPullRequestRefInput, |
| 8 | GitCreateWorktreeInput, | 8 | VcsCreateWorktreeInput, |
| 9 | GitCreateWorktreeResult, | 9 | VcsCreateWorktreeResult, |
| 10 | GitInitInput, | 10 | VcsInitInput, |
| 11 | GitListBranchesInput, | 11 | VcsListRefsInput, |
| 12 | GitListBranchesResult, | 12 | VcsListRefsResult, |
| 13 | GitPullInput, | 13 | VcsPullInput, |
| 14 | GitPullResult, | 14 | VcsPullResult, |
| 15 | GitRemoveWorktreeInput, | 15 | VcsRemoveWorktreeInput, |
| 16 | GitResolvePullRequestResult, | 16 | GitResolvePullRequestResult, |
| 17 | GitStatusInput, | 17 | VcsStatusInput, |
| 18 | GitStatusResult, | 18 | VcsStatusResult, |
| 19 | GitCreateBranchResult, | 19 | VcsCreateRefResult, |
| 20 | } from "./git.ts"; | 20 | } from "./git.ts"; |
| 21 | import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts"; | 21 | import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts"; |
| 22 | import type { | 22 | import type { |
| ... | @@ -55,6 +55,7 @@ import type { | ... | @@ -55,6 +55,7 @@ import type { |
| 55 | import type { EnvironmentId } from "./baseSchemas.ts"; | 55 | import type { EnvironmentId } from "./baseSchemas.ts"; |
| 56 | import { EditorId } from "./editor.ts"; | 56 | import { EditorId } from "./editor.ts"; |
| 57 | import { ServerSettings, type ClientSettings, type ServerSettingsPatch } from "./settings.ts"; | 57 | import { ServerSettings, type ClientSettings, type ServerSettingsPatch } from "./settings.ts"; |
| 58 | import type { SourceControlDiscoveryResult } from "./sourceControl.ts"; | ||
| 58 | 59 | ||
| 59 | export interface ContextMenuItem<T extends string = string> { | 60 | export interface ContextMenuItem<T extends string = string> { |
| 60 | id: T; | 61 | id: T; |
| ... | @@ -227,6 +228,7 @@ export interface LocalApi { | ... | @@ -227,6 +228,7 @@ export interface LocalApi { |
| 227 | upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>; | 228 | upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>; |
| 228 | getSettings: () => Promise<ServerSettings>; | 229 | getSettings: () => Promise<ServerSettings>; |
| 229 | updateSettings: (patch: ServerSettingsPatch) => Promise<ServerSettings>; | 230 | updateSettings: (patch: ServerSettingsPatch) => Promise<ServerSettings>; |
| 231 | discoverSourceControl: () => Promise<SourceControlDiscoveryResult>; | ||
| 230 | }; | 232 | }; |
| 231 | } | 233 | } |
| 232 | 234 | ||
| ... | @@ -235,7 +237,7 @@ export interface LocalApi { | ... | @@ -235,7 +237,7 @@ export interface LocalApi { |
| 235 | * | 237 | * |
| 236 | * These operations must always be routed with explicit environment context. | 238 | * These operations must always be routed with explicit environment context. |
| 237 | * They represent remote stateful capabilities such as orchestration, terminal, | 239 | * They represent remote stateful capabilities such as orchestration, terminal, |
| 238 | * project, and git operations. In multi-environment mode, each environment gets | 240 | * project, VCS, and provider operations. In multi-environment mode, each environment gets |
| 239 | * its own instance of this surface, and callers should resolve it by | 241 | * its own instance of this surface, and callers should resolve it by |
| 240 | * `environmentId` rather than reaching through the local desktop bridge. | 242 | * `environmentId` rather than reaching through the local desktop bridge. |
| 241 | */ | 243 | */ |
| ... | @@ -256,27 +258,29 @@ export interface EnvironmentApi { | ... | @@ -256,27 +258,29 @@ export interface EnvironmentApi { |
| 256 | filesystem: { | 258 | filesystem: { |
| 257 | browse: (input: FilesystemBrowseInput) => Promise<FilesystemBrowseResult>; | 259 | browse: (input: FilesystemBrowseInput) => Promise<FilesystemBrowseResult>; |
| 258 | }; | 260 | }; |
| 259 | git: { | 261 | vcs: { |
| 260 | listBranches: (input: GitListBranchesInput) => Promise<GitListBranchesResult>; | 262 | listRefs: (input: VcsListRefsInput) => Promise<VcsListRefsResult>; |
| 261 | createWorktree: (input: GitCreateWorktreeInput) => Promise<GitCreateWorktreeResult>; | 263 | createWorktree: (input: VcsCreateWorktreeInput) => Promise<VcsCreateWorktreeResult>; |
| 262 | removeWorktree: (input: GitRemoveWorktreeInput) => Promise<void>; | 264 | removeWorktree: (input: VcsRemoveWorktreeInput) => Promise<void>; |
| 263 | createBranch: (input: GitCreateBranchInput) => Promise<GitCreateBranchResult>; | 265 | createRef: (input: VcsCreateRefInput) => Promise<VcsCreateRefResult>; |
| 264 | checkout: (input: GitCheckoutInput) => Promise<GitCheckoutResult>; | 266 | switchRef: (input: VcsSwitchRefInput) => Promise<VcsSwitchRefResult>; |
| 265 | init: (input: GitInitInput) => Promise<void>; | 267 | init: (input: VcsInitInput) => Promise<void>; |
| 266 | resolvePullRequest: (input: GitPullRequestRefInput) => Promise<GitResolvePullRequestResult>; | 268 | pull: (input: VcsPullInput) => Promise<VcsPullResult>; |
| 267 | preparePullRequestThread: ( | 269 | refreshStatus: (input: VcsStatusInput) => Promise<VcsStatusResult>; |
| 268 | input: GitPreparePullRequestThreadInput, | ||
| 269 | ) => Promise<GitPreparePullRequestThreadResult>; | ||
| 270 | pull: (input: GitPullInput) => Promise<GitPullResult>; | ||
| 271 | refreshStatus: (input: GitStatusInput) => Promise<GitStatusResult>; | ||
| 272 | onStatus: ( | 270 | onStatus: ( |
| 273 | input: GitStatusInput, | 271 | input: VcsStatusInput, |
| 274 | callback: (status: GitStatusResult) => void, | 272 | callback: (status: VcsStatusResult) => void, |
| 275 | options?: { | 273 | options?: { |
| 276 | onResubscribe?: () => void; | 274 | onResubscribe?: () => void; |
| 277 | }, | 275 | }, |
| 278 | ) => () => void; | 276 | ) => () => void; |
| 279 | }; | 277 | }; |
| 278 | git: { | ||
| 279 | resolvePullRequest: (input: GitPullRequestRefInput) => Promise<GitResolvePullRequestResult>; | ||
| 280 | preparePullRequestThread: ( | ||
| 281 | input: GitPreparePullRequestThreadInput, | ||
| 282 | ) => Promise<GitPreparePullRequestThreadResult>; | ||
| 283 | }; | ||
| 280 | orchestration: { | 284 | orchestration: { |
| 281 | dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>; | 285 | dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>; |
| 282 | getTurnDiff: (input: OrchestrationGetTurnDiffInput) => Promise<OrchestrationGetTurnDiffResult>; | 286 | getTurnDiff: (input: OrchestrationGetTurnDiffInput) => Promise<OrchestrationGetTurnDiffResult>; |
packages/contracts/src/rpc.ts+71-60| ... | @@ -11,28 +11,28 @@ import { | ... | @@ -11,28 +11,28 @@ import { |
| 11 | } from "./filesystem.ts"; | 11 | } from "./filesystem.ts"; |
| 12 | import { | 12 | import { |
| 13 | GitActionProgressEvent, | 13 | GitActionProgressEvent, |
| 14 | GitCheckoutInput, | 14 | VcsSwitchRefInput, |
| 15 | GitCheckoutResult, | 15 | VcsSwitchRefResult, |
| 16 | GitCommandError, | 16 | GitCommandError, |
| 17 | GitCreateBranchInput, | 17 | VcsCreateRefInput, |
| 18 | GitCreateBranchResult, | 18 | VcsCreateRefResult, |
| 19 | GitCreateWorktreeInput, | 19 | VcsCreateWorktreeInput, |
| 20 | GitCreateWorktreeResult, | 20 | VcsCreateWorktreeResult, |
| 21 | GitInitInput, | 21 | VcsInitInput, |
| 22 | GitListBranchesInput, | 22 | VcsListRefsInput, |
| 23 | GitListBranchesResult, | 23 | VcsListRefsResult, |
| 24 | GitManagerServiceError, | 24 | GitManagerServiceError, |
| 25 | GitPreparePullRequestThreadInput, | 25 | GitPreparePullRequestThreadInput, |
| 26 | GitPreparePullRequestThreadResult, | 26 | GitPreparePullRequestThreadResult, |
| 27 | GitPullInput, | 27 | VcsPullInput, |
| 28 | GitPullRequestRefInput, | 28 | GitPullRequestRefInput, |
| 29 | GitPullResult, | 29 | VcsPullResult, |
| 30 | GitRemoveWorktreeInput, | 30 | VcsRemoveWorktreeInput, |
| 31 | GitResolvePullRequestResult, | 31 | GitResolvePullRequestResult, |
| 32 | GitRunStackedActionInput, | 32 | GitRunStackedActionInput, |
| 33 | GitStatusInput, | 33 | VcsStatusInput, |
| 34 | GitStatusResult, | 34 | VcsStatusResult, |
| 35 | GitStatusStreamEvent, | 35 | VcsStatusStreamEvent, |
| 36 | } from "./git.ts"; | 36 | } from "./git.ts"; |
| 37 | import { KeybindingsConfigError } from "./keybindings.ts"; | 37 | import { KeybindingsConfigError } from "./keybindings.ts"; |
| 38 | import { | 38 | import { |
| ... | @@ -77,6 +77,8 @@ import { | ... | @@ -77,6 +77,8 @@ import { |
| 77 | ServerUpsertKeybindingResult, | 77 | ServerUpsertKeybindingResult, |
| 78 | } from "./server.ts"; | 78 | } from "./server.ts"; |
| 79 | import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; | 79 | import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; |
| 80 | import { SourceControlDiscoveryResult } from "./sourceControl.ts"; | ||
| 81 | import { VcsError } from "./vcs.ts"; | ||
| 80 | 82 | ||
| 81 | export const WS_METHODS = { | 83 | export const WS_METHODS = { |
| 82 | // Project registry methods | 84 | // Project registry methods |
| ... | @@ -92,16 +94,18 @@ export const WS_METHODS = { | ... | @@ -92,16 +94,18 @@ export const WS_METHODS = { |
| 92 | // Filesystem methods | 94 | // Filesystem methods |
| 93 | filesystemBrowse: "filesystem.browse", | 95 | filesystemBrowse: "filesystem.browse", |
| 94 | 96 | ||
| 95 | // Git methods | 97 | // VCS methods |
| 96 | gitPull: "git.pull", | 98 | vcsPull: "vcs.pull", |
| 97 | gitRefreshStatus: "git.refreshStatus", | 99 | vcsRefreshStatus: "vcs.refreshStatus", |
| 100 | vcsListRefs: "vcs.listRefs", | ||
| 101 | vcsCreateWorktree: "vcs.createWorktree", | ||
| 102 | vcsRemoveWorktree: "vcs.removeWorktree", | ||
| 103 | vcsCreateRef: "vcs.createRef", | ||
| 104 | vcsSwitchRef: "vcs.switchRef", | ||
| 105 | vcsInit: "vcs.init", | ||
| 106 | |||
| 107 | // Git workflow methods | ||
| 98 | gitRunStackedAction: "git.runStackedAction", | 108 | gitRunStackedAction: "git.runStackedAction", |
| 99 | gitListBranches: "git.listBranches", | ||
| 100 | gitCreateWorktree: "git.createWorktree", | ||
| 101 | gitRemoveWorktree: "git.removeWorktree", | ||
| 102 | gitCreateBranch: "git.createBranch", | ||
| 103 | gitCheckout: "git.checkout", | ||
| 104 | gitInit: "git.init", | ||
| 105 | gitResolvePullRequest: "git.resolvePullRequest", | 109 | gitResolvePullRequest: "git.resolvePullRequest", |
| 106 | gitPreparePullRequestThread: "git.preparePullRequestThread", | 110 | gitPreparePullRequestThread: "git.preparePullRequestThread", |
| 107 | 111 | ||
| ... | @@ -119,9 +123,10 @@ export const WS_METHODS = { | ... | @@ -119,9 +123,10 @@ export const WS_METHODS = { |
| 119 | serverUpsertKeybinding: "server.upsertKeybinding", | 123 | serverUpsertKeybinding: "server.upsertKeybinding", |
| 120 | serverGetSettings: "server.getSettings", | 124 | serverGetSettings: "server.getSettings", |
| 121 | serverUpdateSettings: "server.updateSettings", | 125 | serverUpdateSettings: "server.updateSettings", |
| 126 | serverDiscoverSourceControl: "server.discoverSourceControl", | ||
| 122 | 127 | ||
| 123 | // Streaming subscriptions | 128 | // Streaming subscriptions |
| 124 | subscribeGitStatus: "subscribeGitStatus", | 129 | subscribeVcsStatus: "subscribeVcsStatus", |
| 125 | subscribeTerminalEvents: "subscribeTerminalEvents", | 130 | subscribeTerminalEvents: "subscribeTerminalEvents", |
| 126 | subscribeServerConfig: "subscribeServerConfig", | 131 | subscribeServerConfig: "subscribeServerConfig", |
| 127 | subscribeServerLifecycle: "subscribeServerLifecycle", | 132 | subscribeServerLifecycle: "subscribeServerLifecycle", |
| ... | @@ -165,6 +170,11 @@ export const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSetting | ... | @@ -165,6 +170,11 @@ export const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSetting |
| 165 | error: ServerSettingsError, | 170 | error: ServerSettingsError, |
| 166 | }); | 171 | }); |
| 167 | 172 | ||
| 173 | export const WsServerDiscoverSourceControlRpc = Rpc.make(WS_METHODS.serverDiscoverSourceControl, { | ||
| 174 | payload: Schema.Struct({}), | ||
| 175 | success: SourceControlDiscoveryResult, | ||
| 176 | }); | ||
| 177 | |||
| 168 | export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, { | 178 | export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, { |
| 169 | payload: ProjectSearchEntriesInput, | 179 | payload: ProjectSearchEntriesInput, |
| 170 | success: ProjectSearchEntriesResult, | 180 | success: ProjectSearchEntriesResult, |
| ... | @@ -188,22 +198,22 @@ export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { | ... | @@ -188,22 +198,22 @@ export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { |
| 188 | error: FilesystemBrowseError, | 198 | error: FilesystemBrowseError, |
| 189 | }); | 199 | }); |
| 190 | 200 | ||
| 191 | export const WsSubscribeGitStatusRpc = Rpc.make(WS_METHODS.subscribeGitStatus, { | 201 | export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { |
| 192 | payload: GitStatusInput, | 202 | payload: VcsStatusInput, |
| 193 | success: GitStatusStreamEvent, | 203 | success: VcsStatusStreamEvent, |
| 194 | error: GitManagerServiceError, | 204 | error: GitManagerServiceError, |
| 195 | stream: true, | 205 | stream: true, |
| 196 | }); | 206 | }); |
| 197 | 207 | ||
| 198 | export const WsGitPullRpc = Rpc.make(WS_METHODS.gitPull, { | 208 | export const WsVcsPullRpc = Rpc.make(WS_METHODS.vcsPull, { |
| 199 | payload: GitPullInput, | 209 | payload: VcsPullInput, |
| 200 | success: GitPullResult, | 210 | success: VcsPullResult, |
| 201 | error: GitCommandError, | 211 | error: GitCommandError, |
| 202 | }); | 212 | }); |
| 203 | 213 | ||
| 204 | export const WsGitRefreshStatusRpc = Rpc.make(WS_METHODS.gitRefreshStatus, { | 214 | export const WsVcsRefreshStatusRpc = Rpc.make(WS_METHODS.vcsRefreshStatus, { |
| 205 | payload: GitStatusInput, | 215 | payload: VcsStatusInput, |
| 206 | success: GitStatusResult, | 216 | success: VcsStatusResult, |
| 207 | error: GitManagerServiceError, | 217 | error: GitManagerServiceError, |
| 208 | }); | 218 | }); |
| 209 | 219 | ||
| ... | @@ -226,38 +236,38 @@ export const WsGitPreparePullRequestThreadRpc = Rpc.make(WS_METHODS.gitPreparePu | ... | @@ -226,38 +236,38 @@ export const WsGitPreparePullRequestThreadRpc = Rpc.make(WS_METHODS.gitPreparePu |
| 226 | error: GitManagerServiceError, | 236 | error: GitManagerServiceError, |
| 227 | }); | 237 | }); |
| 228 | 238 | ||
| 229 | export const WsGitListBranchesRpc = Rpc.make(WS_METHODS.gitListBranches, { | 239 | export const WsVcsListRefsRpc = Rpc.make(WS_METHODS.vcsListRefs, { |
| 230 | payload: GitListBranchesInput, | 240 | payload: VcsListRefsInput, |
| 231 | success: GitListBranchesResult, | 241 | success: VcsListRefsResult, |
| 232 | error: GitCommandError, | 242 | error: GitCommandError, |
| 233 | }); | 243 | }); |
| 234 | 244 | ||
| 235 | export const WsGitCreateWorktreeRpc = Rpc.make(WS_METHODS.gitCreateWorktree, { | 245 | export const WsVcsCreateWorktreeRpc = Rpc.make(WS_METHODS.vcsCreateWorktree, { |
| 236 | payload: GitCreateWorktreeInput, | 246 | payload: VcsCreateWorktreeInput, |
| 237 | success: GitCreateWorktreeResult, | 247 | success: VcsCreateWorktreeResult, |
| 238 | error: GitCommandError, | 248 | error: GitCommandError, |
| 239 | }); | 249 | }); |
| 240 | 250 | ||
| 241 | export const WsGitRemoveWorktreeRpc = Rpc.make(WS_METHODS.gitRemoveWorktree, { | 251 | export const WsVcsRemoveWorktreeRpc = Rpc.make(WS_METHODS.vcsRemoveWorktree, { |
| 242 | payload: GitRemoveWorktreeInput, | 252 | payload: VcsRemoveWorktreeInput, |
| 243 | error: GitCommandError, | 253 | error: GitCommandError, |
| 244 | }); | 254 | }); |
| 245 | 255 | ||
| 246 | export const WsGitCreateBranchRpc = Rpc.make(WS_METHODS.gitCreateBranch, { | 256 | export const WsVcsCreateRefRpc = Rpc.make(WS_METHODS.vcsCreateRef, { |
| 247 | payload: GitCreateBranchInput, | 257 | payload: VcsCreateRefInput, |
| 248 | success: GitCreateBranchResult, | 258 | success: VcsCreateRefResult, |
| 249 | error: GitCommandError, | 259 | error: GitCommandError, |
| 250 | }); | 260 | }); |
| 251 | 261 | ||
| 252 | export const WsGitCheckoutRpc = Rpc.make(WS_METHODS.gitCheckout, { | 262 | export const WsVcsSwitchRefRpc = Rpc.make(WS_METHODS.vcsSwitchRef, { |
| 253 | payload: GitCheckoutInput, | 263 | payload: VcsSwitchRefInput, |
| 254 | success: GitCheckoutResult, | 264 | success: VcsSwitchRefResult, |
| 255 | error: GitCommandError, | 265 | error: GitCommandError, |
| 256 | }); | 266 | }); |
| 257 | 267 | ||
| 258 | export const WsGitInitRpc = Rpc.make(WS_METHODS.gitInit, { | 268 | export const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, { |
| 259 | payload: GitInitInput, | 269 | payload: VcsInitInput, |
| 260 | error: GitCommandError, | 270 | error: VcsError, |
| 261 | }); | 271 | }); |
| 262 | 272 | ||
| 263 | export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, { | 273 | export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, { |
| ... | @@ -370,22 +380,23 @@ export const WsRpcGroup = RpcGroup.make( | ... | @@ -370,22 +380,23 @@ export const WsRpcGroup = RpcGroup.make( |
| 370 | WsServerUpsertKeybindingRpc, | 380 | WsServerUpsertKeybindingRpc, |
| 371 | WsServerGetSettingsRpc, | 381 | WsServerGetSettingsRpc, |
| 372 | WsServerUpdateSettingsRpc, | 382 | WsServerUpdateSettingsRpc, |
| 383 | WsServerDiscoverSourceControlRpc, | ||
| 373 | WsProjectsSearchEntriesRpc, | 384 | WsProjectsSearchEntriesRpc, |
| 374 | WsProjectsWriteFileRpc, | 385 | WsProjectsWriteFileRpc, |
| 375 | WsShellOpenInEditorRpc, | 386 | WsShellOpenInEditorRpc, |
| 376 | WsFilesystemBrowseRpc, | 387 | WsFilesystemBrowseRpc, |
| 377 | WsSubscribeGitStatusRpc, | 388 | WsSubscribeVcsStatusRpc, |
| 378 | WsGitPullRpc, | 389 | WsVcsPullRpc, |
| 379 | WsGitRefreshStatusRpc, | 390 | WsVcsRefreshStatusRpc, |
| 380 | WsGitRunStackedActionRpc, | 391 | WsGitRunStackedActionRpc, |
| 381 | WsGitResolvePullRequestRpc, | 392 | WsGitResolvePullRequestRpc, |
| 382 | WsGitPreparePullRequestThreadRpc, | 393 | WsGitPreparePullRequestThreadRpc, |
| 383 | WsGitListBranchesRpc, | 394 | WsVcsListRefsRpc, |
| 384 | WsGitCreateWorktreeRpc, | 395 | WsVcsCreateWorktreeRpc, |
| 385 | WsGitRemoveWorktreeRpc, | 396 | WsVcsRemoveWorktreeRpc, |
| 386 | WsGitCreateBranchRpc, | 397 | WsVcsCreateRefRpc, |
| 387 | WsGitCheckoutRpc, | 398 | WsVcsSwitchRefRpc, |
| 388 | WsGitInitRpc, | 399 | WsVcsInitRpc, |
| 389 | WsTerminalOpenRpc, | 400 | WsTerminalOpenRpc, |
| 390 | WsTerminalWriteRpc, | 401 | WsTerminalWriteRpc, |
| 391 | WsTerminalResizeRpc, | 402 | WsTerminalResizeRpc, |
packages/contracts/src/sourceControl.ts created+111| ... | @@ -0,0 +1,111 @@ | ||
| 1 | import { Schema } from "effect"; | ||
| 2 | import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; | ||
| 3 | import { VcsDriverKind } from "./vcs.ts"; | ||
| 4 | |||
| 5 | export const SourceControlProviderKind = Schema.Literals([ | ||
| 6 | "github", | ||
| 7 | "gitlab", | ||
| 8 | "azure-devops", | ||
| 9 | "bitbucket", | ||
| 10 | "unknown", | ||
| 11 | ]); | ||
| 12 | export type SourceControlProviderKind = typeof SourceControlProviderKind.Type; | ||
| 13 | |||
| 14 | export const SourceControlProviderInfo = Schema.Struct({ | ||
| 15 | kind: SourceControlProviderKind, | ||
| 16 | name: TrimmedNonEmptyString, | ||
| 17 | baseUrl: Schema.String, | ||
| 18 | }); | ||
| 19 | export type SourceControlProviderInfo = typeof SourceControlProviderInfo.Type; | ||
| 20 | |||
| 21 | export const ChangeRequestState = Schema.Literals(["open", "closed", "merged"]); | ||
| 22 | export type ChangeRequestState = typeof ChangeRequestState.Type; | ||
| 23 | |||
| 24 | export const ChangeRequest = Schema.Struct({ | ||
| 25 | provider: SourceControlProviderKind, | ||
| 26 | number: PositiveInt, | ||
| 27 | title: TrimmedNonEmptyString, | ||
| 28 | url: Schema.String, | ||
| 29 | baseRefName: TrimmedNonEmptyString, | ||
| 30 | headRefName: TrimmedNonEmptyString, | ||
| 31 | state: ChangeRequestState, | ||
| 32 | updatedAt: Schema.Option(Schema.DateTimeUtc), | ||
| 33 | isCrossRepository: Schema.optional(Schema.Boolean), | ||
| 34 | headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | ||
| 35 | headRepositoryOwnerLogin: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | ||
| 36 | }); | ||
| 37 | export type ChangeRequest = typeof ChangeRequest.Type; | ||
| 38 | |||
| 39 | export const SourceControlRepositoryCloneUrls = Schema.Struct({ | ||
| 40 | nameWithOwner: TrimmedNonEmptyString, | ||
| 41 | url: TrimmedNonEmptyString, | ||
| 42 | sshUrl: TrimmedNonEmptyString, | ||
| 43 | }); | ||
| 44 | export type SourceControlRepositoryCloneUrls = typeof SourceControlRepositoryCloneUrls.Type; | ||
| 45 | |||
| 46 | export const SourceControlDiscoveryStatus = Schema.Literals(["available", "missing"]); | ||
| 47 | export type SourceControlDiscoveryStatus = typeof SourceControlDiscoveryStatus.Type; | ||
| 48 | |||
| 49 | export const SourceControlProviderAuthStatus = Schema.Literals([ | ||
| 50 | "authenticated", | ||
| 51 | "unauthenticated", | ||
| 52 | "unknown", | ||
| 53 | ]); | ||
| 54 | export type SourceControlProviderAuthStatus = typeof SourceControlProviderAuthStatus.Type; | ||
| 55 | |||
| 56 | export const SourceControlProviderAuth = Schema.Struct({ | ||
| 57 | status: SourceControlProviderAuthStatus, | ||
| 58 | account: Schema.Option(TrimmedNonEmptyString), | ||
| 59 | host: Schema.Option(TrimmedNonEmptyString), | ||
| 60 | detail: Schema.Option(TrimmedNonEmptyString), | ||
| 61 | }); | ||
| 62 | export type SourceControlProviderAuth = typeof SourceControlProviderAuth.Type; | ||
| 63 | |||
| 64 | const SourceControlDiscoveryItemFields = { | ||
| 65 | label: TrimmedNonEmptyString, | ||
| 66 | executable: TrimmedNonEmptyString, | ||
| 67 | implemented: Schema.Boolean, | ||
| 68 | status: SourceControlDiscoveryStatus, | ||
| 69 | version: Schema.Option(TrimmedNonEmptyString), | ||
| 70 | installHint: TrimmedNonEmptyString, | ||
| 71 | detail: Schema.Option(TrimmedNonEmptyString), | ||
| 72 | } as const; | ||
| 73 | |||
| 74 | export const SourceControlDiscoveryItem = Schema.Struct({ | ||
| 75 | kind: Schema.String, | ||
| 76 | ...SourceControlDiscoveryItemFields, | ||
| 77 | }); | ||
| 78 | export type SourceControlDiscoveryItem = typeof SourceControlDiscoveryItem.Type; | ||
| 79 | |||
| 80 | export const VcsDiscoveryItem = Schema.Struct({ | ||
| 81 | kind: VcsDriverKind, | ||
| 82 | ...SourceControlDiscoveryItemFields, | ||
| 83 | }); | ||
| 84 | export type VcsDiscoveryItem = typeof VcsDiscoveryItem.Type; | ||
| 85 | |||
| 86 | export const SourceControlProviderDiscoveryItem = Schema.Struct({ | ||
| 87 | kind: SourceControlProviderKind, | ||
| 88 | ...SourceControlDiscoveryItemFields, | ||
| 89 | auth: SourceControlProviderAuth, | ||
| 90 | }); | ||
| 91 | export type SourceControlProviderDiscoveryItem = typeof SourceControlProviderDiscoveryItem.Type; | ||
| 92 | |||
| 93 | export const SourceControlDiscoveryResult = Schema.Struct({ | ||
| 94 | versionControlSystems: Schema.Array(VcsDiscoveryItem), | ||
| 95 | sourceControlProviders: Schema.Array(SourceControlProviderDiscoveryItem), | ||
| 96 | }); | ||
| 97 | export type SourceControlDiscoveryResult = typeof SourceControlDiscoveryResult.Type; | ||
| 98 | |||
| 99 | export class SourceControlProviderError extends Schema.TaggedErrorClass<SourceControlProviderError>()( | ||
| 100 | "SourceControlProviderError", | ||
| 101 | { | ||
| 102 | provider: SourceControlProviderKind, | ||
| 103 | operation: Schema.String, | ||
| 104 | detail: Schema.String, | ||
| 105 | cause: Schema.optional(Schema.Defect), | ||
| 106 | }, | ||
| 107 | ) { | ||
| 108 | override get message(): string { | ||
| 109 | return `Source control provider ${this.provider} failed in ${this.operation}: ${this.detail}`; | ||
| 110 | } | ||
| 111 | } | ||
packages/contracts/src/vcs.ts created+154| ... | @@ -0,0 +1,154 @@ | ||
| 1 | import { Schema } from "effect"; | ||
| 2 | import { TrimmedNonEmptyString } from "./baseSchemas.ts"; | ||
| 3 | |||
| 4 | export const VcsDriverKind = Schema.Literals(["git", "jj", "unknown"]); | ||
| 5 | export type VcsDriverKind = typeof VcsDriverKind.Type; | ||
| 6 | |||
| 7 | export const VcsFreshnessSource = Schema.Literals([ | ||
| 8 | "live-local", | ||
| 9 | "cached-local", | ||
| 10 | "cached-remote", | ||
| 11 | "explicit-remote", | ||
| 12 | ]); | ||
| 13 | export type VcsFreshnessSource = typeof VcsFreshnessSource.Type; | ||
| 14 | |||
| 15 | export const VcsFreshness = Schema.Struct({ | ||
| 16 | source: VcsFreshnessSource, | ||
| 17 | observedAt: Schema.DateTimeUtc, | ||
| 18 | expiresAt: Schema.Option(Schema.DateTimeUtc), | ||
| 19 | }); | ||
| 20 | export type VcsFreshness = typeof VcsFreshness.Type; | ||
| 21 | |||
| 22 | export const VcsDriverCapabilities = Schema.Struct({ | ||
| 23 | kind: VcsDriverKind, | ||
| 24 | supportsWorktrees: Schema.Boolean, | ||
| 25 | supportsBookmarks: Schema.Boolean, | ||
| 26 | supportsAtomicSnapshot: Schema.Boolean, | ||
| 27 | supportsPushDefaultRemote: Schema.Boolean, | ||
| 28 | ignoreClassifier: Schema.Literals(["native", "git-compatible-fallback"]), | ||
| 29 | }); | ||
| 30 | export type VcsDriverCapabilities = typeof VcsDriverCapabilities.Type; | ||
| 31 | |||
| 32 | export const VcsRepositoryIdentity = Schema.Struct({ | ||
| 33 | kind: VcsDriverKind, | ||
| 34 | rootPath: TrimmedNonEmptyString, | ||
| 35 | metadataPath: Schema.NullOr(TrimmedNonEmptyString), | ||
| 36 | freshness: VcsFreshness, | ||
| 37 | }); | ||
| 38 | export type VcsRepositoryIdentity = typeof VcsRepositoryIdentity.Type; | ||
| 39 | |||
| 40 | export const VcsListWorkspaceFilesResult = Schema.Struct({ | ||
| 41 | paths: Schema.Array(TrimmedNonEmptyString), | ||
| 42 | truncated: Schema.Boolean, | ||
| 43 | freshness: VcsFreshness, | ||
| 44 | }); | ||
| 45 | export type VcsListWorkspaceFilesResult = typeof VcsListWorkspaceFilesResult.Type; | ||
| 46 | |||
| 47 | export const VcsRemote = Schema.Struct({ | ||
| 48 | name: TrimmedNonEmptyString, | ||
| 49 | url: TrimmedNonEmptyString, | ||
| 50 | pushUrl: Schema.Option(TrimmedNonEmptyString), | ||
| 51 | isPrimary: Schema.Boolean, | ||
| 52 | }); | ||
| 53 | export type VcsRemote = typeof VcsRemote.Type; | ||
| 54 | |||
| 55 | export const VcsListRemotesResult = Schema.Struct({ | ||
| 56 | remotes: Schema.Array(VcsRemote), | ||
| 57 | freshness: VcsFreshness, | ||
| 58 | }); | ||
| 59 | export type VcsListRemotesResult = typeof VcsListRemotesResult.Type; | ||
| 60 | |||
| 61 | export class VcsProcessSpawnError extends Schema.TaggedErrorClass<VcsProcessSpawnError>()( | ||
| 62 | "VcsProcessSpawnError", | ||
| 63 | { | ||
| 64 | operation: Schema.String, | ||
| 65 | command: Schema.String, | ||
| 66 | cwd: Schema.String, | ||
| 67 | cause: Schema.Defect, | ||
| 68 | }, | ||
| 69 | ) { | ||
| 70 | override get message(): string { | ||
| 71 | return `VCS process failed to spawn in ${this.operation}: ${this.command} (${this.cwd})`; | ||
| 72 | } | ||
| 73 | } | ||
| 74 | |||
| 75 | export class VcsProcessExitError extends Schema.TaggedErrorClass<VcsProcessExitError>()( | ||
| 76 | "VcsProcessExitError", | ||
| 77 | { | ||
| 78 | operation: Schema.String, | ||
| 79 | command: Schema.String, | ||
| 80 | cwd: Schema.String, | ||
| 81 | exitCode: Schema.Number, | ||
| 82 | detail: Schema.String, | ||
| 83 | }, | ||
| 84 | ) { | ||
| 85 | override get message(): string { | ||
| 86 | return `VCS process failed in ${this.operation}: ${this.command} (${this.cwd}) exited with ${this.exitCode} - ${this.detail}`; | ||
| 87 | } | ||
| 88 | } | ||
| 89 | |||
| 90 | export class VcsProcessTimeoutError extends Schema.TaggedErrorClass<VcsProcessTimeoutError>()( | ||
| 91 | "VcsProcessTimeoutError", | ||
| 92 | { | ||
| 93 | operation: Schema.String, | ||
| 94 | command: Schema.String, | ||
| 95 | cwd: Schema.String, | ||
| 96 | timeoutMs: Schema.Number, | ||
| 97 | }, | ||
| 98 | ) { | ||
| 99 | override get message(): string { | ||
| 100 | return `VCS process timed out in ${this.operation}: ${this.command} (${this.cwd}) after ${this.timeoutMs}ms`; | ||
| 101 | } | ||
| 102 | } | ||
| 103 | |||
| 104 | export class VcsOutputDecodeError extends Schema.TaggedErrorClass<VcsOutputDecodeError>()( | ||
| 105 | "VcsOutputDecodeError", | ||
| 106 | { | ||
| 107 | operation: Schema.String, | ||
| 108 | command: Schema.String, | ||
| 109 | cwd: Schema.String, | ||
| 110 | detail: Schema.String, | ||
| 111 | cause: Schema.optional(Schema.Defect), | ||
| 112 | }, | ||
| 113 | ) { | ||
| 114 | override get message(): string { | ||
| 115 | return `VCS output decode failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; | ||
| 116 | } | ||
| 117 | } | ||
| 118 | |||
| 119 | export class VcsRepositoryDetectionError extends Schema.TaggedErrorClass<VcsRepositoryDetectionError>()( | ||
| 120 | "VcsRepositoryDetectionError", | ||
| 121 | { | ||
| 122 | operation: Schema.String, | ||
| 123 | cwd: Schema.String, | ||
| 124 | detail: Schema.String, | ||
| 125 | cause: Schema.optional(Schema.Defect), | ||
| 126 | }, | ||
| 127 | ) { | ||
| 128 | override get message(): string { | ||
| 129 | return `VCS repository detection failed in ${this.operation}: ${this.cwd} - ${this.detail}`; | ||
| 130 | } | ||
| 131 | } | ||
| 132 | |||
| 133 | export class VcsUnsupportedOperationError extends Schema.TaggedErrorClass<VcsUnsupportedOperationError>()( | ||
| 134 | "VcsUnsupportedOperationError", | ||
| 135 | { | ||
| 136 | operation: Schema.String, | ||
| 137 | kind: VcsDriverKind, | ||
| 138 | detail: Schema.String, | ||
| 139 | }, | ||
| 140 | ) { | ||
| 141 | override get message(): string { | ||
| 142 | return `VCS operation is unsupported for ${this.kind} in ${this.operation}: ${this.detail}`; | ||
| 143 | } | ||
| 144 | } | ||
| 145 | |||
| 146 | export const VcsError = Schema.Union([ | ||
| 147 | VcsProcessSpawnError, | ||
| 148 | VcsProcessExitError, | ||
| 149 | VcsProcessTimeoutError, | ||
| 150 | VcsOutputDecodeError, | ||
| 151 | VcsRepositoryDetectionError, | ||
| 152 | VcsUnsupportedOperationError, | ||
| 153 | ]); | ||
| 154 | export type VcsError = typeof VcsError.Type; | ||
packages/shared/package.json+4| ... | @@ -12,6 +12,10 @@ | ... | @@ -12,6 +12,10 @@ |
| 12 | "types": "./src/git.ts", | 12 | "types": "./src/git.ts", |
| 13 | "import": "./src/git.ts" | 13 | "import": "./src/git.ts" |
| 14 | }, | 14 | }, |
| 15 | "./sourceControl": { | ||
| 16 | "types": "./src/sourceControl.ts", | ||
| 17 | "import": "./src/sourceControl.ts" | ||
| 18 | }, | ||
| 15 | "./logging": { | 19 | "./logging": { |
| 16 | "types": "./src/logging.ts", | 20 | "types": "./src/logging.ts", |
| 17 | "import": "./src/logging.ts" | 21 | "import": "./src/logging.ts" |
packages/shared/src/git.test.ts+14-14| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { GitStatusRemoteResult, GitStatusResult } from "@t3tools/contracts"; | 1 | import type { VcsStatusRemoteResult, VcsStatusResult } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vitest"; | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| ... | @@ -54,17 +54,17 @@ describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => { | ... | @@ -54,17 +54,17 @@ describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => { |
| 54 | }); | 54 | }); |
| 55 | 55 | ||
| 56 | describe("isTemporaryWorktreeBranch", () => { | 56 | describe("isTemporaryWorktreeBranch", () => { |
| 57 | it("matches the generated temporary worktree branch format", () => { | 57 | it("matches the generated temporary worktree refName format", () => { |
| 58 | expect(isTemporaryWorktreeBranch(buildTemporaryWorktreeBranchName())).toBe(true); | 58 | expect(isTemporaryWorktreeBranch(buildTemporaryWorktreeBranchName())).toBe(true); |
| 59 | }); | 59 | }); |
| 60 | 60 | ||
| 61 | it("matches generated temporary worktree branches", () => { | 61 | it("matches generated temporary worktree refs", () => { |
| 62 | expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/deadbeef`)).toBe(true); | 62 | expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/deadbeef`)).toBe(true); |
| 63 | expect(isTemporaryWorktreeBranch(` ${WORKTREE_BRANCH_PREFIX}/deadbeef `)).toBe(true); | 63 | expect(isTemporaryWorktreeBranch(` ${WORKTREE_BRANCH_PREFIX}/deadbeef `)).toBe(true); |
| 64 | expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/DEADBEEF`)).toBe(true); | 64 | expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/DEADBEEF`)).toBe(true); |
| 65 | }); | 65 | }); |
| 66 | 66 | ||
| 67 | it("rejects non-temporary branch names", () => { | 67 | it("rejects non-temporary refName names", () => { |
| 68 | expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/feature/demo`)).toBe(false); | 68 | expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/feature/demo`)).toBe(false); |
| 69 | expect(isTemporaryWorktreeBranch("main")).toBe(false); | 69 | expect(isTemporaryWorktreeBranch("main")).toBe(false); |
| 70 | expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/deadbeef-extra`)).toBe(false); | 70 | expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/deadbeef-extra`)).toBe(false); |
| ... | @@ -73,7 +73,7 @@ describe("isTemporaryWorktreeBranch", () => { | ... | @@ -73,7 +73,7 @@ describe("isTemporaryWorktreeBranch", () => { |
| 73 | 73 | ||
| 74 | describe("applyGitStatusStreamEvent", () => { | 74 | describe("applyGitStatusStreamEvent", () => { |
| 75 | it("treats a remote-only update as a repository when local state is missing", () => { | 75 | it("treats a remote-only update as a repository when local state is missing", () => { |
| 76 | const remote: GitStatusRemoteResult = { | 76 | const remote: VcsStatusRemoteResult = { |
| 77 | hasUpstream: true, | 77 | hasUpstream: true, |
| 78 | aheadCount: 2, | 78 | aheadCount: 2, |
| 79 | behindCount: 1, | 79 | behindCount: 1, |
| ... | @@ -82,9 +82,9 @@ describe("applyGitStatusStreamEvent", () => { | ... | @@ -82,9 +82,9 @@ describe("applyGitStatusStreamEvent", () => { |
| 82 | 82 | ||
| 83 | expect(applyGitStatusStreamEvent(null, { _tag: "remoteUpdated", remote })).toEqual({ | 83 | expect(applyGitStatusStreamEvent(null, { _tag: "remoteUpdated", remote })).toEqual({ |
| 84 | isRepo: true, | 84 | isRepo: true, |
| 85 | hasOriginRemote: false, | 85 | hasPrimaryRemote: false, |
| 86 | isDefaultBranch: false, | 86 | isDefaultRef: false, |
| 87 | branch: null, | 87 | refName: null, |
| 88 | hasWorkingTreeChanges: false, | 88 | hasWorkingTreeChanges: false, |
| 89 | workingTree: { files: [], insertions: 0, deletions: 0 }, | 89 | workingTree: { files: [], insertions: 0, deletions: 0 }, |
| 90 | hasUpstream: true, | 90 | hasUpstream: true, |
| ... | @@ -95,16 +95,16 @@ describe("applyGitStatusStreamEvent", () => { | ... | @@ -95,16 +95,16 @@ describe("applyGitStatusStreamEvent", () => { |
| 95 | }); | 95 | }); |
| 96 | 96 | ||
| 97 | it("preserves local-only fields when applying a remote update", () => { | 97 | it("preserves local-only fields when applying a remote update", () => { |
| 98 | const current: GitStatusResult = { | 98 | const current: VcsStatusResult = { |
| 99 | isRepo: true, | 99 | isRepo: true, |
| 100 | hostingProvider: { | 100 | sourceControlProvider: { |
| 101 | kind: "github", | 101 | kind: "github", |
| 102 | name: "GitHub", | 102 | name: "GitHub", |
| 103 | baseUrl: "https://github.com", | 103 | baseUrl: "https://github.com", |
| 104 | }, | 104 | }, |
| 105 | hasOriginRemote: true, | 105 | hasPrimaryRemote: true, |
| 106 | isDefaultBranch: false, | 106 | isDefaultRef: false, |
| 107 | branch: "feature/demo", | 107 | refName: "feature/demo", |
| 108 | hasWorkingTreeChanges: true, | 108 | hasWorkingTreeChanges: true, |
| 109 | workingTree: { | 109 | workingTree: { |
| 110 | files: [{ path: "src/demo.ts", insertions: 1, deletions: 0 }], | 110 | files: [{ path: "src/demo.ts", insertions: 1, deletions: 0 }], |
| ... | @@ -117,7 +117,7 @@ describe("applyGitStatusStreamEvent", () => { | ... | @@ -117,7 +117,7 @@ describe("applyGitStatusStreamEvent", () => { |
| 117 | pr: null, | 117 | pr: null, |
| 118 | }; | 118 | }; |
| 119 | 119 | ||
| 120 | const remote: GitStatusRemoteResult = { | 120 | const remote: VcsStatusRemoteResult = { |
| 121 | hasUpstream: true, | 121 | hasUpstream: true, |
| 122 | aheadCount: 2, | 122 | aheadCount: 2, |
| 123 | behindCount: 1, | 123 | behindCount: 1, |
packages/shared/src/git.ts+44-100| ... | @@ -1,19 +1,20 @@ | ... | @@ -1,19 +1,20 @@ |
| 1 | import type { | 1 | import type { |
| 2 | GitBranch, | 2 | VcsRef, |
| 3 | GitHostingProvider, | 3 | SourceControlProviderInfo, |
| 4 | GitStatusLocalResult, | 4 | VcsStatusLocalResult, |
| 5 | GitStatusRemoteResult, | 5 | VcsStatusRemoteResult, |
| 6 | GitStatusResult, | 6 | VcsStatusResult, |
| 7 | GitStatusStreamEvent, | 7 | VcsStatusStreamEvent, |
| 8 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 9 | import * as Effect from "effect/Effect"; | 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as Random from "effect/Random"; | 10 | import * as Random from "effect/Random"; |
| 11 | import { detectSourceControlProviderFromRemoteUrl } from "./sourceControl.ts"; | ||
| 11 | 12 | ||
| 12 | export const WORKTREE_BRANCH_PREFIX = "t3code"; | 13 | export const WORKTREE_BRANCH_PREFIX = "t3code"; |
| 13 | const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); | 14 | const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); |
| 14 | 15 | ||
| 15 | /** | 16 | /** |
| 16 | * Sanitize an arbitrary string into a valid, lowercase git branch fragment. | 17 | * Sanitize an arbitrary string into a valid, lowercase git refName fragment. |
| 17 | * Strips quotes, collapses separators, limits to 64 chars. | 18 | * Strips quotes, collapses separators, limits to 64 chars. |
| 18 | */ | 19 | */ |
| 19 | export function sanitizeBranchFragment(raw: string): string { | 20 | export function sanitizeBranchFragment(raw: string): string { |
| ... | @@ -35,7 +36,7 @@ export function sanitizeBranchFragment(raw: string): string { | ... | @@ -35,7 +36,7 @@ export function sanitizeBranchFragment(raw: string): string { |
| 35 | } | 36 | } |
| 36 | 37 | ||
| 37 | /** | 38 | /** |
| 38 | * Sanitize a string into a `feature/…` branch name. | 39 | * Sanitize a string into a `feature/…` refName name. |
| 39 | * Preserves an existing `feature/` prefix or slash-separated namespace. | 40 | * Preserves an existing `feature/` prefix or slash-separated namespace. |
| 40 | */ | 41 | */ |
| 41 | export function sanitizeFeatureBranchName(raw: string): string { | 42 | export function sanitizeFeatureBranchName(raw: string): string { |
| ... | @@ -49,8 +50,8 @@ export function sanitizeFeatureBranchName(raw: string): string { | ... | @@ -49,8 +50,8 @@ export function sanitizeFeatureBranchName(raw: string): string { |
| 49 | const AUTO_FEATURE_BRANCH_FALLBACK = "feature/update"; | 50 | const AUTO_FEATURE_BRANCH_FALLBACK = "feature/update"; |
| 50 | 51 | ||
| 51 | /** | 52 | /** |
| 52 | * Resolve a unique `feature/…` branch name that doesn't collide with | 53 | * Resolve a unique `feature/…` refName name that doesn't collide with |
| 53 | * any existing branch. Appends a numeric suffix when needed. | 54 | * any existing refName. Appends a numeric suffix when needed. |
| 54 | */ | 55 | */ |
| 55 | export function resolveAutoFeatureBranchName( | 56 | export function resolveAutoFeatureBranchName( |
| 56 | existingBranchNames: readonly string[], | 57 | existingBranchNames: readonly string[], |
| ... | @@ -60,7 +61,7 @@ export function resolveAutoFeatureBranchName( | ... | @@ -60,7 +61,7 @@ export function resolveAutoFeatureBranchName( |
| 60 | const resolvedBase = sanitizeFeatureBranchName( | 61 | const resolvedBase = sanitizeFeatureBranchName( |
| 61 | preferred && preferred.length > 0 ? preferred : AUTO_FEATURE_BRANCH_FALLBACK, | 62 | preferred && preferred.length > 0 ? preferred : AUTO_FEATURE_BRANCH_FALLBACK, |
| 62 | ); | 63 | ); |
| 63 | const existingNames = new Set(existingBranchNames.map((branch) => branch.toLowerCase())); | 64 | const existingNames = new Set(existingBranchNames.map((refName) => refName.toLowerCase())); |
| 64 | 65 | ||
| 65 | if (!existingNames.has(resolvedBase)) { | 66 | if (!existingNames.has(resolvedBase)) { |
| 66 | return resolvedBase; | 67 | return resolvedBase; |
| ... | @@ -90,8 +91,8 @@ export function buildTemporaryWorktreeBranchName(): string { | ... | @@ -90,8 +91,8 @@ export function buildTemporaryWorktreeBranchName(): string { |
| 90 | return `${WORKTREE_BRANCH_PREFIX}/${token}`; | 91 | return `${WORKTREE_BRANCH_PREFIX}/${token}`; |
| 91 | } | 92 | } |
| 92 | 93 | ||
| 93 | export function isTemporaryWorktreeBranch(branch: string): boolean { | 94 | export function isTemporaryWorktreeBranch(refName: string): boolean { |
| 94 | return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase()); | 95 | return TEMP_WORKTREE_BRANCH_PATTERN.test(refName.trim().toLowerCase()); |
| 95 | } | 96 | } |
| 96 | 97 | ||
| 97 | /** | 98 | /** |
| ... | @@ -165,98 +166,39 @@ function deriveLocalBranchNameCandidatesFromRemoteRef( | ... | @@ -165,98 +166,39 @@ function deriveLocalBranchNameCandidatesFromRemoteRef( |
| 165 | } | 166 | } |
| 166 | 167 | ||
| 167 | /** | 168 | /** |
| 168 | * Hide `origin/*` remote refs when a matching local branch already exists. | 169 | * Hide `origin/*` remote refs when a matching local refName already exists. |
| 169 | */ | 170 | */ |
| 170 | export function dedupeRemoteBranchesWithLocalMatches( | 171 | export function dedupeRemoteBranchesWithLocalMatches( |
| 171 | branches: ReadonlyArray<GitBranch>, | 172 | refs: ReadonlyArray<VcsRef>, |
| 172 | ): ReadonlyArray<GitBranch> { | 173 | ): ReadonlyArray<VcsRef> { |
| 173 | const localBranchNames = new Set( | 174 | const localBranchNames = new Set( |
| 174 | branches.filter((branch) => !branch.isRemote).map((branch) => branch.name), | 175 | refs.filter((refName) => !refName.isRemote).map((refName) => refName.name), |
| 175 | ); | 176 | ); |
| 176 | 177 | ||
| 177 | return branches.filter((branch) => { | 178 | return refs.filter((refName) => { |
| 178 | if (!branch.isRemote) { | 179 | if (!refName.isRemote) { |
| 179 | return true; | 180 | return true; |
| 180 | } | 181 | } |
| 181 | 182 | ||
| 182 | if (branch.remoteName !== "origin") { | 183 | if (refName.remoteName !== "origin") { |
| 183 | return true; | 184 | return true; |
| 184 | } | 185 | } |
| 185 | 186 | ||
| 186 | const localBranchCandidates = deriveLocalBranchNameCandidatesFromRemoteRef( | 187 | const localBranchCandidates = deriveLocalBranchNameCandidatesFromRemoteRef( |
| 187 | branch.name, | 188 | refName.name, |
| 188 | branch.remoteName, | 189 | refName.remoteName, |
| 189 | ); | 190 | ); |
| 190 | return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate)); | 191 | return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate)); |
| 191 | }); | 192 | }); |
| 192 | } | 193 | } |
| 193 | 194 | ||
| 194 | function parseGitRemoteHost(remoteUrl: string): string | null { | 195 | export function detectSourceControlProviderFromGitRemoteUrl( |
| 195 | const trimmed = remoteUrl.trim(); | ||
| 196 | if (trimmed.length === 0) { | ||
| 197 | return null; | ||
| 198 | } | ||
| 199 | |||
| 200 | if (trimmed.startsWith("git@")) { | ||
| 201 | const hostWithPath = trimmed.slice("git@".length); | ||
| 202 | const separatorIndex = hostWithPath.search(/[:/]/); | ||
| 203 | if (separatorIndex <= 0) { | ||
| 204 | return null; | ||
| 205 | } | ||
| 206 | return hostWithPath.slice(0, separatorIndex).toLowerCase(); | ||
| 207 | } | ||
| 208 | |||
| 209 | try { | ||
| 210 | return new URL(trimmed).hostname.toLowerCase(); | ||
| 211 | } catch { | ||
| 212 | return null; | ||
| 213 | } | ||
| 214 | } | ||
| 215 | |||
| 216 | function toBaseUrl(host: string): string { | ||
| 217 | return `https://${host}`; | ||
| 218 | } | ||
| 219 | |||
| 220 | function isGitHubHost(host: string): boolean { | ||
| 221 | return host === "github.com" || host.includes("github"); | ||
| 222 | } | ||
| 223 | |||
| 224 | function isGitLabHost(host: string): boolean { | ||
| 225 | return host === "gitlab.com" || host.includes("gitlab"); | ||
| 226 | } | ||
| 227 | |||
| 228 | export function detectGitHostingProviderFromRemoteUrl( | ||
| 229 | remoteUrl: string, | 196 | remoteUrl: string, |
| 230 | ): GitHostingProvider | null { | 197 | ): SourceControlProviderInfo | null { |
| 231 | const host = parseGitRemoteHost(remoteUrl); | 198 | return detectSourceControlProviderFromRemoteUrl(remoteUrl); |
| 232 | if (!host) { | ||
| 233 | return null; | ||
| 234 | } | ||
| 235 | |||
| 236 | if (isGitHubHost(host)) { | ||
| 237 | return { | ||
| 238 | kind: "github", | ||
| 239 | name: host === "github.com" ? "GitHub" : "GitHub Self-Hosted", | ||
| 240 | baseUrl: toBaseUrl(host), | ||
| 241 | }; | ||
| 242 | } | ||
| 243 | |||
| 244 | if (isGitLabHost(host)) { | ||
| 245 | return { | ||
| 246 | kind: "gitlab", | ||
| 247 | name: host === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted", | ||
| 248 | baseUrl: toBaseUrl(host), | ||
| 249 | }; | ||
| 250 | } | ||
| 251 | |||
| 252 | return { | ||
| 253 | kind: "unknown", | ||
| 254 | name: host, | ||
| 255 | baseUrl: toBaseUrl(host), | ||
| 256 | }; | ||
| 257 | } | 199 | } |
| 258 | 200 | ||
| 259 | const EMPTY_GIT_STATUS_REMOTE: GitStatusRemoteResult = { | 201 | const EMPTY_GIT_STATUS_REMOTE: VcsStatusRemoteResult = { |
| 260 | hasUpstream: false, | 202 | hasUpstream: false, |
| 261 | aheadCount: 0, | 203 | aheadCount: 0, |
| 262 | behindCount: 0, | 204 | behindCount: 0, |
| ... | @@ -264,16 +206,16 @@ const EMPTY_GIT_STATUS_REMOTE: GitStatusRemoteResult = { | ... | @@ -264,16 +206,16 @@ const EMPTY_GIT_STATUS_REMOTE: GitStatusRemoteResult = { |
| 264 | }; | 206 | }; |
| 265 | 207 | ||
| 266 | export function mergeGitStatusParts( | 208 | export function mergeGitStatusParts( |
| 267 | local: GitStatusLocalResult, | 209 | local: VcsStatusLocalResult, |
| 268 | remote: GitStatusRemoteResult | null, | 210 | remote: VcsStatusRemoteResult | null, |
| 269 | ): GitStatusResult { | 211 | ): VcsStatusResult { |
| 270 | return { | 212 | return { |
| 271 | ...local, | 213 | ...local, |
| 272 | ...(remote ?? EMPTY_GIT_STATUS_REMOTE), | 214 | ...(remote ?? EMPTY_GIT_STATUS_REMOTE), |
| 273 | }; | 215 | }; |
| 274 | } | 216 | } |
| 275 | 217 | ||
| 276 | function toRemoteStatusPart(status: GitStatusResult): GitStatusRemoteResult { | 218 | function toRemoteStatusPart(status: VcsStatusResult): VcsStatusRemoteResult { |
| 277 | return { | 219 | return { |
| 278 | hasUpstream: status.hasUpstream, | 220 | hasUpstream: status.hasUpstream, |
| 279 | aheadCount: status.aheadCount, | 221 | aheadCount: status.aheadCount, |
| ... | @@ -282,22 +224,24 @@ function toRemoteStatusPart(status: GitStatusResult): GitStatusRemoteResult { | ... | @@ -282,22 +224,24 @@ function toRemoteStatusPart(status: GitStatusResult): GitStatusRemoteResult { |
| 282 | }; | 224 | }; |
| 283 | } | 225 | } |
| 284 | 226 | ||
| 285 | function toLocalStatusPart(status: GitStatusResult): GitStatusLocalResult { | 227 | function toLocalStatusPart(status: VcsStatusResult): VcsStatusLocalResult { |
| 286 | return { | 228 | return { |
| 287 | isRepo: status.isRepo, | 229 | isRepo: status.isRepo, |
| 288 | ...(status.hostingProvider ? { hostingProvider: status.hostingProvider } : {}), | 230 | ...(status.sourceControlProvider |
| 289 | hasOriginRemote: status.hasOriginRemote, | 231 | ? { sourceControlProvider: status.sourceControlProvider } |
| 290 | isDefaultBranch: status.isDefaultBranch, | 232 | : {}), |
| 291 | branch: status.branch, | 233 | hasPrimaryRemote: status.hasPrimaryRemote, |
| 234 | isDefaultRef: status.isDefaultRef, | ||
| 235 | refName: status.refName, | ||
| 292 | hasWorkingTreeChanges: status.hasWorkingTreeChanges, | 236 | hasWorkingTreeChanges: status.hasWorkingTreeChanges, |
| 293 | workingTree: status.workingTree, | 237 | workingTree: status.workingTree, |
| 294 | }; | 238 | }; |
| 295 | } | 239 | } |
| 296 | 240 | ||
| 297 | export function applyGitStatusStreamEvent( | 241 | export function applyGitStatusStreamEvent( |
| 298 | current: GitStatusResult | null, | 242 | current: VcsStatusResult | null, |
| 299 | event: GitStatusStreamEvent, | 243 | event: VcsStatusStreamEvent, |
| 300 | ): GitStatusResult { | 244 | ): VcsStatusResult { |
| 301 | switch (event._tag) { | 245 | switch (event._tag) { |
| 302 | case "snapshot": | 246 | case "snapshot": |
| 303 | return mergeGitStatusParts(event.local, event.remote); | 247 | return mergeGitStatusParts(event.local, event.remote); |
| ... | @@ -308,9 +252,9 @@ export function applyGitStatusStreamEvent( | ... | @@ -308,9 +252,9 @@ export function applyGitStatusStreamEvent( |
| 308 | return mergeGitStatusParts( | 252 | return mergeGitStatusParts( |
| 309 | { | 253 | { |
| 310 | isRepo: true, | 254 | isRepo: true, |
| 311 | hasOriginRemote: false, | 255 | hasPrimaryRemote: false, |
| 312 | isDefaultBranch: false, | 256 | isDefaultRef: false, |
| 313 | branch: null, | 257 | refName: null, |
| 314 | hasWorkingTreeChanges: false, | 258 | hasWorkingTreeChanges: false, |
| 315 | workingTree: { files: [], insertions: 0, deletions: 0 }, | 259 | workingTree: { files: [], insertions: 0, deletions: 0 }, |
| 316 | }, | 260 | }, |
packages/shared/src/sourceControl.test.ts created+59| ... | @@ -0,0 +1,59 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | detectSourceControlProviderFromRemoteUrl, | ||
| 5 | getChangeRequestTerminologyForKind, | ||
| 6 | resolveChangeRequestPresentation, | ||
| 7 | } from "./sourceControl.ts"; | ||
| 8 | |||
| 9 | describe("source control presentation", () => { | ||
| 10 | it("uses merge request terminology for GitLab", () => { | ||
| 11 | expect(getChangeRequestTerminologyForKind("gitlab")).toEqual({ | ||
| 12 | shortLabel: "MR", | ||
| 13 | singular: "merge request", | ||
| 14 | }); | ||
| 15 | }); | ||
| 16 | |||
| 17 | it("uses pull request terminology for GitHub-compatible providers", () => { | ||
| 18 | expect(getChangeRequestTerminologyForKind("github")).toEqual({ | ||
| 19 | shortLabel: "PR", | ||
| 20 | singular: "pull request", | ||
| 21 | }); | ||
| 22 | expect(getChangeRequestTerminologyForKind("azure-devops")).toEqual({ | ||
| 23 | shortLabel: "PR", | ||
| 24 | singular: "pull request", | ||
| 25 | }); | ||
| 26 | expect(getChangeRequestTerminologyForKind("bitbucket")).toEqual({ | ||
| 27 | shortLabel: "PR", | ||
| 28 | singular: "pull request", | ||
| 29 | }); | ||
| 30 | }); | ||
| 31 | |||
| 32 | it("falls back to generic change request copy for unknown providers", () => { | ||
| 33 | expect( | ||
| 34 | resolveChangeRequestPresentation({ kind: "unknown", name: "forge", baseUrl: "" }), | ||
| 35 | ).toEqual( | ||
| 36 | expect.objectContaining({ | ||
| 37 | shortName: "change request", | ||
| 38 | longName: "change request", | ||
| 39 | }), | ||
| 40 | ); | ||
| 41 | }); | ||
| 42 | }); | ||
| 43 | |||
| 44 | describe("detectSourceControlProviderFromRemoteUrl", () => { | ||
| 45 | it("detects common source control hosts", () => { | ||
| 46 | expect(detectSourceControlProviderFromRemoteUrl("git@github.com:owner/repo.git")?.kind).toBe( | ||
| 47 | "github", | ||
| 48 | ); | ||
| 49 | expect( | ||
| 50 | detectSourceControlProviderFromRemoteUrl("https://gitlab.com/group/repo.git")?.kind, | ||
| 51 | ).toBe("gitlab"); | ||
| 52 | expect( | ||
| 53 | detectSourceControlProviderFromRemoteUrl("https://dev.azure.com/org/project/_git/repo")?.kind, | ||
| 54 | ).toBe("azure-devops"); | ||
| 55 | expect( | ||
| 56 | detectSourceControlProviderFromRemoteUrl("git@bitbucket.org:workspace/repo.git")?.kind, | ||
| 57 | ).toBe("bitbucket"); | ||
| 58 | }); | ||
| 59 | }); | ||
packages/shared/src/sourceControl.ts created+225| ... | @@ -0,0 +1,225 @@ | ||
| 1 | import type { SourceControlProviderInfo, SourceControlProviderKind } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | export interface ChangeRequestPresentation { | ||
| 4 | readonly icon: "github" | "gitlab" | "azure-devops" | "bitbucket" | "change-request"; | ||
| 5 | readonly providerName: string; | ||
| 6 | readonly shortName: string; | ||
| 7 | readonly longName: string; | ||
| 8 | readonly pluralLongName: string; | ||
| 9 | readonly providerLongName: string; | ||
| 10 | readonly checkoutCommandExample: string; | ||
| 11 | readonly urlExample: string; | ||
| 12 | } | ||
| 13 | |||
| 14 | export interface ChangeRequestTerminology { | ||
| 15 | readonly shortLabel: string; | ||
| 16 | readonly singular: string; | ||
| 17 | } | ||
| 18 | |||
| 19 | export const DEFAULT_CHANGE_REQUEST_TERMINOLOGY: ChangeRequestTerminology = { | ||
| 20 | shortLabel: "PR", | ||
| 21 | singular: "pull request", | ||
| 22 | }; | ||
| 23 | |||
| 24 | const GITHUB_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { | ||
| 25 | icon: "github", | ||
| 26 | providerName: "GitHub", | ||
| 27 | shortName: "PR", | ||
| 28 | longName: "pull request", | ||
| 29 | pluralLongName: "pull requests", | ||
| 30 | providerLongName: "GitHub pull request", | ||
| 31 | checkoutCommandExample: "gh pr checkout 123", | ||
| 32 | urlExample: "https://github.com/owner/repo/pull/42", | ||
| 33 | }; | ||
| 34 | |||
| 35 | const GITLAB_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { | ||
| 36 | icon: "gitlab", | ||
| 37 | providerName: "GitLab", | ||
| 38 | shortName: "MR", | ||
| 39 | longName: "merge request", | ||
| 40 | pluralLongName: "merge requests", | ||
| 41 | providerLongName: "GitLab merge request", | ||
| 42 | checkoutCommandExample: "glab mr checkout 123", | ||
| 43 | urlExample: "https://gitlab.com/group/project/-/merge_requests/42", | ||
| 44 | }; | ||
| 45 | |||
| 46 | const AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { | ||
| 47 | icon: "azure-devops", | ||
| 48 | providerName: "Azure DevOps", | ||
| 49 | shortName: "PR", | ||
| 50 | longName: "pull request", | ||
| 51 | pluralLongName: "pull requests", | ||
| 52 | providerLongName: "Azure DevOps pull request", | ||
| 53 | checkoutCommandExample: "az repos pr checkout --id 123", | ||
| 54 | urlExample: "https://dev.azure.com/org/project/_git/repo/pullrequest/42", | ||
| 55 | }; | ||
| 56 | |||
| 57 | const BITBUCKET_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { | ||
| 58 | icon: "bitbucket", | ||
| 59 | providerName: "Bitbucket", | ||
| 60 | shortName: "PR", | ||
| 61 | longName: "pull request", | ||
| 62 | pluralLongName: "pull requests", | ||
| 63 | providerLongName: "Bitbucket pull request", | ||
| 64 | checkoutCommandExample: "bb pr checkout 123", | ||
| 65 | urlExample: "https://bitbucket.org/workspace/repo/pull-requests/42", | ||
| 66 | }; | ||
| 67 | |||
| 68 | const GENERIC_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { | ||
| 69 | icon: "change-request", | ||
| 70 | providerName: "source control", | ||
| 71 | shortName: "change request", | ||
| 72 | longName: "change request", | ||
| 73 | pluralLongName: "change requests", | ||
| 74 | providerLongName: "change request", | ||
| 75 | checkoutCommandExample: "123", | ||
| 76 | urlExample: "#42", | ||
| 77 | }; | ||
| 78 | |||
| 79 | export function resolveChangeRequestPresentation( | ||
| 80 | provider: SourceControlProviderInfo | null | undefined, | ||
| 81 | ): ChangeRequestPresentation { | ||
| 82 | switch (provider?.kind) { | ||
| 83 | case "github": | ||
| 84 | case undefined: | ||
| 85 | return GITHUB_CHANGE_REQUEST_PRESENTATION; | ||
| 86 | case "gitlab": | ||
| 87 | return GITLAB_CHANGE_REQUEST_PRESENTATION; | ||
| 88 | case "azure-devops": | ||
| 89 | return AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION; | ||
| 90 | case "bitbucket": | ||
| 91 | return BITBUCKET_CHANGE_REQUEST_PRESENTATION; | ||
| 92 | case "unknown": | ||
| 93 | return GENERIC_CHANGE_REQUEST_PRESENTATION; | ||
| 94 | } | ||
| 95 | } | ||
| 96 | |||
| 97 | export function resolveChangeRequestPresentationForKind( | ||
| 98 | kind: SourceControlProviderKind, | ||
| 99 | ): ChangeRequestPresentation { | ||
| 100 | return resolveChangeRequestPresentation({ kind, name: "", baseUrl: "" }); | ||
| 101 | } | ||
| 102 | |||
| 103 | export function formatChangeRequestAction( | ||
| 104 | verb: "View" | "Create", | ||
| 105 | presentation: ChangeRequestPresentation, | ||
| 106 | ): string { | ||
| 107 | return `${verb} ${presentation.shortName}`; | ||
| 108 | } | ||
| 109 | |||
| 110 | export function formatCreateChangeRequestPhrase(presentation: ChangeRequestPresentation): string { | ||
| 111 | return `create ${presentation.shortName}`; | ||
| 112 | } | ||
| 113 | |||
| 114 | export function getChangeRequestTerminology( | ||
| 115 | provider: SourceControlProviderInfo | null | undefined, | ||
| 116 | ): ChangeRequestTerminology { | ||
| 117 | if (!provider) { | ||
| 118 | return DEFAULT_CHANGE_REQUEST_TERMINOLOGY; | ||
| 119 | } | ||
| 120 | |||
| 121 | const presentation = resolveChangeRequestPresentation(provider); | ||
| 122 | return { | ||
| 123 | shortLabel: presentation.shortName, | ||
| 124 | singular: presentation.longName, | ||
| 125 | }; | ||
| 126 | } | ||
| 127 | |||
| 128 | export function getChangeRequestTerminologyForKind( | ||
| 129 | kind: SourceControlProviderKind, | ||
| 130 | ): ChangeRequestTerminology { | ||
| 131 | const presentation = resolveChangeRequestPresentationForKind(kind); | ||
| 132 | return { | ||
| 133 | shortLabel: presentation.shortName, | ||
| 134 | singular: presentation.longName, | ||
| 135 | }; | ||
| 136 | } | ||
| 137 | |||
| 138 | function parseRemoteHost(remoteUrl: string): string | null { | ||
| 139 | const trimmed = remoteUrl.trim(); | ||
| 140 | if (trimmed.length === 0) { | ||
| 141 | return null; | ||
| 142 | } | ||
| 143 | |||
| 144 | if (trimmed.startsWith("git@")) { | ||
| 145 | const hostWithPath = trimmed.slice("git@".length); | ||
| 146 | const separatorIndex = hostWithPath.search(/[:/]/); | ||
| 147 | if (separatorIndex <= 0) { | ||
| 148 | return null; | ||
| 149 | } | ||
| 150 | return hostWithPath.slice(0, separatorIndex).toLowerCase(); | ||
| 151 | } | ||
| 152 | |||
| 153 | try { | ||
| 154 | return new URL(trimmed).hostname.toLowerCase(); | ||
| 155 | } catch { | ||
| 156 | return null; | ||
| 157 | } | ||
| 158 | } | ||
| 159 | |||
| 160 | function toBaseUrl(host: string): string { | ||
| 161 | return `https://${host}`; | ||
| 162 | } | ||
| 163 | |||
| 164 | function isGitHubHost(host: string): boolean { | ||
| 165 | return host === "github.com" || host.includes("github"); | ||
| 166 | } | ||
| 167 | |||
| 168 | function isGitLabHost(host: string): boolean { | ||
| 169 | return host === "gitlab.com" || host.includes("gitlab"); | ||
| 170 | } | ||
| 171 | |||
| 172 | function isAzureDevOpsHost(host: string): boolean { | ||
| 173 | return host === "dev.azure.com" || host.endsWith(".visualstudio.com"); | ||
| 174 | } | ||
| 175 | |||
| 176 | function isBitbucketHost(host: string): boolean { | ||
| 177 | return host === "bitbucket.org" || host.includes("bitbucket"); | ||
| 178 | } | ||
| 179 | |||
| 180 | export function detectSourceControlProviderFromRemoteUrl( | ||
| 181 | remoteUrl: string, | ||
| 182 | ): SourceControlProviderInfo | null { | ||
| 183 | const host = parseRemoteHost(remoteUrl); | ||
| 184 | if (!host) { | ||
| 185 | return null; | ||
| 186 | } | ||
| 187 | |||
| 188 | if (isGitHubHost(host)) { | ||
| 189 | return { | ||
| 190 | kind: "github", | ||
| 191 | name: host === "github.com" ? "GitHub" : "GitHub Self-Hosted", | ||
| 192 | baseUrl: toBaseUrl(host), | ||
| 193 | }; | ||
| 194 | } | ||
| 195 | |||
| 196 | if (isGitLabHost(host)) { | ||
| 197 | return { | ||
| 198 | kind: "gitlab", | ||
| 199 | name: host === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted", | ||
| 200 | baseUrl: toBaseUrl(host), | ||
| 201 | }; | ||
| 202 | } | ||
| 203 | |||
| 204 | if (isAzureDevOpsHost(host)) { | ||
| 205 | return { | ||
| 206 | kind: "azure-devops", | ||
| 207 | name: "Azure DevOps", | ||
| 208 | baseUrl: toBaseUrl(host), | ||
| 209 | }; | ||
| 210 | } | ||
| 211 | |||
| 212 | if (isBitbucketHost(host)) { | ||
| 213 | return { | ||
| 214 | kind: "bitbucket", | ||
| 215 | name: host === "bitbucket.org" ? "Bitbucket" : "Bitbucket Self-Hosted", | ||
| 216 | baseUrl: toBaseUrl(host), | ||
| 217 | }; | ||
| 218 | } | ||
| 219 | |||
| 220 | return { | ||
| 221 | kind: "unknown", | ||
| 222 | name: host, | ||
| 223 | baseUrl: toBaseUrl(host), | ||
| 224 | }; | ||
| 225 | } | ||