@ktpartners/dgs-platform 3.3.1 → 3.4.2
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 +28 -0
- package/README.md +4 -1
- package/bin/install.js +1 -1
- package/commands/dgs/abandon-milestone.md +28 -0
- package/commands/dgs/new-milestone.md +3 -1
- package/deliver-great-systems/bin/dgs-tools.cjs +22 -4
- package/deliver-great-systems/bin/lib/context.cjs +45 -15
- package/deliver-great-systems/bin/lib/docs.cjs +73 -29
- package/deliver-great-systems/bin/lib/docs.test.cjs +49 -6
- package/deliver-great-systems/bin/lib/init.cjs +9 -3
- package/deliver-great-systems/bin/lib/init.test.cjs +61 -3
- package/deliver-great-systems/bin/lib/milestone.cjs +470 -2
- package/deliver-great-systems/bin/lib/milestone.test.cjs +653 -0
- package/deliver-great-systems/bin/lib/search.cjs +5 -16
- package/deliver-great-systems/bin/lib/state.cjs +152 -1
- package/deliver-great-systems/bin/lib/worktrees.cjs +182 -1
- package/deliver-great-systems/bin/lib/worktrees.test.cjs +409 -0
- package/deliver-great-systems/templates/claude-md.md +2 -0
- package/deliver-great-systems/templates/state.md +16 -0
- package/deliver-great-systems/workflows/abandon-milestone.md +120 -0
- package/deliver-great-systems/workflows/complete-milestone.md +58 -4
- package/deliver-great-systems/workflows/create-milestone-job.md +15 -0
- package/deliver-great-systems/workflows/execute-plan.md +1 -1
- package/deliver-great-systems/workflows/help.md +7 -0
- package/deliver-great-systems/workflows/init-product.md +8 -8
- package/deliver-great-systems/workflows/new-milestone.md +69 -0
- package/deliver-great-systems/workflows/progress.md +5 -1
- package/deliver-great-systems/workflows/run-job.md +23 -1
- package/hooks/dist/dgs-enforce-discipline.js +34 -1
- package/package.json +1 -1
|
@@ -11,6 +11,7 @@ const fs = require('fs');
|
|
|
11
11
|
const path = require('path');
|
|
12
12
|
const { safeReadFile, output, error } = require('./core.cjs');
|
|
13
13
|
const { getPlanningRoot } = require('./paths.cjs');
|
|
14
|
+
const { enumerateIdeaDocsDirs } = require('./docs.cjs');
|
|
14
15
|
|
|
15
16
|
// ─── Fuzzy Matching Engine ──────────────────────────────────────────────────
|
|
16
17
|
|
|
@@ -330,22 +331,10 @@ function scanDocs(cwd) {
|
|
|
330
331
|
const productDocsDir = path.join(planRoot, 'docs');
|
|
331
332
|
scanDocsDir(productDocsDir, 'product', path.join(planRootRel, 'docs'));
|
|
332
333
|
|
|
333
|
-
// Idea-scoped docs
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
let ideaDirs;
|
|
338
|
-
try {
|
|
339
|
-
ideaDirs = fs.readdirSync(stateDir, { withFileTypes: true })
|
|
340
|
-
.filter(e => e.isDirectory())
|
|
341
|
-
.map(e => e.name);
|
|
342
|
-
} catch {
|
|
343
|
-
continue;
|
|
344
|
-
}
|
|
345
|
-
for (const ideaSlug of ideaDirs) {
|
|
346
|
-
const docsDir = path.join(stateDir, ideaSlug, 'docs');
|
|
347
|
-
scanDocsDir(docsDir, `idea:${ideaSlug}`, path.join(planRootRel, 'ideas', state, ideaSlug, 'docs'));
|
|
348
|
-
}
|
|
334
|
+
// Idea-scoped docs — flat-aware via the shared enumerator (legacy dirs included)
|
|
335
|
+
for (const { ideaSlug, docsDir } of enumerateIdeaDocsDirs(cwd)) {
|
|
336
|
+
const rel = path.relative(cwd, docsDir);
|
|
337
|
+
scanDocsDir(docsDir, `idea:${ideaSlug}`, rel);
|
|
349
338
|
}
|
|
350
339
|
|
|
351
340
|
// Spec-scoped docs
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const { loadConfig, getMilestoneInfo, getMilestonePhaseFilter, output, error, getProjectRoot } = require('./core.cjs');
|
|
8
|
-
const { extractFrontmatter, reconstructFrontmatter } = require('./frontmatter.cjs');
|
|
8
|
+
const { extractFrontmatter, reconstructFrontmatter, spliceFrontmatter } = require('./frontmatter.cjs');
|
|
9
9
|
const { getPlanningRoot } = require('./paths.cjs');
|
|
10
10
|
|
|
11
11
|
// Escape special regex characters in a string
|
|
@@ -961,6 +961,151 @@ function cmdMarkMilestoneComplete(cwd, raw) {
|
|
|
961
961
|
output(result, raw);
|
|
962
962
|
}
|
|
963
963
|
|
|
964
|
+
// ─── Ad-hoc milestone marker (Phase 159, ADH-04 primary) ─────────────────────
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* Read the `adhoc` frontmatter marker from STATE.md.
|
|
968
|
+
*
|
|
969
|
+
* The marker is the primary, committed source of truth for whether the active
|
|
970
|
+
* milestone is an ad-hoc container. Degrades to `false` when STATE.md or the
|
|
971
|
+
* field is absent. (extractFrontmatter yields the string "true" for a bare
|
|
972
|
+
* boolean, so both the boolean and string form are accepted.)
|
|
973
|
+
*
|
|
974
|
+
* @param {string} cwd - Planning root directory
|
|
975
|
+
* @returns {boolean}
|
|
976
|
+
*/
|
|
977
|
+
function stateReadAdhoc(cwd) {
|
|
978
|
+
const statePath = resolveStatePath(cwd);
|
|
979
|
+
if (!fs.existsSync(statePath)) return false;
|
|
980
|
+
const content = fs.readFileSync(statePath, 'utf-8');
|
|
981
|
+
const fm = extractFrontmatter(content);
|
|
982
|
+
return fm.adhoc === true || fm.adhoc === 'true';
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Set (or clear) the `adhoc` frontmatter marker on STATE.md.
|
|
987
|
+
*
|
|
988
|
+
* Writes `adhoc: true` when `value` is truthy. When falsy, the key is deleted
|
|
989
|
+
* so normal (non-adhoc) milestones keep clean frontmatter. Body content is
|
|
990
|
+
* preserved via spliceFrontmatter (reconstruct-and-splice).
|
|
991
|
+
*
|
|
992
|
+
* @param {string} cwd - Planning root directory
|
|
993
|
+
* @param {boolean} value
|
|
994
|
+
* @returns {{ success: boolean, adhoc: boolean }}
|
|
995
|
+
*/
|
|
996
|
+
function stateSetAdhoc(cwd, value) {
|
|
997
|
+
const statePath = resolveStatePath(cwd);
|
|
998
|
+
if (!fs.existsSync(statePath)) {
|
|
999
|
+
error('STATE.md not found');
|
|
1000
|
+
}
|
|
1001
|
+
const content = fs.readFileSync(statePath, 'utf-8');
|
|
1002
|
+
const fm = extractFrontmatter(content);
|
|
1003
|
+
if (value) {
|
|
1004
|
+
fm.adhoc = true;
|
|
1005
|
+
} else {
|
|
1006
|
+
delete fm.adhoc;
|
|
1007
|
+
}
|
|
1008
|
+
const newContent = spliceFrontmatter(content, fm);
|
|
1009
|
+
fs.writeFileSync(statePath, newContent, 'utf-8');
|
|
1010
|
+
return { success: true, adhoc: !!value };
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function cmdStateReadAdhoc(cwd, raw) {
|
|
1014
|
+
const adhoc = stateReadAdhoc(cwd);
|
|
1015
|
+
output({ adhoc }, raw, String(adhoc));
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function cmdStateSetAdhoc(cwd, args, raw) {
|
|
1019
|
+
// Default to setting true; accept an explicit false/0/no to clear.
|
|
1020
|
+
const arg = args && args.length > 0 ? String(args[0]).toLowerCase() : 'true';
|
|
1021
|
+
const value = !(arg === 'false' || arg === '0' || arg === 'no');
|
|
1022
|
+
const result = stateSetAdhoc(cwd, value);
|
|
1023
|
+
output(result, raw, String(result.adhoc));
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// ─── Ad-hoc relaxed-completion readiness (Phase 161, ADH-10/11/13) ────────────
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Count well-formed data rows in the active STATE.md "Quick Tasks Completed"
|
|
1030
|
+
* table. Mirrors the section regex used by _archiveSingleStateFile. A row counts
|
|
1031
|
+
* only when its id cell AND its commit cell are both non-empty; header,
|
|
1032
|
+
* separator, and malformed/partial rows are skipped. Handles both the 5-col
|
|
1033
|
+
* (no Status) and 6-col (with Status) layouts — the Commit column is the 4th
|
|
1034
|
+
* data column (cells[4]) in both.
|
|
1035
|
+
*
|
|
1036
|
+
* @param {string} statePath - Resolved STATE.md path
|
|
1037
|
+
* @returns {number}
|
|
1038
|
+
*/
|
|
1039
|
+
function _countCompletedQuickRows(statePath) {
|
|
1040
|
+
if (!fs.existsSync(statePath)) return 0;
|
|
1041
|
+
const content = fs.readFileSync(statePath, 'utf-8');
|
|
1042
|
+
const m = content.match(/(###\s*Quick Tasks Completed\s*\n)([\s\S]*?)(?=\n###?\s|\n##[^#]|$)/i);
|
|
1043
|
+
if (!m) return 0;
|
|
1044
|
+
let count = 0;
|
|
1045
|
+
for (const line of m[2].split('\n')) {
|
|
1046
|
+
if (!line.startsWith('|')) continue;
|
|
1047
|
+
if (/^\|[\s-|]+$/.test(line)) continue; // separator
|
|
1048
|
+
if (line.includes('Description')) continue; // header
|
|
1049
|
+
const cells = line.split('|').map((c) => c.trim());
|
|
1050
|
+
// cells[0] is the empty pre-pipe cell; cells[1] = id, cells[4] = Commit.
|
|
1051
|
+
if (cells[1] && cells[4]) count += 1;
|
|
1052
|
+
}
|
|
1053
|
+
return count;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Count phases with disk_status === 'complete' for the active milestone.
|
|
1058
|
+
* Runs `roadmap analyze --raw` as a subprocess (cmdRoadmapAnalyze is
|
|
1059
|
+
* process-exiting) and reads `.completed_phases`. Defaults to 0 on any
|
|
1060
|
+
* exec/parse failure — a quicks-only ad-hoc milestone may have no phases dir.
|
|
1061
|
+
*
|
|
1062
|
+
* @param {string} cwd - Planning root directory
|
|
1063
|
+
* @returns {number}
|
|
1064
|
+
*/
|
|
1065
|
+
function _countCompletedPhases(cwd) {
|
|
1066
|
+
try {
|
|
1067
|
+
const { execSync } = require('child_process');
|
|
1068
|
+
const toolsPath = path.resolve(__dirname, '..', 'dgs-tools.cjs');
|
|
1069
|
+
const out = execSync(
|
|
1070
|
+
'node ' + JSON.stringify(toolsPath) + ' roadmap analyze --raw',
|
|
1071
|
+
{ cwd, stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' }
|
|
1072
|
+
);
|
|
1073
|
+
const parsed = JSON.parse(out.trim());
|
|
1074
|
+
return Number(parsed.completed_phases) || 0;
|
|
1075
|
+
} catch {
|
|
1076
|
+
return 0;
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* Compute relaxed-completion readiness for the active milestone.
|
|
1082
|
+
*
|
|
1083
|
+
* - adhoc: the committed STATE.md marker (false when absent — ADH-11 fall-through
|
|
1084
|
+
* to the strict completion gate).
|
|
1085
|
+
* - completedQuickRows / completedPhases: the two minimum-bar signals (ADH-10).
|
|
1086
|
+
* A planned-but-incomplete phase and a malformed quick row are NOT counted.
|
|
1087
|
+
* - ready: true iff at least one merged unit of work exists.
|
|
1088
|
+
* - reason: 'ok' when ready, else 'no_merged_work' (drives the ADH-13 refusal).
|
|
1089
|
+
*
|
|
1090
|
+
* @param {string} cwd - Planning root directory
|
|
1091
|
+
* @returns {{ adhoc: boolean, completedQuickRows: number, completedPhases: number, ready: boolean, reason: string }}
|
|
1092
|
+
*/
|
|
1093
|
+
function stateAdhocReadiness(cwd) {
|
|
1094
|
+
const adhoc = stateReadAdhoc(cwd);
|
|
1095
|
+
const completedQuickRows = _countCompletedQuickRows(resolveStatePath(cwd));
|
|
1096
|
+
const completedPhases = _countCompletedPhases(cwd);
|
|
1097
|
+
const ready = completedQuickRows >= 1 || completedPhases >= 1;
|
|
1098
|
+
return { adhoc, completedQuickRows, completedPhases, ready, reason: ready ? 'ok' : 'no_merged_work' };
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function cmdStateAdhocReadiness(cwd, raw) {
|
|
1102
|
+
const r = stateAdhocReadiness(cwd);
|
|
1103
|
+
// The predicate is a machine contract consumed by complete-milestone.md and
|
|
1104
|
+
// the test suite, which `JSON.parse` the --raw output. Emit compact JSON for
|
|
1105
|
+
// --raw and pretty JSON otherwise (never a lossy human string).
|
|
1106
|
+
output(r, raw, JSON.stringify(r));
|
|
1107
|
+
}
|
|
1108
|
+
|
|
964
1109
|
module.exports = {
|
|
965
1110
|
stateExtractField,
|
|
966
1111
|
stateReplaceField,
|
|
@@ -982,4 +1127,10 @@ module.exports = {
|
|
|
982
1127
|
cmdStateArchiveQuickTasks,
|
|
983
1128
|
markMilestoneComplete,
|
|
984
1129
|
cmdMarkMilestoneComplete,
|
|
1130
|
+
stateReadAdhoc,
|
|
1131
|
+
stateSetAdhoc,
|
|
1132
|
+
cmdStateReadAdhoc,
|
|
1133
|
+
cmdStateSetAdhoc,
|
|
1134
|
+
stateAdhocReadiness,
|
|
1135
|
+
cmdStateAdhocReadiness,
|
|
985
1136
|
};
|
|
@@ -17,7 +17,7 @@ const fs = require('fs');
|
|
|
17
17
|
const path = require('path');
|
|
18
18
|
const crypto = require('crypto');
|
|
19
19
|
const { execSync } = require('child_process');
|
|
20
|
-
const { execGit, output, error, loadConfig } = require('./core.cjs');
|
|
20
|
+
const { execGit, output, error, loadConfig, generateSlugInternal } = require('./core.cjs');
|
|
21
21
|
const { getLocalConfigPath } = require('./config.cjs');
|
|
22
22
|
const { getPlanningRoot } = require('./paths.cjs');
|
|
23
23
|
const { parseReposMd } = require('./repos.cjs');
|
|
@@ -152,6 +152,78 @@ function _sanitizeSlug(input) {
|
|
|
152
152
|
.replace(/-+$/, '');
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Compose a structural milestone slug: `[<project>-]<version>-<name-slug>`.
|
|
157
|
+
*
|
|
158
|
+
* This replaces the legacy bare-name slug formula (`generateSlugInternal(name)
|
|
159
|
+
* || 'milestone'`) which collapsed a placeholder/blank milestone name to the
|
|
160
|
+
* bare `milestone` branch, causing cross-milestone branch-name collisions.
|
|
161
|
+
*
|
|
162
|
+
* The bare `'milestone'` slug is returned ONLY as the absolute last resort —
|
|
163
|
+
* when BOTH version and name are empty. A non-empty version always guarantees
|
|
164
|
+
* uniqueness, so a blank name yields the version segment alone (NOT 'milestone').
|
|
165
|
+
*
|
|
166
|
+
* @param {object} opts
|
|
167
|
+
* @param {string} [opts.version] - Milestone version (e.g. 'v25.0'); dots -> dashes.
|
|
168
|
+
* @param {string} [opts.name] - Milestone name (e.g. 'Ad-hoc Container').
|
|
169
|
+
* @param {string} [opts.project] - Project name (used only when multiProject).
|
|
170
|
+
* @param {boolean} [opts.multiProject] - Prefix with project when >1 active project.
|
|
171
|
+
* @returns {string} Sanitized slug, <=50 chars.
|
|
172
|
+
*/
|
|
173
|
+
function composeMilestoneSlug(opts) {
|
|
174
|
+
opts = opts || {};
|
|
175
|
+
// Version dots -> dashes, then sanitized (so 'v25.0' -> 'v25-0'). May be ''.
|
|
176
|
+
const sanitizedVersion = _sanitizeSlug(String(opts.version || '').replace(/\./g, '-'));
|
|
177
|
+
const nameSlug = _sanitizeSlug(opts.name || '');
|
|
178
|
+
const projectSlug = (opts.multiProject && opts.project) ? _sanitizeSlug(opts.project) : null;
|
|
179
|
+
|
|
180
|
+
const segments = [projectSlug, sanitizedVersion || null, nameSlug || null].filter(Boolean);
|
|
181
|
+
const joined = segments.join('-');
|
|
182
|
+
|
|
183
|
+
// Deterministic last-resort: ONLY when there is genuinely nothing else
|
|
184
|
+
// (both version AND name empty). Never applied to a non-empty version.
|
|
185
|
+
if (!joined) return 'milestone';
|
|
186
|
+
|
|
187
|
+
// Final pass guarantees a clean, <=50-char slug.
|
|
188
|
+
return _sanitizeSlug(joined);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Resolve the runtime milestone slug for a project, preferring a stamped
|
|
193
|
+
* worktree entry and legacy-falling-back to the OLD bare-name slug so that
|
|
194
|
+
* in-flight milestones created before the structural-slug upgrade are never
|
|
195
|
+
* orphaned.
|
|
196
|
+
*
|
|
197
|
+
* Resolution order:
|
|
198
|
+
* 1. NEW composed slug has a worktree entry -> use it.
|
|
199
|
+
* 2. Else OLD bare-name slug has a worktree entry -> use it (legacy fallback).
|
|
200
|
+
* 3. Else (fresh, not yet created) -> use the NEW composed slug, so creation
|
|
201
|
+
* stamps the new formula.
|
|
202
|
+
*
|
|
203
|
+
* @param {string} cwd
|
|
204
|
+
* @param {string} project
|
|
205
|
+
* @param {object} opts - { version, name, multiProject }
|
|
206
|
+
* @returns {string}
|
|
207
|
+
*/
|
|
208
|
+
function resolveMilestoneSlug(cwd, project, opts) {
|
|
209
|
+
opts = opts || {};
|
|
210
|
+
const newSlug = composeMilestoneSlug({
|
|
211
|
+
version: opts.version,
|
|
212
|
+
name: opts.name,
|
|
213
|
+
project: project,
|
|
214
|
+
multiProject: opts.multiProject,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
if (_getWorktreeState(cwd, project, newSlug)) return newSlug;
|
|
218
|
+
|
|
219
|
+
// Legacy fallback: the OLD bare-name formula, sanitized identically to how
|
|
220
|
+
// historical keys were derived.
|
|
221
|
+
const oldSlug = _sanitizeSlug(generateSlugInternal(opts.name || '') || '');
|
|
222
|
+
if (oldSlug && _getWorktreeState(cwd, project, oldSlug)) return oldSlug;
|
|
223
|
+
|
|
224
|
+
return newSlug;
|
|
225
|
+
}
|
|
226
|
+
|
|
155
227
|
/**
|
|
156
228
|
* Detect collision and disambiguate path if needed.
|
|
157
229
|
* @param {string} targetPath
|
|
@@ -269,6 +341,13 @@ function cmdWorktreesCreate(cwd, args) {
|
|
|
269
341
|
const repoIdx = args.indexOf('--repo');
|
|
270
342
|
const repoFilter = repoIdx !== -1 ? args[repoIdx + 1] : null;
|
|
271
343
|
|
|
344
|
+
// Ad-hoc milestone container markers (Phase 159, ADH-04 mirror + ADH-06).
|
|
345
|
+
// Additive: only stamped on the entry when present, so non-adhoc worktree
|
|
346
|
+
// entries stay clean. Wired together by the new-milestone --adhoc branch.
|
|
347
|
+
const isAdhoc = args.includes('--adhoc');
|
|
348
|
+
const baseRefIdx = args.indexOf('--base-ref');
|
|
349
|
+
const adhocBaseRef = baseRefIdx !== -1 ? args[baseRefIdx + 1] : null;
|
|
350
|
+
|
|
272
351
|
// Load config
|
|
273
352
|
const config = loadConfig(cwd);
|
|
274
353
|
const project = config.current_project;
|
|
@@ -350,6 +429,16 @@ function cmdWorktreesCreate(cwd, args) {
|
|
|
350
429
|
mode: mode,
|
|
351
430
|
setup_complete: setupComplete,
|
|
352
431
|
milestone_version: milestoneVersion,
|
|
432
|
+
// Stamp the structural-slug formula on milestone entries so the runtime
|
|
433
|
+
// resolver (resolveMilestoneSlug) prefers the stamped key over recomputing
|
|
434
|
+
// from the name. Only present on milestone-type entries; quick entries
|
|
435
|
+
// stay clean.
|
|
436
|
+
...(type === 'milestone' ? { milestone_slug: slug, slug_formula: 'v2' } : {}),
|
|
437
|
+
// Ad-hoc markers (Phase 159): re-passed each repo iteration so they
|
|
438
|
+
// survive the multi-repo merge in _setWorktreeState (which only
|
|
439
|
+
// special-cases `repos`). Idempotent and only present when set.
|
|
440
|
+
...(isAdhoc ? { adhoc: true } : {}),
|
|
441
|
+
...(adhocBaseRef ? { adhoc_base_ref: adhocBaseRef } : {}),
|
|
353
442
|
repos: { [repo.name]: worktreePath },
|
|
354
443
|
});
|
|
355
444
|
|
|
@@ -724,12 +813,24 @@ function rebaseAndMerge(cwd, repoName, slug, options) {
|
|
|
724
813
|
}
|
|
725
814
|
}
|
|
726
815
|
|
|
816
|
+
// Step 6: Delete the owned remote milestone branch (SUCCESS path only).
|
|
817
|
+
// This reclaims the milestone/<slug> name on origin so a future milestone
|
|
818
|
+
// cannot collide with a lingering squatting branch. A delete failure (e.g.
|
|
819
|
+
// no remote branch, offline) is a NON-FATAL warning and never changes the
|
|
820
|
+
// completion result. Never runs on a conflicted/failed merge.
|
|
821
|
+
const delRemote = execGit(mainCheckout, ['push', 'origin', '--delete', branchName]);
|
|
822
|
+
const remoteBranchDeleted = delRemote.exitCode === 0;
|
|
823
|
+
if (!remoteBranchDeleted) {
|
|
824
|
+
process.stderr.write('Warning: failed to delete remote ' + branchName + ': ' + delRemote.stderr + '\n');
|
|
825
|
+
}
|
|
826
|
+
|
|
727
827
|
return {
|
|
728
828
|
success: true,
|
|
729
829
|
merged: true,
|
|
730
830
|
skipped: rebaseSkipped,
|
|
731
831
|
conflicted: false,
|
|
732
832
|
pushed: pushed,
|
|
833
|
+
remoteBranchDeleted: remoteBranchDeleted,
|
|
733
834
|
};
|
|
734
835
|
}
|
|
735
836
|
|
|
@@ -779,6 +880,83 @@ function checkWorktreeHealth(cwd, slug) {
|
|
|
779
880
|
return { healthy: issues.length === 0, issues: issues };
|
|
780
881
|
}
|
|
781
882
|
|
|
883
|
+
/**
|
|
884
|
+
* Pre-flight check for a remote milestone-branch collision.
|
|
885
|
+
*
|
|
886
|
+
* For each repo in REPOS.md, fetch origin (best-effort), then check whether
|
|
887
|
+
* `refs/remotes/origin/milestone/<slug>` exists with commits NOT reachable from
|
|
888
|
+
* base_branch (i.e. unmerged work squatting the milestone name). Used by the
|
|
889
|
+
* run-job pre-flight guard to REFUSE starting a job that would collide.
|
|
890
|
+
*
|
|
891
|
+
* Never throws — degrades to `{ collision:false, ... }` on any git error.
|
|
892
|
+
*
|
|
893
|
+
* @param {string} cwd - Planning root
|
|
894
|
+
* @param {string} slug - Milestone slug
|
|
895
|
+
* @returns {{ collision: boolean, branch: string, repos: Array<{name:string, exists:boolean, unmergedCount:number}>, message: string }}
|
|
896
|
+
*/
|
|
897
|
+
function checkRemoteCollision(cwd, slug) {
|
|
898
|
+
const branch = 'milestone/' + slug;
|
|
899
|
+
const empty = { collision: false, branch: branch, repos: [], message: '' };
|
|
900
|
+
|
|
901
|
+
let config;
|
|
902
|
+
try {
|
|
903
|
+
config = loadConfig(cwd);
|
|
904
|
+
} catch {
|
|
905
|
+
return empty;
|
|
906
|
+
}
|
|
907
|
+
const baseBranch = (config && config.base_branch) || 'main';
|
|
908
|
+
|
|
909
|
+
let parsed;
|
|
910
|
+
try {
|
|
911
|
+
parsed = parseReposMd(cwd);
|
|
912
|
+
} catch {
|
|
913
|
+
return empty;
|
|
914
|
+
}
|
|
915
|
+
const repos = (parsed && parsed.repos) ? parsed.repos : [];
|
|
916
|
+
|
|
917
|
+
const repoResults = [];
|
|
918
|
+
const collidingRepos = [];
|
|
919
|
+
|
|
920
|
+
for (const repo of repos) {
|
|
921
|
+
let mainCheckout;
|
|
922
|
+
try {
|
|
923
|
+
mainCheckout = _findMainCheckoutPath(cwd, repo.name);
|
|
924
|
+
} catch {
|
|
925
|
+
mainCheckout = null;
|
|
926
|
+
}
|
|
927
|
+
if (!mainCheckout || !fs.existsSync(mainCheckout)) {
|
|
928
|
+
repoResults.push({ name: repo.name, exists: false, unmergedCount: 0 });
|
|
929
|
+
continue;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// Best-effort fetch — ignore failure (offline, no remote, etc.).
|
|
933
|
+
execGit(mainCheckout, ['fetch', 'origin']);
|
|
934
|
+
|
|
935
|
+
const remoteRef = 'refs/remotes/origin/' + branch;
|
|
936
|
+
const verify = execGit(mainCheckout, ['rev-parse', '--verify', '--quiet', remoteRef]);
|
|
937
|
+
const exists = verify.exitCode === 0 && !!(verify.stdout || '').trim();
|
|
938
|
+
if (!exists) {
|
|
939
|
+
repoResults.push({ name: repo.name, exists: false, unmergedCount: 0 });
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// Count commits on the remote milestone branch not reachable from base_branch.
|
|
944
|
+
const countRes = execGit(mainCheckout, ['rev-list', '--count', baseBranch + '..origin/' + branch]);
|
|
945
|
+
const unmergedCount = countRes.exitCode === 0 ? parseInt((countRes.stdout || '0').trim(), 10) || 0 : 0;
|
|
946
|
+
repoResults.push({ name: repo.name, exists: true, unmergedCount: unmergedCount });
|
|
947
|
+
if (unmergedCount > 0) collidingRepos.push(repo.name);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
const collision = collidingRepos.length > 0;
|
|
951
|
+
const message = collision
|
|
952
|
+
? 'Remote branch ' + branch + ' already exists with unmerged commits in: ' +
|
|
953
|
+
collidingRepos.join(', ') + '. Merge or delete it before starting this job ' +
|
|
954
|
+
'(e.g. git push origin --delete ' + branch + '), then re-run.'
|
|
955
|
+
: '';
|
|
956
|
+
|
|
957
|
+
return { collision: collision, branch: branch, repos: repoResults, message: message };
|
|
958
|
+
}
|
|
959
|
+
|
|
782
960
|
module.exports = {
|
|
783
961
|
cmdWorktreesCreate,
|
|
784
962
|
cmdWorktreesRemove,
|
|
@@ -787,4 +965,7 @@ module.exports = {
|
|
|
787
965
|
cmdWorktreesPrune,
|
|
788
966
|
rebaseAndMerge,
|
|
789
967
|
checkWorktreeHealth,
|
|
968
|
+
composeMilestoneSlug,
|
|
969
|
+
resolveMilestoneSlug,
|
|
970
|
+
checkRemoteCollision,
|
|
790
971
|
};
|