@alecsibilia/luca 14.0.0-alpha.32 → 14.0.0-alpha.34

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/board/package.json +1 -1
  3. package/board/server/board-vocabulary-engine.ts +9 -1
  4. package/board/server/describe-record.ts +10 -2
  5. package/board/server/luca-version.ts +1 -1
  6. package/engine/cli/setup-build-cache-check.ts +52 -0
  7. package/engine/cli/setup-repo-checks.ts +15 -2
  8. package/engine/config/config-fields.ts +13 -0
  9. package/engine/config/config-reload.ts +18 -1
  10. package/engine/config/engine-config-schema.ts +67 -1
  11. package/engine/config/engine-config.ts +2 -0
  12. package/engine/config/test-commands.ts +55 -2
  13. package/engine/core/build-cache-copy.ts +206 -0
  14. package/engine/core/build-cache.ts +87 -0
  15. package/engine/core/build-context.ts +9 -0
  16. package/engine/core/execute-already-done-check.ts +1 -0
  17. package/engine/core/execute-build.ts +16 -1
  18. package/engine/core/execute-gates.ts +21 -1
  19. package/engine/core/execute.ts +6 -1
  20. package/engine/core/review-text.ts +15 -2
  21. package/engine/gates/gate-runner.ts +29 -2
  22. package/engine/gates/gate-schemas.ts +10 -0
  23. package/engine/git/git-adapter.ts +3 -1
  24. package/engine/git/git-join.ts +4 -0
  25. package/engine/journal/build-cache-entries.ts +62 -0
  26. package/engine/journal/journal-entry-schema.ts +6 -0
  27. package/engine/journal/journal-kinds.ts +2 -0
  28. package/engine/journal/journal-record-schema.ts +6 -0
  29. package/engine/journal/replay.ts +4 -0
  30. package/engine/journal/ticket-replay.ts +2 -0
  31. package/engine/models/calibration.ts +449 -0
  32. package/engine/models/model-score-facts.ts +21 -0
  33. package/engine/models/model-score.ts +22 -1
  34. package/engine/testing/calibration-fixtures.ts +174 -0
  35. package/engine/testing/model-score-fixtures.ts +6 -1
  36. package/engine/testing/practice-repo.ts +5 -0
  37. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @alecsibilia/luca
2
2
 
3
+ ## 14.0.0-alpha.34
4
+
5
+ No changes in this release.
6
+
7
+ ## 14.0.0-alpha.33
8
+
9
+ No changes in this release.
10
+
3
11
  ## 14.0.0-alpha.32
4
12
 
5
13
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luca/board",
3
- "version": "14.0.0-alpha.32",
3
+ "version": "14.0.0-alpha.34",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }
@@ -35,7 +35,15 @@ export const ENGINE_VOCABULARY = {
35
35
  gates_run: z.looseObject({
36
36
  target: z.string(),
37
37
  ok: z.boolean(),
38
- checks: z.array(z.looseObject({ name: z.string(), ok: z.boolean() })),
38
+ /** `skipped`: a ticket gate didn't run the test command (#722). */
39
+ checks: z.array(
40
+ z.looseObject({
41
+ name: z.string(),
42
+ ok: z.boolean(),
43
+ command: z.string().optional(),
44
+ skipped: z.boolean().optional(),
45
+ })
46
+ ),
39
47
  }),
40
48
  ticket_joined: z.looseObject({
41
49
  ok: z.boolean(),
@@ -238,13 +238,21 @@ export const describeRecord = ({
238
238
  ? ' after joining'
239
239
  : ''
240
240
  const who = record.ticket === null ? 'Final review: ' : at
241
+ // The test commands a ticket gate skipped by path (#722).
242
+ const skipped = record.content.checks
243
+ .filter((check) => check.skipped === true)
244
+ .map((check) => check.command ?? check.name)
245
+ const skips =
246
+ record.content.target === 'ticket' && skipped.length > 0
247
+ ? ` Skipped (no changed file matches its paths): ${skipped.join(', ')}.`
248
+ : ''
241
249
  return record.content.ok
242
250
  ? event({
243
- text: `${who}checks passed${where}.`,
251
+ text: `${who}checks passed${where}.${skips}`,
244
252
  tone: 'success',
245
253
  })
246
254
  : event({
247
- text: `${who}checks failed${where}: ${failed.join(', ') || 'no detail'}.`,
255
+ text: `${who}checks failed${where}: ${failed.join(', ') || 'no detail'}.${skips}`,
248
256
  tone: 'danger',
249
257
  })
250
258
  }
@@ -1,2 +1,2 @@
1
1
  /** The Luca version of this board, stamped when the package was packed. */
2
- export const LUCA_VERSION: string = "14.0.0-alpha.32"
2
+ export const LUCA_VERSION: string = "14.0.0-alpha.34"
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Doctor's `build_cache` check (#721): each `build_cache_paths` path should
3
+ * be ignored by git in the repo's checkout, since Luca never copies one
4
+ * that isn't, so a copied cache never ends up in a commit.
5
+ */
6
+ import uniq from 'lodash/uniq'
7
+
8
+ import { ok, warning, type Found } from './doctor-checks'
9
+
10
+ import { buildCachePathsOf, type EngineConfig } from '../config/engine-config'
11
+ import { ignoredAmong, isGlob, matchesIn } from '../core/build-cache'
12
+ import { createGitAdapter } from '../git/git-adapter'
13
+
14
+ /**
15
+ * The `build_cache` check for a config with build cache paths: a warning
16
+ * naming each path git doesn't ignore in the checkout, made into one; else
17
+ * OK. A plain entry is checked as written, even before its folder is there;
18
+ * a glob, by its matches in the checkout.
19
+ */
20
+ export const checkBuildCache = async ({
21
+ repo,
22
+ config,
23
+ }: {
24
+ repo: string
25
+ config: EngineConfig
26
+ }): Promise<Found> => {
27
+ const paths: string[] = []
28
+ for (const entry of buildCachePathsOf({ config })) {
29
+ paths.push(
30
+ ...(isGlob(entry) ? await matchesIn({ entry, dir: repo }) : [entry])
31
+ )
32
+ }
33
+ const checked = uniq(paths)
34
+ const ignored = await ignoredAmong({
35
+ git: createGitAdapter({ repo_root: repo }),
36
+ cwd: repo,
37
+ paths: checked,
38
+ })
39
+ const missing = checked.filter((path) => !ignored.has(path))
40
+ if (missing.length === 0) {
41
+ return ok('Each build_cache_paths path is ignored by git')
42
+ }
43
+ return warning({
44
+ detail: missing
45
+ .map(
46
+ (path) =>
47
+ `\`build_cache_paths\` path ${path} isn't ignored by git, so Luca won't copy it.`
48
+ )
49
+ .join(' '),
50
+ fix: missing.map((path) => `Add ${path} to .gitignore.`).join(' '),
51
+ })
52
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Doctor's repo group (`repoChecks`), which `luca setup` runs once too: the
3
- * labels, the config, its junit commands (#668), the GitHub remote, issue
3
+ * labels, the config, its junit commands (#668) and build caches (#721), the GitHub remote, issue
4
4
  * links, the base branch on `origin`, and the memory vault. Read-only.
5
5
  */
6
6
  import { join } from 'node:path'
@@ -12,10 +12,12 @@ import {
12
12
  type DoctorCheck,
13
13
  type Found,
14
14
  } from './doctor-checks'
15
+ import { checkBuildCache } from './setup-build-cache-check'
15
16
  import { errorText, findConfig } from './setup-config'
16
17
  import { checkJunit } from './setup-junit-check'
17
18
 
18
19
  import {
20
+ buildCachePathsOf,
19
21
  ENGINE_CONFIG_FILE,
20
22
  hasJunitCommand,
21
23
  loadEngineConfig,
@@ -223,7 +225,8 @@ const checkConfig = async ({
223
225
  /**
224
226
  * `luca setup`'s checks as doctor's repo group, read-only: they create no
225
227
  * label and write no file. Each problem carries its fix. The `junit` check
226
- * (#668) is there only when the config loads and has a junit command.
228
+ * (#668) is there only when the config loads and has a junit command, and
229
+ * the `build_cache` check (#721) only when it has build cache paths.
227
230
  *
228
231
  * @example
229
232
  * const checks = await repoChecks({ repo: process.cwd(), github, memory })
@@ -248,12 +251,22 @@ export const repoChecks = async ({
248
251
  loaded.ok && hasJunitCommand({ config: loaded.config })
249
252
  ? [['junit', () => checkJunit({ repo, config: loaded.config })]]
250
253
  : []
254
+ const build_cache: [string, () => Promise<Found>][] =
255
+ loaded.ok && buildCachePathsOf({ config: loaded.config }).length > 0
256
+ ? [
257
+ [
258
+ 'build_cache',
259
+ () => checkBuildCache({ repo, config: loaded.config }),
260
+ ],
261
+ ]
262
+ : []
251
263
  return checksFor({
252
264
  group: 'repo',
253
265
  checks: [
254
266
  ['labels', () => checkLabels({ repo, github, github_repo })],
255
267
  ['config', () => checkConfig({ repo, loaded })],
256
268
  ...junit,
269
+ ...build_cache,
257
270
  ['github_remote', async () => checkGitHubRemote({ github_repo })],
258
271
  ['issue_links', () => checkIssueLinks({ github, github_repo })],
259
272
  ['base_branch', () => checkBaseBranch({ repo, base_branch })],
@@ -104,6 +104,19 @@ export const modelsOf = ({ config }: { config: EngineConfig }): ModelsConfig =>
104
104
  export const autoRetryOf = ({ config }: { config: EngineConfig }): boolean =>
105
105
  config.auto_retry ?? true
106
106
 
107
+ /**
108
+ * The repo's build-cache paths and globs (#721): the config's
109
+ * `build_cache_paths`, else none.
110
+ *
111
+ * @example
112
+ * buildCachePathsOf({ config: { ...config, build_cache_paths: ['cache'] } }) // ['cache']
113
+ */
114
+ export const buildCachePathsOf = ({
115
+ config,
116
+ }: {
117
+ config: EngineConfig
118
+ }): string[] => config.build_cache_paths ?? []
119
+
107
120
  /**
108
121
  * The feature map's path (#583), in the form a worktree-relative path takes
109
122
  * everywhere else (`normalize`, no leading `./`), or `null` with no map.
@@ -3,7 +3,7 @@ import omit from 'lodash/omit'
3
3
  import reduce from 'lodash/reduce'
4
4
  import { z } from 'zod'
5
5
 
6
- import { autoRetryOf } from './config-fields'
6
+ import { autoRetryOf, buildCachePathsOf } from './config-fields'
7
7
  import type { EngineConfig } from './engine-config'
8
8
 
9
9
  /**
@@ -32,6 +32,9 @@ import type { EngineConfig } from './engine-config'
32
32
  * - `auto_retry`: whether the engine retries a routine stuck item once by
33
33
  * itself (#705). Only an item stuck after the resume is retried, so
34
34
  * turning it on leaves an item already told to the owner waiting.
35
+ * - `build_cache_paths`: the build caches cloned into each new ticket
36
+ * worktree (#721). Each new worktree reads it then, so a new value counts
37
+ * from the next ticket started.
35
38
  */
36
39
  export const RELOADED_FIELDS = [
37
40
  'prepare',
@@ -42,6 +45,7 @@ export const RELOADED_FIELDS = [
42
45
  'test_case_timeout_ms',
43
46
  'leftover_allow',
44
47
  'auto_retry',
48
+ 'build_cache_paths',
45
49
  ] as const
46
50
 
47
51
  /**
@@ -141,6 +145,12 @@ export const ConfigChangeSchema = z.discriminatedUnion('field', [
141
145
  from: z.boolean(),
142
146
  to: z.boolean(),
143
147
  }),
148
+ // Never left out: a config without it reads as `[]`.
149
+ z.object({
150
+ field: z.literal('build_cache_paths'),
151
+ from: z.array(z.string().min(1)),
152
+ to: z.array(z.string().min(1)),
153
+ }),
144
154
  ])
145
155
 
146
156
  export type ConfigChange = z.infer<typeof ConfigChangeSchema>
@@ -202,6 +212,11 @@ export const configChanges = ({
202
212
  from: autoRetryOf({ config: current }),
203
213
  to: autoRetryOf({ config: next }),
204
214
  },
215
+ {
216
+ field: 'build_cache_paths',
217
+ from: buildCachePathsOf({ config: current }),
218
+ to: buildCachePathsOf({ config: next }),
219
+ },
205
220
  ]
206
221
  return all.filter(({ from, to }) => !isEqual(from, to))
207
222
  }
@@ -244,6 +259,8 @@ const applyChange = ({
244
259
  : { ...config, leftover_allow: change.to }
245
260
  case 'auto_retry':
246
261
  return { ...config, auto_retry: change.to }
262
+ case 'build_cache_paths':
263
+ return { ...config, build_cache_paths: change.to }
247
264
  }
248
265
  }
249
266
 
@@ -45,12 +45,64 @@ const isFileInsideRepo = (path: string): boolean => {
45
45
  )
46
46
  }
47
47
 
48
+ /**
49
+ * Whether a `build_cache_paths` entry names a path or glob inside the repo
50
+ * (#721): not empty, not absolute, and, once cleaned, neither the repo root
51
+ * nor a path that climbs out of it with `..`.
52
+ */
53
+ const isCachePathInsideRepo = (path: string): boolean => {
54
+ const clean = normalize(path)
55
+ return (
56
+ path !== '' &&
57
+ !isAbsolute(path) &&
58
+ clean !== '.' &&
59
+ clean !== './' &&
60
+ clean !== '..' &&
61
+ !clean.startsWith('../')
62
+ )
63
+ }
64
+
65
+ /** One `build_cache_paths` entry, its trailing `/` dropped. */
66
+ const BuildCachePathSchema = z
67
+ .string()
68
+ .refine(isCachePathInsideRepo, {
69
+ message:
70
+ 'each build_cache_paths entry must be a path or glob inside the repo, relative to its root: not empty, not an absolute path, not the repo root, and not one that leaves the repo with ".."',
71
+ })
72
+ .transform((path) => path.replace(/\/+$/, ''))
73
+
48
74
  /** One JUnit file a junit test command writes, relative to the repo root. */
49
75
  const JunitPathSchema = z.string().min(1).refine(isFileInsideRepo, {
50
76
  message:
51
77
  'must be a file inside the repo, relative to its root: not an absolute path, not one that leaves the repo with "..", and not ending in "/"',
52
78
  })
53
79
 
80
+ /**
81
+ * Whether a `paths` glob stays inside the repo (#722): not absolute, and,
82
+ * once cleaned (`normalize`), not one that climbs out of it with `..`.
83
+ */
84
+ const isGlobInsideRepo = (glob: string): boolean => {
85
+ const clean = normalize(glob)
86
+ return !isAbsolute(glob) && clean !== '..' && !clean.startsWith('../')
87
+ }
88
+
89
+ /**
90
+ * The paths a `checks.test` command covers (#722): repo-relative globs. At a
91
+ * ticket's gates the command runs only when the ticket changed a file under
92
+ * them.
93
+ */
94
+ const TestPathsSchema = z
95
+ .array(
96
+ z.string().refine((glob) => glob !== '' && isGlobInsideRepo(glob), {
97
+ message:
98
+ 'each paths entry must be a glob inside the repo, relative to its root: not empty, not an absolute path, and not one that leaves the repo with ".."',
99
+ })
100
+ )
101
+ .min(1, {
102
+ message:
103
+ 'paths must list at least one glob; leave paths out to run the command at every gate',
104
+ })
105
+
54
106
  /** The text a slow command's `run_files` must hold, where the files go (#683). */
55
107
  export const RUN_FILES_PLACEHOLDER = '{files}'
56
108
 
@@ -60,7 +112,8 @@ export const RUN_FILES_PLACEHOLDER = '{files}'
60
112
  * `junit_path` (#668), one path or a list; no other kind may have one. A slow
61
113
  * entry may also name the test files it owns (`file_patterns`) and how to run
62
114
  * some of them (`run_files`, #683); they come together, and a `checks.test`
63
- * entry may have neither.
115
+ * entry may have neither. Only a `checks.test` entry may name the paths it
116
+ * covers (`paths`, #722).
64
117
  */
65
118
  const testCommandEntrySchema = ({ slow }: { slow: boolean }) =>
66
119
  z.union([
@@ -74,6 +127,11 @@ const testCommandEntrySchema = ({ slow }: { slow: boolean }) =>
74
127
  .optional(),
75
128
  file_patterns: z.array(z.string().min(1)).min(1).optional(),
76
129
  run_files: z.string().min(1).optional(),
130
+ paths: TestPathsSchema.optional(),
131
+ })
132
+ .refine(({ paths }) => !slow || paths === undefined, {
133
+ message:
134
+ 'checks.slow_test: paths belongs only on checks.test entries.',
77
135
  })
78
136
  .refine(
79
137
  ({ file_patterns, run_files }) =>
@@ -351,6 +409,14 @@ export const EngineConfigSchema = z.object({
351
409
  * as `true` too (`autoRetryOf`).
352
410
  */
353
411
  auto_retry: z.boolean().default(true).optional(),
412
+ /**
413
+ * The repo's build-cache folders (#721), as paths or globs relative to
414
+ * the repo root, such as `packages/ios/.derived`: each new ticket
415
+ * worktree gets the ones git ignores cloned in from the run branch's
416
+ * worktree, or else the repo's checkout. Left out, `[]`; a config
417
+ * written before it may lack it (`buildCachePathsOf`).
418
+ */
419
+ build_cache_paths: z.array(BuildCachePathSchema).default([]).optional(),
354
420
  })
355
421
 
356
422
  export type EngineConfig = z.infer<typeof EngineConfigSchema>
@@ -18,6 +18,7 @@ import {
18
18
 
19
19
  export {
20
20
  autoRetryOf,
21
+ buildCachePathsOf,
21
22
  DEFAULT_PREPARE_CONCURRENCY,
22
23
  DEFAULT_PREPARE_TIMEOUT_MS,
23
24
  decisionModelOf,
@@ -60,6 +61,7 @@ export {
60
61
  ownSlowCommands,
61
62
  slowTestCommands,
62
63
  testCommands,
64
+ testCommandsToRun,
63
65
  type OwnSlowCommand,
64
66
  type TestCommand,
65
67
  } from './test-commands'
@@ -8,12 +8,14 @@ import type { EngineConfig, TestResults } from './engine-config-schema'
8
8
  /**
9
9
  * One test command the engine runs, how it reads the results, and the JUnit
10
10
  * files a `junit` command writes (#668), relative to the repo root: `[]`
11
- * for every other kind.
11
+ * for every other kind. `paths` are the globs it covers at a ticket's gates
12
+ * (#722), or `null` when it covers everything.
12
13
  */
13
14
  export type TestCommand = {
14
15
  run: string
15
16
  results: TestResults
16
17
  junit_path: string[]
18
+ paths: string[] | null
17
19
  }
18
20
 
19
21
  /** Whether `run` is a `bun test` command, which gives bun's per-test results. */
@@ -36,7 +38,12 @@ const commandsOf = (value: EngineConfig['checks']['test']): TestCommand[] => {
36
38
  if (value === undefined) return []
37
39
  return (typeof value === 'string' ? [value] : value).map((entry) =>
38
40
  typeof entry === 'string'
39
- ? { run: entry, results: defaultResults(entry), junit_path: [] }
41
+ ? {
42
+ run: entry,
43
+ results: defaultResults(entry),
44
+ junit_path: [],
45
+ paths: null,
46
+ }
40
47
  : {
41
48
  run: entry.run,
42
49
  results: entry.results ?? defaultResults(entry.run),
@@ -44,10 +51,56 @@ const commandsOf = (value: EngineConfig['checks']['test']): TestCommand[] => {
44
51
  typeof entry.junit_path === 'string'
45
52
  ? [entry.junit_path]
46
53
  : (entry.junit_path ?? []),
54
+ paths: entry.paths ?? null,
47
55
  }
48
56
  )
49
57
  }
50
58
 
59
+ /** Whether `file` matches one of the globs. */
60
+ const matchesAny = ({
61
+ file,
62
+ globs,
63
+ }: {
64
+ file: string
65
+ globs: string[]
66
+ }): boolean => globs.some((glob) => new Bun.Glob(glob).match(file))
67
+
68
+ /**
69
+ * The test commands a ticket's gate runs, and the ones it skips (#722), each
70
+ * in config order. A command with no `paths` always runs; one with `paths`
71
+ * runs only when a changed file matches one of them. If a changed test file
72
+ * (by `test_file_patterns`) matches no command's `paths`, every command
73
+ * runs, so a ticket's own tests always run at its gates.
74
+ *
75
+ * @example
76
+ * testCommandsToRun({ commands: [a, b], changed_files: ['src/b/x.ts'], test_file_patterns: ['**\/*.test.ts'] })
77
+ * // { run: [b], skipped: [a] } when a has paths ['src/a/**'] and b ['src/b/**']
78
+ */
79
+ export const testCommandsToRun = ({
80
+ commands,
81
+ changed_files,
82
+ test_file_patterns,
83
+ }: {
84
+ commands: TestCommand[]
85
+ changed_files: string[]
86
+ test_file_patterns: string[]
87
+ }): { run: TestCommand[]; skipped: TestCommand[] } => {
88
+ const covered = commands.flatMap(({ paths }) => paths ?? [])
89
+ const uncoveredTest = changed_files.some(
90
+ (file) =>
91
+ matchesAny({ file, globs: test_file_patterns }) &&
92
+ !matchesAny({ file, globs: covered })
93
+ )
94
+ if (uncoveredTest) return { run: commands, skipped: [] }
95
+ const runs = ({ paths }: TestCommand): boolean =>
96
+ paths === null ||
97
+ changed_files.some((file) => matchesAny({ file, globs: paths }))
98
+ return {
99
+ run: commands.filter(runs),
100
+ skipped: commands.filter((command) => !runs(command)),
101
+ }
102
+ }
103
+
51
104
  /**
52
105
  * Every test command in the config, in order, each with its results kind
53
106
  * and `junit_path`. A missing `results` is `bun` for a `bun test` command,
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Warm builds (#721): `create_ticket_worktree` clones the repo's
3
+ * `build_cache_paths` into a ticket's new worktree, from the run branch's
4
+ * worktree or else the repo's checkout, both only read. Each copy goes to a
5
+ * temporary folder in the run folder first and is then renamed into place,
6
+ * so a worktree never holds a half-made copy. Nothing here ever stops the
7
+ * ticket: a failed scan, git check, copy or temporary folder only skips
8
+ * paths, each journaled with its reason.
9
+ */
10
+ import { existsSync } from 'node:fs'
11
+ import { mkdir, rename, rm } from 'node:fs/promises'
12
+ import { dirname, join } from 'node:path'
13
+
14
+ import {
15
+ copyDir,
16
+ ignoredAmong,
17
+ isGlob,
18
+ matchesIn,
19
+ type CopyDirResult,
20
+ } from './build-cache'
21
+ import type { BuildContext } from './build-context'
22
+
23
+ import { buildCachePathsOf } from '../config/engine-config'
24
+ import type {
25
+ BuildCacheSkipReason,
26
+ BuildCacheSource,
27
+ } from '../journal/build-cache-entries'
28
+
29
+ /** One source folder a cache may be copied from. */
30
+ type Source = { from: BuildCacheSource; dir: string }
31
+
32
+ /** One match of an entry, in the first source that has it. */
33
+ type Match = Source & { path: string }
34
+
35
+ /** An error's text. */
36
+ const messageOf = (error: unknown): string =>
37
+ error instanceof Error ? error.message : String(error)
38
+
39
+ /** Removes a folder; one it can't remove is left, and never throws. */
40
+ const removeQuietly = (dir: string): Promise<void> =>
41
+ rm(dir, { recursive: true, force: true }).catch(() => undefined)
42
+
43
+ /** The run's two warm sources, in order: the run branch's worktree, then the checkout. */
44
+ const sourcesOf = ({ context }: { context: BuildContext }): Source[] => {
45
+ const started = context.journal
46
+ .read()
47
+ .find((record) => record.kind === 'run_started')
48
+ const repo = started?.kind === 'run_started' ? started.content.repo : null
49
+ const run_branch = context.state.run_branch?.path ?? null
50
+ const sources: Source[] = []
51
+ if (run_branch !== null) {
52
+ sources.push({ from: 'run_branch', dir: run_branch })
53
+ }
54
+ if (repo !== null) sources.push({ from: 'checkout', dir: repo })
55
+ return sources
56
+ }
57
+
58
+ /**
59
+ * An entry's matches, each from the first source that has it. A source it
60
+ * can't scan has no match.
61
+ */
62
+ const matchesOf = async ({
63
+ entry,
64
+ sources,
65
+ }: {
66
+ entry: string
67
+ sources: Source[]
68
+ }): Promise<Match[]> => {
69
+ const found: Match[] = []
70
+ for (const source of sources) {
71
+ const paths = await matchesIn({ entry, dir: source.dir }).catch(
72
+ (): string[] => []
73
+ )
74
+ for (const path of paths) {
75
+ if (!found.some((match) => match.path === path)) {
76
+ found.push({ ...source, path })
77
+ }
78
+ if (!isGlob(entry)) return found
79
+ }
80
+ }
81
+ return found
82
+ }
83
+
84
+ /** Copies a match to `temp`, then renames it into the worktree. */
85
+ const copyInto = async ({
86
+ context,
87
+ match,
88
+ temp,
89
+ worktree,
90
+ }: {
91
+ context: BuildContext
92
+ match: Match
93
+ temp: string
94
+ worktree: string
95
+ }): Promise<CopyDirResult> => {
96
+ const copy = context.copy_dir ?? copyDir
97
+ try {
98
+ const copied = await copy({
99
+ from: join(match.dir, match.path),
100
+ to: temp,
101
+ })
102
+ if (!copied.ok) return copied
103
+ const target = join(worktree, match.path)
104
+ await mkdir(dirname(target), { recursive: true })
105
+ await rename(temp, target)
106
+ return { ok: true }
107
+ } catch (error) {
108
+ return { ok: false, error: messageOf(error) }
109
+ } finally {
110
+ await removeQuietly(temp)
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Clones the config's build caches that git ignores into ticket `ticket`'s
116
+ * new worktree, journaling `build_cache_skipped` for each path not copied
117
+ * and one `build_cache_copied` when any was.
118
+ *
119
+ * @example
120
+ * await copyBuildCaches({ context, ticket: 11, worktree })
121
+ */
122
+ export const copyBuildCaches = async ({
123
+ context,
124
+ ticket,
125
+ worktree,
126
+ }: {
127
+ context: BuildContext
128
+ ticket: number
129
+ worktree: string
130
+ }): Promise<void> => {
131
+ const entries = buildCachePathsOf({ config: context.config })
132
+ if (entries.length === 0) return
133
+ const skip = (content: {
134
+ path: string
135
+ reason: BuildCacheSkipReason
136
+ error?: string
137
+ }) =>
138
+ context.journal.append({
139
+ kind: 'build_cache_skipped',
140
+ ticket,
141
+ role: null,
142
+ content,
143
+ })
144
+ // A crashed try's leftovers go first; this try's go at the end. When
145
+ // the temporary folder can't be cleared or made, each copy fails.
146
+ const temps = join(context.run_dir, 'cache-copies', String(ticket))
147
+ const uncleared = await rm(temps, { recursive: true, force: true }).then(
148
+ () => null,
149
+ messageOf
150
+ )
151
+ const sources = sourcesOf({ context })
152
+ const matches: Match[] = []
153
+ for (const entry of entries) {
154
+ const found = await matchesOf({ entry, sources })
155
+ if (found.length === 0) skip({ path: entry, reason: 'no_source' })
156
+ for (const match of found) {
157
+ if (!matches.some(({ path }) => path === match.path)) {
158
+ matches.push(match)
159
+ }
160
+ }
161
+ }
162
+ if (matches.length === 0) return
163
+ const ignored = await ignoredAmong({
164
+ git: context.git,
165
+ cwd: worktree,
166
+ paths: matches.map(({ path }) => path),
167
+ }).catch(() => new Set<string>())
168
+ const unmade =
169
+ uncleared ??
170
+ (await mkdir(temps, { recursive: true }).then(() => null, messageOf))
171
+ const started = performance.now()
172
+ const copied: { path: string; from: BuildCacheSource }[] = []
173
+ for (const [index, match] of matches.entries()) {
174
+ if (!ignored.has(match.path)) {
175
+ skip({ path: match.path, reason: 'not_ignored' })
176
+ } else if (existsSync(join(worktree, match.path))) {
177
+ skip({ path: match.path, reason: 'already_present' })
178
+ } else if (unmade !== null) {
179
+ skip({ path: match.path, reason: 'copy_failed', error: unmade })
180
+ } else {
181
+ const done = await copyInto({
182
+ context,
183
+ match,
184
+ temp: join(temps, String(index)),
185
+ worktree,
186
+ })
187
+ if (done.ok) copied.push({ path: match.path, from: match.from })
188
+ else {
189
+ skip({
190
+ path: match.path,
191
+ reason: 'copy_failed',
192
+ error: done.error,
193
+ })
194
+ }
195
+ }
196
+ }
197
+ const ms = Math.round(performance.now() - started)
198
+ await removeQuietly(temps)
199
+ if (copied.length === 0) return
200
+ context.journal.append({
201
+ kind: 'build_cache_copied',
202
+ ticket,
203
+ role: null,
204
+ content: { paths: copied, ms },
205
+ })
206
+ }