amsd-pipeline-shared 0.5.0 → 0.5.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/docs/gateway-baseline-2.0.80.md5 +47 -0
- package/docs/gateway-files.txt +47 -0
- package/orchestrations/config/repo-artifacts.json +23 -0
- package/orchestrations/config/resume-preserves.json +25 -0
- package/orchestrations/config/run-modes.json +47 -0
- package/orchestrations/scripts/lib/checkpoints.sh +175 -0
- package/orchestrations/scripts/lib/codeline-lanes.sh +1664 -0
- package/orchestrations/scripts/lib/codeline-packages.js +67 -0
- package/orchestrations/scripts/lib/codeline-write-perimeter.sh +318 -0
- package/orchestrations/scripts/lib/common.sh +66 -0
- package/orchestrations/scripts/lib/control-plane.sh +506 -0
- package/orchestrations/scripts/lib/ecosystem-registry.js +148 -0
- package/orchestrations/scripts/lib/engine-paths.sh +52 -0
- package/orchestrations/scripts/lib/flags.sh +38 -0
- package/orchestrations/scripts/lib/git-ops.sh +975 -0
- package/orchestrations/scripts/lib/halt-recovery.sh +56 -0
- package/orchestrations/scripts/lib/handlers/_read-input.js +88 -0
- package/orchestrations/scripts/lib/handlers/carry-plan.js +25 -0
- package/orchestrations/scripts/lib/handlers/cl-entries.js +10 -0
- package/orchestrations/scripts/lib/handlers/codeline-ecosystem.js +164 -0
- package/orchestrations/scripts/lib/handlers/codeline-manifests.js +148 -0
- package/orchestrations/scripts/lib/handlers/filtered-prd.js +85 -0
- package/orchestrations/scripts/lib/handlers/merge-lane-into-canonical.js +39 -0
- package/orchestrations/scripts/lib/handlers/package-engines-node.js +25 -0
- package/orchestrations/scripts/lib/handlers/prd-phases.js +46 -0
- package/orchestrations/scripts/lib/handlers/rebuild-cross-contract.js +7 -0
- package/orchestrations/scripts/lib/handlers/record-declared-pass.js +23 -0
- package/orchestrations/scripts/lib/handlers/release-line-prd.js +31 -0
- package/orchestrations/scripts/lib/handlers/release-lines.js +31 -0
- package/orchestrations/scripts/lib/handlers/repo-exclude-patterns.js +108 -0
- package/orchestrations/scripts/lib/handlers/resume-spec-output-present.js +16 -0
- package/orchestrations/scripts/lib/handlers/sibling-providers.js +43 -0
- package/orchestrations/scripts/lib/handlers/spanning-stories-incomplete.js +50 -0
- package/orchestrations/scripts/lib/handlers/spec-inputs-digest.js +48 -0
- package/orchestrations/scripts/lib/handlers/spec-predates-plan.js +40 -0
- package/orchestrations/scripts/lib/handlers/stories-lacking-spec.js +64 -0
- package/orchestrations/scripts/lib/handlers/write-missing-plans.js +92 -0
- package/orchestrations/scripts/lib/orchestration-resume.sh +74 -0
- package/orchestrations/scripts/lib/phase-exit.sh +90 -0
- package/orchestrations/scripts/lib/pipeline-commit.sh +19 -0
- package/orchestrations/scripts/lib/plan-hash.js +19 -0
- package/orchestrations/scripts/lib/plan-scope.js +73 -0
- package/orchestrations/scripts/lib/qa-gate-evidence.sh +235 -0
- package/orchestrations/scripts/lib/release-lines.sh +166 -0
- package/orchestrations/scripts/lib/resume-semantics.sh +31 -0
- package/orchestrations/scripts/lib/run-checkpoint.sh +842 -0
- package/orchestrations/scripts/lib/run-modes.sh +93 -0
- package/orchestrations/scripts/lib/story-outputs.sh +322 -0
- package/orchestrations/scripts/lib/story-retry-state.sh +355 -0
- package/package.json +1 -1
|
@@ -0,0 +1,975 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Shared git-mutation primitives — single source of truth, sourced by
|
|
3
|
+
# run-agent-orchestration.sh, claude.sh, and codemie-claude.sh.
|
|
4
|
+
#
|
|
5
|
+
# Root cause this fixes (found live, 2026-08-02, git-surface audit): before
|
|
6
|
+
# this file existed, setup_worktrees()/cleanup_worktrees() were independently
|
|
7
|
+
# copy-pasted into claude.sh AND codemie-claude.sh. claude.sh's copy got a
|
|
8
|
+
# real bug fix (2026-07-06: stale-worktree validation, branch deletion on
|
|
9
|
+
# cleanup, prune-before-delete ordering, a final zero-worktrees-remain
|
|
10
|
+
# verification) that codemie-claude.sh's copy never received — a silent,
|
|
11
|
+
# untested drift that would have reproduced the exact "fatal: a branch named
|
|
12
|
+
# 'wt-primary' already exists" live failure the first time codemie-claude.sh
|
|
13
|
+
# was used for a real run. Per the "all lanes must have the same flow, no
|
|
14
|
+
# deviations" principle already applied to lib/story-guards.sh, every caller
|
|
15
|
+
# now shares the exact same function bodies instead of re-implementing them.
|
|
16
|
+
#
|
|
17
|
+
# Requires from the caller's environment: the log/warning/success/error
|
|
18
|
+
# helpers — already defined identically in all three sourcing scripts (same
|
|
19
|
+
# convention lib/story-guards.sh already relies on).
|
|
20
|
+
|
|
21
|
+
# ensure_story_branch <codeline_root> <story_id> [baseline_branch]
|
|
22
|
+
# Every story commits to its OWN dedicated branch ("AI-<story_id>", derived
|
|
23
|
+
# entirely from the story ID in flight — never a hardcoded name), freshly
|
|
24
|
+
# created off origin/<baseline_branch> every single time via `checkout -B`
|
|
25
|
+
# (which resets the branch to that start-point if it already exists,
|
|
26
|
+
# discarding any prior half-done local attempt — same "clean slate every
|
|
27
|
+
# run" principle already applied elsewhere in this pipeline).
|
|
28
|
+
#
|
|
29
|
+
# Live bug this eliminates (2026-07-22): the previous design committed
|
|
30
|
+
# directly onto the shared baseline branch (develop) and relied on a durable
|
|
31
|
+
# local marker (record_brownfield_verified_baseline / brownfield-preflight-
|
|
32
|
+
# reset.sh) to know what "last known good" state to reset back to before each
|
|
33
|
+
# run. That marker only updates when a story genuinely passes story_tsc_gate
|
|
34
|
+
# — a manual correction to the branch (e.g. a human running `git reset --hard
|
|
35
|
+
# origin/develop`) doesn't update it, so it can point at an already-discarded,
|
|
36
|
+
# orphaned commit. The next run then trusts that stale marker and resets the
|
|
37
|
+
# shared branch BACKWARD onto abandoned history, and a new story commit lands
|
|
38
|
+
# on top of the wrong base — confirmed live: a discarded commit
|
|
39
|
+
# (.epam/setup-deps.sh cruft) got silently reintroduced this way, and the
|
|
40
|
+
# story commit built on top of it.
|
|
41
|
+
#
|
|
42
|
+
# Branching per story removes the entire problem class: there is no shared
|
|
43
|
+
# mutable branch state to protect via a local marker. Every story always
|
|
44
|
+
# starts from the ACTUAL current origin/<baseline_branch> tip, live, via
|
|
45
|
+
# `git fetch` — never a cached/remembered SHA that can go stale.
|
|
46
|
+
# _provision_epam_plugin_config <project_root>
|
|
47
|
+
# Writes .epam/settings.json (plugins) and .epam/codeline-facts.json for
|
|
48
|
+
# THIS project_root. Shared so every git-mutating reset that wipes .epam/
|
|
49
|
+
# (untracked, no .gitignore guarantee on a real client repo — confirmed live,
|
|
50
|
+
# none of Metrolinx's 3 codelines ignore it) has exactly one place to restore
|
|
51
|
+
# it from, instead of independently drifting copies.
|
|
52
|
+
#
|
|
53
|
+
# CodeGraph's query tool (orchestrations/plugins/codegraph-plugin.js) ships
|
|
54
|
+
# with epam-cli itself — provisioned into .epam/settings.json's "tools"
|
|
55
|
+
# array for EVERY codeline unconditionally (mirroring run-agent-
|
|
56
|
+
# orchestration.sh's own per-codeline provisioning step), merged with
|
|
57
|
+
# whatever the project's own EPAM_PROJECT_CONFIG_DIR/plugins.json adds on
|
|
58
|
+
# top. codeline-facts.json remains purely project-config-driven and is a
|
|
59
|
+
# silent no-op when no project config dir is set or the codeline can't be
|
|
60
|
+
# matched against project.outputDirs.
|
|
61
|
+
|
|
62
|
+
# _epam_write_verification_manifest <project_root>
|
|
63
|
+
# Generates .epam/verification.json by asking the verification plugin to detect how THIS repo
|
|
64
|
+
# checks itself (its own package scripts, its own lockfile). Never invents a command: an
|
|
65
|
+
# unrecognised stack writes no manifest, and the plugin then reports UNKNOWN rather than a pass.
|
|
66
|
+
_epam_write_verification_manifest() {
|
|
67
|
+
local _root="$1"
|
|
68
|
+
local _plugin="${AUTOMATION_DIR:-$(dirname "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)")}/plugins/verification-plugin.js"
|
|
69
|
+
[ -f "$_plugin" ] || return 0
|
|
70
|
+
local _node="${NODE_CMD:-${NODE_BIN:-node}}"
|
|
71
|
+
command -v "$_node" >/dev/null 2>&1 || return 0
|
|
72
|
+
"$_node" -e '
|
|
73
|
+
const p = require(process.argv[1]);
|
|
74
|
+
const root = process.argv[2];
|
|
75
|
+
// BOTH sections. This wrote only `typecheck`, so the `test` section never existed and
|
|
76
|
+
// every reader of it fell back to an engine-side guess. Detection is independent: a repo
|
|
77
|
+
// may declare one and not the other, and writing a partial manifest is correct — the
|
|
78
|
+
// reader reports UNKNOWN for whichever half is absent, never a pass.
|
|
79
|
+
const d = { ...(p.detectVerification(root) || {}), ...(p.detectTests(root) || {}) };
|
|
80
|
+
if (!Object.keys(d).length) process.exit(3);
|
|
81
|
+
const fs = require("node:fs"), path = require("node:path");
|
|
82
|
+
// PRESERVE WHAT THE PROJECT ALREADY DECLARED. Detection is a default, not an authority:
|
|
83
|
+
// an operator who hand-tuned a command must not have it silently overwritten on the next
|
|
84
|
+
// provisioning pass.
|
|
85
|
+
const out = path.join(root, ".epam", "verification.json");
|
|
86
|
+
let existing = {};
|
|
87
|
+
try { existing = JSON.parse(fs.readFileSync(out, "utf8")) || {}; } catch { existing = {}; }
|
|
88
|
+
// MERGE PER SECTION, NOT PER FILE.
|
|
89
|
+
//
|
|
90
|
+
// This was a flat { ...d, ...existing }, so a codeline that already had a `test` section kept
|
|
91
|
+
// it WHOLE — and every real codeline already has one. A newly detected key could therefore
|
|
92
|
+
// never reach any of them: next.gotransit.com carried a test section with no scopedCommand,
|
|
93
|
+
// so story-scoped verification stayed off and a one-line change ran all 746 suites (live
|
|
94
|
+
// 2026-09-02, AMSD-1919: 10,731MB of an 11,264MB cap, a 70-second suite past 10 minutes).
|
|
95
|
+
//
|
|
96
|
+
// Per-key precedence keeps the original intent: a hand-tuned command still wins over
|
|
97
|
+
// detection, while keys nobody set are filled in.
|
|
98
|
+
// WHAT THE PROJECT DECLARES SURVIVES THE RESET; WHAT THE CODELINE HOLDS DOES NOT.
|
|
99
|
+
//
|
|
100
|
+
// .epam/ is untracked and the brownfield reset runs `git clean -fd`, so this whole file is
|
|
101
|
+
// destroyed before every run and rebuilt by detection. Anything hand-written here lives
|
|
102
|
+
// exactly one run — which is how a declared clock pin vanished on 2026-09-07 and AMSD-1919
|
|
103
|
+
// met the same wall-clock failure the pin exists to remove.
|
|
104
|
+
//
|
|
105
|
+
// A project declares its overrides in ITS OWN config dir, which no codeline reset can touch.
|
|
106
|
+
// Precedence, lowest to highest: detected < project-declared < hand-tuned in the codeline.
|
|
107
|
+
// Detection still fills in what nobody declared, and an operator editing this file for one
|
|
108
|
+
// run still wins for that run.
|
|
109
|
+
let declared = {};
|
|
110
|
+
const declFile = process.env.EPAM_PROJECT_CONFIG_DIR
|
|
111
|
+
? path.join(process.env.EPAM_PROJECT_CONFIG_DIR, "verification.json") : "";
|
|
112
|
+
if (declFile && fs.existsSync(declFile)) {
|
|
113
|
+
try { declared = JSON.parse(fs.readFileSync(declFile, "utf8")) || {}; }
|
|
114
|
+
catch { declared = {}; } // an unreadable declaration changes nothing, and says nothing
|
|
115
|
+
}
|
|
116
|
+
const layer = (lo, hi) => {
|
|
117
|
+
const out = { ...lo, ...hi };
|
|
118
|
+
for (const k of Object.keys(lo)) {
|
|
119
|
+
const a = lo[k]; const b = hi[k];
|
|
120
|
+
if (a && b && typeof a === "object" && typeof b === "object"
|
|
121
|
+
&& !Array.isArray(a) && !Array.isArray(b)) {
|
|
122
|
+
out[k] = { ...a, ...b };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
};
|
|
127
|
+
// A VALUE DETECTION WROTE IS NOT A HAND-TUNING. `existing` wins so an operator can tune a
|
|
128
|
+
// command for one run -- but the file FIRST content was written by DETECTION, and it then
|
|
129
|
+
// outranked detection forever. Live 2026-09-23: this file had said test.command "pytest"
|
|
130
|
+
// since the codeline was created; the ecosystem command was corrected to
|
|
131
|
+
// `python3 -m pytest` (without which the suite cannot import what it tests, and every
|
|
132
|
+
// verification exited 2 with zero tests collected) and the fix could not reach the
|
|
133
|
+
// codeline. The comment above assumes the file is destroyed before every run -- true of
|
|
134
|
+
// brownfield `git clean -fd`, false of a greenfield RESUME, which keeps its codeline.
|
|
135
|
+
//
|
|
136
|
+
// Detection stamps each section it writes with `detected`. A section carrying that stamp
|
|
137
|
+
// belongs to detection and yields to fresh detection; a section without it was authored by
|
|
138
|
+
// a human or a project declaration and still wins. Nothing is guessed: the file records
|
|
139
|
+
// which half wrote each section.
|
|
140
|
+
//
|
|
141
|
+
// NO APOSTROPHES IN THIS BLOCK. It lives inside a single-quoted shell string, so one
|
|
142
|
+
// closes it: on 2026-09-23 an apostrophe here broke the quoting, the mangled shell ran
|
|
143
|
+
// fragments of its own source, and it deleted the test file that was proving this fix.
|
|
144
|
+
const minusOwnDetections = Object.fromEntries(
|
|
145
|
+
Object.entries(existing).filter(([k, v]) => !(v && typeof v === "object" && !Array.isArray(v) && v.detected)),
|
|
146
|
+
);
|
|
147
|
+
const merged = layer(layer(d, declared), minusOwnDetections);
|
|
148
|
+
fs.mkdirSync(path.join(root, ".epam"), { recursive: true });
|
|
149
|
+
fs.writeFileSync(out, JSON.stringify(merged, null, 2) + "\n");
|
|
150
|
+
' "$_plugin" "$_root" 2>/dev/null || true
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
_provision_epam_plugin_config() {
|
|
154
|
+
local _project_root="$1"
|
|
155
|
+
|
|
156
|
+
local _project_tools_json="[]"
|
|
157
|
+
if [ -n "${EPAM_PROJECT_CONFIG_DIR:-}" ] && [ -f "${EPAM_PROJECT_CONFIG_DIR}/plugins.json" ]; then
|
|
158
|
+
_project_tools_json=$(jq -c '.tools // []' "${EPAM_PROJECT_CONFIG_DIR}/plugins.json" 2>/dev/null || echo "[]")
|
|
159
|
+
fi
|
|
160
|
+
|
|
161
|
+
local _codegraph_plugin_abs=""
|
|
162
|
+
local _codegraph_plugin_src="${SCRIPT_DIR:-}/../plugins/codegraph-plugin.js"
|
|
163
|
+
if [ -n "${SCRIPT_DIR:-}" ] && [ -f "$_codegraph_plugin_src" ]; then
|
|
164
|
+
_codegraph_plugin_abs="$(cd "$(dirname "$_codegraph_plugin_src")" 2>/dev/null && pwd)/$(basename "$_codegraph_plugin_src")"
|
|
165
|
+
fi
|
|
166
|
+
|
|
167
|
+
if [ -n "$_codegraph_plugin_abs" ] || [ "$_project_tools_json" != "[]" ]; then
|
|
168
|
+
mkdir -p "${_project_root}/.epam"
|
|
169
|
+
# Verification manifest, generated from the repo's OWN scripts (never a tool name baked
|
|
170
|
+
# into the engine). Written per codeline at provisioning time so every writer, gate and
|
|
171
|
+
# worktree has it. detect + emit live in the plugin; this only places the file.
|
|
172
|
+
_epam_write_verification_manifest "${_project_root}"
|
|
173
|
+
jq -n --argjson project "$_project_tools_json" --arg cg "$_codegraph_plugin_abs" \
|
|
174
|
+
'{tools: (((if $cg != "" then [$cg] else [] end) + $project) | unique)}' \
|
|
175
|
+
> "${_project_root}/.epam/settings.json"
|
|
176
|
+
fi
|
|
177
|
+
|
|
178
|
+
[ -n "${EPAM_PROJECT_CONFIG_DIR:-}" ] || return 0
|
|
179
|
+
local _facts_cfg="${EPAM_PROJECT_CONFIG_DIR}/codeline-facts.json"
|
|
180
|
+
[ -f "$_facts_cfg" ] || return 0
|
|
181
|
+
|
|
182
|
+
local _prd_for_lookup="${MAIN_PRD_FILE:-${PRD_FILE:-}}"
|
|
183
|
+
[ -f "${_prd_for_lookup:-}" ] || return 0
|
|
184
|
+
local _cl
|
|
185
|
+
_cl=$(jq -r --arg root "$_project_root" \
|
|
186
|
+
'.project.outputDirs[]? | select(.path == $root) | .codeline' \
|
|
187
|
+
"$_prd_for_lookup" 2>/dev/null | head -1)
|
|
188
|
+
[ -n "$_cl" ] || return 0
|
|
189
|
+
|
|
190
|
+
local _cl_facts
|
|
191
|
+
_cl_facts=$(jq -c --arg cl "$_cl" '.[$cl] // empty' "$_facts_cfg" 2>/dev/null)
|
|
192
|
+
if [ -n "$_cl_facts" ]; then
|
|
193
|
+
mkdir -p "${_project_root}/.epam"
|
|
194
|
+
echo "$_cl_facts" > "${_project_root}/.epam/codeline-facts.json"
|
|
195
|
+
fi
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
# ── The engine's perimeter at the staging seam ──────────────────────────────
|
|
199
|
+
#
|
|
200
|
+
# Directories epam-cli writes into a client codeline so its gates can read them. They
|
|
201
|
+
# are never client content and must never be committed there.
|
|
202
|
+
#
|
|
203
|
+
# The list is the CLASS, not one instance. Excluding only .epam (as this first did) let
|
|
204
|
+
# .deepeval/.deepeval_telemetry.txt into a live metrolinx commit hours later — the same
|
|
205
|
+
# defect wearing a different filename. orchestrations/ was added 2026-08-01 after the KB
|
|
206
|
+
# writer dropped orchestrations/agents/KB.md into a client repo; only orchestrations/logs/*
|
|
207
|
+
# had been excluded, so the "excluded one instance, not the class" gap reappeared under a
|
|
208
|
+
# new path. All were verified absent from the client baseline tree (origin/develop).
|
|
209
|
+
#
|
|
210
|
+
# Kept in sync with src/config/enginePaths.ts, which enforces the same perimeter at the
|
|
211
|
+
# WRITE seam — where it actually belongs. This is defence in depth: by the time a file
|
|
212
|
+
# reaches staging it already exists in the customer's working tree, where the writer-output
|
|
213
|
+
# manifest picks it up as though the writer produced it (live 20260804T225443Z).
|
|
214
|
+
# shellcheck source=engine-paths.sh
|
|
215
|
+
. "$(dirname "${BASH_SOURCE[0]}")/engine-paths.sh"
|
|
216
|
+
. "$(dirname "${BASH_SOURCE[0]}")/codeline-write-perimeter.sh"
|
|
217
|
+
|
|
218
|
+
# git_add_client_outputs <repo> [timeout_secs]
|
|
219
|
+
# Report a staging failure with git's own words. Writes to stderr so it lands in the run log
|
|
220
|
+
# beside the WARNING the caller emits; a bare "exit 1" is not a diagnosis.
|
|
221
|
+
_git_add_report_failure() {
|
|
222
|
+
local _repo="$1" _rc="$2" _stderr="$3" _summary="$4"
|
|
223
|
+
# A "FAILED ... (exit 0)" line is its own defect: it sends the next investigation after a
|
|
224
|
+
# failure that never happened. Caught by the state sweep the same hour this was added.
|
|
225
|
+
[ "$_rc" -eq 0 ] && return 0
|
|
226
|
+
echo "[git-add] FAILED in ${_repo} (exit ${_rc}): ${_summary}" >&2
|
|
227
|
+
if [ -n "$_stderr" ]; then
|
|
228
|
+
printf '%s\n' "$_stderr" | head -20 | sed 's/^/[git-add] /' >&2
|
|
229
|
+
else
|
|
230
|
+
echo "[git-add] (git printed nothing)" >&2
|
|
231
|
+
fi
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
# Stage every client change, never an engine artefact. Returns git's exit code.
|
|
235
|
+
#
|
|
236
|
+
# There is deliberately NO fallback that stages without the exclusions. Every previous
|
|
237
|
+
# call site had `... || git add -A`, which discarded the whole rule the moment the
|
|
238
|
+
# pathspec form returned non-zero — and `git add -A -- :!x` returns non-zero in ordinary
|
|
239
|
+
# situations (e.g. a repo with no HEAD). The exclusions existed at two of three sites and
|
|
240
|
+
# were bypassable at all three.
|
|
241
|
+
git_add_client_outputs() {
|
|
242
|
+
local _repo="$1"
|
|
243
|
+
local _timeout="${2:-${EPAM_COMMIT_TIMEOUT_SECS}}"
|
|
244
|
+
[ -n "$_repo" ] && [ -e "$_repo/.git" ] || return 0
|
|
245
|
+
|
|
246
|
+
local _excludes=() _resets=() _d
|
|
247
|
+
for _d in "${_ENGINE_OWNED_DIRS[@]}"; do
|
|
248
|
+
# ONE GLOB, NOT TWO PREFIX FORMS. ":!<dir>/*" is FATAL, not merely unmatched, when the
|
|
249
|
+
# directory is a symlink — git refuses a pathspec that crosses one:
|
|
250
|
+
#
|
|
251
|
+
# fatal: pathspec ':(exclude)node_modules/*' is beyond a symbolic link
|
|
252
|
+
#
|
|
253
|
+
# Live 2026-08-18, both lanes, at the LAST step of a run that had otherwise succeeded:
|
|
254
|
+
# the fix was correct, tests passed, the type check passed, and every commit failed with
|
|
255
|
+
# exit 128 because both codelines symlink node_modules to a shared install. Nothing could
|
|
256
|
+
# ever be committed in such a repo.
|
|
257
|
+
#
|
|
258
|
+
# The bare ":!<dir>" form survives the symlink but stops excluding NESTED copies, which
|
|
259
|
+
# would stage a client's vendored tree — fixing the error by weakening the exclusion. This
|
|
260
|
+
# glob is correct on all three counts: top-level contents, nested contents, and a
|
|
261
|
+
# symlinked directory.
|
|
262
|
+
_excludes+=( ":(exclude,glob)**/${_d}/**" ":(exclude,glob)**/${_d}" )
|
|
263
|
+
_resets+=( "$_d" )
|
|
264
|
+
done
|
|
265
|
+
# Build artefacts are not engine state, but staging them is never right either. This named
|
|
266
|
+
# node_modules, build and .next — one ecosystem — so a Rust codeline whose target/ was not
|
|
267
|
+
# gitignored had its whole build tree staged into the CUSTOMER'S repository. The list now
|
|
268
|
+
# comes from lib/ecosystem-registry.js (ecosystem artefacts) and config/repo-artifacts.json (editor
|
|
269
|
+
# and OS droppings), which is also where worktree-health-check.sh reads it — they were two
|
|
270
|
+
# hand-written lists that had drifted apart.
|
|
271
|
+
#
|
|
272
|
+
# NO SILENT FALLBACK. An empty exclusion list stages a client's build tree, so a handler that
|
|
273
|
+
# cannot answer stops the staging rather than widening it.
|
|
274
|
+
local _ex_out
|
|
275
|
+
if ! _ex_out=$("${NODE_BIN:-node}" "$(dirname "${BASH_SOURCE[0]}")/handlers/repo-exclude-patterns.js" pathspec); then
|
|
276
|
+
echo "[git-add] could not resolve the exclusion list — refusing to stage without it" >&2
|
|
277
|
+
return 1
|
|
278
|
+
fi
|
|
279
|
+
while IFS= read -r _ex; do
|
|
280
|
+
[ -n "$_ex" ] && _excludes+=( "$_ex" )
|
|
281
|
+
done <<< "$_ex_out"
|
|
282
|
+
|
|
283
|
+
# CAPTURE THE REASON. This was `2>/dev/null`, and live 2026-08-09 a run halted on
|
|
284
|
+
# "git add failed (exit 1)" with no diagnosis at all: the deliverable gate had passed, tsc
|
|
285
|
+
# had passed, the work was real, and two fixtures reproducing the plausible causes both
|
|
286
|
+
# returned 0 — the actual reason was unknown, because this line deleted it. git's message
|
|
287
|
+
# here is specific and useful ("index file smaller than expected", "The following paths are
|
|
288
|
+
# ignored by one of your .gitignore files") and is the difference between a fix and a theory.
|
|
289
|
+
# Held in a variable rather than passed straight through, so the success path stays silent:
|
|
290
|
+
# an ignored-path warning is normal and must not print on every story.
|
|
291
|
+
local _add_stderr
|
|
292
|
+
_add_stderr=$(git -C "$_repo" add -A -- "${_excludes[@]}" 2>&1)
|
|
293
|
+
local _rc=$?
|
|
294
|
+
|
|
295
|
+
# Belt and braces, run unconditionally: if the pathspec form above failed for any
|
|
296
|
+
# reason, anything engine-owned that slipped into the index comes straight back out.
|
|
297
|
+
# This is what makes the no-fallback rule safe rather than merely strict.
|
|
298
|
+
git -C "$_repo" reset -q -- "${_resets[@]}" 2>/dev/null || true
|
|
299
|
+
|
|
300
|
+
# ASK THE INDEX, NOT THE EXIT CODE.
|
|
301
|
+
#
|
|
302
|
+
# `git add` exits non-zero merely for NAMING an ignored path — which is exactly what the
|
|
303
|
+
# exclusion pathspecs above do. Live 2026-08-09: the writer installed a dependency, so a
|
|
304
|
+
# gitignored top-level node_modules appeared, git printed "The following paths are ignored
|
|
305
|
+
# by one of your .gitignore files" and exited 1 — with all 323 insertions correctly staged.
|
|
306
|
+
# The raw code propagated to commit_completed_story, the story was demoted as undelivered,
|
|
307
|
+
# the phase aborted and the remaining two codelines never ran.
|
|
308
|
+
#
|
|
309
|
+
# Whether work is staged is a fact, so read it. A non-zero exit with a populated index is a
|
|
310
|
+
# warning. A non-zero exit with an EMPTY index, when there was something to stage, is a real
|
|
311
|
+
# failure and still fails. The comment above already predicted this class ("returns non-zero
|
|
312
|
+
# in ordinary situations") without handling it.
|
|
313
|
+
# A TIMEOUT IS NEVER SUCCESS, whatever the index looks like. `timeout` reports 124, and a
|
|
314
|
+
# wedged git is a different condition from git objecting to an ignored pathspec: the repo
|
|
315
|
+
# may be locked or the filesystem hung, and a partially-populated index proves nothing about
|
|
316
|
+
# whether the rest would have staged. Checked BEFORE the index, or the index check silently
|
|
317
|
+
# converts a hang into a clean commit — which it did, until
|
|
318
|
+
# per-story-commit-and-partial-merge.test.ts caught it.
|
|
319
|
+
if [ "$_rc" -eq 124 ]; then
|
|
320
|
+
_git_add_report_failure "$_repo" "$_rc" "$_add_stderr" "timed out after ${_timeout}s"
|
|
321
|
+
return $_rc
|
|
322
|
+
fi
|
|
323
|
+
|
|
324
|
+
local _staged _pending
|
|
325
|
+
# wc -l, not `grep -c . || echo 0`: on empty input grep prints 0 AND exits 1, so the
|
|
326
|
+
# fallback appends a second 0 and the numeric comparison below errors out.
|
|
327
|
+
_staged=$(git -C "$_repo" diff --cached --name-only 2>/dev/null | wc -l | tr -d ' ')
|
|
328
|
+
if [ "${_staged:-0}" -gt 0 ]; then
|
|
329
|
+
return 0
|
|
330
|
+
fi
|
|
331
|
+
# Nothing staged. If nothing was stageable either, that is a correct no-op, not a failure.
|
|
332
|
+
# ASK THE SAME QUESTION THE STAGING ANSWERED.
|
|
333
|
+
#
|
|
334
|
+
# This was a bare `git status --porcelain | wc -l`, which counts EVERYTHING pending —
|
|
335
|
+
# including .epam/ and the other engine-owned paths that `git add` above deliberately
|
|
336
|
+
# excludes. So "nothing staged although something is pending" was a false conclusion
|
|
337
|
+
# whenever the only pending path was one we never intended to stage.
|
|
338
|
+
#
|
|
339
|
+
# Live 2026-08-09, gotransit, reproduced exactly by
|
|
340
|
+
# git-add-verdict-across-repo-states.test.ts: a gitignored top-level node_modules makes
|
|
341
|
+
# `git add` exit 1 merely for naming the ignored path; nothing stages because there is no
|
|
342
|
+
# client work; .epam/ is the only thing "pending" — and the story was demoted as
|
|
343
|
+
# undelivered, the phase aborted, and the codeline HALTed over a repo that had nothing to
|
|
344
|
+
# commit.
|
|
345
|
+
#
|
|
346
|
+
# engine_paths_filter is the same single definition the staging exclusions come from, and the
|
|
347
|
+
# artefact dirs now are too — this was a THIRD copy of the list, naming node_modules, build and
|
|
348
|
+
# .next. A pending target/ or .venv/ counted as work, so a Rust or Python repo with nothing to
|
|
349
|
+
# commit produced 'nothing reached the index although N path(s) are pending' and HALTed the
|
|
350
|
+
# codeline. Reusing $_ex_re, already resolved above; if it is empty the filter is skipped
|
|
351
|
+
# rather than degenerating into a regex that matches everything.
|
|
352
|
+
local _ex_re
|
|
353
|
+
_ex_re=$("${NODE_BIN:-node}" "$(dirname "${BASH_SOURCE[0]}")/handlers/repo-exclude-patterns.js" regex 2>/dev/null || echo "")
|
|
354
|
+
_pending=$(git -C "$_repo" status --porcelain 2>/dev/null \
|
|
355
|
+
| sed 's/^...//' \
|
|
356
|
+
| sed 's/^.* -> //' \
|
|
357
|
+
| engine_paths_filter \
|
|
358
|
+
| { if [ -n "$_ex_re" ]; then grep -vE "$_ex_re"; else cat; fi; } \
|
|
359
|
+
| wc -l | tr -d ' ')
|
|
360
|
+
if [ "$_rc" -ne 0 ] && [ "${_pending:-0}" -eq 0 ]; then
|
|
361
|
+
# git could not even report status: the repository is genuinely unusable.
|
|
362
|
+
if ! git -C "$_repo" status --porcelain >/dev/null 2>&1; then
|
|
363
|
+
_git_add_report_failure "$_repo" "$_rc" "$_add_stderr" "the repository is unusable — git cannot even report status"
|
|
364
|
+
return "$_rc"
|
|
365
|
+
fi
|
|
366
|
+
fi
|
|
367
|
+
[ "${_pending:-0}" -eq 0 ] && return 0
|
|
368
|
+
_git_add_report_failure "$_repo" "$_rc" "$_add_stderr" \
|
|
369
|
+
"nothing reached the index although ${_pending} path(s) are pending"
|
|
370
|
+
return $_rc
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
# story_branch_name <story_id> [suffix] — THE ONE RULE for a story's branch name, for every pipeline that works a story branch (B75, RS-10): the project's
|
|
374
|
+
# prefix (EPAM_BRANCH_PREFIX), "AI-<story_id>", and a release line's suffix (the argument, else EPAM_BRANCH_SUFFIX; an explicit empty suffix means none).
|
|
375
|
+
story_branch_name() { printf '%s' "${EPAM_BRANCH_PREFIX:-}AI-${1}${2-${EPAM_BRANCH_SUFFIX:-}}"; }
|
|
376
|
+
|
|
377
|
+
# _story_local_base_ref <root> <baseline> — the baseline as the checkout already has it, WITHOUT the network: the tag, then origin's branch, then the local
|
|
378
|
+
# branch. Empty when none resolves.
|
|
379
|
+
_story_local_base_ref() {
|
|
380
|
+
local _root="$1" _b="${2#refs/tags/}" _ref
|
|
381
|
+
_b="${_b#refs/heads/}"
|
|
382
|
+
[ -n "$_b" ] || return 0
|
|
383
|
+
for _ref in "refs/tags/${_b}" "refs/remotes/origin/${_b}" "refs/heads/${_b}"; do
|
|
384
|
+
git -C "$_root" rev-parse -q --verify "${_ref}^{commit}" >/dev/null 2>&1 && { printf '%s' "$_ref"; return 0; }
|
|
385
|
+
done
|
|
386
|
+
return 0
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
# _story_branch_carries_own_work <root> <branch> <story_id> [baseline] — true when <branch> exists and carries the story's own work. B75 (RS-1): the evidence
|
|
390
|
+
# is GIT — a commit of the story ("<story_id>: …", the convention every commit of the story follows) between the baseline and the branch — so it holds for
|
|
391
|
+
# any pipeline, wherever it keeps its records; a client's own history using the same ticket prefix does not count (only commits since the baseline). The bash
|
|
392
|
+
# pipeline's own record (LOG_DIR/story-changes.jsonl) is evidence too, as before. Never on a resume sent back to an earlier stage.
|
|
393
|
+
_story_branch_carries_own_work() {
|
|
394
|
+
local _root="$1" _br="$2" _id="$3" _base_name="${4:-}" _f="${LOG_DIR:-}/story-changes.jsonl" _sha _base _re
|
|
395
|
+
[ -n "$_id" ] || return 1
|
|
396
|
+
git -C "$_root" rev-parse -q --verify "refs/heads/${_br}" >/dev/null 2>&1 || return 1
|
|
397
|
+
if [ -n "${EPAM_RESUME_FROM_STAGE:-}" ] && declare -F _stage_rank >/dev/null 2>&1 \
|
|
398
|
+
&& [ "$(_stage_rank "$EPAM_RESUME_FROM_STAGE")" -lt "$(_stage_rank pre-writer)" ]; then
|
|
399
|
+
return 1
|
|
400
|
+
fi
|
|
401
|
+
_base=$(_story_local_base_ref "$_root" "$_base_name")
|
|
402
|
+
if [ -n "$_base" ]; then
|
|
403
|
+
_re=$(printf '%s' "$_id" | sed 's/[][\.*^$/]/\\&/g')
|
|
404
|
+
[ -n "$(git -C "$_root" log -1 --format=%h --grep="^${_re}:" "${_base}..refs/heads/${_br}" 2>/dev/null)" ] && return 0
|
|
405
|
+
fi
|
|
406
|
+
[ -f "$_f" ] || return 1
|
|
407
|
+
while IFS= read -r _sha; do
|
|
408
|
+
[ -n "$_sha" ] && git -C "$_root" merge-base --is-ancestor "$_sha" "refs/heads/${_br}" 2>/dev/null && return 0
|
|
409
|
+
done < <(jq -r --arg id "$_id" 'select(.storyId == $id) | .sha // empty' "$_f" 2>/dev/null)
|
|
410
|
+
return 1
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
# _preserve_uncommitted <root> <story_id> — B75 (RS-3): before ANY reset, whatever the working tree holds that no commit holds (tracked edits and untracked
|
|
414
|
+
# files, the pipeline's own .epam/ aside) is committed to a rescue ref, epam-rescue/<story>-wip-<sha>, without touching the branch, the index or the tree.
|
|
415
|
+
# Nothing to preserve: nothing is done. Fails (non-zero) when it cannot — the caller then refuses to reset, as it does when a commit cannot be pinned.
|
|
416
|
+
_preserve_uncommitted() {
|
|
417
|
+
local _root="$1" _id="$2" _idx _tree _head _commit _ref
|
|
418
|
+
[ -n "$(git -C "$_root" status --porcelain --untracked-files=all -- . ':(exclude).epam' 2>/dev/null)" ] || return 0
|
|
419
|
+
_head=$(git -C "$_root" rev-parse -q --verify HEAD 2>/dev/null) || _head=""
|
|
420
|
+
_idx=$(mktemp -u "${TMPDIR:-/tmp}/epam-wip-index-XXXXXX") || return 1
|
|
421
|
+
GIT_INDEX_FILE="$_idx" git -C "$_root" add -A -- . ':(exclude).epam' >/dev/null 2>&1 || { rm -f "$_idx"; return 1; }
|
|
422
|
+
_tree=$(GIT_INDEX_FILE="$_idx" git -C "$_root" write-tree 2>/dev/null) || { rm -f "$_idx"; return 1; }
|
|
423
|
+
rm -f "$_idx"
|
|
424
|
+
_commit=$(git -C "$_root" -c user.name="${GIT_COMMITTER_NAME:-epam-rescue}" -c user.email="${GIT_COMMITTER_EMAIL:-epam-rescue@local}" \
|
|
425
|
+
commit-tree "$_tree" ${_head:+-p "$_head"} -m "epam-rescue: uncommitted work of ${_id}" 2>/dev/null) || return 1
|
|
426
|
+
_ref="epam-rescue/${_id}-wip-$(git -C "$_root" rev-parse --short "$_commit" 2>/dev/null || echo unknown)"
|
|
427
|
+
git -C "$_root" branch -f "$_ref" "$_commit" --quiet 2>/dev/null || return 1
|
|
428
|
+
warning " [story-branch] ${_id}: uncommitted work preserved on branch '${_ref}'"
|
|
429
|
+
warning " [story-branch] recover with: git -C '${_root}' checkout ${_ref} -- <path>"
|
|
430
|
+
return 0 # the work IS preserved; a logger that cannot write (it returns its own status) must not turn that into a failure
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
ensure_story_branch() {
|
|
434
|
+
local codeline_root="$1"
|
|
435
|
+
local story_id="$2"
|
|
436
|
+
local baseline_branch="${3:-${JIRA_BASELINE_BRANCH:-main}}"
|
|
437
|
+
# A base named as a ref (refs/tags/<tag>, refs/heads/<branch>) is the same base: the name below is resolved as a tag,
|
|
438
|
+
# then as origin's branch (live 2026-10-07: refs/tags/v7.14.0 became origin/refs/tags/v7.14.0 and no branch was made).
|
|
439
|
+
baseline_branch="${baseline_branch#refs/tags/}"; baseline_branch="${baseline_branch#refs/heads/}"
|
|
440
|
+
|
|
441
|
+
[ "${EPAM_BROWNFIELD:-0}" = "1" ] || return 0
|
|
442
|
+
[ -e "$codeline_root/.git" ] || return 0
|
|
443
|
+
[ -n "$story_id" ] || return 0
|
|
444
|
+
|
|
445
|
+
# EPAM_BRANCH_PREFIX is project-configurable (set in the project's own
|
|
446
|
+
# config.env) — never hardcoded here. Some client repos enforce a branch
|
|
447
|
+
# naming convention (e.g. commitlint/husky requiring a leading
|
|
448
|
+
# feature|bugfix|tech|release|hotfix/ segment); most projects have no such
|
|
449
|
+
# constraint, so the default preserves today's plain "AI-<story_id>" shape.
|
|
450
|
+
# ONE STORY BRANCH PER RELEASE LINE (lib/release-lines.sh): a line other than the codeline's own carries a
|
|
451
|
+
# suffix, so the codeline's own story branch is never reset by another line's work.
|
|
452
|
+
local _branch; _branch=$(story_branch_name "$story_id")
|
|
453
|
+
|
|
454
|
+
# A STORY THAT ALREADY HAS ITS OWN WORK ON ITS BRANCH CONTINUES FROM IT (AMSD-2974 run 9, 2026-10-14). A self-heal retry
|
|
455
|
+
# sets the story pending again and comes back here; the hard reset below moved the 7.x line's branch to v7.11.0 twice
|
|
456
|
+
# in one run, discarding the approved full port (c25e819), and the writer re-wrote it from nothing — paid twice, weaker.
|
|
457
|
+
# This run's own record of the story's commits (story-changes.jsonl) is what says the work is its own: a fresh run has
|
|
458
|
+
# none, and a resume sent back to an earlier stage (EPAM_RESUME_FROM_STAGE) starts over as asked.
|
|
459
|
+
if _story_branch_carries_own_work "$codeline_root" "$_branch" "$story_id" "$baseline_branch"; then
|
|
460
|
+
if git -C "$codeline_root" checkout -q "$_branch" 2>/dev/null; then
|
|
461
|
+
_preserve_uncommitted "$codeline_root" "$story_id" || { error " [story-branch] $story_id: uncommitted work could not be preserved — refusing to reset it away"; return 1; }
|
|
462
|
+
git -C "$codeline_root" reset --hard -q "$_branch" 2>/dev/null || true
|
|
463
|
+
git -C "$codeline_root" clean -fd -e .epam -q 2>/dev/null || true
|
|
464
|
+
_provision_epam_plugin_config "$codeline_root"
|
|
465
|
+
perimeter_apply "$codeline_root"
|
|
466
|
+
success " [story-branch] $story_id: on branch '${_branch}', continuing on its own work at $(git -C "$codeline_root" rev-parse --short HEAD) (working tree reset + cleaned)"
|
|
467
|
+
return 0
|
|
468
|
+
fi
|
|
469
|
+
warning " [story-branch] $story_id: could not check out '${_branch}' to continue its own work — basing it afresh"
|
|
470
|
+
fi
|
|
471
|
+
|
|
472
|
+
# THE START POINT IS WHAT VARIES, NOT THE PROCEDURE.
|
|
473
|
+
#
|
|
474
|
+
# This used to begin `git fetch origin` and return on failure, so a repository with no remote
|
|
475
|
+
# never reached checkout -B: the writer worked directly on the baseline branch and one warning
|
|
476
|
+
# line said so. That became a hard failure once the write perimeter went generic, because
|
|
477
|
+
# perimeter_apply — the call that REOPENS the repository — sits past that early return. A
|
|
478
|
+
# sealed repo with no remote could never be unsealed, and the writer was locked out of the one
|
|
479
|
+
# repository it was asked to change.
|
|
480
|
+
#
|
|
481
|
+
# With a remote, everything below is unchanged: fetch, then base off origin/<baseline>.
|
|
482
|
+
# Without one, the LOCAL baseline branch is the start point. With neither, this still fails.
|
|
483
|
+
local _base_ref=""
|
|
484
|
+
# A RELEASE LINE'S BASE IS A RELEASE: a tag, fetched from origin when the checkout lacks it. Tags are not
|
|
485
|
+
# branches, so there is no origin/<tag> to reset to — the tag itself is the start point.
|
|
486
|
+
if ! git -C "$codeline_root" rev-parse -q --verify "refs/tags/${baseline_branch}^{commit}" >/dev/null 2>&1 \
|
|
487
|
+
&& git -C "$codeline_root" remote get-url origin >/dev/null 2>&1; then
|
|
488
|
+
git -C "$codeline_root" fetch origin "refs/tags/${baseline_branch}:refs/tags/${baseline_branch}" --quiet 2>/dev/null || true
|
|
489
|
+
fi
|
|
490
|
+
if git -C "$codeline_root" rev-parse -q --verify "refs/tags/${baseline_branch}^{commit}" >/dev/null 2>&1; then
|
|
491
|
+
_base_ref="refs/tags/${baseline_branch}"
|
|
492
|
+
elif git -C "$codeline_root" remote get-url origin >/dev/null 2>&1; then
|
|
493
|
+
if git -C "$codeline_root" fetch origin "$baseline_branch" --quiet 2>/dev/null; then
|
|
494
|
+
_base_ref="origin/${baseline_branch}"
|
|
495
|
+
else
|
|
496
|
+
# THE CODELINE'S OWN BASELINE. One project declares one baseline for several codelines, and a codeline need not
|
|
497
|
+
# have it (cx-shared's default is master, it has no develop). Left on its current branch, a fresh run's
|
|
498
|
+
# detective read earlier runs' commits as existing code (AMSD-2974, 2026-10-09). codeline_baseline_ref
|
|
499
|
+
# answers from the codeline's own refs: its default branch, origin/HEAD.
|
|
500
|
+
local _own; _own=$(codeline_baseline_ref "$codeline_root" "$baseline_branch")
|
|
501
|
+
if [ -n "$_own" ] && [ "$_own" != "$baseline_branch" ] \
|
|
502
|
+
&& git -C "$codeline_root" fetch origin "${_own#origin/}" --quiet 2>/dev/null; then
|
|
503
|
+
_base_ref="$_own"
|
|
504
|
+
info " [story-branch] $story_id: this codeline has no ${baseline_branch} — basing on its own default, ${_own}"
|
|
505
|
+
else
|
|
506
|
+
warning " [story-branch] $story_id: could not fetch origin/${baseline_branch} — proceeding on current branch state"
|
|
507
|
+
return 1
|
|
508
|
+
fi
|
|
509
|
+
fi
|
|
510
|
+
elif git -C "$codeline_root" rev-parse --verify --quiet "refs/heads/${baseline_branch}" >/dev/null 2>&1; then
|
|
511
|
+
_base_ref="$baseline_branch"
|
|
512
|
+
info " [story-branch] $story_id: no 'origin' remote — basing off the local '${baseline_branch}'"
|
|
513
|
+
else
|
|
514
|
+
warning " [story-branch] $story_id: no 'origin' remote and no local '${baseline_branch}' — proceeding on current branch state"
|
|
515
|
+
return 1
|
|
516
|
+
fi
|
|
517
|
+
|
|
518
|
+
# NOTHING IS DISCARDED SILENTLY OR UNRECOVERABLY.
|
|
519
|
+
#
|
|
520
|
+
# The hard reset below is wanted and stays. What is not acceptable is what it did
|
|
521
|
+
# on 2026-08-14: a story that had COMPLETED — `npm run test` green, `tsc` green,
|
|
522
|
+
# 4 files committed — was rejected by the reviewer over one inconsistent
|
|
523
|
+
# dependency declaration, and the retry moved this branch pointer back to
|
|
524
|
+
# origin/develop. The commit stopped being reachable from any ref. It survived
|
|
525
|
+
# only in the reflog, where nothing in this pipeline looks and where gc removes
|
|
526
|
+
# it. The same shape orphaned three FINISHED gotransit commits the same day
|
|
527
|
+
# (e780a8b7 / 45c82f2a / 20c2cea4), recovered by hand.
|
|
528
|
+
#
|
|
529
|
+
# The log line "freshly based on origin/<baseline>" reads as hygiene. It was
|
|
530
|
+
# deletion. So: before the pointer moves, anything that would stop being
|
|
531
|
+
# reachable is pinned under a real ref and named — recovery must never depend on
|
|
532
|
+
# the reflog. This is a local ref only; it pushes nothing.
|
|
533
|
+
# THE BRANCH BEING RESET, NOT ONLY HEAD: a release line's branch is not what the codeline has checked out, and its
|
|
534
|
+
# approved port (c25e819, 2026-10-14) was moved off every ref with no rescue because only HEAD was measured.
|
|
535
|
+
local _tip _tips=(HEAD)
|
|
536
|
+
git -C "$codeline_root" rev-parse -q --verify "refs/heads/${_branch}" >/dev/null 2>&1 && _tips+=("refs/heads/${_branch}")
|
|
537
|
+
for _tip in "${_tips[@]}"; do
|
|
538
|
+
local _unreachable=0
|
|
539
|
+
_unreachable=$(git -C "$codeline_root" rev-list --count "${_base_ref}..${_tip}" 2>/dev/null || echo 0)
|
|
540
|
+
case "$_unreachable" in (''|*[!0-9]*) _unreachable=0 ;; esac
|
|
541
|
+
[ "$_unreachable" -gt 0 ] || continue
|
|
542
|
+
# Still reachable from another ref (HEAD's own branch, which checkout -B does not move): nothing is lost.
|
|
543
|
+
[ "$_tip" = HEAD ] && [ -n "$(git -C "$codeline_root" symbolic-ref -q HEAD 2>/dev/null)" ] \
|
|
544
|
+
&& [ "$(git -C "$codeline_root" symbolic-ref -q --short HEAD)" != "$_branch" ] && continue
|
|
545
|
+
local _rescue_ref _head_short
|
|
546
|
+
_head_short=$(git -C "$codeline_root" rev-parse --short "$_tip" 2>/dev/null || echo unknown)
|
|
547
|
+
_rescue_ref="epam-rescue/${story_id}-${_head_short}"
|
|
548
|
+
if git -C "$codeline_root" branch -f "$_rescue_ref" "$_tip" --quiet 2>/dev/null; then
|
|
549
|
+
warning " [story-branch] $story_id: ${_unreachable} commit(s) would have been discarded by this reset — preserved on branch '${_rescue_ref}'"
|
|
550
|
+
warning " [story-branch] recover with: git -C '${codeline_root}' log ${_rescue_ref}"
|
|
551
|
+
else
|
|
552
|
+
error " [story-branch] $story_id: ${_unreachable} commit(s) are about to become unreachable and the rescue ref could not be created — refusing to reset"
|
|
553
|
+
return 1
|
|
554
|
+
fi
|
|
555
|
+
done
|
|
556
|
+
|
|
557
|
+
_preserve_uncommitted "$codeline_root" "$story_id" || { error " [story-branch] $story_id: uncommitted work could not be preserved — refusing to reset it away"; return 1; }
|
|
558
|
+
|
|
559
|
+
if git -C "$codeline_root" checkout -B "$_branch" "$_base_ref" --quiet 2>/dev/null; then
|
|
560
|
+
# checkout -B only forces tracked files that DIFFER between the old
|
|
561
|
+
# branch tip and the new start-point — a tracked file whose content
|
|
562
|
+
# is IDENTICAL in both survives untouched, dirty modifications and
|
|
563
|
+
# all. Found live 2026-08-02 (metrolinx, AMSD-2041 Writer Retest): a
|
|
564
|
+
# SEPARATE, already-killed run's leftover uncommitted contentstack.ts
|
|
565
|
+
# changes plus an untracked spec file survived checkout -B intact, so
|
|
566
|
+
# the NEXT run's agent burned its entire watchdog budget (600s+900s)
|
|
567
|
+
# reconciling old debris against an "approved" commit instead of
|
|
568
|
+
# doing fresh work — a real, systemic issue (the same class of
|
|
569
|
+
# leftover-worktree-state bug already documented for gotransit in
|
|
570
|
+
# agent-audit-2026-07-31.md). `checkout -B` promises "freshly based
|
|
571
|
+
# on origin" for the BRANCH; it never promised a clean WORKING TREE.
|
|
572
|
+
# reset --hard forces every tracked file back regardless of diff;
|
|
573
|
+
# clean -fd drops untracked cruft — the exact "predictable teardown"
|
|
574
|
+
# primitive brownfield-preflight-reset.sh already applies between
|
|
575
|
+
# RUNS and _selective_worktree_reset applies between RUNGS, now also
|
|
576
|
+
# applied here, once, before a story's FIRST attempt even begins.
|
|
577
|
+
git -C "$codeline_root" reset --hard "$_base_ref" --quiet 2>/dev/null || true
|
|
578
|
+
# -e .epam: THIS run's provisioned declarations for the codeline, written at lane setup just
|
|
579
|
+
# before this (the pre-flight reset already cleared any earlier run's). Cleaned here, the
|
|
580
|
+
# codeline lost dependency-check.json (vendorDirs, provisionCommand) — _provision_epam_plugin_config
|
|
581
|
+
# restores only settings, facts and the verification manifest — and every worktree
|
|
582
|
+
# verification then ran the declared install against the registry (multi-lane £0, 2026-10-02).
|
|
583
|
+
git -C "$codeline_root" clean -fd -e .epam --quiet 2>/dev/null || true
|
|
584
|
+
_provision_epam_plugin_config "$codeline_root"
|
|
585
|
+
# The repo is now on a story branch, which is where edits are allowed to
|
|
586
|
+
# land — reopen it. While it sat on the baseline branch it was chmod'd
|
|
587
|
+
# read-only (see lib/codeline-write-perimeter.sh): a spec-pass agent
|
|
588
|
+
# rewrote ~1050 lines of client source there before any writer ran, and
|
|
589
|
+
# a per-tool allowlist cannot stop that because `bash` bypasses it.
|
|
590
|
+
perimeter_apply "$codeline_root"
|
|
591
|
+
success " [story-branch] $story_id: on branch '${_branch}', freshly based on ${_base_ref} (working tree hard-reset + cleaned)"
|
|
592
|
+
return 0
|
|
593
|
+
fi
|
|
594
|
+
|
|
595
|
+
warning " [story-branch] $story_id: could not create/reset branch '${_branch}' off ${_base_ref} — proceeding on current branch"
|
|
596
|
+
return 1
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
# record_story_changes <story_id> <commit_root>
|
|
600
|
+
# Appends one JSON line describing the commit just made to $LOG_DIR/story-changes.jsonl:
|
|
601
|
+
# the sha, subject, changed files, insertion/deletion counts and the diff itself.
|
|
602
|
+
#
|
|
603
|
+
# Never fails its caller. A missing record degrades the report; a raised error here would fail a
|
|
604
|
+
# story that has already succeeded, which is strictly worse.
|
|
605
|
+
record_story_changes() {
|
|
606
|
+
local story_id="$1" root="$2"
|
|
607
|
+
local _t="${EPAM_COMMIT_TIMEOUT_SECS}"
|
|
608
|
+
|
|
609
|
+
if [ -z "${LOG_DIR:-}" ]; then
|
|
610
|
+
# Loud, not silent: without this the report has only the live codeline to go on.
|
|
611
|
+
warning " [record_story_changes] LOG_DIR unset — ${story_id}'s changes were not recorded for the run report"
|
|
612
|
+
return 1
|
|
613
|
+
fi
|
|
614
|
+
mkdir -p "$LOG_DIR" 2>/dev/null || return 1
|
|
615
|
+
|
|
616
|
+
local _sha _subject _when
|
|
617
|
+
_sha=$(git -C "$root" rev-parse HEAD 2>/dev/null) || return 1
|
|
618
|
+
[ -n "$_sha" ] || return 1
|
|
619
|
+
_subject=$(git -C "$root" log -1 --format=%s "$_sha" 2>/dev/null)
|
|
620
|
+
_when=$(git -C "$root" log -1 --format=%aI "$_sha" 2>/dev/null)
|
|
621
|
+
|
|
622
|
+
local _names _stat _diff
|
|
623
|
+
_names=$(git -C "$root" diff --name-only "${_sha}^" "$_sha" 2>/dev/null) \
|
|
624
|
+
|| _names=$(git -C "$root" show --pretty=format: --name-only "$_sha" 2>/dev/null)
|
|
625
|
+
_stat=$(git -C "$root" diff --stat "${_sha}^" "$_sha" 2>/dev/null) \
|
|
626
|
+
|| _stat=$(git -C "$root" show --pretty=format: --stat "$_sha" 2>/dev/null)
|
|
627
|
+
_diff=$(git -C "$root" diff "${_sha}^" "$_sha" 2>/dev/null) \
|
|
628
|
+
|| _diff=$(git -C "$root" show --pretty=format: "$_sha" 2>/dev/null)
|
|
629
|
+
|
|
630
|
+
# BOUNDED. A pathological story must not write an unbounded diff into the evidence. Over the
|
|
631
|
+
# cap the diff is dropped WHOLE and said to be dropped — never truncated mid-hunk into
|
|
632
|
+
# something that reads like a complete diff but is not one.
|
|
633
|
+
local _cap="${EPAM_STORY_DIFF_MAX_BYTES:-2000000}"
|
|
634
|
+
local _omitted=false
|
|
635
|
+
if [ "${#_diff}" -gt "$_cap" ]; then
|
|
636
|
+
_diff=""
|
|
637
|
+
_omitted=true
|
|
638
|
+
fi
|
|
639
|
+
|
|
640
|
+
STORY_ID="$story_id" SHA="$_sha" SUBJECT="$_subject" WHEN="$_when" \
|
|
641
|
+
NAMES="$_names" STAT="$_stat" DIFF="$_diff" OMITTED="$_omitted" \
|
|
642
|
+
"${NODE_BIN:-node}" -e '
|
|
643
|
+
const g = (k) => process.env[k] || "";
|
|
644
|
+
const files = g("NAMES").split("\n").map(s => s.trim()).filter(Boolean);
|
|
645
|
+
const diff = g("DIFF");
|
|
646
|
+
const count = (p, q) => diff.split("\n")
|
|
647
|
+
.filter(l => l.startsWith(p) && !l.startsWith(q)).length;
|
|
648
|
+
process.stdout.write(JSON.stringify({
|
|
649
|
+
storyId: g("STORY_ID"), sha: g("SHA"), subject: g("SUBJECT"),
|
|
650
|
+
committedAt: g("WHEN"), files,
|
|
651
|
+
insertions: count("+", "+++"), deletions: count("-", "---"),
|
|
652
|
+
diffstat: g("STAT"), diff, diffOmitted: g("OMITTED") === "true",
|
|
653
|
+
}) + "\n");
|
|
654
|
+
' >> "$LOG_DIR/story-changes.jsonl" 2>/dev/null || {
|
|
655
|
+
warning " [record_story_changes] could not record ${story_id}'s changes for the run report"
|
|
656
|
+
return 1
|
|
657
|
+
}
|
|
658
|
+
return 0
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
# The flags of every pipeline commit — lib/pipeline-commit.sh says why (the codeline's hooks are skipped).
|
|
662
|
+
# shellcheck source=lib/pipeline-commit.sh
|
|
663
|
+
. "$(dirname "${BASH_SOURCE[0]}")/pipeline-commit.sh"
|
|
664
|
+
|
|
665
|
+
# worktree_uncommitted_work <worktree> — prints the tracked files a worktree changed but never committed;
|
|
666
|
+
# empty when there are none (or no worktree). Step 17 used to call a lane with no commits and completed
|
|
667
|
+
# stories "nothing to merge" — with the writer's changes still sitting uncommitted in the worktree (its
|
|
668
|
+
# commit refused by the codeline's hooks): the work was silently dropped (real-estate rehearsal,
|
|
669
|
+
# 2026-10-05). Untracked files are not counted: the pipeline provisions its own (.epam/) there.
|
|
670
|
+
worktree_uncommitted_work() {
|
|
671
|
+
local _w="${1:-}"
|
|
672
|
+
[ -n "$_w" ] && [ -e "$_w/.git" ] || return 0
|
|
673
|
+
git -C "$_w" status --porcelain --untracked-files=no 2>/dev/null | sed 's/^...//'
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
# commit_completed_story <story_id>
|
|
677
|
+
# Stages and commits whatever a completed story wrote, scoped to the current
|
|
678
|
+
# GIT_WORK_ROOT (the worktree checkout when running --worktree, the main repo
|
|
679
|
+
# otherwise, falling back to PROJECT_ROOT for callers that only set that).
|
|
680
|
+
# Best-effort: a commit failure here must not fail the story itself, since
|
|
681
|
+
# the retry/health-check machinery downstream still has its own commit gates.
|
|
682
|
+
commit_completed_story() {
|
|
683
|
+
local story_id="$1"
|
|
684
|
+
local _commit_root="${GIT_WORK_ROOT:-$PROJECT_ROOT}"
|
|
685
|
+
# Bounded timeout on git operations (added 2026-07-06): a live run's story-
|
|
686
|
+
# level 600s watchdog killed the whole claude.sh subprocess with zero log
|
|
687
|
+
# output after a story succeeded — generate_story_contract()/
|
|
688
|
+
# commit_completed_story() were the only unlogged steps left, and neither
|
|
689
|
+
# had any bound on how long its git/python calls could take (e.g. a stale
|
|
690
|
+
# lock, a slow filesystem). 60s is generous for `git add`/`git commit` on
|
|
691
|
+
# this project's size; a hang here now fails fast and visibly instead of
|
|
692
|
+
# silently consuming the entire story-level watchdog budget.
|
|
693
|
+
local _git_timeout="${EPAM_COMMIT_TIMEOUT_SECS}"
|
|
694
|
+
|
|
695
|
+
# set +e/-e around this block (found live, 2026-07-14, tier3-travel-app
|
|
696
|
+
# run — first time a worktree lane ran real multi-story work): under
|
|
697
|
+
# set -e (active for this whole script), `CMD1 || CMD2` as a bare
|
|
698
|
+
# statement DOES still abort the script if CMD2 (the last command in the
|
|
699
|
+
# || list) also fails — the fallback `git add -A` failing for ANY reason
|
|
700
|
+
# (not just the 124-timeout case this code checks for) silently killed
|
|
701
|
+
# the entire claude.sh process here, before `_add_rc=$?` was ever
|
|
702
|
+
# reached, with zero warning logged and every remaining story in this
|
|
703
|
+
# worktree lane (SKY-003-impl/-test, SKY-004 in the observed incident)
|
|
704
|
+
# never even attempted.
|
|
705
|
+
# ONE staging rule, one implementation. This block used to be duplicated in
|
|
706
|
+
# worktree-health-check.sh (which excluded only orchestrations/logs/*, so
|
|
707
|
+
# orchestrations/agents/KB.md passed straight through) and absent entirely from
|
|
708
|
+
# run-agent-orchestration.sh's Step 9 (a bare `git add -A`). Three copies is how a
|
|
709
|
+
# rule drifts; see git_add_client_outputs for the full history.
|
|
710
|
+
set +e
|
|
711
|
+
git_add_client_outputs "$_commit_root" "$_git_timeout"
|
|
712
|
+
local _add_rc=$?
|
|
713
|
+
set -e
|
|
714
|
+
if [ "$_add_rc" -ne 0 ]; then
|
|
715
|
+
if [ "$_add_rc" -eq 124 ]; then
|
|
716
|
+
warning " [commit_completed_story] git add timed out after ${_git_timeout}s for ${story_id} — work remains staged/uncommitted"
|
|
717
|
+
else
|
|
718
|
+
warning " [commit_completed_story] git add failed (exit ${_add_rc}) for ${story_id} — work remains staged/uncommitted"
|
|
719
|
+
fi
|
|
720
|
+
return 1
|
|
721
|
+
fi
|
|
722
|
+
|
|
723
|
+
local _changed_count
|
|
724
|
+
_changed_count=$(git -C "$_commit_root" diff --cached --name-only 2>/dev/null | wc -l | tr -d ' ')
|
|
725
|
+
if [ "${_changed_count:-0}" -eq 0 ]; then
|
|
726
|
+
return 0
|
|
727
|
+
fi
|
|
728
|
+
|
|
729
|
+
# Generic credential scan (flow-gap analysis finding #2, 2026-07-12): no
|
|
730
|
+
# commit site in this pipeline scanned staged changes for accidentally-
|
|
731
|
+
# committed secrets before this. SAST (Step 4.2) is the first thing that
|
|
732
|
+
# even looks at code content for this, and it runs long after this commit
|
|
733
|
+
# would already be in git history. scan-secrets.sh is generic and stack-
|
|
734
|
+
# agnostic (well-known credential formats only) — see its own header.
|
|
735
|
+
local _scan_sh="${SCRIPT_DIR}/scan-secrets.sh"
|
|
736
|
+
if [ "${SKIP_SECRET_SCAN:-true}" != "true" ] && [ -f "$_scan_sh" ]; then
|
|
737
|
+
local _scan_output _scan_rc
|
|
738
|
+
# set +e/-e (found live, 2026-07-14, same incident as the git-add fix
|
|
739
|
+
# above): `var=$(failing_cmd)` as a bare assignment statement is
|
|
740
|
+
# ALSO a set -e trigger — scan-secrets.sh exiting non-zero (its
|
|
741
|
+
# intentional, designed signal for "found a secret") killed the
|
|
742
|
+
# whole script on THIS line, one statement before `_scan_rc=$?` and
|
|
743
|
+
# the warning/return-1 handling below ever ran, so a real secret hit
|
|
744
|
+
# (or, per this incident, any other non-zero exit from the scan)
|
|
745
|
+
# silently took down every remaining story in the lane instead of
|
|
746
|
+
# gracefully unstaging and skipping just this one commit.
|
|
747
|
+
set +e
|
|
748
|
+
_scan_output=$(bash "$_scan_sh" "$_commit_root" 2>&1)
|
|
749
|
+
_scan_rc=$?
|
|
750
|
+
set -e
|
|
751
|
+
if [ "$_scan_rc" -ne 0 ]; then
|
|
752
|
+
warning " [commit_completed_story] $_scan_output"
|
|
753
|
+
warning " [commit_completed_story] Refusing to commit for ${story_id} — unstaging (SECRET_SCAN)"
|
|
754
|
+
git -C "$_commit_root" reset 2>/dev/null || true
|
|
755
|
+
return 1
|
|
756
|
+
fi
|
|
757
|
+
fi
|
|
758
|
+
|
|
759
|
+
# Real stderr (e.g. a pre-commit hook's own output — husky/lint-staged
|
|
760
|
+
# can run type-check/eslint/prettier and legitimately fail or just be
|
|
761
|
+
# slow under concurrent-lane load) was previously discarded entirely
|
|
762
|
+
# (>/dev/null 2>&1), so a commit failure here was never diagnosable
|
|
763
|
+
# after the fact — found live 2026-08-01, AMSD-2041: a real, verified-
|
|
764
|
+
# correct, tsc-passing fix sat staged/uncommitted with no way to tell
|
|
765
|
+
# whether a hook rejected it, it timed out, or something else failed.
|
|
766
|
+
# set +e/-e (same reason as the git-add block above): this whole function
|
|
767
|
+
# runs under set -e, so a bare `_commit_output=$(failing_cmd)` assignment
|
|
768
|
+
# aborts the script immediately on a real commit failure — found while
|
|
769
|
+
# testing this exact fix, 2026-08-01 — silently reintroducing the "commit
|
|
770
|
+
# failure gives zero diagnosis" defect this stderr capture exists to fix.
|
|
771
|
+
# Message leads with story_id, colon-separated — found live 2026-08-02:
|
|
772
|
+
# a client repo's own commitlint (commitlint-plugin-jira-rules) rejected
|
|
773
|
+
# "story: complete AMSD-2041 (3 file(s))" because the ticket ID must be
|
|
774
|
+
# the FIRST token, not buried mid-message. This isn't Jira-specific
|
|
775
|
+
# knowledge baked in here — ticket-ID-first is the standard shape most
|
|
776
|
+
# commit-message linters expect, so it's a strictly more correct default
|
|
777
|
+
# template, not a per-project config lookup. "story complete" is kept as
|
|
778
|
+
# a stable marker — reset-to-baseline.sh's `git log --grep` and
|
|
779
|
+
# run-agent-orchestration.sh's git-history story-id fallback both key off
|
|
780
|
+
# it; both were updated in the SAME change (see their own comments).
|
|
781
|
+
set +e
|
|
782
|
+
local _commit_output
|
|
783
|
+
local _hook_flags=(); mapfile -t _hook_flags < <(pipeline_commit_flags)
|
|
784
|
+
_commit_output=$(git -C "$_commit_root" commit ${_hook_flags[@]+"${_hook_flags[@]}"} -m "${story_id}: story complete (${_changed_count} file(s))" 2>&1)
|
|
785
|
+
local _commit_rc=$?
|
|
786
|
+
set -e
|
|
787
|
+
if [ "$_commit_rc" -eq 0 ]; then
|
|
788
|
+
log " Committed ${_changed_count} file(s) for ${story_id}"
|
|
789
|
+
# RECORD WHAT THIS STORY WROTE, HERE, NOW.
|
|
790
|
+
#
|
|
791
|
+
# The run report used to reconstruct the diff at report time — `git log <baseline>..HEAD`
|
|
792
|
+
# against the live codeline, baseline read from the shared, every-phase-overwritten
|
|
793
|
+
# phase-baseline-sha.txt. The codeline is reset to origin/<base> at the start of each
|
|
794
|
+
# cycle, so by report time that pair no longer brackets the story: the range comes back
|
|
795
|
+
# empty and code.html renders "0 files changed / 0 lines / not recorded" for a run that
|
|
796
|
+
# demonstrably committed a fix. Live: Successful-Run-Sept-07-1, AMSD-1919.
|
|
797
|
+
#
|
|
798
|
+
# This commit is the only moment the story's changes are unambiguously identifiable, so
|
|
799
|
+
# they are captured now, into LOG_DIR (which archive-run-artifacts.sh carries into the
|
|
800
|
+
# run's evidence directory). A later reset cannot invalidate a fact already on disk.
|
|
801
|
+
record_story_changes "$story_id" "$_commit_root" || true
|
|
802
|
+
# Refresh the CodeGraph index now that this story's writes are final
|
|
803
|
+
# on disk. Until 2026-08-06 the index was built ONCE per run, before
|
|
804
|
+
# any writer ran, and never rebuilt — so the reviewer's codegraph_query
|
|
805
|
+
# tool (team-lead-review.sh hands it out explicitly to check "does a
|
|
806
|
+
# helper already exist?") was reading a pre-writer snapshot and could
|
|
807
|
+
# not see the writer's own output. See codegraph-reindex.sh's docstring
|
|
808
|
+
# for the full diagnosis. Never blocks: the script always exits 0.
|
|
809
|
+
local _cg_reindex="${SCRIPT_DIR:-}/codegraph-reindex.sh"
|
|
810
|
+
if [ -n "${SCRIPT_DIR:-}" ] && [ -f "$_cg_reindex" ]; then
|
|
811
|
+
bash "$_cg_reindex" "$_commit_root" "post-commit ${story_id}" || true
|
|
812
|
+
fi
|
|
813
|
+
elif [ "$_commit_rc" -eq 124 ]; then
|
|
814
|
+
warning " [commit_completed_story] git commit timed out after ${_git_timeout}s for ${story_id} — work remains staged/uncommitted"
|
|
815
|
+
else
|
|
816
|
+
warning " Commit failed for ${story_id} (exit ${_commit_rc}) — work remains staged/uncommitted. Output:"
|
|
817
|
+
warning " $_commit_output"
|
|
818
|
+
fi
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
# Setup git worktrees for parallel execution.
|
|
822
|
+
# Runs git commands from GIT_WORK_ROOT (falling back to PROJECT_ROOT for
|
|
823
|
+
# callers, like codemie-claude.sh, that only set that) — the directory
|
|
824
|
+
# containing .git. Worktrees are created as siblings of that root.
|
|
825
|
+
# codeline_baseline_ref <codeline_root> <declared_branch>
|
|
826
|
+
#
|
|
827
|
+
# THE REF A CODELINE'S WORK IS CUT FROM, from LOCAL refs only — no remote is contacted. The declared
|
|
828
|
+
# baseline when this codeline has it (origin/<b>, else <b>); otherwise this codeline's own default
|
|
829
|
+
# branch (refs/remotes/origin/HEAD). One project declares one baseline for several codelines, and a
|
|
830
|
+
# codeline need not have it (AMSD-2974, 2026-10-02: cx-shared's default is master, it has no
|
|
831
|
+
# develop). Prints nothing when nothing is found — never a guess.
|
|
832
|
+
codeline_baseline_ref() {
|
|
833
|
+
local _root="${1:-}" _declared="${2:-}"
|
|
834
|
+
[ -n "$_root" ] && [ -e "$_root/.git" ] || return 0
|
|
835
|
+
if [ -n "$_declared" ]; then
|
|
836
|
+
if git -C "$_root" rev-parse -q --verify "refs/remotes/origin/${_declared}" >/dev/null 2>&1; then
|
|
837
|
+
printf 'origin/%s\n' "$_declared"; return 0
|
|
838
|
+
fi
|
|
839
|
+
if git -C "$_root" rev-parse -q --verify "refs/heads/${_declared}" >/dev/null 2>&1; then
|
|
840
|
+
printf '%s\n' "$_declared"; return 0
|
|
841
|
+
fi
|
|
842
|
+
# A release line's base is a release tag (lib/release-lines.sh).
|
|
843
|
+
if git -C "$_root" rev-parse -q --verify "refs/tags/${_declared}^{commit}" >/dev/null 2>&1; then
|
|
844
|
+
printf '%s\n' "$_declared"; return 0
|
|
845
|
+
fi
|
|
846
|
+
fi
|
|
847
|
+
local _head
|
|
848
|
+
_head=$(git -C "$_root" symbolic-ref -q --short refs/remotes/origin/HEAD 2>/dev/null || true)
|
|
849
|
+
[ -n "$_head" ] && printf '%s\n' "$_head"
|
|
850
|
+
return 0
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
setup_worktrees() {
|
|
854
|
+
local worktrees=("primary" "independent")
|
|
855
|
+
local git_root
|
|
856
|
+
git_root="$(cd "${GIT_WORK_ROOT:-$PROJECT_ROOT}" && pwd)"
|
|
857
|
+
local git_basename
|
|
858
|
+
git_basename="$(basename "$git_root")"
|
|
859
|
+
|
|
860
|
+
log "Setting up git worktrees (git root: $git_root)..."
|
|
861
|
+
|
|
862
|
+
# Validate the resolved root is a git repo
|
|
863
|
+
if ! git -C "$git_root" rev-parse --is-inside-work-tree &>/dev/null; then
|
|
864
|
+
error "Git root ($git_root) is not a git repository"
|
|
865
|
+
return 1
|
|
866
|
+
fi
|
|
867
|
+
|
|
868
|
+
for wt in "${worktrees[@]}"; do
|
|
869
|
+
local wt_path="$git_root/../${git_basename}-wt-$wt"
|
|
870
|
+
local wt_branch="wt-$wt"
|
|
871
|
+
|
|
872
|
+
# A directory existing at $wt_path is NOT sufficient proof of a usable
|
|
873
|
+
# worktree — a prior crash (or a raw `rm -rf` instead of `git worktree
|
|
874
|
+
# remove`) can leave a stale, non-git-tracked directory here, or a
|
|
875
|
+
# directory whose worktree registration was lost. Silently "continuing"
|
|
876
|
+
# on directory-existence alone left the CALLER believing a real
|
|
877
|
+
# worktree was set up when it wasn't — verify it's actually a
|
|
878
|
+
# registered, valid worktree of THIS repo before skipping creation.
|
|
879
|
+
if [ -d "$wt_path" ]; then
|
|
880
|
+
# `git worktree list --porcelain` reports canonicalized, resolved
|
|
881
|
+
# paths — $wt_path contains a literal `..` component, so a raw
|
|
882
|
+
# string comparison against the porcelain output NEVER matches
|
|
883
|
+
# even for a genuinely valid worktree. Resolve $wt_path the same
|
|
884
|
+
# way before comparing.
|
|
885
|
+
local wt_path_resolved
|
|
886
|
+
wt_path_resolved="$(cd "$wt_path" 2>/dev/null && pwd)"
|
|
887
|
+
if [ -n "$wt_path_resolved" ] && git -C "$git_root" worktree list --porcelain 2>/dev/null | grep -q "^worktree ${wt_path_resolved}$"; then
|
|
888
|
+
warning "Worktree already exists and is valid: $wt_path"
|
|
889
|
+
continue
|
|
890
|
+
fi
|
|
891
|
+
warning "Stale non-worktree directory found at $wt_path (not registered with git) — removing before recreating"
|
|
892
|
+
rm -rf "$wt_path"
|
|
893
|
+
fi
|
|
894
|
+
|
|
895
|
+
# Delete branch if it exists from previous run
|
|
896
|
+
if git -C "$git_root" show-ref --verify --quiet "refs/heads/$wt_branch"; then
|
|
897
|
+
info "Deleting existing branch: $wt_branch"
|
|
898
|
+
git -C "$git_root" branch -D "$wt_branch" 2>/dev/null || true
|
|
899
|
+
fi
|
|
900
|
+
|
|
901
|
+
# Create worktree with a new branch based on current HEAD
|
|
902
|
+
info "Creating worktree: $wt ($wt_path) on branch $wt_branch"
|
|
903
|
+
git -C "$git_root" worktree add -b "$wt_branch" "$wt_path" HEAD || {
|
|
904
|
+
error "Failed to create worktree: $wt"
|
|
905
|
+
return 1
|
|
906
|
+
}
|
|
907
|
+
done
|
|
908
|
+
|
|
909
|
+
success "Worktrees created successfully"
|
|
910
|
+
return 0
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
# Cleanup git worktrees
|
|
914
|
+
cleanup_worktrees() {
|
|
915
|
+
local worktrees=("primary" "independent")
|
|
916
|
+
local git_root
|
|
917
|
+
git_root="$(cd "${GIT_WORK_ROOT:-$PROJECT_ROOT}" && pwd)"
|
|
918
|
+
local git_basename
|
|
919
|
+
git_basename="$(basename "$git_root")"
|
|
920
|
+
|
|
921
|
+
log "Cleaning up git worktrees..."
|
|
922
|
+
|
|
923
|
+
for wt in "${worktrees[@]}"; do
|
|
924
|
+
local wt_path="$git_root/../${git_basename}-wt-$wt"
|
|
925
|
+
local wt_branch="wt-$wt"
|
|
926
|
+
|
|
927
|
+
# Check if worktree exists
|
|
928
|
+
if [ ! -d "$wt_path" ]; then
|
|
929
|
+
info "Worktree does not exist: $wt_path (already removed)"
|
|
930
|
+
else
|
|
931
|
+
# Remove worktree — fall back to manual rm + prune if `git worktree
|
|
932
|
+
# remove` fails (e.g. the directory was already partially deleted
|
|
933
|
+
# out-of-band), so a failed removal never leaves the checkout
|
|
934
|
+
# behind for the next run to trip over.
|
|
935
|
+
info "Removing worktree: $wt ($wt_path)"
|
|
936
|
+
if ! git -C "$git_root" worktree remove "$wt_path" --force 2>/dev/null; then
|
|
937
|
+
warning "git worktree remove failed for $wt — falling back to manual rm + prune"
|
|
938
|
+
rm -rf "$wt_path"
|
|
939
|
+
fi
|
|
940
|
+
fi
|
|
941
|
+
|
|
942
|
+
# Prune BEFORE attempting the branch delete below — if the worktree
|
|
943
|
+
# directory was removed out-of-band (not via `git worktree remove`),
|
|
944
|
+
# git still considers the branch "checked out" by the orphaned admin
|
|
945
|
+
# metadata and silently refuses `git branch -D` until pruned. This bug
|
|
946
|
+
# was found live via this exact scenario in this function's own tests.
|
|
947
|
+
git -C "$git_root" worktree prune 2>/dev/null || true
|
|
948
|
+
|
|
949
|
+
# Delete the branch too — a worktree checkout being removed does NOT
|
|
950
|
+
# delete the branch it pointed to, and a leftover branch collides with
|
|
951
|
+
# the NEXT setup_worktrees() call's `git worktree add -b $wt_branch`
|
|
952
|
+
# (the exact "fatal: a branch named 'wt-primary' already exists" live
|
|
953
|
+
# failure this fixes). setup_worktrees() also deletes stale branches
|
|
954
|
+
# defensively, but cleanup should not rely on the next run to do it.
|
|
955
|
+
if git -C "$git_root" show-ref --verify --quiet "refs/heads/$wt_branch"; then
|
|
956
|
+
git -C "$git_root" branch -D "$wt_branch" 2>/dev/null || true
|
|
957
|
+
fi
|
|
958
|
+
done
|
|
959
|
+
|
|
960
|
+
# Prune worktree references
|
|
961
|
+
git -C "$git_root" worktree prune
|
|
962
|
+
|
|
963
|
+
# Final verification — a pristine cleanup MUST end with zero wt-* worktrees
|
|
964
|
+
# registered. Fail loudly instead of silently leaving a corrupt registry.
|
|
965
|
+
local remaining
|
|
966
|
+
remaining=$(git -C "$git_root" worktree list --porcelain 2>/dev/null | grep -c "^worktree .*-wt-\(primary\|independent\)$" || true)
|
|
967
|
+
if [ "${remaining:-0}" -gt 0 ]; then
|
|
968
|
+
error "Worktree cleanup incomplete — ${remaining} wt-* worktree(s) still registered"
|
|
969
|
+
git -C "$git_root" worktree list
|
|
970
|
+
return 1
|
|
971
|
+
fi
|
|
972
|
+
|
|
973
|
+
success "Worktrees cleaned up"
|
|
974
|
+
return 0
|
|
975
|
+
}
|