@sous-io/sous 0.2.17 → 0.2.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,215 @@
1
+ /**
2
+ * `sous subscription update [ref]`.
3
+ *
4
+ * Moves the lockfile's pins to the newest versions their ranges allow. With no
5
+ * reference it covers every subscription; a reference narrows it to one
6
+ * repository, one namespace or one recipe:
7
+ *
8
+ * sous subscription update
9
+ * sous subscription update sous-recipes
10
+ * sous subscription update workflow
11
+ * sous subscription update workflow/task-files
12
+ *
13
+ * Every trusted repository's index is fetched fresh first. A pin moves only
14
+ * within the range its subscription (or the recipe depending on it) declares,
15
+ * and dependencies move with the closure. Only the lockfile changes; the
16
+ * subscriptions themselves are never edited. The whole change is printed as a
17
+ * plan and asked about once, and the project is rebuilt afterwards.
18
+ */
19
+
20
+ import { Args, Flags } from "@oclif/core";
21
+ import { BaseCommand } from "../../base-command.js";
22
+ import { buildProjectOutputs } from "../../lib/build-service.js";
23
+ import { ConfigError } from "../../lib/errors.js";
24
+ import { subscriptionServiceFor } from "../../lib/repos/subscription-service.js";
25
+ import { describeUpdateScope } from "../../lib/repos/update-plan.js";
26
+ import { formatAskReport } from "../../lib/vars/ask.js";
27
+ import { collectProvidedAnswers } from "../../lib/vars/index.js";
28
+ import {
29
+ blankLine,
30
+ dryRunNotice,
31
+ footer,
32
+ heading,
33
+ indent,
34
+ log,
35
+ paragraph,
36
+ showCommandVars,
37
+ subheading,
38
+ warning,
39
+ } from "../../utils/formatting.js";
40
+ import { answerFlags, confirmationFlag } from "../../utils/flags.js";
41
+
42
+ export default class SubscriptionUpdate extends BaseCommand {
43
+ static description =
44
+ "Move this project's pinned recipe versions to the newest ones their ranges allow";
45
+
46
+ /**
47
+ * The other spelling of the topic. It lives under a hidden topic, so it is
48
+ * typable everywhere without ever reaching the top-level listing.
49
+ */
50
+ static aliases = ["subscriptions:update"];
51
+
52
+ static examples = [
53
+ "<%= config.bin %> subscription update",
54
+ "<%= config.bin %> subscription update sous-recipes",
55
+ "<%= config.bin %> subscription update workflow/task-files",
56
+ "<%= config.bin %> subscription update --dry-run",
57
+ "<%= config.bin %> subscription update --yes",
58
+ ];
59
+
60
+ static args = {
61
+ ref: Args.string({
62
+ description:
63
+ "What to update: a repository, a namespace or a recipe. Leave it out to update everything",
64
+ required: false,
65
+ }),
66
+ };
67
+
68
+ static flags = {
69
+ ...BaseCommand.baseFlags,
70
+ // One flag answers both questions this command can ask: the plan, and the
71
+ // trust question for a repository a newer version needs.
72
+ yes: confirmationFlag({ extraAliases: ["trust"] }),
73
+ "accept-first": Flags.boolean({
74
+ description:
75
+ "When the reference matches several things, take the first one listed",
76
+ default: false,
77
+ }),
78
+ "dry-run": Flags.boolean({
79
+ description:
80
+ "Print what would change without writing anything or downloading any recipe",
81
+ default: false,
82
+ }),
83
+ "no-build": Flags.boolean({
84
+ description: "Change the lockfile without rebuilding the project",
85
+ default: false,
86
+ }),
87
+ ...answerFlags(),
88
+ };
89
+
90
+ async run(): Promise<void> {
91
+ const { args, flags } = await this.parse(SubscriptionUpdate);
92
+ const dryRun = flags["dry-run"];
93
+
94
+ showCommandVars({
95
+ Project: this.projectLabel,
96
+ Config: this.configContext.configPath,
97
+ Updating: args.ref ?? "every subscription",
98
+ "Dry Run": dryRun,
99
+ });
100
+
101
+ heading("Updating");
102
+
103
+ if (dryRun) {
104
+ dryRunNotice(
105
+ "The indexes are fetched so the plan is current; no recipe is downloaded and " +
106
+ "nothing in this project is written."
107
+ );
108
+ }
109
+
110
+ const service = subscriptionServiceFor({
111
+ configContext: this.configContext,
112
+ settings: this.settings,
113
+ shellEnv: this.shellEnv,
114
+ });
115
+
116
+ const provided = collectProvidedAnswers({
117
+ ...(flags.answer === undefined ? {} : { answer: flags.answer }),
118
+ ...(flags["answers-file"] === undefined ? {} : { answersFile: flags["answers-file"] }),
119
+ });
120
+
121
+ const outcome = await service.update({
122
+ ...(args.ref === undefined ? {} : { ref: args.ref }),
123
+ yes: flags.yes,
124
+ acceptFirst: flags["accept-first"],
125
+ answers: provided,
126
+ dryRun,
127
+ });
128
+
129
+ const scope = describeUpdateScope(outcome.scope);
130
+
131
+ if (outcome.nothingToUpdate) {
132
+ footer();
133
+ return;
134
+ }
135
+
136
+ if (dryRun) {
137
+ paragraph(
138
+ `Nothing was written. Run the same command without '--dry-run' to update ${scope}.`
139
+ );
140
+ footer();
141
+ return;
142
+ }
143
+
144
+ blankLine();
145
+ subheading("Lockfile");
146
+ blankLine();
147
+ if (outcome.diff.unchanged) {
148
+ paragraph("Nothing changed.");
149
+ } else {
150
+ for (const line of outcome.diff.lines) log(indent(line));
151
+ }
152
+
153
+ if (outcome.trusted.length > 0) {
154
+ blankLine();
155
+ paragraph(
156
+ `Repositories trusted along the way: ${outcome.trusted.join(", ")}. They are ` +
157
+ `now recorded in this project's config, and your colleagues inherit them.`
158
+ );
159
+ }
160
+
161
+ if (outcome.answers !== undefined) {
162
+ blankLine();
163
+ subheading("Variables");
164
+ for (const line of formatAskReport(outcome.answers)) {
165
+ log(line === "" ? "" : indent(line));
166
+ }
167
+ }
168
+
169
+ if (outcome.cycles.length > 0) {
170
+ warning(
171
+ `Some of these recipes co-subscribe to each other in a circle:\n` +
172
+ outcome.cycles.map((cycle) => ` ${cycle.join(" -> ")}`).join("\n") +
173
+ `\nThat is unusual but not broken, and the lockfile was updated.`
174
+ );
175
+ }
176
+
177
+ const rebuilding = !flags["no-build"] && !outcome.diff.unchanged;
178
+
179
+ blankLine();
180
+ paragraph(
181
+ `The lockfile now pins the newest versions the ranges allow for ${scope}. ` +
182
+ `This project's subscriptions are exactly as they were.` +
183
+ (rebuilding || outcome.diff.unchanged ? "" : " The project was not rebuilt.")
184
+ );
185
+
186
+ footer();
187
+
188
+ if (rebuilding) await this.rebuildProject();
189
+ }
190
+
191
+ /**
192
+ * Rebuilds the project now that the lockfile has moved, so the outputs match
193
+ * the new versions when this command returns. The config is reloaded first,
194
+ * because a repository trusted along the way was written into a managed layer.
195
+ * A build that fails leaves the update in place, because it is already
196
+ * written; the message says so.
197
+ */
198
+ private async rebuildProject(): Promise<void> {
199
+ await this.reloadDiscoveredConfig();
200
+
201
+ heading("Building the project");
202
+
203
+ const succeeded = await buildProjectOutputs(this.settings, this.configContext);
204
+
205
+ footer();
206
+
207
+ if (!succeeded) {
208
+ throw new ConfigError(
209
+ `The lockfile was updated, but the build that followed it failed, so this ` +
210
+ `project's outputs may not match the new versions yet. The update itself is ` +
211
+ `recorded; fix what the build reported above and run 'sous build' again.`
212
+ );
213
+ }
214
+ }
215
+ }
@@ -4,7 +4,8 @@
4
4
  * A link redirects a repo's resolution away from the store and at a real
5
5
  * working copy on disk, which is how a maintainer edits recipes: edits happen
6
6
  * in a checkout, never in the store. `sous repo link` writes an entry;
7
- * `sous repo unlink` removes it and leaves the checkout in place.
7
+ * `sous repo unlink` removes it and leaves the checkout in place unless asked
8
+ * to delete one sous cloned.
8
9
  *
9
10
  * Two maps are read: the project's `.sous/sous.links.json` and the machine-wide
10
11
  * `$SOUS_HOME/sous.links.json`, with the project map winning on conflict. The
@@ -34,8 +35,9 @@ export const repoLinkSchema = z.strictObject({
34
35
  linkedAt: isoTimestampSchema,
35
36
  /**
36
37
  * Whether sous cloned the working copy itself ('clone') or was pointed at an
37
- * existing checkout ('path'). Unlinking never deletes either, but the origin
38
- * tells the user what sous put there.
38
+ * existing checkout ('path'). Unlinking leaves either in place unless
39
+ * `--remove` is passed, and even then only a 'clone' is ever deleted: a
40
+ * 'path' checkout belongs to whoever linked it.
39
41
  */
40
42
  origin: z.enum(LINK_ORIGINS),
41
43
  });
@@ -227,6 +227,77 @@ export function cloneRepo(
227
227
  return { depth: 0, fellBackToFullClone: depth > 0 };
228
228
  }
229
229
 
230
+ /** Work in a checkout that exists nowhere else, and would be lost with it. */
231
+ export type UnsavedWork = {
232
+ /** Every changed or untracked path, as `git status --porcelain` prints it. */
233
+ uncommitted: string[];
234
+ /** Every commit on a local branch that no remote has, one line each. */
235
+ unpushed: string[];
236
+ /** Every stash entry, one line each. */
237
+ stashes: string[];
238
+ /**
239
+ * Why git could not be asked, when it could not. Nothing is known about the
240
+ * checkout then, which a caller must treat as possibly holding work.
241
+ */
242
+ unknown?: string;
243
+ };
244
+
245
+ /**
246
+ * What in a checkout would be lost if it were deleted: uncommitted changes,
247
+ * commits no remote has, and stashes.
248
+ *
249
+ * unsavedWork("/path/to/checkout")
250
+ * // -> { uncommitted: ["M README.md"], unpushed: [], stashes: [] }
251
+ *
252
+ * @param directory - The checkout to inspect.
253
+ * @param options - The git runner to use.
254
+ */
255
+ export function unsavedWork(directory: string, options: GitOptions = {}): UnsavedWork {
256
+ const runner = options.runner ?? runGit;
257
+ const lines = (text: string): string[] =>
258
+ text
259
+ .split("\n")
260
+ .map((line) => line.trim())
261
+ .filter((line) => line.length > 0);
262
+
263
+ const status = runner(["status", "--porcelain"], { cwd: directory });
264
+ if (status.status !== 0) {
265
+ return {
266
+ uncommitted: [],
267
+ unpushed: [],
268
+ stashes: [],
269
+ unknown:
270
+ `git could not read the checkout at ${directory}` +
271
+ (status.stderr.length > 0 ? `: ${status.stderr}` : "."),
272
+ };
273
+ }
274
+
275
+ const unpushed = runner(["log", "--branches", "--not", "--remotes", "--oneline"], {
276
+ cwd: directory,
277
+ });
278
+ const stashes = runner(["stash", "list"], { cwd: directory });
279
+
280
+ return {
281
+ uncommitted: lines(status.stdout),
282
+ unpushed: unpushed.status === 0 ? lines(unpushed.stdout) : [],
283
+ stashes: stashes.status === 0 ? lines(stashes.stdout) : [],
284
+ };
285
+ }
286
+
287
+ /**
288
+ * True when an inspection found nothing that would be lost.
289
+ *
290
+ * @param work - What `unsavedWork` found.
291
+ */
292
+ export function hasNoUnsavedWork(work: UnsavedWork): boolean {
293
+ return (
294
+ work.unknown === undefined &&
295
+ work.uncommitted.length === 0 &&
296
+ work.unpushed.length === 0 &&
297
+ work.stashes.length === 0
298
+ );
299
+ }
300
+
230
301
  /**
231
302
  * True when two remote URLs name the same repository, ignoring the differences
232
303
  * that never change what is fetched: a `.git` suffix, a trailing slash, the
@@ -4,7 +4,8 @@
4
4
  * A link redirects one repository's resolution away from the store and at a
5
5
  * real working copy on disk, which is how a maintainer edits recipes: edits
6
6
  * happen in a checkout, never in the store. `sous repo link` writes an entry
7
- * here; `sous repo unlink` removes it and leaves the checkout alone.
7
+ * here; `sous repo unlink` removes it and leaves the checkout alone unless
8
+ * `--remove` asks it to delete one sous cloned.
8
9
  *
9
10
  * Two maps exist. The project's `.sous/sous.links.json` covers one project; the
10
11
  * machine-wide `$SOUS_HOME/sous.links.json` covers every project on the machine,
@@ -252,3 +252,25 @@ export function projectSubscriptionRefs(
252
252
  }
253
253
  return [...refs].sort();
254
254
  }
255
+
256
+ /**
257
+ * Every locked recipe reachable from a set of keys through the lockfile's
258
+ * holder lists: the keys themselves, whatever they hold, and so on.
259
+ *
260
+ * @param lock - The lockfile.
261
+ * @param roots - The keys to start from.
262
+ */
263
+ export function lockedClosure(lock: Lockfile, roots: string[]): string[] {
264
+ const reached = new Set(roots.filter((key) => Object.hasOwn(lock.recipes, key)));
265
+ const queue = [...reached];
266
+ while (queue.length > 0) {
267
+ const holder = queue.shift()!;
268
+ for (const [key, entry] of Object.entries(lock.recipes)) {
269
+ if (reached.has(key) || !entry.requestedBy.includes(holder)) continue;
270
+ reached.add(key);
271
+ queue.push(key);
272
+ }
273
+ }
274
+ return [...reached].sort();
275
+ }
276
+
@@ -95,6 +95,14 @@ export type ResolveContext = {
95
95
  loadManifest: RecipeManifestLoader;
96
96
  /** Whether prereleases are allowed when a request does not say. Defaults to false. */
97
97
  prerelease?: boolean;
98
+ /**
99
+ * Versions to hold where they are, keyed by recipe key. A recipe named here
100
+ * resolves to that version whenever it still satisfies every range asked of
101
+ * it, rather than to the newest one that does; a range that no longer allows
102
+ * it wins, and the newest satisfying version is chosen as usual. This is how
103
+ * an update moves only the pins it was asked to move.
104
+ */
105
+ keep?: Record<string, string>;
98
106
  };
99
107
 
100
108
  /** One recipe version the resolver settled on. */
@@ -608,7 +616,7 @@ function resolveRecipeRef(
608
616
  (previous?.prerelease ?? false) ||
609
617
  (item.prerelease ?? context.prerelease ?? false);
610
618
 
611
- const version = pickVersion(key, entry, ranges, prerelease);
619
+ const version = pickVersion(key, entry, ranges, prerelease, context.keep?.[key]);
612
620
  const versionEntry = entry.versions[version]!;
613
621
 
614
622
  const requestedBy = [...(previous?.requestedBy ?? [])];
@@ -645,12 +653,14 @@ function resolveRecipeRef(
645
653
  * @param entry - The recipe's index entry.
646
654
  * @param ranges - Every range that has to hold, with who asked for it.
647
655
  * @param prerelease - Whether prereleases may match.
656
+ * @param keep - A version to hold, chosen whenever it is among the candidates.
648
657
  */
649
658
  function pickVersion(
650
659
  key: string,
651
660
  entry: IndexFile["recipes"][string],
652
661
  ranges: Array<{ range: string; requestedBy: string }>,
653
- prerelease: boolean
662
+ prerelease: boolean,
663
+ keep?: string
654
664
  ): string {
655
665
  const published = Object.keys(entry.versions);
656
666
  const eligible = prerelease
@@ -664,6 +674,19 @@ function pickVersion(
664
674
  );
665
675
  }
666
676
 
677
+ // A held version is kept exactly as long as every range still allows it. It
678
+ // is checked against the published list rather than the eligible one, so a
679
+ // prerelease a subscription once opted into is not moved just for being one.
680
+ if (
681
+ keep !== undefined &&
682
+ Object.hasOwn(entry.versions, keep) &&
683
+ ranges.every(({ range }) =>
684
+ semver.satisfies(keep, range, { includePrerelease: true })
685
+ )
686
+ ) {
687
+ return keep;
688
+ }
689
+
667
690
  const best = semver.maxSatisfying(candidates, "*", { includePrerelease: prerelease });
668
691
  if (best !== null) return best;
669
692
 
@@ -48,6 +48,7 @@ import {
48
48
  type IndexOverlay,
49
49
  } from "./providers/index-cache.js";
50
50
  import { warning } from "../../utils/formatting.js";
51
+ import { hashDirectorySync } from "./store/hash.js";
51
52
  import { identitySegments } from "./identity.js";
52
53
  import { ensureIndexCacheDirectory } from "../../utils/sous-directory.js";
53
54
  import type { RecipeStoreLike, StoreKey } from "./store/contract.js";
@@ -204,14 +205,70 @@ export async function seedCoreRecipe(
204
205
  export type CoreIndexOverlayOptions = {
205
206
  /** The packaged version, which is by rule the running sous version. */
206
207
  version: string;
207
- /** The content hash of the entry the seed put in the store. */
208
- hash: string;
208
+ /**
209
+ * The content hash of the entry the seed put in the store, or a function
210
+ * that works it out the first time it is needed. The function form is what
211
+ * lets an overlay be installed before anything has been seeded.
212
+ */
213
+ hash: string | (() => string);
209
214
  /** The installed package's root directory. Defaults to the running CLI's own. */
210
215
  packageRoot?: string;
211
216
  /** Where the one warning this can produce goes. Defaults to the console banner. */
212
217
  warn?: (message: string) => void;
213
218
  };
214
219
 
220
+ /**
221
+ * The overlay every index cache a command builds starts with: the packaged core
222
+ * version, hashed from the package itself the first time the official
223
+ * repository's index is read.
224
+ *
225
+ * Seeding installs a precise overlay of its own (carrying the hash of the entry
226
+ * it just wrote), but only the commands that seed get that one. This is what
227
+ * makes the packaged version resolvable everywhere else too: a lockfile rebuild,
228
+ * a browsing command, anything that reads the official index. The hash of the
229
+ * packaged folder is the hash a seeded store entry carries, because the store
230
+ * copies the folder byte for byte.
231
+ *
232
+ * A package whose core recipe cannot be read adds nothing, silently: seeding
233
+ * reports that failure in full, and a read-only command has nothing better to
234
+ * say about it.
235
+ *
236
+ * @param options - The packaged version, and where the package is.
237
+ */
238
+ export function packagedCoreIndexOverlay(options: {
239
+ /** The packaged version, which is by rule the running sous version. */
240
+ version: string;
241
+ /** The installed package's root directory. Defaults to the running CLI's own. */
242
+ packageRoot?: string;
243
+ /** Where the one warning the overlay can produce goes. */
244
+ warn?: (message: string) => void;
245
+ }): IndexOverlay {
246
+ let hash: string | null | undefined;
247
+ const packagedHash = (): string | null => {
248
+ if (hash === undefined) {
249
+ try {
250
+ hash = hashDirectorySync(packagedCoreRecipeDir(options.packageRoot));
251
+ } catch {
252
+ hash = null;
253
+ }
254
+ }
255
+ return hash;
256
+ };
257
+
258
+ const overlay = coreIndexOverlay({
259
+ version: options.version,
260
+ hash: () => packagedHash()!,
261
+ ...(options.packageRoot === undefined ? {} : { packageRoot: options.packageRoot }),
262
+ ...(options.warn === undefined ? {} : { warn: options.warn }),
263
+ });
264
+
265
+ return (identity, index) => {
266
+ if (identity !== OFFICIAL_REPO_IDENTITY) return index;
267
+ if (packagedHash() === null) return index;
268
+ return overlay(identity, index);
269
+ };
270
+ }
271
+
215
272
  /**
216
273
  * Builds the overlay that makes the packaged core recipe resolvable whatever
217
274
  * the official repository has published so far.
@@ -236,6 +293,8 @@ export type CoreIndexOverlayOptions = {
236
293
  */
237
294
  export function coreIndexOverlay(options: CoreIndexOverlayOptions): IndexOverlay {
238
295
  let warned = false;
296
+ const packagedHash = (): string =>
297
+ typeof options.hash === "function" ? options.hash() : options.hash;
239
298
 
240
299
  return (identity: string, index: IndexFile): IndexFile => {
241
300
  if (identity !== OFFICIAL_REPO_IDENTITY) return index;
@@ -243,14 +302,14 @@ export function coreIndexOverlay(options: CoreIndexOverlayOptions): IndexOverlay
243
302
  const published = index.recipes[CORE_RECIPE_KEY]?.versions[options.version];
244
303
 
245
304
  if (published !== undefined) {
246
- if (published.hash !== options.hash && !warned) {
305
+ if (published.hash !== packagedHash() && !warned) {
247
306
  warned = true;
248
307
  (options.warn ?? warning)(
249
308
  `The repository '${OFFICIAL_REPO_NAME}' publishes version ${options.version} of ` +
250
309
  `'${CORE_RECIPE_KEY}' with different contents from the copy inside this ` +
251
310
  `installation of sous, so sous is using the published one.\n` +
252
311
  ` Published: ${published.hash}\n` +
253
- ` Packaged: ${options.hash}\n` +
312
+ ` Packaged: ${packagedHash()}\n` +
254
313
  ` Reinstalling sous will bring the two back into line.`
255
314
  );
256
315
  }
@@ -276,7 +335,7 @@ export function coreIndexOverlay(options: CoreIndexOverlayOptions): IndexOverlay
276
335
  versions: {
277
336
  ...recipe?.versions,
278
337
  [options.version]: {
279
- hash: options.hash,
338
+ hash: packagedHash(),
280
339
  tag: `${CORE_RECIPE_KEY}@${options.version}`,
281
340
  prerelease: semver.prerelease(options.version) !== null,
282
341
  seeded: true,
@@ -29,7 +29,8 @@
29
29
  * recipe folder containing a link.
30
30
  */
31
31
 
32
- import { createHash } from "node:crypto";
32
+ import { createHash, type Hash } from "node:crypto";
33
+ import fsSync from "node:fs";
33
34
  import fs from "node:fs/promises";
34
35
  import path from "node:path";
35
36
  import { STORE_ENTRY_FILENAME } from "../formats/common.js";
@@ -89,18 +90,77 @@ export async function hashDirectory(dir: string): Promise<string> {
89
90
  const hash = createHash("sha256");
90
91
 
91
92
  for (const relative of files) {
92
- const bytes = await fs.readFile(path.join(root, ...relative.split("/")));
93
- hash.update(Buffer.from(relative, "utf8"));
94
- hash.update(FIELD_SEPARATOR);
95
- hash.update(Buffer.from(String(bytes.byteLength), "utf8"));
96
- hash.update(FIELD_SEPARATOR);
97
- hash.update(bytes);
98
- hash.update(FIELD_SEPARATOR);
93
+ updateWithFile(hash, relative, await fs.readFile(path.join(root, ...relative.split("/"))));
99
94
  }
100
95
 
101
96
  return `sha256-${hash.digest("hex")}`;
102
97
  }
103
98
 
99
+ /**
100
+ * The same hash as `hashDirectory`, computed synchronously. It exists for the
101
+ * one place that needs a hash inside a synchronous read: folding the packaged
102
+ * core recipe into a cached index as the index is read.
103
+ *
104
+ * hashDirectorySync(dir) === (await hashDirectory(dir))
105
+ * // -> true, for any directory
106
+ *
107
+ * @param dir - Absolute path to the directory to hash.
108
+ */
109
+ export function hashDirectorySync(dir: string): string {
110
+ const root = path.resolve(dir);
111
+ const files = collectFilesSync(root).sort(bytewiseCompare);
112
+ const hash = createHash("sha256");
113
+
114
+ for (const relative of files) {
115
+ updateWithFile(hash, relative, fsSync.readFileSync(path.join(root, ...relative.split("/"))));
116
+ }
117
+
118
+ return `sha256-${hash.digest("hex")}`;
119
+ }
120
+
121
+ /**
122
+ * Collects every hashable file under `dir`, synchronously. The rules are exactly
123
+ * those of `collectFiles`; the two differ only in how they wait.
124
+ *
125
+ * @param dir - The directory to walk.
126
+ * @param prefix - The relative path of `dir` within the tree being hashed.
127
+ */
128
+ function collectFilesSync(dir: string, prefix = ""): string[] {
129
+ const entries = fsSync.readdirSync(dir, { withFileTypes: true });
130
+ const found: string[] = [];
131
+
132
+ for (const entry of entries) {
133
+ if (entry.name === GIT_DIR_NAME) continue;
134
+ if (entry.name === STORE_ENTRY_FILENAME) continue;
135
+ if (entry.isSymbolicLink()) continue;
136
+
137
+ const relative = prefix.length > 0 ? `${prefix}/${entry.name}` : entry.name;
138
+ const absolute = path.join(dir, entry.name);
139
+
140
+ if (entry.isDirectory()) found.push(...collectFilesSync(absolute, relative));
141
+ else if (entry.isFile()) found.push(relative);
142
+ }
143
+
144
+ return found;
145
+ }
146
+
147
+ /**
148
+ * Feeds one file's canonical record into a hash: its path, its byte length and
149
+ * its bytes, each followed by the separator.
150
+ *
151
+ * @param hash - The hash being built.
152
+ * @param relative - The file's path relative to the tree root, posix separators.
153
+ * @param bytes - The file's contents.
154
+ */
155
+ function updateWithFile(hash: Hash, relative: string, bytes: Buffer): void {
156
+ hash.update(Buffer.from(relative, "utf8"));
157
+ hash.update(FIELD_SEPARATOR);
158
+ hash.update(Buffer.from(String(bytes.byteLength), "utf8"));
159
+ hash.update(FIELD_SEPARATOR);
160
+ hash.update(bytes);
161
+ hash.update(FIELD_SEPARATOR);
162
+ }
163
+
104
164
  /**
105
165
  * Compares two content hashes. Both are canonical lowercase strings, so this is
106
166
  * an exact comparison; it exists so callers read as intent rather than as