1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Path from "effect/Path";
4import { dotAlchemyDirectory } from "../AlchemyContext.ts";
5import { isPathWithin } from "../Util/isPathWithin.ts";
6import type { PlatformError } from "effect/PlatformError";
7import { convertPathToPattern, glob } from "tinyglobby";
8import { gitignoreRulesToGlobs } from "../Util/gitignore-rules-to-globs.ts";
9import { initialCwd } from "../Util/Node.ts";
10import { 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 */
20export 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
52interface 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 */
64const 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 */
223export 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});