| 1 | import { assert, it, describe } from "@effect/vitest"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as Path from "effect/Path"; |
| 6 | import type * as PlatformError from "effect/PlatformError"; |
| 7 | import type * as Scope from "effect/Scope"; |
| 8 | import * as DateTime from "effect/DateTime"; |
| 9 | import * as Option from "effect/Option"; |
| 10 | |
| 11 | import type { VcsDriverKind } from "@t3tools/contracts"; |
| 12 | import * as VcsDriver from "../VcsDriver.ts"; |
| 13 | |
| 14 | function normalizePathForComparison(value: string): string { |
| 15 | return value.replaceAll("\\", "/"); |
| 16 | } |
| 17 | |
| 18 | export interface VcsDriverFixture<R, E> { |
| 19 | readonly createRepo: (cwd: string) => Effect.Effect<void, E, R>; |
| 20 | readonly writeFile: ( |
| 21 | cwd: string, |
| 22 | relativePath: string, |
| 23 | contents: string, |
| 24 | ) => Effect.Effect<void, E, R | FileSystem.FileSystem | Path.Path>; |
| 25 | readonly trackFile?: (cwd: string, relativePath: string) => Effect.Effect<void, E, R>; |
| 26 | readonly commit?: (cwd: string, message: string) => Effect.Effect<void, E, R>; |
| 27 | readonly ignorePath: ( |
| 28 | cwd: string, |
| 29 | pattern: string, |
| 30 | ) => Effect.Effect<void, E, R | FileSystem.FileSystem | Path.Path>; |
| 31 | } |
| 32 | |
| 33 | export interface VcsDriverContractSuiteInput<R, E> { |
| 34 | readonly name: string; |
| 35 | readonly kind: VcsDriverKind; |
| 36 | readonly layer: Layer.Layer< |
| 37 | VcsDriver.VcsDriver | R | FileSystem.FileSystem | Path.Path, |
| 38 | E, |
| 39 | never |
| 40 | >; |
| 41 | readonly fixture: VcsDriverFixture<R, E>; |
| 42 | } |
| 43 | |
| 44 | export function runVcsDriverContractSuite<R, E>(input: VcsDriverContractSuiteInput<R, E>) { |
| 45 | const makeTmpDir = ( |
| 46 | prefix = `t3-${input.kind}-vcs-contract-`, |
| 47 | ): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> => |
| 48 | Effect.gen(function* () { |
| 49 | const fileSystem = yield* FileSystem.FileSystem; |
| 50 | return yield* fileSystem.makeTempDirectoryScoped({ prefix }); |
| 51 | }); |
| 52 | |
| 53 | it.layer(input.layer)(`${input.name} VCS driver contract`, (it) => { |
| 54 | describe("repository detection", () => { |
| 55 | it.effect("returns null outside a repository", () => |
| 56 | Effect.gen(function* () { |
| 57 | const cwd = yield* makeTmpDir(); |
| 58 | const driver = yield* VcsDriver.VcsDriver; |
| 59 | |
| 60 | assert.equal(yield* driver.detectRepository(cwd), null); |
| 61 | assert.equal(yield* driver.isInsideWorkTree(cwd), false); |
| 62 | }), |
| 63 | ); |
| 64 | |
| 65 | it.effect("detects repository identity inside a repository and nested directories", () => |
| 66 | Effect.gen(function* () { |
| 67 | const cwd = yield* makeTmpDir(); |
| 68 | const driver = yield* VcsDriver.VcsDriver; |
| 69 | |
| 70 | yield* input.fixture.createRepo(cwd); |
| 71 | yield* input.fixture.writeFile(cwd, "src/index.ts", "export const value = 1;\n"); |
| 72 | const identity = yield* driver.detectRepository(cwd); |
| 73 | assert.equal(identity?.kind, input.kind); |
| 74 | assert.isTrue( |
| 75 | normalizePathForComparison(identity?.rootPath ?? "").endsWith( |
| 76 | normalizePathForComparison(cwd), |
| 77 | ), |
| 78 | ); |
| 79 | assert.equal(identity?.freshness.source, "live-local"); |
| 80 | assert.isTrue(DateTime.isDateTime(identity?.freshness.observedAt)); |
| 81 | assert.isTrue(Option.isNone(identity?.freshness.expiresAt ?? Option.none())); |
| 82 | assert.equal(yield* driver.isInsideWorkTree(cwd), true); |
| 83 | |
| 84 | const path = yield* Path.Path; |
| 85 | const nestedDir = path.join(cwd, "src"); |
| 86 | const nestedIdentity = yield* driver.detectRepository(nestedDir); |
| 87 | assert.equal(nestedIdentity?.rootPath, identity?.rootPath); |
| 88 | assert.equal(yield* driver.isInsideWorkTree(nestedDir), true); |
| 89 | }), |
| 90 | ); |
| 91 | }); |
| 92 | |
| 93 | describe("workspace files", () => { |
| 94 | it.effect("lists tracked and untracked non-ignored files", () => |
| 95 | Effect.gen(function* () { |
| 96 | const cwd = yield* makeTmpDir(); |
| 97 | const driver = yield* VcsDriver.VcsDriver; |
| 98 | |
| 99 | yield* input.fixture.createRepo(cwd); |
| 100 | yield* input.fixture.writeFile(cwd, "tracked.ts", "export const tracked = true;\n"); |
| 101 | if (input.fixture.trackFile && input.fixture.commit) { |
| 102 | yield* input.fixture.trackFile(cwd, "tracked.ts"); |
| 103 | yield* input.fixture.commit(cwd, "Track file"); |
| 104 | } |
| 105 | yield* input.fixture.writeFile(cwd, "untracked.ts", "export const untracked = true;\n"); |
| 106 | |
| 107 | const result = yield* driver.listWorkspaceFiles(cwd); |
| 108 | |
| 109 | assert.include(result.paths, "tracked.ts"); |
| 110 | assert.include(result.paths, "untracked.ts"); |
| 111 | assert.equal(result.truncated, false); |
| 112 | assert.equal(result.freshness.source, "live-local"); |
| 113 | assert.isTrue(DateTime.isDateTime(result.freshness.observedAt)); |
| 114 | assert.isTrue(Option.isNone(result.freshness.expiresAt)); |
| 115 | }), |
| 116 | ); |
| 117 | |
| 118 | it.effect("excludes ignored files from workspace listing", () => |
| 119 | Effect.gen(function* () { |
| 120 | const cwd = yield* makeTmpDir(); |
| 121 | const driver = yield* VcsDriver.VcsDriver; |
| 122 | |
| 123 | yield* input.fixture.createRepo(cwd); |
| 124 | yield* input.fixture.ignorePath(cwd, "*.log"); |
| 125 | yield* input.fixture.writeFile(cwd, "included.ts", "export const included = true;\n"); |
| 126 | yield* input.fixture.writeFile(cwd, "debug.log", "ignore me\n"); |
| 127 | yield* input.fixture.writeFile(cwd, "nested/error.log", "ignore me too\n"); |
| 128 | |
| 129 | const result = yield* driver.listWorkspaceFiles(cwd); |
| 130 | |
| 131 | assert.include(result.paths, "included.ts"); |
| 132 | assert.notInclude(result.paths, "debug.log"); |
| 133 | assert.notInclude(result.paths, "nested/error.log"); |
| 134 | }), |
| 135 | ); |
| 136 | }); |
| 137 | |
| 138 | describe("ignored path filtering", () => { |
| 139 | it.effect("filters ignored paths", () => |
| 140 | Effect.gen(function* () { |
| 141 | const cwd = yield* makeTmpDir(); |
| 142 | const driver = yield* VcsDriver.VcsDriver; |
| 143 | |
| 144 | yield* input.fixture.createRepo(cwd); |
| 145 | yield* input.fixture.ignorePath(cwd, "*.log"); |
| 146 | |
| 147 | const result = yield* driver.filterIgnoredPaths(cwd, [ |
| 148 | "keep.ts", |
| 149 | "debug.log", |
| 150 | "nested/error.log", |
| 151 | ]); |
| 152 | |
| 153 | assert.deepStrictEqual(result, ["keep.ts"]); |
| 154 | }), |
| 155 | ); |
| 156 | |
| 157 | it.effect("returns empty input unchanged", () => |
| 158 | Effect.gen(function* () { |
| 159 | const cwd = yield* makeTmpDir(); |
| 160 | const driver = yield* VcsDriver.VcsDriver; |
| 161 | |
| 162 | yield* input.fixture.createRepo(cwd); |
| 163 | |
| 164 | assert.deepStrictEqual(yield* driver.filterIgnoredPaths(cwd, []), []); |
| 165 | }), |
| 166 | ); |
| 167 | }); |
| 168 | }); |
| 169 | } |