@adhdev/daemon-core 0.9.82-rc.457 → 0.9.82-rc.458
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/dist/index.d.ts +1 -1
- package/dist/index.js +126 -22
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +124 -22
- package/dist/index.mjs.map +1 -1
- package/dist/logging/debug-config.d.ts +16 -0
- package/dist/mesh/worktree-bootstrap-config.d.ts +45 -0
- package/package.json +3 -3
- package/src/index.ts +2 -0
- package/src/logging/debug-config.ts +25 -0
- package/src/logging/debug-trace.ts +7 -2
- package/src/mesh/coordinator-prompt.ts +1 -1
- package/src/mesh/mesh-fast-forward.ts +22 -9
- package/src/mesh/mesh-refine-gates.ts +22 -9
- package/src/mesh/worktree-bootstrap-config.ts +130 -0
|
@@ -14,6 +14,22 @@ export interface DebugRuntimeConfig {
|
|
|
14
14
|
traceBufferSize: number;
|
|
15
15
|
traceCategories: string[];
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* ALWAYS-ON trace categories. These bypass the `collectDebugTrace` master switch
|
|
19
|
+
* (and category selection) so they are collected in production daemons where
|
|
20
|
+
* `--trace` is unset. They exist so mesh completion diagnostics — the FSM-transition
|
|
21
|
+
* and completion-gate snapshots that explain an early / missing agent:generating_completed
|
|
22
|
+
* notification — are retrievable via mesh_read_debug (chat_debug_bundle) without asking
|
|
23
|
+
* an operator to relaunch the daemon with tracing on.
|
|
24
|
+
*
|
|
25
|
+
* SAFETY: only add a category here after confirming every record() call site for it
|
|
26
|
+
* carries a content-free payload (statuses, epochs, timestamps, deltas, lengths, roles,
|
|
27
|
+
* enum-like reasons — never transcript / prompt / bubble text). Always-on collection makes
|
|
28
|
+
* such payloads unconditional, so a content-bearing field would leak into the ring buffer
|
|
29
|
+
* in production.
|
|
30
|
+
*/
|
|
31
|
+
export declare const ALWAYS_ON_TRACE_CATEGORIES: readonly string[];
|
|
32
|
+
export declare function isAlwaysOnTraceCategory(category?: string | null): boolean;
|
|
17
33
|
export declare function resolveDebugRuntimeConfig(options?: DebugRuntimeOptions): DebugRuntimeConfig;
|
|
18
34
|
export declare function setDebugRuntimeConfig(config: DebugRuntimeConfig): void;
|
|
19
35
|
export declare function getDebugRuntimeConfig(): DebugRuntimeConfig;
|
|
@@ -38,6 +38,51 @@ export declare const WORKTREE_BOOTSTRAP_STALE_RUNNING_MS: number;
|
|
|
38
38
|
* lookup fails — callers must then treat any change as dirty (conservative).
|
|
39
39
|
*/
|
|
40
40
|
export declare function getRegisteredSubmodulePaths(workspace: string): Set<string>;
|
|
41
|
+
/**
|
|
42
|
+
* Read each registered submodule's configured `branch` from `.gitmodules`, keyed
|
|
43
|
+
* by the submodule's normalized path (matching {@link getRegisteredSubmodulePaths}).
|
|
44
|
+
*
|
|
45
|
+
* `.gitmodules` stores `submodule.<name>.path` and (optionally)
|
|
46
|
+
* `submodule.<name>.branch`; this joins the two on `<name>`. The special branch
|
|
47
|
+
* value `.` ("track the superproject's branch") is deliberately OMITTED so callers
|
|
48
|
+
* fall through to remote-HEAD detection instead of treating `.` as a literal branch
|
|
49
|
+
* name. Returns an empty map when there are no submodules, no `.gitmodules`, or the
|
|
50
|
+
* lookup fails (conservative — callers then detect or fall back).
|
|
51
|
+
*/
|
|
52
|
+
export declare function getSubmoduleConfiguredBranches(workspace: string): Map<string, string>;
|
|
53
|
+
/** Fallback submodule branch when no configured/detected default can be resolved. */
|
|
54
|
+
export declare const SUBMODULE_DEFAULT_BRANCH_FALLBACK = "main";
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the default branch a submodule's commits are published to / checked for
|
|
57
|
+
* reachability against. Generalizes the previously hardcoded `main` so a submodule
|
|
58
|
+
* whose default branch is `master`/`trunk`/etc. is handled. Priority (each tier
|
|
59
|
+
* falls through to the next on miss/error):
|
|
60
|
+
*
|
|
61
|
+
* 1. `.gitmodules` `submodule.<name>.branch` (via {@link getSubmoduleConfiguredBranches};
|
|
62
|
+
* `.` is ignored) — an explicit, local, zero-cost declaration.
|
|
63
|
+
* 2. the submodule checkout's LOCAL remote HEAD: `git symbolic-ref --short
|
|
64
|
+
* refs/remotes/<remote>/HEAD` → strip the `<remote>/` prefix (no network).
|
|
65
|
+
* 3. the submodule remote's advertised HEAD: `git ls-remote --symref <remote> HEAD`
|
|
66
|
+
* → `ref: refs/heads/<branch>` (one network round-trip).
|
|
67
|
+
* 4. fallback {@link SUBMODULE_DEFAULT_BRANCH_FALLBACK} (`'main'`).
|
|
68
|
+
*
|
|
69
|
+
* Because the final fallback is `'main'` and every earlier tier that resolves `'main'`
|
|
70
|
+
* yields the same string, a repo whose submodules default to `main` (the common case)
|
|
71
|
+
* produces byte-identical downstream fetch/merge-base/push ref targets — only a
|
|
72
|
+
* read-only resolution probe is added.
|
|
73
|
+
*/
|
|
74
|
+
export declare function resolveSubmoduleDefaultBranch(opts: {
|
|
75
|
+
/** The submodule's local checkout — cwd for symbolic-ref / ls-remote. */
|
|
76
|
+
submoduleRepoPath: string;
|
|
77
|
+
/** The superproject workspace — for the `.gitmodules` branch lookup (tier 1). */
|
|
78
|
+
superprojectWorkspace?: string;
|
|
79
|
+
/** The submodule's path relative to the superproject (key into `.gitmodules`). */
|
|
80
|
+
submodulePath?: string;
|
|
81
|
+
/** Remote name (default `origin`). */
|
|
82
|
+
remote?: string;
|
|
83
|
+
/** Timeout for the local probe (tier 2); the network probe (tier 3) gets max(this, 30s). */
|
|
84
|
+
timeoutMs?: number;
|
|
85
|
+
}): Promise<string>;
|
|
41
86
|
export declare function isWorktreeBootstrapStaleRunning(node: {
|
|
42
87
|
worktreeBootstrap?: {
|
|
43
88
|
status?: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adhdev/daemon-core",
|
|
3
|
-
"version": "0.9.82-rc.
|
|
3
|
+
"version": "0.9.82-rc.458",
|
|
4
4
|
"description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"author": "vilmire",
|
|
47
47
|
"license": "AGPL-3.0-or-later",
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@adhdev/mesh-shared": "0.9.82-rc.
|
|
50
|
-
"@adhdev/session-host-core": "0.9.82-rc.
|
|
49
|
+
"@adhdev/mesh-shared": "0.9.82-rc.458",
|
|
50
|
+
"@adhdev/session-host-core": "0.9.82-rc.458",
|
|
51
51
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
52
52
|
"ajv": "^8.20.0",
|
|
53
53
|
"ajv-formats": "^3.0.1",
|
package/src/index.ts
CHANGED
|
@@ -394,6 +394,8 @@ export {
|
|
|
394
394
|
getDebugRuntimeConfig,
|
|
395
395
|
resetDebugRuntimeConfig,
|
|
396
396
|
shouldCollectTraceCategory,
|
|
397
|
+
isAlwaysOnTraceCategory,
|
|
398
|
+
ALWAYS_ON_TRACE_CATEGORIES,
|
|
397
399
|
} from './logging/debug-config.js';
|
|
398
400
|
export type { DebugRuntimeOptions, DebugRuntimeConfig } from './logging/debug-config.js';
|
|
399
401
|
export {
|
|
@@ -20,6 +20,26 @@ export interface DebugRuntimeConfig {
|
|
|
20
20
|
const NORMAL_TRACE_BUFFER_SIZE = 200
|
|
21
21
|
const DEV_TRACE_BUFFER_SIZE = 1000
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* ALWAYS-ON trace categories. These bypass the `collectDebugTrace` master switch
|
|
25
|
+
* (and category selection) so they are collected in production daemons where
|
|
26
|
+
* `--trace` is unset. They exist so mesh completion diagnostics — the FSM-transition
|
|
27
|
+
* and completion-gate snapshots that explain an early / missing agent:generating_completed
|
|
28
|
+
* notification — are retrievable via mesh_read_debug (chat_debug_bundle) without asking
|
|
29
|
+
* an operator to relaunch the daemon with tracing on.
|
|
30
|
+
*
|
|
31
|
+
* SAFETY: only add a category here after confirming every record() call site for it
|
|
32
|
+
* carries a content-free payload (statuses, epochs, timestamps, deltas, lengths, roles,
|
|
33
|
+
* enum-like reasons — never transcript / prompt / bubble text). Always-on collection makes
|
|
34
|
+
* such payloads unconditional, so a content-bearing field would leak into the ring buffer
|
|
35
|
+
* in production.
|
|
36
|
+
*/
|
|
37
|
+
export const ALWAYS_ON_TRACE_CATEGORIES: readonly string[] = ['completion-gate', 'fsm-transition']
|
|
38
|
+
|
|
39
|
+
export function isAlwaysOnTraceCategory(category?: string | null): boolean {
|
|
40
|
+
return !!category && ALWAYS_ON_TRACE_CATEGORIES.includes(category)
|
|
41
|
+
}
|
|
42
|
+
|
|
23
43
|
const DEFAULT_CONFIG: DebugRuntimeConfig = {
|
|
24
44
|
logLevel: 'info',
|
|
25
45
|
collectDebugTrace: false,
|
|
@@ -68,6 +88,11 @@ export function resetDebugRuntimeConfig(): void {
|
|
|
68
88
|
|
|
69
89
|
export function shouldCollectTraceCategory(category?: string | null): boolean {
|
|
70
90
|
const config = currentConfig
|
|
91
|
+
// Always-on categories are collected regardless of the collectDebugTrace master switch
|
|
92
|
+
// and regardless of any explicit traceCategories selection (they form a superset on top of
|
|
93
|
+
// whatever the operator requested), so an explicit --trace / --trace-categories run still
|
|
94
|
+
// includes them with its existing behavior unchanged.
|
|
95
|
+
if (isAlwaysOnTraceCategory(category)) return true
|
|
71
96
|
if (!config.collectDebugTrace) return false
|
|
72
97
|
if (!category) return true
|
|
73
98
|
if (config.traceCategories.length === 0) return true
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getDebugRuntimeConfig, shouldCollectTraceCategory } from './debug-config.js'
|
|
1
|
+
import { getDebugRuntimeConfig, isAlwaysOnTraceCategory, shouldCollectTraceCategory } from './debug-config.js'
|
|
2
2
|
|
|
3
3
|
export type DebugTraceLevel = 'debug' | 'info' | 'warn' | 'error'
|
|
4
4
|
|
|
@@ -80,7 +80,12 @@ export function createDebugTraceStore(options: DebugTraceStoreOptions): DebugTra
|
|
|
80
80
|
|
|
81
81
|
return {
|
|
82
82
|
record(event: DebugTraceEvent): DebugTraceEntry | null {
|
|
83
|
-
|
|
83
|
+
// The store's `enabled` flag mirrors collectDebugTrace (set by configureDebugTraceStore),
|
|
84
|
+
// so it is false on a production daemon. Always-on categories must still land in the ring
|
|
85
|
+
// even then — otherwise the second gate here would swallow what shouldCollectTraceCategory
|
|
86
|
+
// just admitted. They share the same fixed-capacity buffer, so heavy always-on traffic can
|
|
87
|
+
// evict older opt-in entries; that is accepted (no separate ring).
|
|
88
|
+
if (!options.enabled && !isAlwaysOnTraceCategory(event.category)) return null
|
|
84
89
|
const entry = createEntry(event)
|
|
85
90
|
entries.push(entry)
|
|
86
91
|
if (entries.length > capacity) {
|
|
@@ -577,7 +577,7 @@ function buildRulesSection(coordinatorCliType?: string): string {
|
|
|
577
577
|
- **Verify via git, not source.** Use \`mesh_git_status\` to confirm side effects. Treat agent summaries as self-reports, not verification.
|
|
578
578
|
- **Limit parallelism.** Start with 1–2 tasks; scale only on success. Never duplicate a session because \`mesh_read_chat\` shows no final message while tool/terminal activity is ongoing. This caps *concurrent* load — it does not mean serialize independent work: when a new, independent request arrives and there is headroom under \`maxParallelTasks\`, dispatch it right away rather than waiting for an in-flight task or a user nudge (read-only diagnosis especially, since it has no merge cost).
|
|
579
579
|
- **Check history first.** Call \`mesh_task_history\` at session start to avoid duplicate work and inform recovery. On failure, read task history before retrying.
|
|
580
|
-
- **Sequence shared-base-moving merges.** Parallel dispatch is encouraged, but merging one worktree can advance another in-flight worktree's base — especially
|
|
580
|
+
- **Sequence shared-base-moving merges.** Parallel dispatch is encouraged, but merging one worktree can advance another in-flight worktree's base — especially a shared submodule pointer — turning a clean fast-forward into a diverged rebase (patch-equivalence correctly blocks this). Before merging an in-flight worktree while siblings are also in flight, land in an intentional order, re-clone long-running worktrees from the advanced base, or expect to manually rebase + ff-only the laggards; merging an independent fix mid-flight can strand siblings into a rebase.
|
|
581
581
|
- **Converge branches.** After worktree tasks: refine/fast-forward, or classify as \`pushed_feature_branch_needs_merge\` / \`blocked_review\` / \`cleanup_candidate\` / \`not_mergeable\`. Clean up with \`mesh_remove_node\`.
|
|
582
582
|
- **Refinery is config-driven.** \`mesh_refine_node\` must run validation from \`.adhdev/refine.{json,yaml,yml}\` or \`repo-mesh.refine.*\`. Heuristics are scaffolding only.
|
|
583
583
|
- **Submodule reachability = publish-needed.** \`submodule_reachability_failed\` → classify as \`blocked_review\`, request user approval to push to submodule main, then rerun \`mesh_refine_node\`.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { GitRepoStatus, GitSubmoduleStatus } from '../git/git-types.js';
|
|
2
2
|
import { getGitRepoStatus } from '../git/git-status.js';
|
|
3
3
|
import { GitCommandError, runGit } from '../git/git-executor.js';
|
|
4
|
+
import { resolveSubmoduleDefaultBranch } from './worktree-bootstrap-config.js';
|
|
4
5
|
|
|
5
6
|
export interface MeshFastForwardNodeArgs {
|
|
6
7
|
nodeId?: string;
|
|
@@ -521,31 +522,43 @@ async function resolveSubmodulePushes(
|
|
|
521
522
|
results.push({ ...base, code: 'submodule_status_incomplete' });
|
|
522
523
|
continue;
|
|
523
524
|
}
|
|
524
|
-
//
|
|
525
|
+
// Generalize the submodule's default branch (F18): '.gitmodules' branch →
|
|
526
|
+
// local remote HEAD → remote-advertised HEAD → 'main'. On a main-default
|
|
527
|
+
// submodule this resolves to 'main', keeping every ref below byte-identical.
|
|
528
|
+
const remoteBranch = await resolveSubmoduleDefaultBranch({
|
|
529
|
+
submoduleRepoPath: repoPath,
|
|
530
|
+
superprojectWorkspace: status.repoRoot ?? status.workspace,
|
|
531
|
+
submodulePath: submodule.path,
|
|
532
|
+
timeoutMs,
|
|
533
|
+
});
|
|
534
|
+
base.remoteBranch = remoteBranch;
|
|
535
|
+
const remoteRef = `refs/remotes/origin/${remoteBranch}`;
|
|
536
|
+
const fetchRefspec = `refs/heads/${remoteBranch}:${remoteRef}`;
|
|
537
|
+
// Refresh the submodule's origin/<branch>, then require it to be an ancestor of
|
|
525
538
|
// the gitlink commit (strict ff-only).
|
|
526
539
|
try {
|
|
527
|
-
await runGit(repoPath, ['-c', 'protocol.file.allow=always', 'fetch', 'origin',
|
|
540
|
+
await runGit(repoPath, ['-c', 'protocol.file.allow=always', 'fetch', 'origin', fetchRefspec], { timeoutMs: timeoutMs ?? 30_000 });
|
|
528
541
|
} catch (error) {
|
|
529
542
|
results.push({ ...base, code: 'submodule_fetch_failed', error: formatGitError(error) });
|
|
530
543
|
continue;
|
|
531
544
|
}
|
|
532
545
|
let alreadyReachable = false;
|
|
533
546
|
try {
|
|
534
|
-
await runGit(repoPath, ['merge-base', '--is-ancestor', submodule.commit,
|
|
547
|
+
await runGit(repoPath, ['merge-base', '--is-ancestor', submodule.commit, remoteRef], { timeoutMs: timeoutMs ?? 15_000 });
|
|
535
548
|
alreadyReachable = true;
|
|
536
|
-
} catch { /* not yet on origin
|
|
549
|
+
} catch { /* not yet on origin/<branch> — candidate for push */ }
|
|
537
550
|
if (alreadyReachable) {
|
|
538
551
|
results.push({ ...base, pushed: false, skipped: true, code: 'submodule_already_reachable' });
|
|
539
552
|
continue;
|
|
540
553
|
}
|
|
541
|
-
// Strict ff-only: origin
|
|
554
|
+
// Strict ff-only: origin/<branch> must be an ancestor of the commit we publish.
|
|
542
555
|
try {
|
|
543
|
-
await runGit(repoPath, ['merge-base', '--is-ancestor',
|
|
556
|
+
await runGit(repoPath, ['merge-base', '--is-ancestor', remoteRef, submodule.commit], { timeoutMs: timeoutMs ?? 15_000 });
|
|
544
557
|
} catch (error) {
|
|
545
558
|
results.push({ ...base, pushed: false, skipped: false, code: 'submodule_non_fast_forward', error: formatGitError(error) });
|
|
546
559
|
continue;
|
|
547
560
|
}
|
|
548
|
-
const refspec = `${submodule.commit}:refs/heads
|
|
561
|
+
const refspec = `${submodule.commit}:refs/heads/${remoteBranch}`;
|
|
549
562
|
if (!execute) {
|
|
550
563
|
results.push({ ...base, pushed: false, skipped: false, code: 'submodule_push_available', refspec });
|
|
551
564
|
continue;
|
|
@@ -553,8 +566,8 @@ async function resolveSubmodulePushes(
|
|
|
553
566
|
try {
|
|
554
567
|
await runGit(repoPath, ['push', 'origin', refspec], { timeoutMs: timeoutMs ?? 30_000 });
|
|
555
568
|
// Verify reachability after the push.
|
|
556
|
-
await runGit(repoPath, ['-c', 'protocol.file.allow=always', 'fetch', 'origin',
|
|
557
|
-
await runGit(repoPath, ['merge-base', '--is-ancestor', submodule.commit,
|
|
569
|
+
await runGit(repoPath, ['-c', 'protocol.file.allow=always', 'fetch', 'origin', fetchRefspec], { timeoutMs: timeoutMs ?? 30_000 });
|
|
570
|
+
await runGit(repoPath, ['merge-base', '--is-ancestor', submodule.commit, remoteRef], { timeoutMs: timeoutMs ?? 15_000 });
|
|
558
571
|
results.push({ ...base, pushed: true, skipped: false, code: 'submodule_pushed', refspec });
|
|
559
572
|
} catch (error) {
|
|
560
573
|
results.push({ ...base, pushed: false, skipped: false, code: 'submodule_push_failed', refspec, error: formatGitError(error) });
|
|
@@ -16,7 +16,7 @@ import { getGitRepoStatus } from '../git/git-status.js';
|
|
|
16
16
|
import * as yaml from 'js-yaml';
|
|
17
17
|
import { loadMeshRefineConfig, resolveMeshRefineValidationPlan } from '../mesh/refine-config.js';
|
|
18
18
|
import type { MeshRefineValidationCommandPlan } from '../mesh/refine-config.js';
|
|
19
|
-
import { evaluateWorktreeBootstrapState, loadMeshWorktreeBootstrapConfig, runMeshWorktreeBootstrap } from '../mesh/worktree-bootstrap-config.js';
|
|
19
|
+
import { evaluateWorktreeBootstrapState, loadMeshWorktreeBootstrapConfig, runMeshWorktreeBootstrap, resolveSubmoduleDefaultBranch } from '../mesh/worktree-bootstrap-config.js';
|
|
20
20
|
import type { WorktreeBootstrapState } from '../mesh/worktree-bootstrap-config.js';
|
|
21
21
|
import { basename as pathBasename, join as pathJoin, resolve as pathResolve } from 'path';
|
|
22
22
|
import * as fs from 'fs';
|
|
@@ -1295,6 +1295,10 @@ export async function runMeshRefineSubmoduleReachabilityGate(
|
|
|
1295
1295
|
commit: gitlink.commit,
|
|
1296
1296
|
reachable: false,
|
|
1297
1297
|
};
|
|
1298
|
+
// Resolved lazily once the submodule checkout/remote are confirmed; defaults
|
|
1299
|
+
// to 'main' so error messages emitted before resolution stay byte-identical
|
|
1300
|
+
// to the pre-generalization behavior on a main-default repo.
|
|
1301
|
+
let submoduleDefaultBranch = 'main';
|
|
1298
1302
|
try {
|
|
1299
1303
|
if (!fs.existsSync(submodulePath)) {
|
|
1300
1304
|
entry.error = `Submodule checkout missing at ${gitlink.path}`;
|
|
@@ -1350,9 +1354,18 @@ export async function runMeshRefineSubmoduleReachabilityGate(
|
|
|
1350
1354
|
entries.push(entry);
|
|
1351
1355
|
continue;
|
|
1352
1356
|
}
|
|
1353
|
-
|
|
1357
|
+
// Generalize the submodule's default branch (F18): '.gitmodules'
|
|
1358
|
+
// branch → local remote HEAD → remote-advertised HEAD → 'main'. On a
|
|
1359
|
+
// main-default submodule this resolves to 'main' and every ref target
|
|
1360
|
+
// below is byte-identical to the prior hardcoded path.
|
|
1361
|
+
submoduleDefaultBranch = await resolveSubmoduleDefaultBranch({
|
|
1362
|
+
submoduleRepoPath: submodulePath,
|
|
1363
|
+
superprojectWorkspace: repoRoot,
|
|
1364
|
+
submodulePath: gitlink.path,
|
|
1365
|
+
});
|
|
1366
|
+
entry.remoteMainBranch = submoduleDefaultBranch;
|
|
1354
1367
|
try {
|
|
1355
|
-
await verifyRemoteMainContainsCommit(submodulePath, gitlink.commit,
|
|
1368
|
+
await verifyRemoteMainContainsCommit(submodulePath, gitlink.commit, submoduleDefaultBranch);
|
|
1356
1369
|
entry.fetchedFromOrigin = true;
|
|
1357
1370
|
entry.remoteReachable = true;
|
|
1358
1371
|
entry.remoteMainReachable = true;
|
|
@@ -1362,17 +1375,17 @@ export async function runMeshRefineSubmoduleReachabilityGate(
|
|
|
1362
1375
|
entry.remoteMainReachable = false;
|
|
1363
1376
|
entry.publishRequired = true;
|
|
1364
1377
|
const details = truncateValidationOutput(e?.stderr || e?.message || String(e));
|
|
1365
|
-
entry.error = `Submodule remote main reachability check failed for origin
|
|
1378
|
+
entry.error = `Submodule remote main reachability check failed for origin/${submoduleDefaultBranch}: ${details}`;
|
|
1366
1379
|
if (options.allowAutoPublishSubmoduleMainCommits === true && entry.localReachable === true) {
|
|
1367
1380
|
entry.autoPublishAllowed = true;
|
|
1368
1381
|
entry.autoPublishAttempted = true;
|
|
1369
1382
|
try {
|
|
1370
|
-
const publish = await publishCommitToRemoteMain(submodulePath, gitlink.commit,
|
|
1383
|
+
const publish = await publishCommitToRemoteMain(submodulePath, gitlink.commit, submoduleDefaultBranch);
|
|
1371
1384
|
entry.autoPublishRefspec = publish.refspec;
|
|
1372
1385
|
entry.publishStdout = truncateValidationOutput(publish.stdout);
|
|
1373
1386
|
entry.publishStderr = truncateValidationOutput(publish.stderr);
|
|
1374
1387
|
entry.autoPublishSucceeded = true;
|
|
1375
|
-
await verifyRemoteMainContainsCommit(submodulePath, gitlink.commit,
|
|
1388
|
+
await verifyRemoteMainContainsCommit(submodulePath, gitlink.commit, submoduleDefaultBranch);
|
|
1376
1389
|
entry.fetchedFromOrigin = true;
|
|
1377
1390
|
entry.remoteReachable = true;
|
|
1378
1391
|
entry.remoteMainReachable = true;
|
|
@@ -1384,13 +1397,13 @@ export async function runMeshRefineSubmoduleReachabilityGate(
|
|
|
1384
1397
|
entry.autoPublishSucceeded = false;
|
|
1385
1398
|
entry.autoPublishVerified = false;
|
|
1386
1399
|
const publishDetails = truncateValidationOutput(publishError?.stderr || publishError?.message || String(publishError));
|
|
1387
|
-
entry.error = `Submodule auto-publish to origin
|
|
1400
|
+
entry.error = `Submodule auto-publish to origin/${submoduleDefaultBranch} failed or could not be verified: ${publishDetails}`;
|
|
1388
1401
|
}
|
|
1389
1402
|
} else if (options.allowAutoPublishSubmoduleMainCommits === true) {
|
|
1390
1403
|
entry.autoPublishAllowed = true;
|
|
1391
1404
|
entry.autoPublishAttempted = false;
|
|
1392
1405
|
entry.autoPublishSkippedReason = entry.autoPublishSkippedReason
|
|
1393
|
-
||
|
|
1406
|
+
|| `candidate commit is not reachable in the source checkout or worktree submodule, so Refinery cannot push it to origin/${submoduleDefaultBranch}`;
|
|
1394
1407
|
}
|
|
1395
1408
|
}
|
|
1396
1409
|
} catch (e: any) {
|
|
@@ -1398,7 +1411,7 @@ export async function runMeshRefineSubmoduleReachabilityGate(
|
|
|
1398
1411
|
entry.remoteMainReachable = false;
|
|
1399
1412
|
entry.publishRequired = true;
|
|
1400
1413
|
const details = truncateValidationOutput(e?.stderr || e?.message || String(e));
|
|
1401
|
-
entry.error = `Submodule remote main reachability check failed for origin
|
|
1414
|
+
entry.error = `Submodule remote main reachability check failed for origin/${submoduleDefaultBranch}: ${details}`;
|
|
1402
1415
|
}
|
|
1403
1416
|
} catch (e: any) {
|
|
1404
1417
|
entry.error = truncateValidationOutput(e?.message || String(e));
|
|
@@ -92,6 +92,136 @@ export function getRegisteredSubmodulePaths(workspace: string): Set<string> {
|
|
|
92
92
|
return paths;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Read each registered submodule's configured `branch` from `.gitmodules`, keyed
|
|
97
|
+
* by the submodule's normalized path (matching {@link getRegisteredSubmodulePaths}).
|
|
98
|
+
*
|
|
99
|
+
* `.gitmodules` stores `submodule.<name>.path` and (optionally)
|
|
100
|
+
* `submodule.<name>.branch`; this joins the two on `<name>`. The special branch
|
|
101
|
+
* value `.` ("track the superproject's branch") is deliberately OMITTED so callers
|
|
102
|
+
* fall through to remote-HEAD detection instead of treating `.` as a literal branch
|
|
103
|
+
* name. Returns an empty map when there are no submodules, no `.gitmodules`, or the
|
|
104
|
+
* lookup fails (conservative — callers then detect or fall back).
|
|
105
|
+
*/
|
|
106
|
+
export function getSubmoduleConfiguredBranches(workspace: string): Map<string, string> {
|
|
107
|
+
const branchesByPath = new Map<string, string>();
|
|
108
|
+
try {
|
|
109
|
+
const out = execFileSync(
|
|
110
|
+
resolveWin32Executable('git'),
|
|
111
|
+
['config', '--file', '.gitmodules', '--list'],
|
|
112
|
+
{ cwd: workspace, encoding: 'utf8', timeout: 10_000, windowsHide: true },
|
|
113
|
+
);
|
|
114
|
+
// Join `submodule.<name>.path` with `submodule.<name>.branch` on <name>.
|
|
115
|
+
const pathByName = new Map<string, string>();
|
|
116
|
+
const branchByName = new Map<string, string>();
|
|
117
|
+
for (const line of String(out).split(/\r?\n/)) {
|
|
118
|
+
const trimmed = line.trim();
|
|
119
|
+
if (!trimmed) continue;
|
|
120
|
+
const eq = trimmed.indexOf('=');
|
|
121
|
+
if (eq < 0) continue;
|
|
122
|
+
const key = trimmed.slice(0, eq);
|
|
123
|
+
const value = trimmed.slice(eq + 1).trim();
|
|
124
|
+
// key: submodule.<name>.<field>; <name> may itself contain dots, so match
|
|
125
|
+
// the leading `submodule.` and trailing `.<field>` and take the middle.
|
|
126
|
+
const match = /^submodule\.(.+)\.(path|branch)$/.exec(key);
|
|
127
|
+
if (!match) continue;
|
|
128
|
+
const name = match[1];
|
|
129
|
+
if (match[2] === 'path') {
|
|
130
|
+
const norm = value.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
131
|
+
if (norm) pathByName.set(name, norm);
|
|
132
|
+
} else if (value) {
|
|
133
|
+
branchByName.set(name, value);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const [name, submodulePath] of pathByName) {
|
|
137
|
+
const branch = branchByName.get(name);
|
|
138
|
+
if (branch && branch !== '.') branchesByPath.set(submodulePath, branch);
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
// No .gitmodules / git error → no configured branches.
|
|
142
|
+
}
|
|
143
|
+
return branchesByPath;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Fallback submodule branch when no configured/detected default can be resolved. */
|
|
147
|
+
export const SUBMODULE_DEFAULT_BRANCH_FALLBACK = 'main';
|
|
148
|
+
|
|
149
|
+
function isPlausibleBranchName(name: unknown): name is string {
|
|
150
|
+
return typeof name === 'string' && name.length > 0 && !/\s/.test(name) && name !== 'HEAD';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Resolve the default branch a submodule's commits are published to / checked for
|
|
155
|
+
* reachability against. Generalizes the previously hardcoded `main` so a submodule
|
|
156
|
+
* whose default branch is `master`/`trunk`/etc. is handled. Priority (each tier
|
|
157
|
+
* falls through to the next on miss/error):
|
|
158
|
+
*
|
|
159
|
+
* 1. `.gitmodules` `submodule.<name>.branch` (via {@link getSubmoduleConfiguredBranches};
|
|
160
|
+
* `.` is ignored) — an explicit, local, zero-cost declaration.
|
|
161
|
+
* 2. the submodule checkout's LOCAL remote HEAD: `git symbolic-ref --short
|
|
162
|
+
* refs/remotes/<remote>/HEAD` → strip the `<remote>/` prefix (no network).
|
|
163
|
+
* 3. the submodule remote's advertised HEAD: `git ls-remote --symref <remote> HEAD`
|
|
164
|
+
* → `ref: refs/heads/<branch>` (one network round-trip).
|
|
165
|
+
* 4. fallback {@link SUBMODULE_DEFAULT_BRANCH_FALLBACK} (`'main'`).
|
|
166
|
+
*
|
|
167
|
+
* Because the final fallback is `'main'` and every earlier tier that resolves `'main'`
|
|
168
|
+
* yields the same string, a repo whose submodules default to `main` (the common case)
|
|
169
|
+
* produces byte-identical downstream fetch/merge-base/push ref targets — only a
|
|
170
|
+
* read-only resolution probe is added.
|
|
171
|
+
*/
|
|
172
|
+
export async function resolveSubmoduleDefaultBranch(opts: {
|
|
173
|
+
/** The submodule's local checkout — cwd for symbolic-ref / ls-remote. */
|
|
174
|
+
submoduleRepoPath: string;
|
|
175
|
+
/** The superproject workspace — for the `.gitmodules` branch lookup (tier 1). */
|
|
176
|
+
superprojectWorkspace?: string;
|
|
177
|
+
/** The submodule's path relative to the superproject (key into `.gitmodules`). */
|
|
178
|
+
submodulePath?: string;
|
|
179
|
+
/** Remote name (default `origin`). */
|
|
180
|
+
remote?: string;
|
|
181
|
+
/** Timeout for the local probe (tier 2); the network probe (tier 3) gets max(this, 30s). */
|
|
182
|
+
timeoutMs?: number;
|
|
183
|
+
}): Promise<string> {
|
|
184
|
+
const remote = opts.remote?.trim() || 'origin';
|
|
185
|
+
const localTimeout = opts.timeoutMs ?? 10_000;
|
|
186
|
+
const git = resolveWin32Executable('git');
|
|
187
|
+
const execFileAsync = promisify(execFile);
|
|
188
|
+
|
|
189
|
+
// Tier 1: .gitmodules configured branch (local, zero-cost).
|
|
190
|
+
if (opts.superprojectWorkspace && opts.submodulePath) {
|
|
191
|
+
try {
|
|
192
|
+
const normalized = opts.submodulePath.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
193
|
+
const configured = getSubmoduleConfiguredBranches(opts.superprojectWorkspace).get(normalized);
|
|
194
|
+
if (isPlausibleBranchName(configured)) return configured;
|
|
195
|
+
} catch { /* fall through */ }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Tier 2: the local remote HEAD (no network) — set by clone/`git remote set-head`.
|
|
199
|
+
try {
|
|
200
|
+
const { stdout } = await execFileAsync(
|
|
201
|
+
git,
|
|
202
|
+
['symbolic-ref', '--short', `refs/remotes/${remote}/HEAD`],
|
|
203
|
+
{ cwd: opts.submoduleRepoPath, encoding: 'utf8', timeout: localTimeout, windowsHide: true },
|
|
204
|
+
);
|
|
205
|
+
const short = String(stdout || '').trim();
|
|
206
|
+
const prefix = `${remote}/`;
|
|
207
|
+
const branch = short.startsWith(prefix) ? short.slice(prefix.length) : short;
|
|
208
|
+
if (isPlausibleBranchName(branch)) return branch;
|
|
209
|
+
} catch { /* fall through */ }
|
|
210
|
+
|
|
211
|
+
// Tier 3: the remote's advertised HEAD (one network round-trip).
|
|
212
|
+
try {
|
|
213
|
+
const { stdout } = await execFileAsync(
|
|
214
|
+
git,
|
|
215
|
+
['ls-remote', '--symref', remote, 'HEAD'],
|
|
216
|
+
{ cwd: opts.submoduleRepoPath, encoding: 'utf8', timeout: Math.max(localTimeout, 30_000), windowsHide: true },
|
|
217
|
+
);
|
|
218
|
+
const match = /^ref:\s+refs\/heads\/(\S+)\s+HEAD/m.exec(String(stdout || ''));
|
|
219
|
+
if (match && isPlausibleBranchName(match[1])) return match[1];
|
|
220
|
+
} catch { /* fall through */ }
|
|
221
|
+
|
|
222
|
+
return SUBMODULE_DEFAULT_BRANCH_FALLBACK;
|
|
223
|
+
}
|
|
224
|
+
|
|
95
225
|
/**
|
|
96
226
|
* True when `git status --porcelain` output represents a worktree that is clean
|
|
97
227
|
* EXCEPT for submodule-gitlink-pointer moves. A worktree task that commits inside a
|