| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as FileSystem from "effect/FileSystem"; |
| 3 | import * as Path from "effect/Path"; |
| 4 | import { dotAlchemyDirectory } from "../AlchemyContext.ts"; |
| 5 | import { isPathWithin } from "../Util/isPathWithin.ts"; |
| 6 | import type { PlatformError } from "effect/PlatformError"; |
| 7 | import { convertPathToPattern, glob } from "tinyglobby"; |
| 8 | import { gitignoreRulesToGlobs } from "../Util/gitignore-rules-to-globs.ts"; |
| 9 | import { initialCwd } from "../Util/Node.ts"; |
| 10 | import { sha256, sha256Object } from "../Util/sha256.ts"; |
| 11 | |
| 12 | /** |
| 13 | * Controls which files are included in the content hash that determines |
| 14 | * whether a build needs to re-run. |
| 15 | * |
| 16 | * By default (no options), every non-gitignored file in the working directory |
| 17 | * is hashed, plus the nearest package-manager lockfile. Provide explicit |
| 18 | * `include`/`exclude` globs to narrow the scope when the default is too broad. |
| 19 | */ |
| 20 | export interface MemoOptions { |
| 21 | /** |
| 22 | * Glob patterns of files to hash. Paths are relative to the working |
| 23 | * directory and may reach outside it with `../` segments — useful in a |
| 24 | * monorepo where the build consumes sibling workspace packages that the |
| 25 | * default (files under the working directory) does not cover. |
| 26 | * |
| 27 | * Note: providing `include` (or `exclude`) flips the {@link lockfile} |
| 28 | * default to `false` — pair it with `lockfile: true` to keep rebuilding |
| 29 | * when dependencies change. |
| 30 | * |
| 31 | * @default ["**\/*"] (all files, filtered by `exclude`) |
| 32 | * @example ["src/**", "package.json", "tsconfig.json"] |
| 33 | * @example ["**\/*", "../env/src/**"] (also rebuild when a sibling workspace package changes) |
| 34 | */ |
| 35 | include?: string[]; |
| 36 | /** |
| 37 | * Glob patterns to exclude from hashing. Paths are relative to the working directory. |
| 38 | * |
| 39 | * @default gitignore rules collected from the working directory up to the repo root |
| 40 | */ |
| 41 | exclude?: string[]; |
| 42 | /** |
| 43 | * Whether to include the nearest package-manager lockfile (`bun.lock`, |
| 44 | * `package-lock.json`, `pnpm-lock.yaml`, or `yarn.lock`) in the hash, |
| 45 | * even when it lives above the working directory (e.g. monorepo root). |
| 46 | * |
| 47 | * @default true when both `include` and `exclude` are unset; false otherwise |
| 48 | */ |
| 49 | lockfile?: boolean; |
| 50 | } |
| 51 | |
| 52 | interface ResolvedMemoOptions { |
| 53 | cwd: string; |
| 54 | include: string[]; |
| 55 | exclude: string[]; |
| 56 | lockfile: boolean; |
| 57 | } |
| 58 | |
| 59 | /** |
| 60 | * Internal service that resolves memo options, lists matching files, and |
| 61 | * produces a single SHA-256 content hash. Constructed as an Effect so it |
| 62 | * can access the platform `FileSystem` and `Path` services. |
| 63 | */ |
| 64 | const Memo = Effect.gen(function* () { |
| 65 | const fs = yield* FileSystem.FileSystem; |
| 66 | const path = yield* Path.Path; |
| 67 | const runtimeBase = process.cwd(); |
| 68 | const dotAlchemy = yield* dotAlchemyDirectory; |
| 69 | |
| 70 | const findUp = Effect.fn(function* ( |
| 71 | cwd: string, |
| 72 | filenames: string[], |
| 73 | ): Effect.fn.Return<string | undefined, PlatformError> { |
| 74 | const [file] = yield* Effect.filter( |
| 75 | filenames.map((filename) => path.join(cwd, filename)), |
| 76 | fs.exists, |
| 77 | { concurrency: "unbounded" }, |
| 78 | ); |
| 79 | if (file) { |
| 80 | return file; |
| 81 | } |
| 82 | const parent = path.dirname(cwd); |
| 83 | if (parent === cwd) { |
| 84 | return undefined; |
| 85 | } |
| 86 | return yield* findUp(parent, filenames); |
| 87 | }); |
| 88 | |
| 89 | const readGitIgnoreRules = Effect.fn(function* ( |
| 90 | cwd: string, |
| 91 | ): Effect.fn.Return<string[], PlatformError> { |
| 92 | const rules = yield* fs.readFileString(path.join(cwd, ".gitignore")).pipe( |
| 93 | Effect.map((file) => file.split("\n")), |
| 94 | Effect.catchIf( |
| 95 | (error) => |
| 96 | error._tag === "PlatformError" && error.reason._tag === "NotFound", |
| 97 | () => Effect.succeed([]), |
| 98 | ), |
| 99 | ); |
| 100 | const parent = path.dirname(cwd); |
| 101 | if (parent === cwd || (yield* fs.exists(path.join(cwd, ".git")))) { |
| 102 | return rules; |
| 103 | } |
| 104 | return [...(yield* readGitIgnoreRules(parent)), ...rules]; |
| 105 | }); |
| 106 | |
| 107 | const resolveMemoOptions = Effect.fn(function* ( |
| 108 | cwd: string | undefined, |
| 109 | options: MemoOptions, |
| 110 | ): Effect.fn.Return<ResolvedMemoOptions, PlatformError> { |
| 111 | // Anchored: a live `process.cwd()` read can race a concurrent tool's |
| 112 | // transient chdir (see Util/Node.ts `initialCwd`). |
| 113 | const resolvedCwd = path.resolve(initialCwd, cwd ?? "."); |
| 114 | return { |
| 115 | cwd: resolvedCwd, |
| 116 | // Normalize absolute include patterns to cwd-relative ones so matched |
| 117 | // keys (and therefore the memo hash) stay free of machine-specific |
| 118 | // path prefixes. |
| 119 | include: (options.include ?? ["**/*"]).map((pattern) => |
| 120 | path.isAbsolute(pattern) |
| 121 | ? path.relative(resolvedCwd, pattern).replaceAll("\\", "/") |
| 122 | : pattern, |
| 123 | ), |
| 124 | exclude: |
| 125 | options.exclude ?? |
| 126 | (yield* readGitIgnoreRules(resolvedCwd).pipe( |
| 127 | Effect.map(gitignoreRulesToGlobs), |
| 128 | Effect.map((globs) => ["**/.git/**", ...globs]), |
| 129 | )), |
| 130 | lockfile: options.lockfile ?? !(options.exclude || options.include), |
| 131 | }; |
| 132 | }); |
| 133 | |
| 134 | const listFiles = Effect.fn(function* ( |
| 135 | options: ResolvedMemoOptions, |
| 136 | ): Effect.fn.Return<string[], PlatformError> { |
| 137 | // Explicitly hashing a generated artifact still hashes its contents. |
| 138 | const excludeRuntime = !isPathWithin(dotAlchemy, options.cwd, runtimeBase); |
| 139 | const [files, lockfile] = yield* Effect.all( |
| 140 | [ |
| 141 | Effect.promise(() => |
| 142 | glob(options.include, { |
| 143 | cwd: options.cwd, |
| 144 | ignore: [ |
| 145 | ...options.exclude, |
| 146 | ...(excludeRuntime |
| 147 | ? [ |
| 148 | `${convertPathToPattern(path.resolve(runtimeBase, dotAlchemy))}/**`, |
| 149 | ] |
| 150 | : []), |
| 151 | ], |
| 152 | onlyFiles: true, |
| 153 | expandDirectories: false, |
| 154 | dot: true, |
| 155 | }), |
| 156 | ), |
| 157 | options.lockfile |
| 158 | ? findUp(options.cwd, [ |
| 159 | "bun.lock", |
| 160 | "bun.lockb", |
| 161 | "package-lock.json", |
| 162 | "pnpm-lock.yaml", |
| 163 | "yarn.lock", |
| 164 | ]).pipe( |
| 165 | Effect.map((lockfile) => |
| 166 | lockfile ? path.relative(options.cwd, lockfile) : undefined, |
| 167 | ), |
| 168 | ) |
| 169 | : Effect.succeed(undefined), |
| 170 | ], |
| 171 | { concurrency: "unbounded" }, |
| 172 | ); |
| 173 | if (lockfile && !files.includes(lockfile)) { |
| 174 | files.push(lockfile); |
| 175 | } |
| 176 | // Absolute include patterns produce absolute matches; normalize them to |
| 177 | // cwd-relative (like the lockfile above) so `hashFiles` resolves them |
| 178 | // correctly and machine-specific path prefixes never leak into the hash. |
| 179 | return files |
| 180 | .filter( |
| 181 | (file) => |
| 182 | !excludeRuntime || |
| 183 | !isPathWithin( |
| 184 | dotAlchemy, |
| 185 | path.resolve(options.cwd, file), |
| 186 | runtimeBase, |
| 187 | ), |
| 188 | ) |
| 189 | .map((file) => |
| 190 | path.isAbsolute(file) ? path.relative(options.cwd, file) : file, |
| 191 | ) |
| 192 | .sort(); |
| 193 | }); |
| 194 | |
| 195 | const hashFiles = Effect.fn(function* ( |
| 196 | cwd: string, |
| 197 | files: string[], |
| 198 | ): Effect.fn.Return<string, PlatformError> { |
| 199 | const hashes = yield* Effect.forEach( |
| 200 | files, |
| 201 | (file) => |
| 202 | fs.readFile(path.join(cwd, file)).pipe( |
| 203 | Effect.flatMap(sha256), |
| 204 | Effect.map((hash) => `${file}:${hash}`), |
| 205 | ), |
| 206 | { concurrency: "unbounded" }, |
| 207 | ); |
| 208 | return yield* sha256Object(hashes); |
| 209 | }); |
| 210 | |
| 211 | return { |
| 212 | resolveMemoOptions, |
| 213 | listFiles, |
| 214 | hashFiles, |
| 215 | }; |
| 216 | }); |
| 217 | |
| 218 | /** |
| 219 | * Produces a deterministic SHA-256 hash of all files matched by the given |
| 220 | * memo options. The hash changes if and only if the content of the matched |
| 221 | * files changes, making it suitable for cache-busting build outputs. |
| 222 | */ |
| 223 | export const hashDirectory = Effect.fn(function* (props: { |
| 224 | cwd?: string; |
| 225 | memo?: MemoOptions; |
| 226 | }): Effect.fn.Return<string, PlatformError, FileSystem.FileSystem | Path.Path> { |
| 227 | const service = yield* Memo; |
| 228 | const resolvedOptions = yield* service.resolveMemoOptions( |
| 229 | props.cwd, |
| 230 | props.memo ?? {}, |
| 231 | ); |
| 232 | const files = yield* service.listFiles(resolvedOptions); |
| 233 | const hash = yield* service.hashFiles(resolvedOptions.cwd, files); |
| 234 | return hash; |
| 235 | }); |