@opengsd/gsd-core 1.20.0 → 1.21.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +33 -2
- package/agents/gsd-executor.md +1 -1
- package/agents/gsd-plan-checker.md +1 -1
- package/agents/gsd-planner.md +3 -3
- package/agents/gsd-verifier.md +2 -2
- package/bin/install.js +265 -161
- package/commands/gsd/autonomous.md +1 -1
- package/commands/gsd/new-milestone.md +2 -2
- package/commands/gsd/new-project.md +2 -2
- package/gsd-core/bin/gsd-tools.cjs +554 -823
- package/gsd-core/bin/lib/capability-activation.cjs +8 -2
- package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
- package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
- package/gsd-core/bin/lib/capability-lock.cjs +19 -27
- package/gsd-core/bin/lib/capability-registry.cjs +74 -71
- package/gsd-core/bin/lib/capability-validator.cjs +1 -0
- package/gsd-core/bin/lib/capability-writer.cjs +7 -4
- package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
- package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
- package/gsd-core/bin/lib/cli-surface.cjs +545 -0
- package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
- package/gsd-core/bin/lib/commands.cjs +99 -2
- package/gsd-core/bin/lib/config-file-read.cjs +85 -0
- package/gsd-core/bin/lib/config-loader.cjs +23 -15
- package/gsd-core/bin/lib/config.cjs +139 -101
- package/gsd-core/bin/lib/configuration.cjs +41 -2
- package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
- package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
- package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
- package/gsd-core/bin/lib/gap-checker.cjs +12 -1
- package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
- package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
- package/gsd-core/bin/lib/graphify.cjs +19 -2
- package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
- package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
- package/gsd-core/bin/lib/init.cjs +20 -18
- package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
- package/gsd-core/bin/lib/install-engine.cjs +20 -28
- package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
- package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
- package/gsd-core/bin/lib/install-profiles.cjs +4 -2
- package/gsd-core/bin/lib/io.cjs +6 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
- package/gsd-core/bin/lib/markdown-table.cjs +39 -1
- package/gsd-core/bin/lib/milestone.cjs +8 -6
- package/gsd-core/bin/lib/model-resolver.cjs +8 -4
- package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
- package/gsd-core/bin/lib/phase-id.cjs +26 -9
- package/gsd-core/bin/lib/phase-locator.cjs +8 -3
- package/gsd-core/bin/lib/phase.cjs +170 -45
- package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
- package/gsd-core/bin/lib/planning-snapshot.cjs +10 -6
- package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
- package/gsd-core/bin/lib/profile-output.cjs +9 -6
- package/gsd-core/bin/lib/project-context.cjs +439 -0
- package/gsd-core/bin/lib/project-root.cjs +9 -230
- package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
- package/gsd-core/bin/lib/report-parser.cjs +16 -0
- package/gsd-core/bin/lib/roadmap-parser.cjs +357 -50
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
- package/gsd-core/bin/lib/roadmap.cjs +2 -2
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +317 -134
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
- package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
- package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
- package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
- package/gsd-core/bin/lib/state-transition.cjs +103 -46
- package/gsd-core/bin/lib/state.cjs +448 -63
- package/gsd-core/bin/lib/surface.cjs +4 -7
- package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
- package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
- package/gsd-core/bin/lib/verify.cjs +49 -6
- package/gsd-core/bin/lib/workstream-inventory.cjs +4 -4
- package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
- package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
- package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
- package/gsd-core/references/autonomous-smart-discuss.md +99 -18
- package/gsd-core/references/checkpoints.md +1 -1
- package/gsd-core/references/planner-guidance.md +11 -0
- package/gsd-core/references/planner-human-verify-mode.md +1 -1
- package/gsd-core/references/planner-reversibility.md +2 -0
- package/gsd-core/references/planner-verify-command-grounding.md +22 -6
- package/gsd-core/workflows/autonomous.md +4 -4
- package/gsd-core/workflows/code-review-fix.md +5 -7
- package/gsd-core/workflows/execute-phase.md +21 -7
- package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
- package/gsd-core/workflows/plan-phase.md +18 -19
- package/hooks/dist/gsd-agent-isolation-guard.js +4 -3
- package/hooks/dist/gsd-config-reload.js +2 -2
- package/hooks/dist/gsd-context-monitor.js +2 -1
- package/hooks/dist/gsd-cursor-subagent-start.js +9 -6
- package/hooks/dist/gsd-read-injection-scanner.js +2 -2
- package/hooks/dist/gsd-secret-read-guard.js +141 -6
- package/hooks/dist/gsd-statusline.js +3 -2
- package/hooks/dist/gsd-workflow-guard.js +2 -1
- package/hooks/dist/lib/config-read.js +49 -0
- package/hooks/dist/lib/git-cmd.js +3 -1
- package/hooks/dist/lib/git-head-tree.js +474 -0
- package/hooks/dist/lib/isolation-sentinel.js +168 -13
- package/hooks/dist/lib/secret-read-exemptions.js +161 -0
- package/hooks/gsd-agent-isolation-guard.js +4 -3
- package/hooks/gsd-config-reload.js +2 -2
- package/hooks/gsd-context-monitor.js +2 -1
- package/hooks/gsd-cursor-subagent-start.js +9 -6
- package/hooks/gsd-read-injection-scanner.js +2 -2
- package/hooks/gsd-secret-read-guard.js +141 -6
- package/hooks/gsd-statusline.js +3 -2
- package/hooks/gsd-workflow-guard.js +2 -1
- package/hooks/lib/config-read.js +49 -0
- package/hooks/lib/git-cmd.js +3 -1
- package/hooks/lib/git-head-tree.js +474 -0
- package/hooks/lib/isolation-sentinel.js +168 -13
- package/hooks/lib/secret-read-exemptions.js +161 -0
- package/package.json +7 -2
- package/pi/package-agents/gsd-executor.md +1 -1
- package/pi/package-agents/gsd-plan-checker.md +1 -1
- package/pi/package-agents/gsd-planner.md +3 -3
- package/pi/package-agents/gsd-verifier.md +2 -2
- package/pi/package-agents/manifest.json +4 -4
- package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
- package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
- package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
- package/scripts/changeset/cli.cjs +2 -2
- package/scripts/changeset/lint.cjs +28 -3
- package/scripts/changeset/serialize.cjs +12 -2
- package/scripts/ci-compact-v8-coverage.cjs +204 -0
- package/scripts/ci-fix-next-rerun.cjs +149 -0
- package/scripts/ci-next-health.cjs +52 -9
- package/scripts/ci-test-scope.cjs +77 -71
- package/scripts/command-contract-helpers.cjs +56 -7
- package/scripts/docs-guard-registry.cjs +3 -0
- package/scripts/gen-cli-surface-manifest.cjs +287 -0
- package/scripts/gen-platform-conformance-tier.cjs +258 -19
- package/scripts/lib/macos-conformance-tier.generated.cjs +59 -47
- package/scripts/lib/platform-conformance-tier.generated.cjs +23 -64
- package/scripts/lib/shellcheck-fetch.cjs +71 -4
- package/scripts/lint-removed-but-needed.cjs +562 -50
- package/scripts/merge-commit-pr.cjs +186 -0
- package/scripts/release-ancestry.cjs +86 -0
- package/scripts/release-notes/changelog-release-notes.cjs +345 -0
- package/scripts/release-notes/discord-release-summary.cjs +82 -1
- package/scripts/release-pipeline-pr.cjs +92 -0
- package/scripts/require-issue-link-policy.cjs +18 -9
- package/scripts/sync-next-version.cjs +11 -2
- package/scripts/timeout-evidence-from-ci.cjs +964 -0
- package/skills/gsd-autonomous/SKILL.md +1 -1
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-new-project/SKILL.md +1 -1
- package/vscode/package.json +1 -1
|
@@ -41,6 +41,8 @@ exports.isSafeRefArgument = isSafeRefArgument;
|
|
|
41
41
|
exports.isSafeDateArgument = isSafeDateArgument;
|
|
42
42
|
exports.commitIsAncestor = commitIsAncestor;
|
|
43
43
|
exports.resolveEvaluationScope = resolveEvaluationScope;
|
|
44
|
+
exports.parseGitVersion = parseGitVersion;
|
|
45
|
+
exports.remergeDiffSupported = remergeDiffSupported;
|
|
44
46
|
exports.evaluateEvaluationScope = evaluateEvaluationScope;
|
|
45
47
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
46
48
|
const node_path_1 = __importDefault(require("node:path"));
|
|
@@ -48,6 +50,7 @@ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs")
|
|
|
48
50
|
const gate_verdict_cjs_1 = require("./gate-verdict.cjs");
|
|
49
51
|
const gate_phase_context_cjs_1 = require("./gate-phase-context.cjs");
|
|
50
52
|
const pattern_cjs_1 = require("./pattern.cjs");
|
|
53
|
+
const security_cjs_1 = require("./security.cjs");
|
|
51
54
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
52
55
|
const planningWorkspaceMod = require("./planning-workspace.cjs");
|
|
53
56
|
const { readScopedConfigValue } = planningWorkspaceMod;
|
|
@@ -63,6 +66,8 @@ exports.SCOPE_EXCLUSION_PATHSPECS = Object.freeze([
|
|
|
63
66
|
const SHA_CHUNK = 400;
|
|
64
67
|
const DEFAULT_MAX_COMMITS = 200;
|
|
65
68
|
const MAX_PATHSPECS = 20;
|
|
69
|
+
const MAX_SUB_REPOS = 64;
|
|
70
|
+
const MAX_ID_LOOKUPS = 1024;
|
|
66
71
|
// ─── SUMMARY `## Task Commits` extraction (port of the #4127 model) ───────────
|
|
67
72
|
const TASK_COMMITS_HEADING = /^## Task Commits[ \t\r]*$/;
|
|
68
73
|
const NEXT_HEADING = /^## /;
|
|
@@ -242,15 +247,33 @@ class ScopeUnreadable extends Error {
|
|
|
242
247
|
this.exitCode = exitCode;
|
|
243
248
|
}
|
|
244
249
|
}
|
|
245
|
-
|
|
250
|
+
/** The git subcommand of `args`: the first argument after any leading `-c <key=value>` pairs. */
|
|
251
|
+
function gitSubcommand(args) {
|
|
252
|
+
let index = 0;
|
|
253
|
+
while (args[index] === '-c')
|
|
254
|
+
index += 2;
|
|
255
|
+
return args[index] ?? '';
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* git settings a `planning.sub_repos` workspace's own `.git/config` must not override: a configured
|
|
259
|
+
* repo is checkout content, so no setting that makes a read-only command run a program may apply
|
|
260
|
+
* (an fsmonitor command; `log.showSignature` with a `gpg.program` on a signed commit). Applied to
|
|
261
|
+
* every git call of the sub_repos path, root included; the single-repo path keeps its exact argv.
|
|
262
|
+
* Bounded by construction: at most MAX_SUB_REPOS + 1 repos, each a fixed number of calls, each
|
|
263
|
+
* bounded by the runner's timeout.
|
|
264
|
+
*/
|
|
265
|
+
const SUB_REPO_GIT_HARDENING = [
|
|
266
|
+
'-c', 'core.fsmonitor=false', '-c', 'log.showSignature=false', '-c', 'gpg.program=false',
|
|
267
|
+
];
|
|
268
|
+
function makeGit(projectDir, runner, leading = []) {
|
|
246
269
|
return (args, cwd = projectDir) => {
|
|
247
|
-
const result = runner(args, { cwd });
|
|
270
|
+
const result = runner([...leading, ...args], { cwd });
|
|
248
271
|
if (result.timedOut)
|
|
249
272
|
throw new ScopeUnreadable('git-timeout');
|
|
250
273
|
if (result.exitCode === 127)
|
|
251
274
|
throw new ScopeUnreadable('git-unavailable');
|
|
252
275
|
if (result.exitCode !== 0)
|
|
253
|
-
throw new ScopeUnreadable(`git-failed:${args
|
|
276
|
+
throw new ScopeUnreadable(`git-failed:${gitSubcommand(args)}`, result.exitCode);
|
|
254
277
|
return result.stdout;
|
|
255
278
|
};
|
|
256
279
|
}
|
|
@@ -301,8 +324,41 @@ function isAncestor(git, sha, of) {
|
|
|
301
324
|
function commitIsAncestor(projectDir, sha, of, execGit) {
|
|
302
325
|
if (!isSafeRefArgument(sha) || !isSafeRefArgument(of))
|
|
303
326
|
return null;
|
|
327
|
+
const runner = execGit ?? shell_command_projection_cjs_1.execGit;
|
|
304
328
|
try {
|
|
305
|
-
|
|
329
|
+
const names = readSubRepoNames(projectDir);
|
|
330
|
+
if (names.length === 0)
|
|
331
|
+
return isAncestor(makeGit(projectDir, runner), sha, of);
|
|
332
|
+
// #5867: the commits of a `planning.sub_repos` workspace live in the sub-repos (and the
|
|
333
|
+
// workspace root, when it is a repo itself). A repo that does not hold the commit cannot answer
|
|
334
|
+
// (exit 128 -> unanswered). The first repo that holds both commits is authoritative (a clone's
|
|
335
|
+
// answer is the same).
|
|
336
|
+
let rootGit = null;
|
|
337
|
+
try {
|
|
338
|
+
const rootTop = makeGit(projectDir, runner, SUB_REPO_GIT_HARDENING)(['rev-parse', '--show-toplevel']);
|
|
339
|
+
if (realDir(rootTop) === realDir(projectDir))
|
|
340
|
+
rootGit = makeGit(projectDir, runner, SUB_REPO_GIT_HARDENING);
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
if (!(error instanceof ScopeUnreadable && error.exitCode === 128))
|
|
344
|
+
throw error;
|
|
345
|
+
}
|
|
346
|
+
const gits = [
|
|
347
|
+
...(rootGit === null ? [] : [rootGit]),
|
|
348
|
+
...names.map((name) => makeGit(containedSubRepoDir(projectDir, name), runner, SUB_REPO_GIT_HARDENING)),
|
|
349
|
+
];
|
|
350
|
+
for (const git of gits) {
|
|
351
|
+
try {
|
|
352
|
+
return isAncestor(git, sha, of);
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
// Only "this repo does not hold the commit" (exit 128) is unanswered; a timeout or a missing
|
|
356
|
+
// git is not an answer and must not let another repo's "no" stand for it.
|
|
357
|
+
if (!(error instanceof ScopeUnreadable && error.exitCode === 128))
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return null;
|
|
306
362
|
}
|
|
307
363
|
catch (error) {
|
|
308
364
|
if (error instanceof ScopeUnreadable)
|
|
@@ -317,15 +373,327 @@ function emptyScope(unit) {
|
|
|
317
373
|
changedFiles: [], files: [], missingOnDisk: [], outsideUnion: [], unreachable: [], rangeBase: null,
|
|
318
374
|
};
|
|
319
375
|
}
|
|
376
|
+
// ─── planning.sub_repos (#5867) ───────────────────────────────────────────────
|
|
377
|
+
/**
|
|
378
|
+
* The configured `planning.sub_repos` names (a legacy top-level `sub_repos` is honored when the
|
|
379
|
+
* nested key is absent, the order `detectSubRepos` and `loadConfig` apply). Empty when none are
|
|
380
|
+
* configured. Read with the scope-aware config reader (no `loadConfig`: it rewrites config.json and
|
|
381
|
+
* spawns git). A blank string is dropped like `detectSubRepos` drops it; any other non-string is
|
|
382
|
+
* `unresolvable`, never silently skipped, because a skipped repo narrows the scope.
|
|
383
|
+
*/
|
|
384
|
+
function readSubRepoNames(projectDir) {
|
|
385
|
+
const nested = readScopedConfigValue(projectDir, ['planning', 'sub_repos']);
|
|
386
|
+
const raw = nested.present ? nested.value : readScopedConfigValue(projectDir, ['sub_repos']).value;
|
|
387
|
+
if (!Array.isArray(raw))
|
|
388
|
+
return [];
|
|
389
|
+
// Each repo costs several bounded git spawns; refuse rather than amplify.
|
|
390
|
+
if (raw.length > MAX_SUB_REPOS)
|
|
391
|
+
throw new ScopeUnreadable('sub-repo-too-many');
|
|
392
|
+
const names = [];
|
|
393
|
+
raw.forEach((entry, index) => {
|
|
394
|
+
if (typeof entry !== 'string')
|
|
395
|
+
throw new ScopeUnreadable(`sub-repo-invalid:${index}`);
|
|
396
|
+
const trimmed = entry.trim().replace(/\\/g, '/');
|
|
397
|
+
if (trimmed === '')
|
|
398
|
+
return;
|
|
399
|
+
// Canonical spelling (`./x`, `a/../b`, `x/`): it prefixes the paths the scope reports.
|
|
400
|
+
const name = node_path_1.default.posix.normalize(trimmed).replace(/\/+$/, '');
|
|
401
|
+
if (name !== '.' && !names.includes(name))
|
|
402
|
+
names.push(name);
|
|
403
|
+
});
|
|
404
|
+
return names;
|
|
405
|
+
}
|
|
406
|
+
/** A sub-repo's directory, which must stay inside the project (realpath containment, symlinks included). */
|
|
407
|
+
function containedSubRepoDir(projectDir, name) {
|
|
408
|
+
const contained = (0, security_cjs_1.tryWithinRoot)(name, projectDir, security_cjs_1.PathAcceptance.RelativeOnly);
|
|
409
|
+
if (contained === null)
|
|
410
|
+
throw new ScopeUnreadable(`sub-repo-escapes-root:${name}`);
|
|
411
|
+
return contained;
|
|
412
|
+
}
|
|
413
|
+
/** The real path of a directory git or the config named; one that cannot be resolved is unreadable. */
|
|
414
|
+
function realDir(dir) {
|
|
415
|
+
try {
|
|
416
|
+
return node_fs_1.default.realpathSync.native(dir);
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
throw new ScopeUnreadable('sub-repo-realpath-failed');
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Every repo a `planning.sub_repos` workspace's history lives in: the workspace root when it is
|
|
424
|
+
* itself a repo (its own toplevel), then each configured sub-repo. A configured sub-repo that
|
|
425
|
+
* cannot be read, or is not the root of its own repo (git would otherwise answer from a parent),
|
|
426
|
+
* makes the whole scope `unresolvable` and names the repo.
|
|
427
|
+
*/
|
|
428
|
+
function openSources(projectDir, names, runner, ref) {
|
|
429
|
+
const sources = [];
|
|
430
|
+
const seen = new Set();
|
|
431
|
+
const rootGit = makeGit(projectDir, runner, SUB_REPO_GIT_HARDENING);
|
|
432
|
+
try {
|
|
433
|
+
const top = rootGit(['rev-parse', '--show-toplevel']);
|
|
434
|
+
if (realDir(top) === realDir(projectDir)) {
|
|
435
|
+
seen.add(realDir(projectDir));
|
|
436
|
+
rootGit(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
|
|
437
|
+
sources.push({ prefix: '', git: rootGit, repoRoot: top });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
catch (error) {
|
|
441
|
+
// Not a repo (128) or no such commit yet (1): the workspace root contributes nothing. A timeout
|
|
442
|
+
// or a missing git still rethrows.
|
|
443
|
+
if (!(error instanceof ScopeUnreadable && (error.exitCode === 128 || error.exitCode === 1)))
|
|
444
|
+
throw error;
|
|
445
|
+
}
|
|
446
|
+
const rootCovers = seen.has(realDir(projectDir));
|
|
447
|
+
for (const name of names) {
|
|
448
|
+
const dir = containedSubRepoDir(projectDir, name);
|
|
449
|
+
const stat = (0, gate_evidence_cjs_1.statEvidence)(dir);
|
|
450
|
+
if (stat.kind === 'unreadable')
|
|
451
|
+
throw new ScopeUnreadable(`sub-repo-unreadable:${name}:${stat.reason}`);
|
|
452
|
+
if (stat.kind === 'none' || !stat.value.isDirectory())
|
|
453
|
+
throw new ScopeUnreadable(`sub-repo-unreadable:${name}:not-a-directory`);
|
|
454
|
+
const git = makeGit(dir, runner, SUB_REPO_GIT_HARDENING);
|
|
455
|
+
let top;
|
|
456
|
+
try {
|
|
457
|
+
top = git(['rev-parse', '--show-toplevel']);
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
if (error instanceof ScopeUnreadable)
|
|
461
|
+
throw new ScopeUnreadable(`sub-repo-unreadable:${name}:${error.reason}`, error.exitCode);
|
|
462
|
+
throw error;
|
|
463
|
+
}
|
|
464
|
+
if (realDir(top) !== realDir(dir)) {
|
|
465
|
+
// A plain folder of the workspace root's own repo is already covered by the root source; any
|
|
466
|
+
// other directory would make git answer from a parent repo it does not own.
|
|
467
|
+
if (rootCovers && realDir(top) === realDir(projectDir))
|
|
468
|
+
continue;
|
|
469
|
+
throw new ScopeUnreadable(`sub-repo-unreadable:${name}:not-a-repo-root`);
|
|
470
|
+
}
|
|
471
|
+
if (seen.has(realDir(dir)))
|
|
472
|
+
continue;
|
|
473
|
+
seen.add(realDir(dir));
|
|
474
|
+
try {
|
|
475
|
+
git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
|
|
476
|
+
}
|
|
477
|
+
catch (error) {
|
|
478
|
+
// No such commit in this repo (an empty repo, or a ref only another repo has): it holds nothing
|
|
479
|
+
// to scope. A timeout or a missing git still rethrows.
|
|
480
|
+
if (error instanceof ScopeUnreadable && error.exitCode === 1)
|
|
481
|
+
continue;
|
|
482
|
+
if (error instanceof ScopeUnreadable)
|
|
483
|
+
throw new ScopeUnreadable(`sub-repo-unreadable:${name}:${error.reason}`, error.exitCode);
|
|
484
|
+
throw error;
|
|
485
|
+
}
|
|
486
|
+
sources.push({ prefix: name, git, repoRoot: top });
|
|
487
|
+
}
|
|
488
|
+
// No repo could answer for the ref: the same "could not look" the single-repo path gives.
|
|
489
|
+
if (sources.length === 0)
|
|
490
|
+
throw new ScopeUnreadable('git-failed:rev-parse', 1);
|
|
491
|
+
return sources;
|
|
492
|
+
}
|
|
493
|
+
const withPrefix = (prefix, file) => (prefix === '' ? file : `${prefix}/${file}`);
|
|
494
|
+
/** Committer time (unix seconds) per sha, for ordering commits that come from different repos. */
|
|
495
|
+
function commitTimes(git, shas) {
|
|
496
|
+
const times = new Map();
|
|
497
|
+
for (const group of chunk(shas, SHA_CHUNK)) {
|
|
498
|
+
for (const line of git(['log', '--no-walk=unsorted', '--format=%H%x1f%ct', ...group]).split('\n')) {
|
|
499
|
+
const [sha, time] = line.split('\x1f');
|
|
500
|
+
if (sha && time !== undefined && /^[0-9]+$/.test(time))
|
|
501
|
+
times.set(sha, Number(time));
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return times;
|
|
505
|
+
}
|
|
506
|
+
function splitSince(options, sources) {
|
|
507
|
+
const split = { holders: new Set(), time: null };
|
|
508
|
+
const since = options.since;
|
|
509
|
+
if (since === undefined)
|
|
510
|
+
return split;
|
|
511
|
+
for (const source of sources) {
|
|
512
|
+
const sha = gitOrNo(source.git, ['rev-parse', '--verify', '--quiet', `${since}^{commit}`]);
|
|
513
|
+
if (sha === null)
|
|
514
|
+
continue;
|
|
515
|
+
split.holders.add(source);
|
|
516
|
+
split.time ??= commitTimes(source.git, [sha]).get(sha) ?? null;
|
|
517
|
+
}
|
|
518
|
+
if (split.holders.size === 0)
|
|
519
|
+
throw new ScopeUnreadable('since-not-found');
|
|
520
|
+
if (split.time === null)
|
|
521
|
+
throw new ScopeUnreadable('since-time-unreadable');
|
|
522
|
+
return split;
|
|
523
|
+
}
|
|
524
|
+
/** `options` for one source: a repo that does not hold the `since` commit is filtered by its time instead. */
|
|
525
|
+
function optionsForSource(options, source, split) {
|
|
526
|
+
if (options.since === undefined || split.holders.has(source))
|
|
527
|
+
return options;
|
|
528
|
+
const { since: _since, ...rest } = options;
|
|
529
|
+
void _since;
|
|
530
|
+
return rest;
|
|
531
|
+
}
|
|
532
|
+
function withSourcePrefix(commit, prefix) {
|
|
533
|
+
return commit.files === undefined ? commit : { ...commit, files: commit.files.map((f) => withPrefix(prefix, f)) };
|
|
534
|
+
}
|
|
535
|
+
function resolveAcrossSources(projectDir, unit, options, sources, ref) {
|
|
536
|
+
if (unit.kind === 'phase')
|
|
537
|
+
return resolvePhaseAcrossSources(projectDir, unit, options, sources, ref);
|
|
538
|
+
const scope = emptyScope(unit);
|
|
539
|
+
const max = clampMaxCommits(options.maxCommits);
|
|
540
|
+
const split = splitSince(options, sources);
|
|
541
|
+
const found = sources.map((source) => {
|
|
542
|
+
const opts = optionsForSource(options, source, split);
|
|
543
|
+
const lookup = lookupSubjectCommits(projectDir, unit, emptyScope(unit), source.git, ref, opts);
|
|
544
|
+
const times = commitTimes(source.git, lookup.commits.map((c) => c.sha));
|
|
545
|
+
if (split.time !== null && !split.holders.has(source)) {
|
|
546
|
+
const boundary = split.time;
|
|
547
|
+
lookup.commits = lookup.commits.filter((c) => (times.get(c.sha) ?? 0) > boundary);
|
|
548
|
+
}
|
|
549
|
+
return { source, opts, lookup, times };
|
|
550
|
+
});
|
|
551
|
+
scope.source = unit.kind === 'plan' ? 'plan-subjects' : 'quick-subjects';
|
|
552
|
+
// Newest first across repos: a k-way merge that keeps each repo's own log order (a stable pick:
|
|
553
|
+
// ties go to the earlier source); one entry per sha.
|
|
554
|
+
const seenShas = new Set();
|
|
555
|
+
const queues = found.map((entry) => entry.lookup.commits.filter((commit) => {
|
|
556
|
+
if (seenShas.has(commit.sha))
|
|
557
|
+
return false;
|
|
558
|
+
seenShas.add(commit.sha);
|
|
559
|
+
return true;
|
|
560
|
+
}));
|
|
561
|
+
const kept = [];
|
|
562
|
+
const heads = queues.map(() => 0);
|
|
563
|
+
while (kept.length < max) {
|
|
564
|
+
let best = -1;
|
|
565
|
+
let bestTime = -1;
|
|
566
|
+
queues.forEach((queue, index) => {
|
|
567
|
+
const head = queue[heads[index] ?? 0];
|
|
568
|
+
if (head === undefined)
|
|
569
|
+
return;
|
|
570
|
+
const time = found[index]?.times.get(head.sha) ?? 0;
|
|
571
|
+
if (best === -1 || time > bestTime) {
|
|
572
|
+
best = index;
|
|
573
|
+
bestTime = time;
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
if (best === -1)
|
|
577
|
+
break;
|
|
578
|
+
const commit = queues[best]?.[heads[best] ?? 0];
|
|
579
|
+
if (commit === undefined)
|
|
580
|
+
break;
|
|
581
|
+
kept.push({ index: best, commit });
|
|
582
|
+
heads[best] = (heads[best] ?? 0) + 1;
|
|
583
|
+
}
|
|
584
|
+
const finished = found.map((entry, index) => {
|
|
585
|
+
const part = emptyScope(unit);
|
|
586
|
+
finishSubjectScope(part, entry.source.git, kept.filter((k) => k.index === index).map((k) => k.commit), entry.lookup.parentsOf, entry.opts, entry.source.repoRoot);
|
|
587
|
+
return part;
|
|
588
|
+
});
|
|
589
|
+
const byRepo = new Map();
|
|
590
|
+
finished.forEach((part, index) => {
|
|
591
|
+
const prefix = found[index]?.source.prefix ?? '';
|
|
592
|
+
for (const commit of part.commits)
|
|
593
|
+
byRepo.set(commit.sha, withSourcePrefix(commit, prefix));
|
|
594
|
+
});
|
|
595
|
+
scope.commits = kept.map((k) => byRepo.get(k.commit.sha) ?? k.commit);
|
|
596
|
+
const gather = (pick) => uniqueSorted(finished.flatMap((part, index) => pick(part).map((f) => withPrefix(found[index]?.source.prefix ?? '', f))));
|
|
597
|
+
scope.changedFiles = gather((part) => part.changedFiles);
|
|
598
|
+
scope.files = gather((part) => part.files);
|
|
599
|
+
scope.missingOnDisk = gather((part) => part.missingOnDisk);
|
|
600
|
+
if (scope.commits.length === 0)
|
|
601
|
+
scope.reason = 'no-matching-commits';
|
|
602
|
+
else if (options.commitsOnly !== true && scope.changedFiles.length === 0) {
|
|
603
|
+
scope.reason = finished.every((part) => part.commits.length === 0 || part.reason === 'merge-only') ? 'merge-only' : 'empty-after-exclusions';
|
|
604
|
+
}
|
|
605
|
+
return scope;
|
|
606
|
+
}
|
|
607
|
+
function resolvePhaseAcrossSources(projectDir, unit, options, sources, ref) {
|
|
608
|
+
const gathered = gatherPhase(projectDir, unit);
|
|
609
|
+
const primary = sources[0];
|
|
610
|
+
if (primary === undefined)
|
|
611
|
+
throw new ScopeUnreadable('no-sources');
|
|
612
|
+
// A task commit id belongs to the repo that holds it: an id only another repo has is not
|
|
613
|
+
// "unreachable" here. An id no repo holds goes to the primary source, which names it unreachable.
|
|
614
|
+
const ids = [...new Set(gathered.refs)];
|
|
615
|
+
// One lookup per id per repo: bound the aggregate, not just the repo count.
|
|
616
|
+
if (ids.length * sources.length > MAX_ID_LOOKUPS)
|
|
617
|
+
throw new ScopeUnreadable('sub-repo-too-many-lookups');
|
|
618
|
+
const holders = ids.map((id) => sources.filter((source) => {
|
|
619
|
+
try {
|
|
620
|
+
return gitOrNo(source.git, ['rev-parse', '--verify', '--quiet', `${id}^{commit}`]) !== null;
|
|
621
|
+
}
|
|
622
|
+
catch (error) {
|
|
623
|
+
// exit 128 = an ambiguous short id: that repo owns it, and names it unreachable below.
|
|
624
|
+
if (error instanceof ScopeUnreadable && error.exitCode === 128)
|
|
625
|
+
return true;
|
|
626
|
+
throw error;
|
|
627
|
+
}
|
|
628
|
+
}));
|
|
629
|
+
const assigned = new Map();
|
|
630
|
+
ids.forEach((id, i) => {
|
|
631
|
+
const owners = holders[i] ?? [];
|
|
632
|
+
for (const owner of owners.length > 0 ? owners : [primary])
|
|
633
|
+
assigned.set(owner, [...(assigned.get(owner) ?? []), id]);
|
|
634
|
+
});
|
|
635
|
+
const active = sources.filter((source) => assigned.has(source));
|
|
636
|
+
// The workspace root (when it is a repo) always takes part: it may hold the phase-start anchor a
|
|
637
|
+
// sub-repo cannot, so a phase whose task commits add nothing still degrades to its range.
|
|
638
|
+
const run = sources.filter((source) => assigned.has(source) || source.prefix === '');
|
|
639
|
+
if (run.length === 0)
|
|
640
|
+
run.push(primary);
|
|
641
|
+
const split = splitSince(options, sources);
|
|
642
|
+
const parts = run.map((source) => {
|
|
643
|
+
const part = emptyScope(unit);
|
|
644
|
+
const subset = { ...gathered, refs: assigned.get(source) ?? (active.length === 0 ? gathered.refs : []) };
|
|
645
|
+
const notAfter = split.time !== null && !split.holders.has(source) ? split.time : undefined;
|
|
646
|
+
resolvePhase(projectDir, unit, part, source.git, ref, optionsForSource(options, source, split), source.repoRoot, subset, source.prefix === '', true, notAfter);
|
|
647
|
+
return { source, part };
|
|
648
|
+
});
|
|
649
|
+
const merged = emptyScope(unit);
|
|
650
|
+
merged.unreachable = uniqueSorted(parts.flatMap(({ part }) => part.unreachable));
|
|
651
|
+
merged.rangeBase = parts.map(({ part }) => part.rangeBase).find((base) => base !== null) ?? null;
|
|
652
|
+
const taken = parts.filter(({ part }) => part.source === 'task-commits');
|
|
653
|
+
const degraded = parts.find(({ part }) => part.status === 'degraded');
|
|
654
|
+
const pick = taken.length > 0 ? taken : degraded ? [degraded] : [];
|
|
655
|
+
if (pick.length === 0) {
|
|
656
|
+
// Every repo was read and none has task commits for the phase, and a sub-repo has no phase-start
|
|
657
|
+
// anchor to widen to: a loud, empty `degraded` scope, never a pass-shaped empty one and never
|
|
658
|
+
// `unresolvable` (nothing was unreadable).
|
|
659
|
+
merged.status = 'degraded';
|
|
660
|
+
merged.source = 'phase-range';
|
|
661
|
+
merged.reason = `${parts[0]?.part.reason ?? 'no-task-commits'}:no-phase-start-anchor`;
|
|
662
|
+
return merged;
|
|
663
|
+
}
|
|
664
|
+
merged.status = taken.length > 0 ? 'resolved' : 'degraded';
|
|
665
|
+
merged.source = taken.length > 0 ? 'task-commits' : 'phase-range';
|
|
666
|
+
merged.reason = taken.length > 0 ? null : (parts.find(({ source, part }) => source.prefix !== '' && part.reason !== null)?.part.reason ?? degraded?.part.reason ?? null);
|
|
667
|
+
const seenShas = new Set();
|
|
668
|
+
for (const { source, part } of pick) {
|
|
669
|
+
for (const commit of part.commits) {
|
|
670
|
+
if (seenShas.has(commit.sha))
|
|
671
|
+
continue;
|
|
672
|
+
seenShas.add(commit.sha);
|
|
673
|
+
merged.commits.push(withSourcePrefix(commit, source.prefix));
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
const gather = (pickFiles) => uniqueSorted(pick.flatMap(({ source, part }) => pickFiles(part).map((f) => withPrefix(source.prefix, f))));
|
|
677
|
+
merged.changedFiles = gather((part) => part.changedFiles);
|
|
678
|
+
merged.files = gather((part) => part.files);
|
|
679
|
+
merged.missingOnDisk = gather((part) => part.missingOnDisk);
|
|
680
|
+
merged.outsideUnion = gather((part) => part.outsideUnion);
|
|
681
|
+
return merged;
|
|
682
|
+
}
|
|
320
683
|
function resolveEvaluationScope(projectDir, unit, options = {}) {
|
|
321
684
|
const scope = emptyScope(unit);
|
|
322
|
-
const
|
|
685
|
+
const runner = options.execGit ?? shell_command_projection_cjs_1.execGit;
|
|
686
|
+
const git = makeGit(projectDir, runner);
|
|
323
687
|
const ref = options.ref ?? 'HEAD';
|
|
324
688
|
try {
|
|
325
689
|
if (!isSafeRefArgument(ref))
|
|
326
690
|
throw new ScopeUnreadable('unsafe-ref');
|
|
327
691
|
if (options.since !== undefined && !isSafeRefArgument(options.since))
|
|
328
692
|
throw new ScopeUnreadable('unsafe-since');
|
|
693
|
+
// #5867: a `planning.sub_repos` workspace keeps its history in the sub-repos, not at the root.
|
|
694
|
+
const subRepoNames = readSubRepoNames(projectDir);
|
|
695
|
+
if (subRepoNames.length > 0)
|
|
696
|
+
return resolveAcrossSources(projectDir, unit, options, openSources(projectDir, subRepoNames, runner, ref), ref);
|
|
329
697
|
git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
|
|
330
698
|
const repoRoot = git(['rev-parse', '--show-toplevel']);
|
|
331
699
|
if (unit.kind === 'phase')
|
|
@@ -339,8 +707,62 @@ function resolveEvaluationScope(projectDir, unit, options = {}) {
|
|
|
339
707
|
return { ...emptyScope(unit), status: 'unresolvable', reason };
|
|
340
708
|
}
|
|
341
709
|
}
|
|
342
|
-
/**
|
|
343
|
-
|
|
710
|
+
/** First git release with `show --remerge-diff` (#5751). */
|
|
711
|
+
const REMERGE_DIFF_MIN_GIT = [2, 36];
|
|
712
|
+
/**
|
|
713
|
+
* The first `<major>.<minor>` in `git --version` output ("git version 2.45.1.windows.1",
|
|
714
|
+
* "2.36.0-rc0", "2.54.0 (Apple Git-157)"), or null when there is none.
|
|
715
|
+
*/
|
|
716
|
+
function parseGitVersion(output) {
|
|
717
|
+
const match = /(\d+)\.(\d+)/.exec(output);
|
|
718
|
+
return match ? { major: Number(match[1]), minor: Number(match[2]) } : null;
|
|
719
|
+
}
|
|
720
|
+
/** Whether `git --version` output names a git that has `show --remerge-diff`; unparseable reads as no. */
|
|
721
|
+
function remergeDiffSupported(output) {
|
|
722
|
+
const version = parseGitVersion(output);
|
|
723
|
+
if (version === null)
|
|
724
|
+
return false;
|
|
725
|
+
return version.major > REMERGE_DIFF_MIN_GIT[0]
|
|
726
|
+
|| (version.major === REMERGE_DIFF_MIN_GIT[0] && version.minor >= REMERGE_DIFF_MIN_GIT[1]);
|
|
727
|
+
}
|
|
728
|
+
/** Per-resolve cache of the `git --version` probe, keyed by the bound git function. */
|
|
729
|
+
const remergeSupportCache = new WeakMap();
|
|
730
|
+
/**
|
|
731
|
+
* Whether this git can report a merge's own changes (`--remerge-diff`, git >= 2.36). Detected from
|
|
732
|
+
* `git --version`, never from error output: an unknown option exits 128 or 129 depending on where
|
|
733
|
+
* it is parsed. An unparseable version reads as unsupported, the safe side. A timeout still
|
|
734
|
+
* rethrows (unresolvable); any other probe failure reads as unsupported.
|
|
735
|
+
*/
|
|
736
|
+
function supportsRemergeDiff(git) {
|
|
737
|
+
const cached = remergeSupportCache.get(git);
|
|
738
|
+
if (cached !== undefined)
|
|
739
|
+
return cached;
|
|
740
|
+
let supported = false;
|
|
741
|
+
try {
|
|
742
|
+
supported = remergeDiffSupported(git(['--version']));
|
|
743
|
+
}
|
|
744
|
+
catch (error) {
|
|
745
|
+
if (error instanceof ScopeUnreadable && error.reason === 'git-timeout')
|
|
746
|
+
throw error;
|
|
747
|
+
}
|
|
748
|
+
remergeSupportCache.set(git, supported);
|
|
749
|
+
return supported;
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* A commit's own file set. A non-merge commit contributes what it changed. A merge commit
|
|
753
|
+
* contributes only what the merge itself changed (conflict resolutions and edits, from
|
|
754
|
+
* `--remerge-diff`), so a clean merge contributes nothing and both merge orientations agree (#5751).
|
|
755
|
+
* `--first-parent` would report a catch-up merge's whole base branch. On a git without
|
|
756
|
+
* `--remerge-diff`, or for an octopus merge (remerge covers two-parent merges), a merge contributes
|
|
757
|
+
* nothing: it may under-report, and never reports the base branch's work. `parents` is the commit's
|
|
758
|
+
* parent count, read from the log that listed it (no per-commit lookup).
|
|
759
|
+
*/
|
|
760
|
+
function commitFiles(git, sha, parents) {
|
|
761
|
+
if (parents > 1) {
|
|
762
|
+
if (parents !== 2 || !supportsRemergeDiff(git))
|
|
763
|
+
return [];
|
|
764
|
+
return splitNul(git(['-c', 'core.quotepath=off', 'show', '--remerge-diff', '--pretty=format:', '--name-only', '-z', sha, '--', '.', ...exports.SCOPE_EXCLUSION_PATHSPECS]));
|
|
765
|
+
}
|
|
344
766
|
const out = git(['-c', 'core.quotepath=off', 'show', '--first-parent', '--pretty=format:', '--name-only', '-z', sha, '--', '.', ...exports.SCOPE_EXCLUSION_PATHSPECS]);
|
|
345
767
|
return splitNul(out);
|
|
346
768
|
}
|
|
@@ -358,32 +780,125 @@ function finalizeFiles(scope, repoRoot, changed) {
|
|
|
358
780
|
scope.files = existing;
|
|
359
781
|
scope.missingOnDisk = missing;
|
|
360
782
|
}
|
|
783
|
+
/** `%P` rides every log so a commit's parents come from the listing itself, never a per-commit lookup. */
|
|
361
784
|
function logFormat(withBody) {
|
|
362
|
-
return withBody ? '--format=%H%x1f%s%x1f%b%x1e' : '--format=%H%x1f%s%x1e';
|
|
785
|
+
return withBody ? '--format=%H%x1f%P%x1f%s%x1f%b%x1e' : '--format=%H%x1f%P%x1f%s%x1e';
|
|
363
786
|
}
|
|
364
|
-
function parseLog(out, withBody) {
|
|
787
|
+
function parseLog(out, withBody, parentsOut) {
|
|
365
788
|
const commits = [];
|
|
366
789
|
for (const record of out.split('\x1e')) {
|
|
367
790
|
const trimmed = record.replace(/^\n+/, '');
|
|
368
791
|
if (!trimmed)
|
|
369
792
|
continue;
|
|
370
|
-
const [sha, subject, body] = trimmed.split('\x1f');
|
|
793
|
+
const [sha, parents, subject, body] = trimmed.split('\x1f');
|
|
371
794
|
if (!sha || !/^[0-9a-f]{40}$/.test(sha))
|
|
372
795
|
continue;
|
|
373
796
|
const commit = { sha, subject: subject ?? '' };
|
|
374
797
|
if (withBody)
|
|
375
798
|
commit.body = (body ?? '').trim();
|
|
376
799
|
commits.push(commit);
|
|
800
|
+
parentsOut?.set(sha, (parents ?? '').split(' ').filter(Boolean));
|
|
377
801
|
}
|
|
378
802
|
return commits;
|
|
379
803
|
}
|
|
380
|
-
function subjectsFor(git, shas, withBody, chunkSize) {
|
|
804
|
+
function subjectsFor(git, shas, withBody, chunkSize, parentsOut) {
|
|
381
805
|
const out = [];
|
|
382
806
|
for (const group of chunk(shas, chunkSize)) {
|
|
383
|
-
out.push(...parseLog(git(['log', '--no-walk=unsorted', logFormat(withBody), ...group]), withBody));
|
|
807
|
+
out.push(...parseLog(git(['log', '--no-walk=unsorted', logFormat(withBody), ...group]), withBody, parentsOut));
|
|
384
808
|
}
|
|
385
809
|
return out;
|
|
386
810
|
}
|
|
811
|
+
/**
|
|
812
|
+
* Whether every merge-base of `p1` and `p2` is `rangeBase` or one of its ancestors: the second side
|
|
813
|
+
* was forked from history that predates the phase, so it is base work. False when `rangeBase` is the
|
|
814
|
+
* empty tree, when there is no merge-base, and when git cannot answer (a timeout or a failure): the
|
|
815
|
+
* caller then subtracts nothing and the files stay in `outsideUnion`.
|
|
816
|
+
*/
|
|
817
|
+
function forkedAtOrBeforePhaseStart(git, p1, p2, rangeBase) {
|
|
818
|
+
if (rangeBase === EMPTY_TREE)
|
|
819
|
+
return false;
|
|
820
|
+
try {
|
|
821
|
+
const bases = (gitOrNo(git, ['merge-base', '--all', p1, p2]) ?? '').split('\n').filter(Boolean);
|
|
822
|
+
return bases.length > 0 && bases.every((base) => isAncestor(git, base, rangeBase));
|
|
823
|
+
}
|
|
824
|
+
catch (error) {
|
|
825
|
+
if (error instanceof ScopeUnreadable)
|
|
826
|
+
return false;
|
|
827
|
+
throw error;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* The files a listed two-parent merge brought in from its SECOND parent's side: base work, not the
|
|
832
|
+
* unit's. A merge contributes nothing when ANY of these holds, so unlisted phase work is never hidden:
|
|
833
|
+
* a listed commit sits on that side (`<p2> ^<p1>`: a phase branch merged into its base); a listed
|
|
834
|
+
* non-merge commit is an ancestor of `<p2>` after the phase start (`<p2> ^<rangeBase>`, one walk: a
|
|
835
|
+
* branch forked from the phase, whose unlisted commits are phase work); the fork point
|
|
836
|
+
* (`git merge-base <p1> <p2>`) is not at or before `rangeBase` (a side forked from phase work, or a
|
|
837
|
+
* later catch-up whose fork point is an earlier catch-up's base commit: reported as an advisory
|
|
838
|
+
* false positive, never hidden); `rangeBase` is the empty tree or there is no merge-base; any git
|
|
839
|
+
* call for this merge fails or times out (this merge subtracts nothing, the others are unaffected);
|
|
840
|
+
* or the file was touched by a commit of the phase window (`<rangeBase>..<tip>` minus what `<p2>`
|
|
841
|
+
* reaches, listed or not, before or after the merge, other merge commits included through their own
|
|
842
|
+
* changes against each parent, `git log -m`; the merge itself is not counted). Only files touched
|
|
843
|
+
* solely by the second parent's side are subtracted.
|
|
844
|
+
*/
|
|
845
|
+
function phaseSideFiles(git, tip, p2, rangeBase, mergeSha) {
|
|
846
|
+
const files = new Set();
|
|
847
|
+
const out = git([
|
|
848
|
+
'-c', 'core.quotepath=off', 'log', '-m', '--format=%x01%H', '--name-only', '-z', tip, `^${p2}`,
|
|
849
|
+
...(rangeBase === EMPTY_TREE ? [] : [`^${rangeBase}`]), '--', '.', ...exports.SCOPE_EXCLUSION_PATHSPECS,
|
|
850
|
+
]);
|
|
851
|
+
let own = false;
|
|
852
|
+
for (const raw of out.split('\0')) {
|
|
853
|
+
const token = raw.replace(/^\n+/, '');
|
|
854
|
+
if (token.length === 0)
|
|
855
|
+
continue;
|
|
856
|
+
if (token.startsWith('\x01')) {
|
|
857
|
+
own = token.slice(1) === mergeSha;
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
if (!own)
|
|
861
|
+
files.add(token);
|
|
862
|
+
}
|
|
863
|
+
return files;
|
|
864
|
+
}
|
|
865
|
+
function baseSideMergeFiles(git, merges, parentsOf, listed, listedNonMerge, rangeBase, tip) {
|
|
866
|
+
const files = new Set();
|
|
867
|
+
for (const sha of merges) {
|
|
868
|
+
const parents = parentsOf.get(sha) ?? [];
|
|
869
|
+
if (parents.length !== 2)
|
|
870
|
+
continue;
|
|
871
|
+
const [p1 = '', p2 = ''] = parents;
|
|
872
|
+
try {
|
|
873
|
+
const secondSide = git(['rev-list', p2, `^${p1}`]).split('\n').filter(Boolean);
|
|
874
|
+
if (secondSide.some((entry) => listed.has(entry)))
|
|
875
|
+
continue;
|
|
876
|
+
// One walk of the second parent's history after the phase start, intersected with the listed
|
|
877
|
+
// non-merge commits (never one ancestry spawn per listed commit).
|
|
878
|
+
const reachable = new Set(git([
|
|
879
|
+
'rev-list', p2, ...(rangeBase === EMPTY_TREE ? [] : [`^${rangeBase}`]),
|
|
880
|
+
]).split('\n').filter(Boolean));
|
|
881
|
+
if (listedNonMerge.some((entry) => reachable.has(entry)))
|
|
882
|
+
continue;
|
|
883
|
+
if (!forkedAtOrBeforePhaseStart(git, p1, p2, rangeBase))
|
|
884
|
+
continue;
|
|
885
|
+
// Every commit in the window `<rangeBase>..<tip>` the second parent does not reach, before AND
|
|
886
|
+
// after this merge, minus this merge's own records (its first-parent diff is what is being
|
|
887
|
+
// subtracted from). `-m` lists a merge commit's changes against each parent: another merge's
|
|
888
|
+
// conflict resolution or edit is phase work. Over-inclusion only keeps a file in outsideUnion.
|
|
889
|
+
const phaseSide = phaseSideFiles(git, tip, p2, rangeBase, sha);
|
|
890
|
+
const brought = rangeFiles(git, `${sha}^1`, sha).filter((file) => !phaseSide.has(file));
|
|
891
|
+
for (const file of brought)
|
|
892
|
+
files.add(file);
|
|
893
|
+
}
|
|
894
|
+
catch (error) {
|
|
895
|
+
// A merge whose check could not be answered subtracts nothing (its files stay named).
|
|
896
|
+
if (!(error instanceof ScopeUnreadable))
|
|
897
|
+
throw error;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
return files;
|
|
901
|
+
}
|
|
387
902
|
/**
|
|
388
903
|
* The tip of `shas`: topological order lists a descendant before its ancestors, so the first
|
|
389
904
|
* commit of a walk started at all of them is one no other listed commit descends from. (Commit
|
|
@@ -418,7 +933,7 @@ function phaseRangeBase(git, phaseDir, ref) {
|
|
|
418
933
|
return null;
|
|
419
934
|
return gitOrNo(git, ['rev-parse', '--verify', '--quiet', `${first}^`]) ?? EMPTY_TREE;
|
|
420
935
|
}
|
|
421
|
-
function
|
|
936
|
+
function gatherPhase(projectDir, unit) {
|
|
422
937
|
let phaseDir = unit.phaseDir ?? '';
|
|
423
938
|
if (unit.phaseDir === undefined) {
|
|
424
939
|
const located = (0, gate_phase_context_cjs_1.resolvePhaseDir)(projectDir, unit.phase);
|
|
@@ -447,6 +962,17 @@ function resolvePhase(projectDir, unit, scope, git, ref, options, repoRoot) {
|
|
|
447
962
|
throw new ScopeUnreadable(`summary-unreadable:${name}`);
|
|
448
963
|
}
|
|
449
964
|
}
|
|
965
|
+
return { phaseDir, refs, summaryCount };
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* `gathered` / `anchored` / `partial` serve a `planning.sub_repos` workspace (#5867): the ids are
|
|
969
|
+
* read once and each repo resolves only the ids it holds (`gathered`); only the workspace root can
|
|
970
|
+
* hold the phase directory's history, so a sub-repo has no phase-start anchor (`anchored` false); and
|
|
971
|
+
* a repo whose own ids add nothing returns an empty `none` scope instead of degrading (`partial`),
|
|
972
|
+
* because another repo may have the phase's files.
|
|
973
|
+
*/
|
|
974
|
+
function resolvePhase(projectDir, unit, scope, git, ref, options, repoRoot, gathered, anchored = true, partial = false, notAfter) {
|
|
975
|
+
const { phaseDir, refs, summaryCount } = gathered ?? gatherPhase(projectDir, unit);
|
|
450
976
|
// Resolve each listed id to a full sha reachable from the ref (and, for wave scoping, newer than `since`).
|
|
451
977
|
const shas = [];
|
|
452
978
|
const unreachable = [];
|
|
@@ -470,28 +996,47 @@ function resolvePhase(projectDir, unit, scope, git, ref, options, repoRoot) {
|
|
|
470
996
|
sinceDropped += 1;
|
|
471
997
|
continue;
|
|
472
998
|
}
|
|
999
|
+
if (notAfter !== undefined && (commitTimes(git, [full]).get(full) ?? 0) <= notAfter) {
|
|
1000
|
+
sinceDropped += 1;
|
|
1001
|
+
continue;
|
|
1002
|
+
}
|
|
473
1003
|
shas.push(full);
|
|
474
1004
|
}
|
|
475
1005
|
scope.unreachable = unreachable.sort();
|
|
476
|
-
scope.rangeBase = options.since ?? phaseRangeBase(git, phaseDir, ref);
|
|
1006
|
+
scope.rangeBase = options.since ?? (anchored ? phaseRangeBase(git, phaseDir, ref) : null);
|
|
477
1007
|
const union = new Set();
|
|
1008
|
+
const merges = [];
|
|
1009
|
+
const parentsOf = new Map();
|
|
478
1010
|
const commits = [];
|
|
479
1011
|
const withFiles = options.includeFiles ?? true;
|
|
480
1012
|
// At least 2: a chunk of one never shrinks the list `newestOf` reduces.
|
|
481
1013
|
const chunkSize = Math.max(2, options.shaChunk ?? SHA_CHUNK);
|
|
482
|
-
for (const meta of subjectsFor(git, [...new Set(shas)], options.includeBody === true, chunkSize)) {
|
|
483
|
-
const
|
|
1014
|
+
for (const meta of subjectsFor(git, [...new Set(shas)], options.includeBody === true, chunkSize, parentsOf)) {
|
|
1015
|
+
const parentCount = parentsOf.get(meta.sha)?.length ?? 0;
|
|
1016
|
+
const files = commitFiles(git, meta.sha, parentCount);
|
|
1017
|
+
if (parentCount > 1)
|
|
1018
|
+
merges.push(meta.sha);
|
|
484
1019
|
for (const file of files)
|
|
485
1020
|
union.add(file);
|
|
486
1021
|
commits.push(withFiles ? { ...meta, files: [...files].sort() } : meta);
|
|
487
1022
|
}
|
|
488
|
-
|
|
1023
|
+
// Every listed commit is a merge: a merge carries only its resolutions, so the union is not the
|
|
1024
|
+
// phase's work (a SUMMARY that lists just the phase-into-base merge sha). A listed task commit that
|
|
1025
|
+
// touched only excluded paths is still a task commit, so it keeps the pre-existing reasons.
|
|
1026
|
+
const mergeOnly = merges.length > 0 && merges.length === commits.length;
|
|
1027
|
+
if (union.size > 0 && !mergeOnly) {
|
|
489
1028
|
scope.source = 'task-commits';
|
|
490
1029
|
scope.commits = commits;
|
|
491
1030
|
finalizeFiles(scope, repoRoot, union);
|
|
492
1031
|
if (scope.rangeBase) {
|
|
493
|
-
const
|
|
494
|
-
|
|
1032
|
+
const listed = new Set(commits.map((c) => c.sha));
|
|
1033
|
+
const tip = newestOf(git, [...listed], chunkSize);
|
|
1034
|
+
const window = rangeFiles(git, scope.rangeBase, tip);
|
|
1035
|
+
// The window still holds what a listed catch-up merge brought in from the base; that is base
|
|
1036
|
+
// work the union deliberately omits, not phase work another commit did.
|
|
1037
|
+
const listedNonMerge = commits.filter((c) => (parentsOf.get(c.sha)?.length ?? 0) <= 1).map((c) => c.sha);
|
|
1038
|
+
const baseSide = baseSideMergeFiles(git, merges, parentsOf, listed, listedNonMerge, scope.rangeBase, tip);
|
|
1039
|
+
scope.outsideUnion = uniqueSorted(window.filter((f) => !union.has(f) && !baseSide.has(f)));
|
|
495
1040
|
}
|
|
496
1041
|
return;
|
|
497
1042
|
}
|
|
@@ -499,9 +1044,15 @@ function resolvePhase(projectDir, unit, scope, git, ref, options, repoRoot) {
|
|
|
499
1044
|
const reason = summaryCount === 0 ? 'no-summary'
|
|
500
1045
|
: refs.length === 0 ? 'no-task-commit-rows'
|
|
501
1046
|
: shas.length === 0 ? (sinceDropped > 0 ? 'no-task-commits-since' : 'no-reachable-task-commits')
|
|
502
|
-
: '
|
|
503
|
-
|
|
1047
|
+
: mergeOnly ? 'merge-only-task-commits'
|
|
1048
|
+
: 'empty-after-exclusions';
|
|
1049
|
+
if (!scope.rangeBase) {
|
|
1050
|
+
if (partial) {
|
|
1051
|
+
scope.reason = reason;
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
504
1054
|
throw new ScopeUnreadable(`${reason}:no-phase-start-anchor`);
|
|
1055
|
+
}
|
|
505
1056
|
scope.status = 'degraded';
|
|
506
1057
|
scope.reason = reason;
|
|
507
1058
|
scope.source = 'phase-range';
|
|
@@ -509,7 +1060,8 @@ function resolvePhase(projectDir, unit, scope, git, ref, options, repoRoot) {
|
|
|
509
1060
|
scope.commits = parseLog(git(['log', `${scope.rangeBase}..${ref}`, logFormat(options.includeBody === true), '-n', String(max)]), options.includeBody === true);
|
|
510
1061
|
finalizeFiles(scope, repoRoot, rangeFiles(git, scope.rangeBase, ref));
|
|
511
1062
|
}
|
|
512
|
-
|
|
1063
|
+
/** The plan/quick unit's matching commits (newest first, subject-anchored, `since` applied). */
|
|
1064
|
+
function lookupSubjectCommits(projectDir, unit, scope, git, ref, options) {
|
|
513
1065
|
const withBody = options.includeBody === true;
|
|
514
1066
|
// Refuse rather than truncate: a silently dropped positive pathspec narrows the match.
|
|
515
1067
|
const pathspecs = options.pathspecs ?? [];
|
|
@@ -554,12 +1106,17 @@ function resolveBySubject(projectDir, unit, scope, git, ref, options, repoRoot)
|
|
|
554
1106
|
}
|
|
555
1107
|
const out = git(['log', range, logFormat(withBody), '-n', String(max * 2), ...sinceArgs, ...grepArgs, ...(pathspecs.length > 0 ? ['--', ...pathspecs] : [])]);
|
|
556
1108
|
// `--grep` matches ANY message line; a plan lookup is subject-anchored, so filter on the subject.
|
|
557
|
-
|
|
1109
|
+
const parentsOf = new Map();
|
|
1110
|
+
let commits = parseLog(out, withBody, parentsOf).filter((c) => anchored === null || anchored.test(c.subject)).slice(0, max);
|
|
558
1111
|
const since = options.since;
|
|
559
1112
|
if (since !== undefined)
|
|
560
1113
|
commits = commits.filter((c) => !isAncestor(git, c.sha, since));
|
|
1114
|
+
return { commits, parentsOf };
|
|
1115
|
+
}
|
|
1116
|
+
/** The scope (commits, file sets, reason) of the commits `lookupSubjectCommits` found. */
|
|
1117
|
+
function finishSubjectScope(scope, git, commits, parentsOf, options, repoRoot) {
|
|
561
1118
|
if (options.commitsOnly === true) {
|
|
562
|
-
scope.commits = commits;
|
|
1119
|
+
scope.commits = [...commits];
|
|
563
1120
|
if (commits.length === 0)
|
|
564
1121
|
scope.reason = 'no-matching-commits';
|
|
565
1122
|
return;
|
|
@@ -567,8 +1124,12 @@ function resolveBySubject(projectDir, unit, scope, git, ref, options, repoRoot)
|
|
|
567
1124
|
const withFiles = options.includeFiles ?? false;
|
|
568
1125
|
const union = new Set();
|
|
569
1126
|
const result = [];
|
|
1127
|
+
let mergeCount = 0;
|
|
570
1128
|
for (const meta of commits) {
|
|
571
|
-
const
|
|
1129
|
+
const parentCount = parentsOf.get(meta.sha)?.length ?? 0;
|
|
1130
|
+
if (parentCount > 1)
|
|
1131
|
+
mergeCount += 1;
|
|
1132
|
+
const files = commitFiles(git, meta.sha, parentCount);
|
|
572
1133
|
for (const file of files)
|
|
573
1134
|
union.add(file);
|
|
574
1135
|
result.push(withFiles ? { ...meta, files: [...files].sort() } : meta);
|
|
@@ -577,11 +1138,16 @@ function resolveBySubject(projectDir, unit, scope, git, ref, options, repoRoot)
|
|
|
577
1138
|
finalizeFiles(scope, repoRoot, union);
|
|
578
1139
|
// Found nothing: a truthful "none" (status stays `resolved` — git was read), said out loud so a
|
|
579
1140
|
// caller can tell "no such commits" from "an empty scope that was never computed". Commits whose
|
|
580
|
-
// every path is excluded (planning-only) are an empty scope too, and say so
|
|
1141
|
+
// every path is excluded (planning-only) are an empty scope too, and say so; so are matches that
|
|
1142
|
+
// are all merges (a merge carries only its own resolutions, #5751).
|
|
581
1143
|
if (result.length === 0)
|
|
582
1144
|
scope.reason = 'no-matching-commits';
|
|
583
1145
|
else if (scope.changedFiles.length === 0)
|
|
584
|
-
scope.reason = 'empty-after-exclusions';
|
|
1146
|
+
scope.reason = mergeCount === result.length ? 'merge-only' : 'empty-after-exclusions';
|
|
1147
|
+
}
|
|
1148
|
+
function resolveBySubject(projectDir, unit, scope, git, ref, options, repoRoot) {
|
|
1149
|
+
const { commits, parentsOf } = lookupSubjectCommits(projectDir, unit, scope, git, ref, options);
|
|
1150
|
+
finishSubjectScope(scope, git, commits, parentsOf, options, repoRoot);
|
|
585
1151
|
}
|
|
586
1152
|
// ─── The `check evaluation-scope` verb ────────────────────────────────────────
|
|
587
1153
|
const VALUE_FLAGS = new Set(['--phase', '--phase-dir', '--plan', '--quick', '--since', '--ref', '--pathspec', '--committed-since', '--max-commits']);
|