@hominis/fireforge 0.50.0 → 0.52.0
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.
- package/CHANGELOG.md +67 -0
- package/dist/build-info.json +4 -4
- package/dist/src/commands/check-report.d.ts +17 -4
- package/dist/src/commands/check-report.js +19 -6
- package/dist/src/commands/check-run.d.ts +5 -0
- package/dist/src/commands/check-run.js +9 -1
- package/dist/src/commands/check-steps.d.ts +8 -0
- package/dist/src/commands/check-steps.js +31 -6
- package/dist/src/commands/check.js +1 -1
- package/dist/src/commands/declarations-report.d.ts +43 -0
- package/dist/src/commands/declarations-report.js +76 -0
- package/dist/src/commands/export-placement-gate.js +8 -0
- package/dist/src/commands/furnace/rename-registrations.js +7 -2
- package/dist/src/commands/furnace/validate.js +26 -1
- package/dist/src/commands/lint-per-patch.js +6 -2
- package/dist/src/commands/lint-per-run-checkjs.js +2 -2
- package/dist/src/commands/lint-report.js +7 -2
- package/dist/src/commands/manifest.js +2 -0
- package/dist/src/commands/park.d.ts +22 -0
- package/dist/src/commands/park.js +232 -0
- package/dist/src/commands/patch/delete.js +11 -6
- package/dist/src/commands/patch/index.d.ts +3 -3
- package/dist/src/commands/patch/index.js +8 -4
- package/dist/src/commands/patch/lint-ignore-from.d.ts +44 -0
- package/dist/src/commands/patch/lint-ignore-from.js +142 -0
- package/dist/src/commands/patch/lint-ignore.js +39 -29
- package/dist/src/commands/patch/move-files-create.js +11 -4
- package/dist/src/commands/patch/move-files-into.js +10 -4
- package/dist/src/commands/patch/move-files.js +3 -1
- package/dist/src/commands/patch/removal-created-files.d.ts +48 -0
- package/dist/src/commands/patch/removal-created-files.js +83 -0
- package/dist/src/commands/patch/rename.js +15 -3
- package/dist/src/commands/patch/reserve.d.ts +33 -0
- package/dist/src/commands/patch/reserve.js +152 -0
- package/dist/src/commands/patch/retire.js +11 -6
- package/dist/src/commands/patch/split-plan.d.ts +10 -1
- package/dist/src/commands/patch/split-plan.js +16 -1
- package/dist/src/commands/patch/split.js +14 -5
- package/dist/src/commands/patch/stage.d.ts +34 -0
- package/dist/src/commands/patch/stage.js +153 -0
- package/dist/src/commands/patch/staged-dependency-remove.d.ts +31 -0
- package/dist/src/commands/patch/staged-dependency-remove.js +80 -0
- package/dist/src/commands/patch/staged-dependency.js +40 -98
- package/dist/src/commands/re-export-adjacent.d.ts +5 -0
- package/dist/src/commands/re-export-adjacent.js +4 -3
- package/dist/src/commands/re-export-bulk-scan.d.ts +2 -1
- package/dist/src/commands/re-export-bulk-scan.js +22 -7
- package/dist/src/commands/re-export-hunks.d.ts +35 -0
- package/dist/src/commands/re-export-hunks.js +220 -0
- package/dist/src/commands/re-export-options.js +29 -2
- package/dist/src/commands/re-export-purity-scope.d.ts +18 -0
- package/dist/src/commands/re-export-purity-scope.js +131 -0
- package/dist/src/commands/re-export-register.js +15 -1
- package/dist/src/commands/re-export-removed.d.ts +1 -1
- package/dist/src/commands/re-export-removed.js +17 -2
- package/dist/src/commands/re-export-scan.d.ts +2 -0
- package/dist/src/commands/re-export-scan.js +4 -1
- package/dist/src/commands/re-export-single.js +102 -42
- package/dist/src/commands/re-export-stale-registrations.d.ts +18 -0
- package/dist/src/commands/re-export-stale-registrations.js +62 -0
- package/dist/src/commands/re-export.js +35 -34
- package/dist/src/commands/status-output.js +5 -1
- package/dist/src/commands/test-build-furnace.d.ts +3 -2
- package/dist/src/commands/test-build-furnace.js +8 -2
- package/dist/src/commands/test-build-phase.d.ts +2 -0
- package/dist/src/commands/test-build-phase.js +34 -17
- package/dist/src/commands/test-checkjs.js +3 -2
- package/dist/src/commands/test-dispatch.d.ts +39 -0
- package/dist/src/commands/test-dispatch.js +220 -0
- package/dist/src/commands/test-furnace-reach.d.ts +37 -0
- package/dist/src/commands/test-furnace-reach.js +132 -0
- package/dist/src/commands/test-orchestrate.d.ts +14 -0
- package/dist/src/commands/test-orchestrate.js +25 -4
- package/dist/src/commands/test-perf-scope.d.ts +21 -0
- package/dist/src/commands/test-perf-scope.js +55 -0
- package/dist/src/commands/test-prelock.js +100 -4
- package/dist/src/commands/test-register.js +18 -3
- package/dist/src/commands/test-results.d.ts +62 -0
- package/dist/src/commands/test-results.js +62 -0
- package/dist/src/commands/test-run.d.ts +13 -55
- package/dist/src/commands/test-run.js +73 -101
- package/dist/src/commands/test-shards.d.ts +110 -0
- package/dist/src/commands/test-shards.js +255 -0
- package/dist/src/commands/test-stale-gate.d.ts +5 -1
- package/dist/src/commands/test-stale-gate.js +11 -2
- package/dist/src/commands/test-verdict.d.ts +2 -1
- package/dist/src/commands/test.d.ts +1 -1
- package/dist/src/commands/test.js +68 -75
- package/dist/src/commands/token-lint.js +48 -8
- package/dist/src/commands/verify.js +9 -2
- package/dist/src/core/build-baseline-types.d.ts +13 -0
- package/dist/src/core/build-baseline-validate.js +9 -0
- package/dist/src/core/build-baseline.js +7 -0
- package/dist/src/core/build-prepare.d.ts +6 -0
- package/dist/src/core/build-prepare.js +29 -3
- package/dist/src/core/config-paths.d.ts +1 -1
- package/dist/src/core/config-paths.js +2 -0
- package/dist/src/core/config-validate-check.js +83 -7
- package/dist/src/core/config-validate-test-toolchains.js +14 -0
- package/dist/src/core/config-validate-token-scales.d.ts +3 -0
- package/dist/src/core/config-validate-token-scales.js +47 -5
- package/dist/src/core/config-validate.js +6 -0
- package/dist/src/core/coverage-extend.js +2 -2
- package/dist/src/core/coverage-records.d.ts +11 -0
- package/dist/src/core/coverage-records.js +34 -0
- package/dist/src/core/engine-session-lock.d.ts +6 -0
- package/dist/src/core/engine-session-lock.js +10 -2
- package/dist/src/core/file-lock-shared.d.ts +57 -0
- package/dist/src/core/file-lock-shared.js +117 -0
- package/dist/src/core/file-lock.d.ts +9 -0
- package/dist/src/core/file-lock.js +58 -2
- package/dist/src/core/furnace-apply.js +10 -10
- package/dist/src/core/furnace-config-validate-exceptions.d.ts +11 -3
- package/dist/src/core/furnace-config-validate-exceptions.js +59 -8
- package/dist/src/core/furnace-config-validate.js +7 -1
- package/dist/src/core/furnace-registration-jar-inplace.d.ts +12 -0
- package/dist/src/core/furnace-registration-jar-inplace.js +80 -0
- package/dist/src/core/furnace-validate-accessibility.d.ts +5 -3
- package/dist/src/core/furnace-validate-accessibility.js +75 -30
- package/dist/src/core/furnace-validate-exceptions.d.ts +13 -4
- package/dist/src/core/furnace-validate-exceptions.js +59 -4
- package/dist/src/core/furnace-validate-icon-button.js +30 -1
- package/dist/src/core/history-log-file.js +8 -1
- package/dist/src/core/line-prefixer.d.ts +18 -0
- package/dist/src/core/line-prefixer.js +34 -0
- package/dist/src/core/mach.d.ts +7 -0
- package/dist/src/core/mach.js +24 -9
- package/dist/src/core/module-resolution-preflight.d.ts +19 -3
- package/dist/src/core/module-resolution-preflight.js +24 -6
- package/dist/src/core/negative-control-compiled.d.ts +36 -0
- package/dist/src/core/negative-control-compiled.js +87 -0
- package/dist/src/core/negative-control-diff.d.ts +14 -2
- package/dist/src/core/negative-control-diff.js +49 -6
- package/dist/src/core/negative-control.d.ts +12 -0
- package/dist/src/core/negative-control.js +48 -5
- package/dist/src/core/park-store.d.ts +63 -0
- package/dist/src/core/park-store.js +216 -0
- package/dist/src/core/patch-export-update.d.ts +4 -0
- package/dist/src/core/patch-export-update.js +12 -3
- package/dist/src/core/patch-export.d.ts +3 -2
- package/dist/src/core/patch-export.js +21 -14
- package/dist/src/core/patch-files.js +26 -8
- package/dist/src/core/patch-lint-checkjs-any-import.d.ts +6 -4
- package/dist/src/core/patch-lint-checkjs-any-import.js +9 -7
- package/dist/src/core/patch-lint-checkjs-diagnostics.d.ts +17 -0
- package/dist/src/core/patch-lint-checkjs-diagnostics.js +32 -0
- package/dist/src/core/patch-lint-checkjs.d.ts +10 -1
- package/dist/src/core/patch-lint-checkjs.js +13 -3
- package/dist/src/core/patch-lint-cross.d.ts +3 -1
- package/dist/src/core/patch-lint-cross.js +5 -0
- package/dist/src/core/patch-lint-css.js +14 -10
- package/dist/src/core/patch-lint-forward-registration-extended.d.ts +5 -0
- package/dist/src/core/patch-lint-forward-registration-extended.js +1 -1
- package/dist/src/core/patch-lint-ignore.d.ts +56 -12
- package/dist/src/core/patch-lint-ignore.js +173 -43
- package/dist/src/core/patch-lint-ownership.d.ts +10 -0
- package/dist/src/core/patch-lint-ownership.js +26 -0
- package/dist/src/core/patch-lint-size.js +9 -1
- package/dist/src/core/patch-lint-token-scale.d.ts +26 -8
- package/dist/src/core/patch-lint-token-scale.js +206 -29
- package/dist/src/core/patch-lint.js +2 -2
- package/dist/src/core/patch-manifest-io.js +8 -1
- package/dist/src/core/patch-manifest-key-order.d.ts +24 -0
- package/dist/src/core/patch-manifest-key-order.js +61 -0
- package/dist/src/core/patch-manifest-stage.d.ts +46 -0
- package/dist/src/core/patch-manifest-stage.js +96 -0
- package/dist/src/core/patch-manifest-validate.js +27 -1
- package/dist/src/core/patch-order-legal.d.ts +2 -1
- package/dist/src/core/patch-order-legal.js +7 -2
- package/dist/src/core/patch-policy.d.ts +6 -2
- package/dist/src/core/patch-policy.js +14 -7
- package/dist/src/core/patch-retired.d.ts +26 -4
- package/dist/src/core/patch-retired.js +40 -2
- package/dist/src/core/run-log.d.ts +6 -0
- package/dist/src/core/run-log.js +17 -1
- package/dist/src/core/test-harness-crash.d.ts +2 -1
- package/dist/src/core/test-harness-crash.js +5 -8
- package/dist/src/core/test-harness-signature.d.ts +16 -0
- package/dist/src/core/test-harness-signature.js +19 -1
- package/dist/src/core/test-raw-log.d.ts +66 -0
- package/dist/src/core/test-raw-log.js +122 -0
- package/dist/src/core/test-run-completeness.d.ts +47 -7
- package/dist/src/core/test-run-completeness.js +70 -23
- package/dist/src/core/test-stale-check.d.ts +10 -1
- package/dist/src/core/test-stale-check.js +15 -1
- package/dist/src/core/test-xpcshell-retry.d.ts +2 -0
- package/dist/src/core/test-xpcshell-retry.js +2 -1
- package/dist/src/core/token-lint.d.ts +5 -2
- package/dist/src/core/token-lint.js +8 -3
- package/dist/src/core/token-sibling-sheets.d.ts +46 -0
- package/dist/src/core/token-sibling-sheets.js +119 -0
- package/dist/src/core/tree-guard.js +2 -0
- package/dist/src/core/typecheck-shim.d.ts +6 -1
- package/dist/src/core/typecheck-shim.js +139 -2
- package/dist/src/core/typecheck.js +3 -1
- package/dist/src/index.d.ts +9 -3
- package/dist/src/index.js +4 -1
- package/dist/src/types/commands/index.d.ts +2 -2
- package/dist/src/types/commands/options.d.ts +68 -7
- package/dist/src/types/commands/patches.d.ts +43 -0
- package/dist/src/types/config.d.ts +74 -2
- package/dist/src/types/furnace.d.ts +29 -0
- package/dist/src/types/index.d.ts +1 -1
- package/dist/src/utils/options.d.ts +3 -2
- package/dist/src/utils/options.js +9 -4
- package/docs/configuration.md +110 -9
- package/docs/furnace.md +61 -1
- package/docs/lifecycle-invariants.md +13 -11
- package/docs/machine-output.md +68 -18
- package/docs/patch-workflow.md +174 -13
- package/docs/testing.md +89 -5
- package/package.json +3 -3
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which negative-control targets only a build can deliver.
|
|
3
|
+
*
|
|
4
|
+
* A control is applied after the pre-test build and right before dispatch,
|
|
5
|
+
* which is right for a file the runtime reads from the source tree or the
|
|
6
|
+
* objdir links to. A C++ (or Rust, IDL, …) source edited at that point is
|
|
7
|
+
* never compiled: the run tests the unmutated binary and reports
|
|
8
|
+
* `control=missed`, which reads as a test that cannot fail. Such a target
|
|
9
|
+
* is now refused without `--build`, and with `--build` the control is
|
|
10
|
+
* applied before the build so the binary under test carries it. That build
|
|
11
|
+
* is a full `mach build`: the pre-test build is otherwise `mach build
|
|
12
|
+
* faster`, which compiles nothing, so the control still missed.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* The engine-relative targets that reach the runtime only through a
|
|
16
|
+
* compile: a compiled-source extension and no install-manifest entry that
|
|
17
|
+
* ships the file itself.
|
|
18
|
+
*
|
|
19
|
+
* @param engineDir - Absolute engine directory
|
|
20
|
+
* @param enginePaths - The control's engine-relative targets
|
|
21
|
+
*/
|
|
22
|
+
export declare function findCompiledControlTargets(engineDir: string, enginePaths: readonly string[]): Promise<string[]>;
|
|
23
|
+
/**
|
|
24
|
+
* The notice that explains why a compiled control's pre-test build is a
|
|
25
|
+
* full `mach build` instead of `mach build faster`.
|
|
26
|
+
*
|
|
27
|
+
* @param compiled - Engine-relative compiled targets
|
|
28
|
+
*/
|
|
29
|
+
export declare function formatCompiledControlBuildNotice(compiled: readonly string[]): string;
|
|
30
|
+
/**
|
|
31
|
+
* The refusal for a compiled control without `--build`, naming the manual
|
|
32
|
+
* procedure for a run that wants to keep its build out of FireForge.
|
|
33
|
+
*
|
|
34
|
+
* @param compiled - Engine-relative compiled targets
|
|
35
|
+
*/
|
|
36
|
+
export declare function formatCompiledControlRefusal(compiled: readonly string[]): string;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Which negative-control targets only a build can deliver.
|
|
4
|
+
*
|
|
5
|
+
* A control is applied after the pre-test build and right before dispatch,
|
|
6
|
+
* which is right for a file the runtime reads from the source tree or the
|
|
7
|
+
* objdir links to. A C++ (or Rust, IDL, …) source edited at that point is
|
|
8
|
+
* never compiled: the run tests the unmutated binary and reports
|
|
9
|
+
* `control=missed`, which reads as a test that cannot fail. Such a target
|
|
10
|
+
* is now refused without `--build`, and with `--build` the control is
|
|
11
|
+
* applied before the build so the binary under test carries it. That build
|
|
12
|
+
* is a full `mach build`: the pre-test build is otherwise `mach build
|
|
13
|
+
* faster`, which compiles nothing, so the control still missed.
|
|
14
|
+
*/
|
|
15
|
+
import { extname } from 'node:path';
|
|
16
|
+
import { classifyRuntimeDelivery } from './objdir-install-manifests.js';
|
|
17
|
+
import { loadEngineInstallIndex } from './test-stale-exempt.js';
|
|
18
|
+
/** Extensions of sources the build compiles or code-generates into the binary. */
|
|
19
|
+
const COMPILED_EXTENSIONS = new Set([
|
|
20
|
+
'.c',
|
|
21
|
+
'.cc',
|
|
22
|
+
'.cpp',
|
|
23
|
+
'.cxx',
|
|
24
|
+
'.h',
|
|
25
|
+
'.hh',
|
|
26
|
+
'.hpp',
|
|
27
|
+
'.m',
|
|
28
|
+
'.mm',
|
|
29
|
+
'.rs',
|
|
30
|
+
'.idl',
|
|
31
|
+
'.webidl',
|
|
32
|
+
'.ipdl',
|
|
33
|
+
'.ipdlh',
|
|
34
|
+
'.yaml',
|
|
35
|
+
'.yml',
|
|
36
|
+
]);
|
|
37
|
+
/**
|
|
38
|
+
* The engine-relative targets that reach the runtime only through a
|
|
39
|
+
* compile: a compiled-source extension and no install-manifest entry that
|
|
40
|
+
* ships the file itself.
|
|
41
|
+
*
|
|
42
|
+
* @param engineDir - Absolute engine directory
|
|
43
|
+
* @param enginePaths - The control's engine-relative targets
|
|
44
|
+
*/
|
|
45
|
+
export async function findCompiledControlTargets(engineDir, enginePaths) {
|
|
46
|
+
const candidates = enginePaths.filter((path) => COMPILED_EXTENSIONS.has(extname(path)));
|
|
47
|
+
if (candidates.length === 0)
|
|
48
|
+
return [];
|
|
49
|
+
const index = await loadEngineInstallIndex(engineDir);
|
|
50
|
+
if (index === undefined)
|
|
51
|
+
return candidates;
|
|
52
|
+
const compiled = [];
|
|
53
|
+
for (const path of candidates) {
|
|
54
|
+
if ((await classifyRuntimeDelivery(index, engineDir, path)) === 'unlisted') {
|
|
55
|
+
compiled.push(path);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return compiled;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The notice that explains why a compiled control's pre-test build is a
|
|
62
|
+
* full `mach build` instead of `mach build faster`.
|
|
63
|
+
*
|
|
64
|
+
* @param compiled - Engine-relative compiled targets
|
|
65
|
+
*/
|
|
66
|
+
export function formatCompiledControlBuildNotice(compiled) {
|
|
67
|
+
const files = compiled.map((path) => `engine/${path}`).join(', ');
|
|
68
|
+
return (`The negative control edits compiled source (${files}); escalating this pre-test build to ` +
|
|
69
|
+
'a full mach build, since mach build faster compiles nothing and the binary under test ' +
|
|
70
|
+
'would not carry the control.');
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The refusal for a compiled control without `--build`, naming the manual
|
|
74
|
+
* procedure for a run that wants to keep its build out of FireForge.
|
|
75
|
+
*
|
|
76
|
+
* @param compiled - Engine-relative compiled targets
|
|
77
|
+
*/
|
|
78
|
+
export function formatCompiledControlRefusal(compiled) {
|
|
79
|
+
const files = compiled.map((path) => `engine/${path}`).join(', ');
|
|
80
|
+
return (`This negative control edits compiled source (${files}). Applied right before dispatch, ` +
|
|
81
|
+
'it would never be compiled: the run would test the unmutated binary and report ' +
|
|
82
|
+
'control=missed. Add --build: the control is then applied before the pre-test build, so ' +
|
|
83
|
+
'the binary under test carries it, and restored afterwards with a fresh mtime so the next ' +
|
|
84
|
+
'build recompiles the original. To run it by hand instead: copy the mutated file in, ' +
|
|
85
|
+
'`touch` it, `fireforge build`, `fireforge test`, restore the file byte-exact, `touch` it ' +
|
|
86
|
+
'again and rebuild.');
|
|
87
|
+
}
|
|
@@ -18,15 +18,27 @@ export interface ControlDiffFile {
|
|
|
18
18
|
/** The hunks to apply, in file order. */
|
|
19
19
|
hunks: ParsedHunk[];
|
|
20
20
|
}
|
|
21
|
+
/** Options for {@link parseControlDiff}. */
|
|
22
|
+
export interface ParseControlDiffOptions {
|
|
23
|
+
/**
|
|
24
|
+
* Called for a section whose old and new paths differ. Returns the path
|
|
25
|
+
* the control targets (a `git diff --no-index <file> <edited copy>` names
|
|
26
|
+
* the copy on its new side), or undefined for a real rename, which is
|
|
27
|
+
* refused.
|
|
28
|
+
*/
|
|
29
|
+
resolveRename?: (sourcePath: string, targetPath: string) => string | undefined;
|
|
30
|
+
}
|
|
21
31
|
/**
|
|
22
32
|
* Parses a control diff. Accepts `git diff` output (a `diff --git` header
|
|
23
33
|
* per file), which is what `git -C engine diff -- <file>` or a project-root
|
|
24
|
-
* `git diff` writes
|
|
34
|
+
* `git diff` writes, a plain unified diff (`diff -u`), and, through
|
|
35
|
+
* `resolveRename`, a `git diff --no-index` against an edited copy.
|
|
25
36
|
*
|
|
26
37
|
* @param content - The diff file's text
|
|
38
|
+
* @param options - See {@link ParseControlDiffOptions}
|
|
27
39
|
* @returns One entry per file, in diff order
|
|
28
40
|
*/
|
|
29
|
-
export declare function parseControlDiff(content: string): ControlDiffFile[];
|
|
41
|
+
export declare function parseControlDiff(content: string, options?: ParseControlDiffOptions): ControlDiffFile[];
|
|
30
42
|
/**
|
|
31
43
|
* Applies `hunks` to `original`, strictly.
|
|
32
44
|
*
|
|
@@ -17,30 +17,73 @@ import { parseDiffSections } from './patch-parse.js';
|
|
|
17
17
|
function refuse(message) {
|
|
18
18
|
throw new GeneralError(`Negative control diff refused: ${message}`);
|
|
19
19
|
}
|
|
20
|
+
/** A `---`/`+++` file name, without a trailing timestamp or git's `a/`/`b/`. */
|
|
21
|
+
function plainDiffPath(raw) {
|
|
22
|
+
const name = raw.split('\t')[0]?.trim() ?? '';
|
|
23
|
+
return name.startsWith('a/') || name.startsWith('b/') ? name.slice(2) : name;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Gives a plain unified diff (`diff -u`, no `diff --git` lines) the git
|
|
27
|
+
* header the section parser keys on, one per `---`/`+++` pair. A diff that
|
|
28
|
+
* already carries `diff --git` headers is returned unchanged.
|
|
29
|
+
*
|
|
30
|
+
* @param content - The diff file's text
|
|
31
|
+
*/
|
|
32
|
+
function withGitHeaders(content) {
|
|
33
|
+
const lines = content.split('\n');
|
|
34
|
+
if (lines.some((line) => line.startsWith('diff --git ')))
|
|
35
|
+
return content;
|
|
36
|
+
const out = [];
|
|
37
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
38
|
+
const line = lines[index] ?? '';
|
|
39
|
+
const next = lines[index + 1] ?? '';
|
|
40
|
+
if (line.startsWith('--- ') && next.startsWith('+++ ')) {
|
|
41
|
+
const oldPath = plainDiffPath(line.slice(4));
|
|
42
|
+
const newPath = plainDiffPath(next.slice(4));
|
|
43
|
+
index += 1;
|
|
44
|
+
if (oldPath === '/dev/null' || newPath === '/dev/null') {
|
|
45
|
+
// Kept as a create/delete, which the parser below refuses by name.
|
|
46
|
+
const path = oldPath === '/dev/null' ? newPath : oldPath;
|
|
47
|
+
out.push(`diff --git a/${path} b/${path}`, oldPath === '/dev/null' ? 'new file mode 100644' : 'deleted file mode 100644', oldPath === '/dev/null' ? '--- /dev/null' : `--- a/${path}`, newPath === '/dev/null' ? '+++ /dev/null' : `+++ b/${path}`);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
out.push(`diff --git a/${oldPath} b/${newPath}`, `--- a/${oldPath}`, `+++ b/${newPath}`);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
out.push(line);
|
|
54
|
+
}
|
|
55
|
+
return out.join('\n');
|
|
56
|
+
}
|
|
20
57
|
/**
|
|
21
58
|
* Parses a control diff. Accepts `git diff` output (a `diff --git` header
|
|
22
59
|
* per file), which is what `git -C engine diff -- <file>` or a project-root
|
|
23
|
-
* `git diff` writes
|
|
60
|
+
* `git diff` writes, a plain unified diff (`diff -u`), and, through
|
|
61
|
+
* `resolveRename`, a `git diff --no-index` against an edited copy.
|
|
24
62
|
*
|
|
25
63
|
* @param content - The diff file's text
|
|
64
|
+
* @param options - See {@link ParseControlDiffOptions}
|
|
26
65
|
* @returns One entry per file, in diff order
|
|
27
66
|
*/
|
|
28
|
-
export function parseControlDiff(content) {
|
|
29
|
-
const sections = parseDiffSections(content);
|
|
67
|
+
export function parseControlDiff(content, options = {}) {
|
|
68
|
+
const sections = parseDiffSections(withGitHeaders(content));
|
|
30
69
|
if (sections.length === 0) {
|
|
31
|
-
refuse('no
|
|
70
|
+
refuse('no diff sections found. Write the control with git, e.g. ' +
|
|
32
71
|
'`git -C engine diff -- <file> > control.diff` after making the edit, then revert the edit.');
|
|
33
72
|
}
|
|
34
73
|
const seen = new Set();
|
|
35
74
|
return sections.map((section) => {
|
|
36
|
-
|
|
75
|
+
let path = normalizePathSlashes(section.targetPath);
|
|
37
76
|
if (section.isBinary)
|
|
38
77
|
refuse(`${path} is a binary section.`);
|
|
39
78
|
if (section.isNewFile || section.isDeletedFile) {
|
|
40
79
|
refuse(`${path} creates or deletes a file; a control may only modify existing text files.`);
|
|
41
80
|
}
|
|
42
81
|
if (section.sourcePath !== section.targetPath) {
|
|
43
|
-
|
|
82
|
+
const resolved = options.resolveRename?.(section.sourcePath, section.targetPath);
|
|
83
|
+
if (resolved === undefined) {
|
|
84
|
+
refuse(`${section.sourcePath} -> ${path} is a rename; a control may only modify files.`);
|
|
85
|
+
}
|
|
86
|
+
path = normalizePathSlashes(resolved);
|
|
44
87
|
}
|
|
45
88
|
if (!isContainedRelativePath(path)) {
|
|
46
89
|
refuse(`${path} is not a contained relative path.`);
|
|
@@ -40,6 +40,12 @@ export interface NegativeControlPlan {
|
|
|
40
40
|
/** The diff file, as the operator passed it. */
|
|
41
41
|
diffFile: string;
|
|
42
42
|
targets: NegativeControlTarget[];
|
|
43
|
+
/**
|
|
44
|
+
* Engine-relative targets only a build delivers (compiled sources). A
|
|
45
|
+
* control with any must be applied before the pre-test build, and is
|
|
46
|
+
* restored with a fresh mtime. See `negative-control-compiled.ts`.
|
|
47
|
+
*/
|
|
48
|
+
compiled?: string[];
|
|
43
49
|
}
|
|
44
50
|
/** Locations a control works against. */
|
|
45
51
|
export interface NegativeControlPaths {
|
|
@@ -55,6 +61,12 @@ interface JournalEntry {
|
|
|
55
61
|
mode: number;
|
|
56
62
|
atimeMs: number;
|
|
57
63
|
mtimeMs: number;
|
|
64
|
+
/**
|
|
65
|
+
* Restore with the current time as mtime, not the original: a compiled
|
|
66
|
+
* source restored with its old mtime is skipped by the incremental build,
|
|
67
|
+
* which would leave the mutated code in the binary.
|
|
68
|
+
*/
|
|
69
|
+
freshMtime?: boolean;
|
|
58
70
|
}
|
|
59
71
|
interface Journal {
|
|
60
72
|
version: 1;
|
|
@@ -25,14 +25,15 @@
|
|
|
25
25
|
* it releases the held locks.
|
|
26
26
|
*/
|
|
27
27
|
import { lstat, mkdir, readFile, rm, utimes } from 'node:fs/promises';
|
|
28
|
-
import { join, posix, resolve } from 'node:path';
|
|
28
|
+
import { isAbsolute, join, posix, relative, resolve } from 'node:path';
|
|
29
29
|
import { GeneralError, PreflightRefusalError } from '../errors/base.js';
|
|
30
30
|
import { getNodeErrorCode, toError } from '../utils/errors.js';
|
|
31
31
|
import { writeFileAtomic } from '../utils/fs.js';
|
|
32
32
|
import { sha256Hex } from '../utils/hash.js';
|
|
33
33
|
import { notice } from '../utils/logger.js';
|
|
34
|
-
import { normalizePathSlashes, stripEnginePrefix } from '../utils/paths.js';
|
|
34
|
+
import { isContainedRelativePath, isPathInsideRoot, normalizePathSlashes, stripEnginePrefix, } from '../utils/paths.js';
|
|
35
35
|
import { loadFurnaceConfig } from './furnace-config.js';
|
|
36
|
+
import { findCompiledControlTargets } from './negative-control-compiled.js';
|
|
36
37
|
import { applyControlHunks, parseControlDiff, } from './negative-control-diff.js';
|
|
37
38
|
import { runInSignalCriticalSection } from './signal-critical.js';
|
|
38
39
|
/** Journal directory, relative to `.fireforge/`. */
|
|
@@ -159,7 +160,9 @@ export async function planNegativeControl(projectRoot, engineDir, diffFile, opti
|
|
|
159
160
|
}
|
|
160
161
|
let files;
|
|
161
162
|
try {
|
|
162
|
-
files = parseControlDiff(content
|
|
163
|
+
files = parseControlDiff(content, {
|
|
164
|
+
resolveRename: (source, target) => resolveEditedCopyRename(projectRoot, source, target),
|
|
165
|
+
});
|
|
163
166
|
}
|
|
164
167
|
catch (error) {
|
|
165
168
|
refuse(toError(error).message);
|
|
@@ -170,7 +173,40 @@ export async function planNegativeControl(projectRoot, engineDir, diffFile, opti
|
|
|
170
173
|
continue;
|
|
171
174
|
await prepareTarget(engineDir, target);
|
|
172
175
|
}
|
|
173
|
-
|
|
176
|
+
const compiled = await findCompiledControlTargets(engineDir, targets.filter((target) => !target.fromComponents).map((target) => target.enginePath));
|
|
177
|
+
return { diffFile, targets, ...(compiled.length > 0 ? { compiled } : {}) };
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* A diff path as a project-relative path: a contained relative path as
|
|
181
|
+
* is, and an absolute one (git strips the leading `/` into `a/Users/…`)
|
|
182
|
+
* when it lies inside the project. Undefined otherwise.
|
|
183
|
+
*/
|
|
184
|
+
function projectRelative(projectRoot, path) {
|
|
185
|
+
const normalized = normalizePathSlashes(path);
|
|
186
|
+
for (const candidate of [`/${normalized}`, normalized]) {
|
|
187
|
+
if (!isAbsolute(candidate))
|
|
188
|
+
continue;
|
|
189
|
+
if (isPathInsideRoot(projectRoot, candidate)) {
|
|
190
|
+
return normalizePathSlashes(relative(projectRoot, candidate));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return isContainedRelativePath(normalized) ? normalized : undefined;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* The control target of a `git diff --no-index <engine file> <edited copy>`
|
|
197
|
+
* section: its old side, when that names a project file and the new side
|
|
198
|
+
* names a copy outside `engine/` and `components/`. A section whose sides
|
|
199
|
+
* both live in those trees is a real rename and stays refused.
|
|
200
|
+
*/
|
|
201
|
+
function resolveEditedCopyRename(projectRoot, source, target) {
|
|
202
|
+
const old = projectRelative(projectRoot, source);
|
|
203
|
+
if (old === undefined)
|
|
204
|
+
return undefined;
|
|
205
|
+
const edited = projectRelative(projectRoot, target);
|
|
206
|
+
const tracked = (path) => path.startsWith('engine/') || path.startsWith('components/');
|
|
207
|
+
if (edited !== undefined && tracked(edited))
|
|
208
|
+
return undefined;
|
|
209
|
+
return old;
|
|
174
210
|
}
|
|
175
211
|
async function writeJournal(paths, journal) {
|
|
176
212
|
await writeFileAtomic(join(journalDir(paths), JOURNAL_FILE), `${JSON.stringify(journal, null, 2)}\n`);
|
|
@@ -203,6 +239,7 @@ export async function applyNegativeControl(paths, plan) {
|
|
|
203
239
|
mode: item.mode,
|
|
204
240
|
atimeMs: item.atimeMs,
|
|
205
241
|
mtimeMs: item.mtimeMs,
|
|
242
|
+
...(plan.compiled?.includes(item.target.enginePath) === true ? { freshMtime: true } : {}),
|
|
206
243
|
});
|
|
207
244
|
}
|
|
208
245
|
const journal = {
|
|
@@ -248,7 +285,13 @@ async function restoreEntry(paths, entry) {
|
|
|
248
285
|
`left the file as is. Inspect ${journalDir(paths)}.`);
|
|
249
286
|
}
|
|
250
287
|
await writeFileAtomic(absolutePath, original);
|
|
251
|
-
|
|
288
|
+
if (entry.freshMtime === true) {
|
|
289
|
+
const now = Date.now() / 1000;
|
|
290
|
+
await utimes(absolutePath, now, now);
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
await utimes(absolutePath, entry.atimeMs / 1000, entry.mtimeMs / 1000);
|
|
294
|
+
}
|
|
252
295
|
const readBack = await readFile(absolutePath);
|
|
253
296
|
const stats = await lstat(absolutePath);
|
|
254
297
|
if (!readBack.equals(original) || (stats.mode & 0o7777) !== (entry.mode & 0o7777)) {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** One parked file: both sides, null meaning absent. */
|
|
2
|
+
export interface ParkedFile {
|
|
3
|
+
/** Engine-relative path. */
|
|
4
|
+
path: string;
|
|
5
|
+
/** The file as the session left it. */
|
|
6
|
+
before: string | null;
|
|
7
|
+
/** The file the park wrote in its place. */
|
|
8
|
+
after: string | null;
|
|
9
|
+
}
|
|
10
|
+
/** The record of one park. */
|
|
11
|
+
export interface ParkRecord {
|
|
12
|
+
label: string;
|
|
13
|
+
/** The patch whose files were parked. */
|
|
14
|
+
patch: string;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
/** `baseline` parks every drifted hunk, `hunks` only those a diff named. */
|
|
17
|
+
mode: 'baseline' | 'hunks';
|
|
18
|
+
files: ParkedFile[];
|
|
19
|
+
}
|
|
20
|
+
/** What `park.json` holds: the record without file contents. */
|
|
21
|
+
interface ParkIndex extends Omit<ParkRecord, 'files'> {
|
|
22
|
+
files: {
|
|
23
|
+
path: string;
|
|
24
|
+
before: boolean;
|
|
25
|
+
after: boolean;
|
|
26
|
+
}[];
|
|
27
|
+
}
|
|
28
|
+
/** The outcome of restoring one file. */
|
|
29
|
+
export type RestoreOutcome = 'restored' | 'unchanged' | 'merged' | 'conflict';
|
|
30
|
+
/** Writes a park; refuses to overwrite an existing label. */
|
|
31
|
+
export declare function writePark(projectRoot: string, record: ParkRecord): Promise<string>;
|
|
32
|
+
/** Reads a park back, contents included. */
|
|
33
|
+
export declare function readPark(projectRoot: string, label: string): Promise<ParkRecord>;
|
|
34
|
+
/** Every park of the project, oldest first. */
|
|
35
|
+
export declare function listParks(projectRoot: string): Promise<ParkIndex[]>;
|
|
36
|
+
/** Deletes a park. */
|
|
37
|
+
export declare function dropPark(projectRoot: string, label: string): Promise<void>;
|
|
38
|
+
/** Reads an engine file, null when absent. */
|
|
39
|
+
export declare function readEngineFile(engineDir: string, path: string): Promise<string | null>;
|
|
40
|
+
/** Writes an engine file, removing it for null. */
|
|
41
|
+
export declare function writeEngineFile(engineDir: string, path: string, content: string | null): Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* Three-way merges `ours` and `theirs` over `base` with `git merge-file`.
|
|
44
|
+
* Conflicts come back as marker blocks.
|
|
45
|
+
*/
|
|
46
|
+
export declare function mergeThreeWay(ours: string, base: string, theirs: string, path: string): Promise<{
|
|
47
|
+
content: string;
|
|
48
|
+
conflicts: number;
|
|
49
|
+
}>;
|
|
50
|
+
/**
|
|
51
|
+
* What restoring one parked file writes: `before` when the file is still
|
|
52
|
+
* as the park left it, nothing when it already matches `before`, and a
|
|
53
|
+
* three-way merge otherwise. Absence only merges with absence.
|
|
54
|
+
*/
|
|
55
|
+
export declare function planRestore(file: ParkedFile, now: string | null): Promise<{
|
|
56
|
+
outcome: RestoreOutcome;
|
|
57
|
+
content: string | null;
|
|
58
|
+
}>;
|
|
59
|
+
/** Message ids a Fluent file's lines declare (`id = …`, `-term = …`). */
|
|
60
|
+
export declare function fluentIds(lines: readonly string[]): string[];
|
|
61
|
+
/** Lines `before` has that `after` lacks, as a multiset difference. */
|
|
62
|
+
export declare function removedLines(before: string | null, after: string | null): string[];
|
|
63
|
+
export {};
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Storage and merge logic behind `fireforge park`.
|
|
4
|
+
*
|
|
5
|
+
* Several sessions edit the same Hominis files on one engine, and a peer's
|
|
6
|
+
* `re-export --refuse-foreign-drift` refuses while another session's hunks
|
|
7
|
+
* sit in its patch's files. Setting those hunks aside meant reversing each
|
|
8
|
+
* one by hand. A park records, for every file of one patch that drifted:
|
|
9
|
+
*
|
|
10
|
+
* - `before`: the file as the session left it (what the park takes away),
|
|
11
|
+
* - `after`: what the park writes in its place (the queue-applied
|
|
12
|
+
* baseline, or the working tree minus the named hunks).
|
|
13
|
+
*
|
|
14
|
+
* Restoring is a three-way merge of the file as it is now (ours) with
|
|
15
|
+
* `before` (theirs) over `after` (base), so the owner's commit in between
|
|
16
|
+
* is kept and the parked hunks come back on top of it.
|
|
17
|
+
*
|
|
18
|
+
* A park lives under `.fireforge/park/<label>/`: `park.json` plus one copy
|
|
19
|
+
* of each side per file. Absent files are recorded as absent, never as
|
|
20
|
+
* empty.
|
|
21
|
+
*/
|
|
22
|
+
import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
|
|
23
|
+
import { tmpdir } from 'node:os';
|
|
24
|
+
import { dirname, join } from 'node:path';
|
|
25
|
+
import { InvalidArgumentError } from '../errors/base.js';
|
|
26
|
+
import { getNodeErrorCode } from '../utils/errors.js';
|
|
27
|
+
import { ensureDir, pathExists, readJson, readText, removeFile, writeJson, writeText, } from '../utils/fs.js';
|
|
28
|
+
import { exec } from '../utils/process.js';
|
|
29
|
+
import { FIREFORGE_DIR } from './config-paths.js';
|
|
30
|
+
const LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
31
|
+
/** The directory holding every park of a project. */
|
|
32
|
+
function parkRoot(projectRoot) {
|
|
33
|
+
return join(projectRoot, FIREFORGE_DIR, 'park');
|
|
34
|
+
}
|
|
35
|
+
/** Refuses a label that is not a plain file-name segment. */
|
|
36
|
+
function assertParkLabel(label) {
|
|
37
|
+
if (!LABEL_PATTERN.test(label)) {
|
|
38
|
+
throw new InvalidArgumentError(`Park label "${label}" must be letters, digits, ".", "_" or "-", starting with a letter or digit.`, '--label');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function sideDir(dir, side) {
|
|
42
|
+
return join(dir, side);
|
|
43
|
+
}
|
|
44
|
+
/** Writes a park; refuses to overwrite an existing label. */
|
|
45
|
+
export async function writePark(projectRoot, record) {
|
|
46
|
+
assertParkLabel(record.label);
|
|
47
|
+
const dir = join(parkRoot(projectRoot), record.label);
|
|
48
|
+
if (await pathExists(dir)) {
|
|
49
|
+
throw new InvalidArgumentError(`A park named "${record.label}" already exists. Restore or drop it first, or pass another --label.`, '--label');
|
|
50
|
+
}
|
|
51
|
+
for (const file of record.files) {
|
|
52
|
+
for (const side of ['before', 'after']) {
|
|
53
|
+
const content = file[side];
|
|
54
|
+
if (content === null)
|
|
55
|
+
continue;
|
|
56
|
+
const target = join(sideDir(dir, side), file.path);
|
|
57
|
+
await ensureDir(dirname(target));
|
|
58
|
+
await writeText(target, content);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const index = {
|
|
62
|
+
label: record.label,
|
|
63
|
+
patch: record.patch,
|
|
64
|
+
createdAt: record.createdAt,
|
|
65
|
+
mode: record.mode,
|
|
66
|
+
files: record.files.map((file) => ({
|
|
67
|
+
path: file.path,
|
|
68
|
+
before: file.before !== null,
|
|
69
|
+
after: file.after !== null,
|
|
70
|
+
})),
|
|
71
|
+
};
|
|
72
|
+
await writeJson(join(dir, 'park.json'), index);
|
|
73
|
+
return dir;
|
|
74
|
+
}
|
|
75
|
+
/** Reads a park back, contents included. */
|
|
76
|
+
export async function readPark(projectRoot, label) {
|
|
77
|
+
assertParkLabel(label);
|
|
78
|
+
const dir = join(parkRoot(projectRoot), label);
|
|
79
|
+
if (!(await pathExists(join(dir, 'park.json')))) {
|
|
80
|
+
throw new InvalidArgumentError(`No park named "${label}". List them with "fireforge park --list".`, label);
|
|
81
|
+
}
|
|
82
|
+
const index = await readJson(join(dir, 'park.json'));
|
|
83
|
+
const files = [];
|
|
84
|
+
for (const entry of index.files) {
|
|
85
|
+
files.push({
|
|
86
|
+
path: entry.path,
|
|
87
|
+
before: entry.before ? await readText(join(sideDir(dir, 'before'), entry.path)) : null,
|
|
88
|
+
after: entry.after ? await readText(join(sideDir(dir, 'after'), entry.path)) : null,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return { ...index, files };
|
|
92
|
+
}
|
|
93
|
+
/** Every park of the project, oldest first. */
|
|
94
|
+
export async function listParks(projectRoot) {
|
|
95
|
+
let names;
|
|
96
|
+
try {
|
|
97
|
+
names = (await readdir(parkRoot(projectRoot), { withFileTypes: true }))
|
|
98
|
+
.filter((entry) => entry.isDirectory())
|
|
99
|
+
.map((entry) => entry.name);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (getNodeErrorCode(error) === 'ENOENT')
|
|
103
|
+
return [];
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
const parks = [];
|
|
107
|
+
for (const name of names) {
|
|
108
|
+
const path = join(parkRoot(projectRoot), name, 'park.json');
|
|
109
|
+
if (await pathExists(path))
|
|
110
|
+
parks.push(await readJson(path));
|
|
111
|
+
}
|
|
112
|
+
return parks.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
113
|
+
}
|
|
114
|
+
/** Deletes a park. */
|
|
115
|
+
export async function dropPark(projectRoot, label) {
|
|
116
|
+
assertParkLabel(label);
|
|
117
|
+
const dir = join(parkRoot(projectRoot), label);
|
|
118
|
+
if (!(await pathExists(dir))) {
|
|
119
|
+
throw new InvalidArgumentError(`No park named "${label}".`, label);
|
|
120
|
+
}
|
|
121
|
+
await rm(dir, { recursive: true, force: true });
|
|
122
|
+
}
|
|
123
|
+
/** Reads an engine file, null when absent. */
|
|
124
|
+
export async function readEngineFile(engineDir, path) {
|
|
125
|
+
const full = join(engineDir, path);
|
|
126
|
+
return (await pathExists(full)) ? await readText(full) : null;
|
|
127
|
+
}
|
|
128
|
+
/** Writes an engine file, removing it for null. */
|
|
129
|
+
export async function writeEngineFile(engineDir, path, content) {
|
|
130
|
+
const full = join(engineDir, path);
|
|
131
|
+
if (content === null) {
|
|
132
|
+
if (await pathExists(full))
|
|
133
|
+
await removeFile(full);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
await ensureDir(dirname(full));
|
|
137
|
+
await writeText(full, content);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Three-way merges `ours` and `theirs` over `base` with `git merge-file`.
|
|
141
|
+
* Conflicts come back as marker blocks.
|
|
142
|
+
*/
|
|
143
|
+
export async function mergeThreeWay(ours, base, theirs, path) {
|
|
144
|
+
const scratch = await mkdtemp(join(tmpdir(), 'fireforge-park-'));
|
|
145
|
+
try {
|
|
146
|
+
const files = {
|
|
147
|
+
ours: join(scratch, 'ours'),
|
|
148
|
+
base: join(scratch, 'base'),
|
|
149
|
+
theirs: join(scratch, 'theirs'),
|
|
150
|
+
};
|
|
151
|
+
await writeFile(files.ours, ours);
|
|
152
|
+
await writeFile(files.base, base);
|
|
153
|
+
await writeFile(files.theirs, theirs);
|
|
154
|
+
const result = await exec('git', [
|
|
155
|
+
'merge-file',
|
|
156
|
+
'-p',
|
|
157
|
+
'-L',
|
|
158
|
+
`${path} (now)`,
|
|
159
|
+
'-L',
|
|
160
|
+
`${path} (parked from)`,
|
|
161
|
+
'-L',
|
|
162
|
+
`${path} (parked)`,
|
|
163
|
+
files.ours,
|
|
164
|
+
files.base,
|
|
165
|
+
files.theirs,
|
|
166
|
+
]);
|
|
167
|
+
if (result.exitCode < 0 || result.exitCode > 127) {
|
|
168
|
+
throw new Error(`git merge-file failed for ${path}: ${result.stderr.trim()}`);
|
|
169
|
+
}
|
|
170
|
+
return { content: result.stdout, conflicts: result.exitCode };
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
await rm(scratch, { recursive: true, force: true });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* What restoring one parked file writes: `before` when the file is still
|
|
178
|
+
* as the park left it, nothing when it already matches `before`, and a
|
|
179
|
+
* three-way merge otherwise. Absence only merges with absence.
|
|
180
|
+
*/
|
|
181
|
+
export async function planRestore(file, now) {
|
|
182
|
+
if (now === file.after)
|
|
183
|
+
return { outcome: 'restored', content: file.before };
|
|
184
|
+
if (now === file.before)
|
|
185
|
+
return { outcome: 'unchanged', content: now };
|
|
186
|
+
if (now === null || file.before === null || file.after === null) {
|
|
187
|
+
return { outcome: 'conflict', content: now };
|
|
188
|
+
}
|
|
189
|
+
const merged = await mergeThreeWay(now, file.after, file.before, file.path);
|
|
190
|
+
return { outcome: merged.conflicts > 0 ? 'conflict' : 'merged', content: merged.content };
|
|
191
|
+
}
|
|
192
|
+
/** Message ids a Fluent file's lines declare (`id = …`, `-term = …`). */
|
|
193
|
+
export function fluentIds(lines) {
|
|
194
|
+
const ids = [];
|
|
195
|
+
for (const line of lines) {
|
|
196
|
+
const match = /^(-?[A-Za-z][\w-]*)\s*=/.exec(line);
|
|
197
|
+
if (match?.[1] !== undefined)
|
|
198
|
+
ids.push(match[1]);
|
|
199
|
+
}
|
|
200
|
+
return ids;
|
|
201
|
+
}
|
|
202
|
+
/** Lines `before` has that `after` lacks, as a multiset difference. */
|
|
203
|
+
export function removedLines(before, after) {
|
|
204
|
+
const remaining = new Map();
|
|
205
|
+
for (const line of (after ?? '').split('\n'))
|
|
206
|
+
remaining.set(line, (remaining.get(line) ?? 0) + 1);
|
|
207
|
+
const removed = [];
|
|
208
|
+
for (const line of (before ?? '').split('\n')) {
|
|
209
|
+
const count = remaining.get(line) ?? 0;
|
|
210
|
+
if (count > 0)
|
|
211
|
+
remaining.set(line, count - 1);
|
|
212
|
+
else
|
|
213
|
+
removed.push(line);
|
|
214
|
+
}
|
|
215
|
+
return removed;
|
|
216
|
+
}
|
|
@@ -24,6 +24,8 @@ export interface UpdatePatchAndMetadataOptions {
|
|
|
24
24
|
newContent: string;
|
|
25
25
|
/** Manifest-row fields to merge into the patch's metadata. */
|
|
26
26
|
updates: Partial<PatchMetadata>;
|
|
27
|
+
/** Manifest-row fields to delete, e.g. a staged block left empty. */
|
|
28
|
+
unset?: readonly (keyof PatchMetadata)[] | undefined;
|
|
27
29
|
/** Hook run under the lock after the mutation succeeds. */
|
|
28
30
|
onCommitted?: UpdatePatchCommittedHook | undefined;
|
|
29
31
|
/** Policy gate run against the under-lock projected manifest. */
|
|
@@ -49,3 +51,5 @@ export interface UpdatePatchAndMetadataOptions {
|
|
|
49
51
|
* already byte-identical to the new content.
|
|
50
52
|
*/
|
|
51
53
|
export declare function updatePatchAndMetadata(options: UpdatePatchAndMetadataOptions): Promise<boolean>;
|
|
54
|
+
/** A copy of `row` without `fields`. */
|
|
55
|
+
export declare function withoutFields(row: PatchMetadata, fields: readonly (keyof PatchMetadata)[]): PatchMetadata;
|