| 1 | import { assert, describe, expect, it, vi } from "@effect/vitest"; |
| 2 | import * as DateTime from "effect/DateTime"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as Option from "effect/Option"; |
| 6 | |
| 7 | import { VcsRepositoryDetectionError } from "@t3tools/contracts"; |
| 8 | |
| 9 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 10 | import * as FileSystem from "effect/FileSystem"; |
| 11 | import * as Path from "effect/Path"; |
| 12 | |
| 13 | import * as ServerConfig from "../config.ts"; |
| 14 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 15 | import * as GitManager from "./GitManager.ts"; |
| 16 | import * as GitWorkflowService from "./GitWorkflowService.ts"; |
| 17 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 18 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 19 | |
| 20 | function layer(input: { |
| 21 | readonly detect: VcsDriverRegistry.VcsDriverRegistry["Service"]["detect"]; |
| 22 | }) { |
| 23 | return GitWorkflowService.layer.pipe( |
| 24 | Layer.provide( |
| 25 | Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ |
| 26 | detect: input.detect, |
| 27 | }), |
| 28 | ), |
| 29 | Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), |
| 30 | Layer.provide(Layer.mock(GitManager.GitManager)({})), |
| 31 | Layer.provide(NodeServices.layer), |
| 32 | ); |
| 33 | } |
| 34 | |
| 35 | describe("GitWorkflowService", () => { |
| 36 | it.effect("reports a non-Git VCS repository as not a Git repository", () => |
| 37 | Effect.gen(function* () { |
| 38 | const workflow = yield* GitWorkflowService.GitWorkflowService; |
| 39 | const isRepository = yield* workflow.isRepository("/jj-repo"); |
| 40 | |
| 41 | assert.equal(isRepository, false); |
| 42 | }).pipe( |
| 43 | Effect.provide( |
| 44 | layer({ |
| 45 | detect: () => |
| 46 | Effect.succeed({ |
| 47 | kind: "jj", |
| 48 | repository: { |
| 49 | kind: "jj", |
| 50 | rootPath: "/jj-repo", |
| 51 | metadataPath: "/jj-repo/.jj", |
| 52 | freshness: { |
| 53 | source: "live-local", |
| 54 | observedAt: DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"), |
| 55 | expiresAt: Option.none(), |
| 56 | }, |
| 57 | }, |
| 58 | driver: {} as VcsDriverRegistry.VcsDriverHandle["driver"], |
| 59 | }), |
| 60 | }), |
| 61 | ), |
| 62 | ), |
| 63 | ); |
| 64 | |
| 65 | it.effect("returns an empty local status when no VCS repository is detected", () => |
| 66 | Effect.gen(function* () { |
| 67 | const workflow = yield* GitWorkflowService.GitWorkflowService; |
| 68 | const status = yield* workflow.localStatus({ cwd: "/not-a-repo" }); |
| 69 | |
| 70 | assert.deepStrictEqual(status, { |
| 71 | isRepo: false, |
| 72 | hasPrimaryRemote: false, |
| 73 | isDefaultRef: false, |
| 74 | refName: null, |
| 75 | hasWorkingTreeChanges: false, |
| 76 | workingTree: { |
| 77 | files: [], |
| 78 | insertions: 0, |
| 79 | deletions: 0, |
| 80 | }, |
| 81 | }); |
| 82 | }).pipe( |
| 83 | Effect.provide( |
| 84 | layer({ |
| 85 | detect: () => Effect.succeed(null), |
| 86 | }), |
| 87 | ), |
| 88 | ), |
| 89 | ); |
| 90 | |
| 91 | it.effect( |
| 92 | "keeps non-colocated Jujutsu changes available without reporting a Git repository", |
| 93 | () => |
| 94 | Effect.gen(function* () { |
| 95 | const workflow = yield* GitWorkflowService.GitWorkflowService; |
| 96 | const fs = yield* FileSystem.FileSystem; |
| 97 | const path = yield* Path.Path; |
| 98 | const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-jj-workflow-" }); |
| 99 | yield* fs.makeDirectory(path.join(root, ".jj")); |
| 100 | const nested = path.join(root, "packages", "app"); |
| 101 | yield* fs.makeDirectory(nested, { recursive: true }); |
| 102 | |
| 103 | for (const cwd of [root, nested]) { |
| 104 | const local = yield* workflow.localStatus({ cwd }); |
| 105 | const status = yield* workflow.status({ cwd }); |
| 106 | expect(local.isRepo).toBe(false); |
| 107 | expect(local.isJjWorkspace).toBe(true); |
| 108 | expect(status.isRepo).toBe(false); |
| 109 | expect(status.isJjWorkspace).toBe(true); |
| 110 | expect(status.refName).toBeNull(); |
| 111 | } |
| 112 | }).pipe( |
| 113 | Effect.scoped, |
| 114 | Effect.provide( |
| 115 | Layer.mergeAll(layer({ detect: () => Effect.succeed(null) }), NodeServices.layer), |
| 116 | ), |
| 117 | ), |
| 118 | ); |
| 119 | |
| 120 | it.effect("returns an empty full status when no VCS repository is detected", () => |
| 121 | Effect.gen(function* () { |
| 122 | const workflow = yield* GitWorkflowService.GitWorkflowService; |
| 123 | const status = yield* workflow.status({ cwd: "/not-a-repo" }); |
| 124 | |
| 125 | assert.deepStrictEqual(status, { |
| 126 | isRepo: false, |
| 127 | hasPrimaryRemote: false, |
| 128 | isDefaultRef: false, |
| 129 | refName: null, |
| 130 | hasWorkingTreeChanges: false, |
| 131 | workingTree: { |
| 132 | files: [], |
| 133 | insertions: 0, |
| 134 | deletions: 0, |
| 135 | }, |
| 136 | hasUpstream: false, |
| 137 | aheadCount: 0, |
| 138 | behindCount: 0, |
| 139 | aheadOfDefaultCount: 0, |
| 140 | pr: null, |
| 141 | }); |
| 142 | }).pipe( |
| 143 | Effect.provide( |
| 144 | layer({ |
| 145 | detect: () => Effect.succeed(null), |
| 146 | }), |
| 147 | ), |
| 148 | ), |
| 149 | ); |
| 150 | |
| 151 | it.effect("does not call GitManager status methods when no VCS repository is detected", () => { |
| 152 | const localStatus = vi.fn(); |
| 153 | const remoteStatus = vi.fn(); |
| 154 | const status = vi.fn(); |
| 155 | |
| 156 | const layerTest = GitWorkflowService.layer.pipe( |
| 157 | Layer.provide( |
| 158 | Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ |
| 159 | detect: () => Effect.succeed(null), |
| 160 | }), |
| 161 | ), |
| 162 | Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), |
| 163 | Layer.provide( |
| 164 | Layer.mock(GitManager.GitManager)({ |
| 165 | localStatus, |
| 166 | remoteStatus, |
| 167 | status, |
| 168 | }), |
| 169 | ), |
| 170 | Layer.provide(NodeServices.layer), |
| 171 | ); |
| 172 | |
| 173 | return Effect.gen(function* () { |
| 174 | const workflow = yield* GitWorkflowService.GitWorkflowService; |
| 175 | yield* workflow.localStatus({ cwd: "/not-a-repo" }); |
| 176 | yield* workflow.remoteStatus({ cwd: "/not-a-repo" }); |
| 177 | yield* workflow.status({ cwd: "/not-a-repo" }); |
| 178 | |
| 179 | assert.equal(localStatus.mock.calls.length, 0); |
| 180 | assert.equal(remoteStatus.mock.calls.length, 0); |
| 181 | assert.equal(status.mock.calls.length, 0); |
| 182 | }).pipe(Effect.provide(layerTest)); |
| 183 | }); |
| 184 | |
| 185 | it.effect("returns an empty ref list when no VCS repository is detected", () => |
| 186 | Effect.gen(function* () { |
| 187 | const workflow = yield* GitWorkflowService.GitWorkflowService; |
| 188 | const refs = yield* workflow.listRefs({ cwd: "/not-a-repo" }); |
| 189 | |
| 190 | assert.deepStrictEqual(refs, { |
| 191 | refs: [], |
| 192 | isRepo: false, |
| 193 | hasPrimaryRemote: false, |
| 194 | nextCursor: null, |
| 195 | totalCount: 0, |
| 196 | }); |
| 197 | }).pipe( |
| 198 | Effect.provide( |
| 199 | layer({ |
| 200 | detect: () => Effect.succeed(null), |
| 201 | }), |
| 202 | ), |
| 203 | ), |
| 204 | ); |
| 205 | |
| 206 | it.effect("structures workflow detection failures without exposing upstream details", () => { |
| 207 | const cause = new VcsRepositoryDetectionError({ |
| 208 | operation: "VcsDriverRegistry.detect", |
| 209 | cwd: "/repo", |
| 210 | detail: "upstream detail must stay in the cause chain", |
| 211 | }); |
| 212 | |
| 213 | return Effect.gen(function* () { |
| 214 | const workflow = yield* GitWorkflowService.GitWorkflowService; |
| 215 | const error = yield* workflow.status({ cwd: "/repo" }).pipe(Effect.flip); |
| 216 | |
| 217 | expect(error).toMatchObject({ |
| 218 | _tag: "GitManagerError", |
| 219 | operation: "GitWorkflowService.status", |
| 220 | cwd: "/repo", |
| 221 | detail: "Failed to detect a VCS repository for this Git workflow.", |
| 222 | }); |
| 223 | expect(error.message).not.toContain(cause.detail); |
| 224 | }).pipe( |
| 225 | Effect.provide( |
| 226 | layer({ |
| 227 | detect: () => Effect.fail(cause), |
| 228 | }), |
| 229 | ), |
| 230 | ); |
| 231 | }); |
| 232 | |
| 233 | it.effect("structures command detection failures without exposing upstream details", () => { |
| 234 | const cause = new VcsRepositoryDetectionError({ |
| 235 | operation: "VcsDriverRegistry.detect", |
| 236 | cwd: "/repo", |
| 237 | detail: "upstream command detail must stay in the cause chain", |
| 238 | }); |
| 239 | |
| 240 | return Effect.gen(function* () { |
| 241 | const workflow = yield* GitWorkflowService.GitWorkflowService; |
| 242 | const error = yield* workflow.listRefs({ cwd: "/repo" }).pipe(Effect.flip); |
| 243 | |
| 244 | expect(error).toMatchObject({ |
| 245 | _tag: "GitCommandError", |
| 246 | operation: "GitWorkflowService.listRefs", |
| 247 | command: "vcs-route", |
| 248 | cwd: "/repo", |
| 249 | detail: "Failed to detect a VCS repository for this Git command.", |
| 250 | }); |
| 251 | expect(error.message).not.toContain(cause.detail); |
| 252 | }).pipe( |
| 253 | Effect.provide( |
| 254 | layer({ |
| 255 | detect: () => Effect.fail(cause), |
| 256 | }), |
| 257 | ), |
| 258 | ); |
| 259 | }); |
| 260 | |
| 261 | it.effect("finds a name among the refs of every namespace", () => |
| 262 | Effect.gen(function* () { |
| 263 | const fileSystem = yield* FileSystem.FileSystem; |
| 264 | const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-ref-named-" }); |
| 265 | const workflow = yield* GitWorkflowService.GitWorkflowService; |
| 266 | const driver = yield* GitVcsDriver.GitVcsDriver; |
| 267 | for (const args of [ |
| 268 | ["init", "-b", "main"], |
| 269 | ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "--allow-empty", "-m", "init"], |
| 270 | ["update-ref", "refs/remotes/upstream/feature/topic", "HEAD"], |
| 271 | ["symbolic-ref", "refs/remotes/origin/alias", "refs/heads/main"], |
| 272 | ["update-ref", "refs/mirror/origin/mirrored", "HEAD"], |
| 273 | ]) { |
| 274 | yield* driver.execute({ operation: "test.setupRepo", cwd, args }); |
| 275 | } |
| 276 | const found = yield* Effect.forEach( |
| 277 | ["main", "feature/topic", "alias", "mirrored", "t3/renamed", "feature/*"], |
| 278 | (refName) => workflow.hasRefNamed({ cwd, refName }), |
| 279 | ); |
| 280 | assert.deepEqual(found, [true, true, true, true, false, false]); |
| 281 | }).pipe( |
| 282 | Effect.scoped, |
| 283 | Effect.provide( |
| 284 | GitWorkflowService.layer.pipe( |
| 285 | Layer.provide( |
| 286 | Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ |
| 287 | resolve: () => Effect.succeed({ kind: "git" } as VcsDriverRegistry.VcsDriverHandle), |
| 288 | }), |
| 289 | ), |
| 290 | Layer.provide(Layer.mock(GitManager.GitManager)({})), |
| 291 | Layer.provideMerge(GitVcsDriver.layer), |
| 292 | Layer.provideMerge(VcsProcess.layer), |
| 293 | Layer.provideMerge( |
| 294 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-ref-named-" }).pipe( |
| 295 | Layer.provide(NodeServices.layer), |
| 296 | ), |
| 297 | ), |
| 298 | Layer.provideMerge(NodeServices.layer), |
| 299 | ), |
| 300 | ), |
| 301 | ), |
| 302 | ); |
| 303 | }); |