valent-pipeline 0.19.65 → 0.19.67
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/package.json +1 -1
- package/src/lib/git-flow.js +106 -9
package/package.json
CHANGED
package/src/lib/git-flow.js
CHANGED
|
@@ -139,15 +139,19 @@ const REFRESH_TARGET_WINS = new Set(['pipeline-backlog.yaml']);
|
|
|
139
139
|
// bug entry never reached the branch and `backlog ship --story <bug>` then errored "not found" →
|
|
140
140
|
// manual reconcile every bug-fix sprint. The ship CLAMP is untouched — evidence still verifies against
|
|
141
141
|
// the branch's own tree; this only delivers the backlog ENTRY the target already committed.
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
let conflicted;
|
|
142
|
+
// The repo-relative paths git reports as unmerged (conflicted) in the current merge. [] on error.
|
|
143
|
+
function conflictedPaths(dir) {
|
|
145
144
|
try {
|
|
146
|
-
|
|
145
|
+
return git(dir, ['diff', '--name-only', '--diff-filter=U'])
|
|
147
146
|
.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
148
147
|
} catch {
|
|
149
|
-
return
|
|
148
|
+
return [];
|
|
150
149
|
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function resolveRefreshConflict(dir) {
|
|
153
|
+
if (!mergeInProgress(dir)) return false; // a non-conflict merge failure — the caller aborts
|
|
154
|
+
const conflicted = conflictedPaths(dir);
|
|
151
155
|
if (!conflicted.length || !conflicted.every((p) => REFRESH_TARGET_WINS.has(p))) return false;
|
|
152
156
|
for (const p of conflicted) {
|
|
153
157
|
git(dir, ['checkout', '--theirs', '--', p]);
|
|
@@ -167,6 +171,54 @@ function isMergedInto(root, branch, target) {
|
|
|
167
171
|
}
|
|
168
172
|
}
|
|
169
173
|
|
|
174
|
+
// RESUME-path backlog refresh (M-208). The create-path sync (startStory, the `merge --no-ff target`
|
|
175
|
+
// below) fires ONLY on create — never on a RESUME, because a full merge there would move HEAD past the
|
|
176
|
+
// critic pin (the documented constraint at the create-path guard). But a --from-story bug-fix branch
|
|
177
|
+
// can still END UP missing the target-filed bug entry across a resume: if the entry is filed to the
|
|
178
|
+
// target AFTER the branch is cut (the rejecting sprint's rollover-persist lands ~seconds after the
|
|
179
|
+
// branch base, or the create launch dies before the entry exists) and the run then RESUMES the branch,
|
|
180
|
+
// the create refresh never re-runs → `backlog ship --story <bug>` errors "backlog item not found"
|
|
181
|
+
// (3.5-BUG-001, the M-36/M-65 class on the resume surface). Bring ONLY the target-canonical backlog
|
|
182
|
+
// forward — a single path-scoped chore commit, NOT a merge — so the entry arrives while the pin and all
|
|
183
|
+
// code stay exactly put (the backlog is already target-wins on every merge, REFRESH_TARGET_WINS). A
|
|
184
|
+
// no-op when the branch backlog already matches the target (the create refresh delivered it, or nothing
|
|
185
|
+
// was filed) so a resume never churns an empty commit; gated to inline --from-story fixes (basedOnStory)
|
|
186
|
+
// whose target genuinely advanced, mirroring the create-path guard.
|
|
187
|
+
function syncBacklogFromTargetOnResume(gitDir, root, story, target) {
|
|
188
|
+
try {
|
|
189
|
+
if (!target || !branchExists(root, target)) return false;
|
|
190
|
+
const rec = readFlowRecord(gitDir, story) || readFlowRecord(root, story);
|
|
191
|
+
if (!rec || !rec.basedOnStory) return false; // only inline bug fixes need this
|
|
192
|
+
if (mergeInProgress(gitDir)) return false; // never touch a half-merged tree
|
|
193
|
+
const branch = rec.branch || currentBranch(gitDir);
|
|
194
|
+
if (!branch || isMergedInto(root, target, branch)) return false; // target not ahead → nothing to inherit
|
|
195
|
+
return commitTargetBacklog(gitDir, story, target,
|
|
196
|
+
'inherit target backlog (filed bug entries) on resume — no code merge, pin intact (M-208)');
|
|
197
|
+
} catch {
|
|
198
|
+
return false; // best-effort: a sync failure leaves the branch as it was (degraded, not wedged)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Deliver ONLY the target's canonical pipeline-backlog.yaml blob onto the CURRENT branch as a
|
|
203
|
+
// path-scoped chore commit (no code merge, HEAD/pin/code untouched). Returns true iff it committed a
|
|
204
|
+
// change (no-op + false when the branch backlog already matches the target — never an empty commit).
|
|
205
|
+
// Shared by the resume-path sync (M-208) and the create-path refresh-abort fallback (M-219): a refresh
|
|
206
|
+
// merge that ABORTS on a non-backlog co-conflict (a project coordination doc, the M-52 untrack reset, …)
|
|
207
|
+
// rolls the cleanly-mergeable bug entry back with the whole merge, so the disposition's filed entry never
|
|
208
|
+
// reaches the branch and `backlog ship --story <bug>` later errors "not found". The backlog is
|
|
209
|
+
// target-canonical (never legitimately branch-edited — target-wins on every merge), so deliver it
|
|
210
|
+
// directly. Caller must ensure no merge is in progress (a clean tree at base).
|
|
211
|
+
function commitTargetBacklog(gitDir, story, target, summary) {
|
|
212
|
+
const backlogFile = 'pipeline-backlog.yaml';
|
|
213
|
+
git(gitDir, ['checkout', target, '--', backlogFile]); // stage the target's canonical backlog blob
|
|
214
|
+
const staged = git(gitDir, ['diff', '--cached', '--name-only'])
|
|
215
|
+
.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
216
|
+
if (!staged.includes(backlogFile)) return false; // already current → no empty commit
|
|
217
|
+
git(gitDir, ['commit', '-m',
|
|
218
|
+
buildCommitMessage({ storyId: story, phase: 'sync', summary }), '--', backlogFile]);
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
|
|
170
222
|
// --- worktree mode (parallel story execution) -------------------------------------------------
|
|
171
223
|
// Each parallel story gets its own worktree under `<root>/<worktreeDir>/<storyId>` with the story
|
|
172
224
|
// branch checked out there; the MAIN checkout stays on the target branch and is the only place
|
|
@@ -470,7 +522,10 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
|
|
|
470
522
|
const onBranch = currentBranch(root);
|
|
471
523
|
|
|
472
524
|
if (!worktree && onBranch === branch) {
|
|
473
|
-
|
|
525
|
+
const rec = readFlowRecord(root, story);
|
|
526
|
+
const backlogResynced = syncBacklogFromTargetOnResume(root, root, story, rec?.target);
|
|
527
|
+
return { branch, base: rec?.base ?? null, created: false, resumed: true,
|
|
528
|
+
...(backlogResynced ? { backlogResynced: true } : {}) };
|
|
474
529
|
}
|
|
475
530
|
|
|
476
531
|
// Leftover dirt belongs to whatever produced it (planning artifacts, a prior crash) — sweep it
|
|
@@ -516,7 +571,10 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
|
|
|
516
571
|
// when intact, and a half-created worktree (crash between `worktree add` and setup) gets
|
|
517
572
|
// its missing pieces — setup_commands must therefore be idempotent (documented).
|
|
518
573
|
const setup = setupWorktree(root, existing, setupCommands);
|
|
519
|
-
|
|
574
|
+
const rec = readFlowRecord(existing, story) || readFlowRecord(root, story);
|
|
575
|
+
const backlogResynced = syncBacklogFromTargetOnResume(existing, root, story, rec?.target);
|
|
576
|
+
return { branch, base: rec?.base ?? null, worktree: existing, created: false, resumed: true, snapshotSha,
|
|
577
|
+
...(backlogResynced ? { backlogResynced: true } : {}), ...setup };
|
|
520
578
|
}
|
|
521
579
|
const creating = !branchExists(root, branch);
|
|
522
580
|
if (creating) {
|
|
@@ -544,6 +602,7 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
|
|
|
544
602
|
// a conflict aborts cleanly in the worktree, leaving the branch at its base. Needed for parallel:2.
|
|
545
603
|
let refreshedFromTarget = false;
|
|
546
604
|
let refreshConflict = false;
|
|
605
|
+
let backlogCleanlyMergeable = false;
|
|
547
606
|
if (creating && basedOnStory && branchExists(root, target) && !isMergedInto(root, target, branch)) {
|
|
548
607
|
try {
|
|
549
608
|
git(wtPath, ['merge', '--no-ff', '-m',
|
|
@@ -553,14 +612,27 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
|
|
|
553
612
|
// A conflict ONLY on target-authoritative shared state (the backlog with the filed bug entry)
|
|
554
613
|
// → resolve toward the target so the entry reaches this branch (M-65/M-69); any real-code
|
|
555
614
|
// conflict aborts cleanly (setupWorktree re-materializes the install if the sync removed it).
|
|
615
|
+
const conflicted = conflictedPaths(wtPath); // captured BEFORE resolve concludes/abort clears it
|
|
556
616
|
if (resolveRefreshConflict(wtPath)) {
|
|
557
617
|
refreshedFromTarget = true;
|
|
558
618
|
} else {
|
|
559
619
|
if (mergeInProgress(wtPath)) git(wtPath, ['merge', '--abort']);
|
|
560
620
|
refreshConflict = true;
|
|
621
|
+
backlogCleanlyMergeable = conflicted.length > 0 && !conflicted.some((p) => REFRESH_TARGET_WINS.has(p));
|
|
561
622
|
}
|
|
562
623
|
}
|
|
563
624
|
}
|
|
625
|
+
// M-219 (worktree variant): the refresh aborted on a non-backlog co-conflict while the backlog itself
|
|
626
|
+
// would have merged clean → deliver ONLY the target backlog blob into the worktree so the filed bug
|
|
627
|
+
// entry is present for `backlog ship` (code stays at base; conflict still surfaced via refreshConflict).
|
|
628
|
+
// Runs in wtPath BEFORE setupWorktree re-materializes the install — the tree is clean at base.
|
|
629
|
+
let backlogDelivered = false;
|
|
630
|
+
if (creating && basedOnStory && refreshConflict && !refreshedFromTarget && backlogCleanlyMergeable && branchExists(root, target)) {
|
|
631
|
+
try {
|
|
632
|
+
backlogDelivered = commitTargetBacklog(wtPath, story, target,
|
|
633
|
+
'inherit target backlog (filed bug entries) after refresh aborted on a co-conflict — no code merge (M-219)');
|
|
634
|
+
} catch { /* best-effort: leave the branch at its base */ }
|
|
635
|
+
}
|
|
564
636
|
const setup = setupWorktree(root, wtPath, setupCommands);
|
|
565
637
|
if (creating) {
|
|
566
638
|
writeFlowRecord(wtPath, story, {
|
|
@@ -580,13 +652,17 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
|
|
|
580
652
|
...(orphanMovedTo ? { orphanMovedTo, warning: `orphaned worktree directory moved aside to ${orphanMovedTo} (crashed run; inspect/delete it manually)` } : {}),
|
|
581
653
|
...(refreshedFromTarget ? { refreshedFromTarget: true } : {}),
|
|
582
654
|
...(refreshConflict ? { refreshConflict: true } : {}),
|
|
655
|
+
...(backlogDelivered ? { backlogDelivered: true } : {}),
|
|
583
656
|
...setup,
|
|
584
657
|
};
|
|
585
658
|
}
|
|
586
659
|
|
|
587
660
|
if (branchExists(root, branch)) {
|
|
588
661
|
git(root, ['checkout', branch]);
|
|
589
|
-
|
|
662
|
+
const rec = readFlowRecord(root, story);
|
|
663
|
+
const backlogResynced = syncBacklogFromTargetOnResume(root, root, story, rec?.target);
|
|
664
|
+
return { branch, base: rec?.base ?? null, created: false, resumed: true, snapshotSha,
|
|
665
|
+
...(backlogResynced ? { backlogResynced: true } : {}) };
|
|
590
666
|
}
|
|
591
667
|
if (!branchExists(root, base)) throw new Error(`base branch "${base}" does not exist`);
|
|
592
668
|
|
|
@@ -605,6 +681,7 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
|
|
|
605
681
|
// than wedging a half-merge that would hard-stop every later commit in this checkout.
|
|
606
682
|
let refreshedFromTarget = false;
|
|
607
683
|
let refreshConflict = false;
|
|
684
|
+
let backlogCleanlyMergeable = false;
|
|
608
685
|
if (basedOnStory && branchExists(root, target) && !isMergedInto(root, target, branch)) {
|
|
609
686
|
// Capture whether the vendored install is present BEFORE the sync, so the hazard guard below fires
|
|
610
687
|
// ONLY when the merge actually REMOVED it — not when a repo simply never had a materialized install
|
|
@@ -619,11 +696,17 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
|
|
|
619
696
|
// bug entry into) is resolved toward the target so the entry reaches this branch (M-65/M-69); a
|
|
620
697
|
// conflict touching any real code aborts cleanly as before (degraded, but no worse). The M-52
|
|
621
698
|
// framework-deletion guard below still runs on the concluded merge.
|
|
699
|
+
const conflicted = conflictedPaths(root); // captured BEFORE resolve concludes/abort clears it
|
|
622
700
|
if (resolveRefreshConflict(root)) {
|
|
623
701
|
refreshedFromTarget = true;
|
|
624
702
|
} else {
|
|
625
703
|
if (mergeInProgress(root)) git(root, ['merge', '--abort']);
|
|
626
704
|
refreshConflict = true;
|
|
705
|
+
// M-219: deliver the backlog entry below ONLY when the backlog itself did NOT conflict — i.e. the
|
|
706
|
+
// entry would have merged clean but an UNRELATED file's conflict (a project coordination doc, …)
|
|
707
|
+
// aborted the whole merge. A backlog+code divergence is a genuinely diverged branch (recut) —
|
|
708
|
+
// leave it untouched so the negative guard "never swallows a code conflict" holds.
|
|
709
|
+
backlogCleanlyMergeable = conflicted.length > 0 && !conflicted.some((p) => REFRESH_TARGET_WINS.has(p));
|
|
627
710
|
}
|
|
628
711
|
}
|
|
629
712
|
// M-52 transition hazard: when `target` UNTRACKS the vendored framework (the post-untrack install
|
|
@@ -645,6 +728,19 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
|
|
|
645
728
|
);
|
|
646
729
|
}
|
|
647
730
|
}
|
|
731
|
+
// M-219: a refresh that ABORTED (a non-backlog co-conflict — e.g. a project coordination doc whose
|
|
732
|
+
// conflict makes resolveRefreshConflict bail — or the M-52 untrack reset above) rolled the
|
|
733
|
+
// cleanly-mergeable target backlog back with the whole merge, so the disposition's filed bug entry
|
|
734
|
+
// never reached this branch and `backlog ship --story <bug>` would error "not found" (3.5/3.7-BUG-001).
|
|
735
|
+
// The tree is clean at base here (merge --abort / reset --hard ran), so deliver ONLY the target
|
|
736
|
+
// backlog blob directly — code stays at base (the conflict is still surfaced via refreshConflict).
|
|
737
|
+
let backlogDelivered = false;
|
|
738
|
+
if (basedOnStory && refreshConflict && !refreshedFromTarget && backlogCleanlyMergeable && branchExists(root, target)) {
|
|
739
|
+
try {
|
|
740
|
+
backlogDelivered = commitTargetBacklog(root, story, target,
|
|
741
|
+
'inherit target backlog (filed bug entries) after refresh aborted on a co-conflict — no code merge (M-219)');
|
|
742
|
+
} catch { /* best-effort: leave the branch at its base */ }
|
|
743
|
+
}
|
|
648
744
|
writeFlowRecord(root, story, {
|
|
649
745
|
schema: 1,
|
|
650
746
|
kind: 'git-flow',
|
|
@@ -657,7 +753,8 @@ export function startStory({ root, story, targetBranch = '', prefix = 'story/',
|
|
|
657
753
|
});
|
|
658
754
|
return { branch, base, target, created: true, resumed: false, snapshotSha,
|
|
659
755
|
...(refreshedFromTarget ? { refreshedFromTarget: true } : {}),
|
|
660
|
-
...(refreshConflict ? { refreshConflict: true } : {})
|
|
756
|
+
...(refreshConflict ? { refreshConflict: true } : {}),
|
|
757
|
+
...(backlogDelivered ? { backlogDelivered: true } : {}) };
|
|
661
758
|
}
|
|
662
759
|
|
|
663
760
|
/**
|