autonomous-sdlc-harness 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +7 -0
- package/README.md +24 -0
- package/dist/cli.js +194 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/config.js +561 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/daemon.js +791 -0
- package/dist/commands/daemon.js.map +1 -0
- package/dist/commands/doctor.js +336 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/init.js +2023 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/registry.js +42 -0
- package/dist/commands/registry.js.map +1 -0
- package/dist/config/check.js +505 -0
- package/dist/config/check.js.map +1 -0
- package/dist/config/io.js +177 -0
- package/dist/config/io.js.map +1 -0
- package/dist/config/model.js +406 -0
- package/dist/config/model.js.map +1 -0
- package/dist/core/errors.js +71 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/git.js +537 -0
- package/dist/core/git.js.map +1 -0
- package/dist/core/json.js +125 -0
- package/dist/core/json.js.map +1 -0
- package/dist/core/layerCoverage.js +141 -0
- package/dist/core/layerCoverage.js.map +1 -0
- package/dist/core/layerGapRemedy.js +62 -0
- package/dist/core/layerGapRemedy.js.map +1 -0
- package/dist/core/nameList.js +23 -0
- package/dist/core/nameList.js.map +1 -0
- package/dist/core/paths.js +153 -0
- package/dist/core/paths.js.map +1 -0
- package/dist/core/prompt.js +206 -0
- package/dist/core/prompt.js.map +1 -0
- package/dist/core/repoPaths.js +55 -0
- package/dist/core/repoPaths.js.map +1 -0
- package/dist/core/report.js +150 -0
- package/dist/core/report.js.map +1 -0
- package/dist/core/templating.js +88 -0
- package/dist/core/templating.js.map +1 -0
- package/dist/core/writer.js +479 -0
- package/dist/core/writer.js.map +1 -0
- package/dist/daemon/backend.js +180 -0
- package/dist/daemon/backend.js.map +1 -0
- package/dist/daemon/units.js +380 -0
- package/dist/daemon/units.js.map +1 -0
- package/dist/detect/nestedApplication.js +79 -0
- package/dist/detect/nestedApplication.js.map +1 -0
- package/dist/detect/presets.js +2033 -0
- package/dist/detect/presets.js.map +1 -0
- package/dist/detect/signals.js +1368 -0
- package/dist/detect/signals.js.map +1 -0
- package/dist/doctor/checks.js +3530 -0
- package/dist/doctor/checks.js.map +1 -0
- package/dist/generators/claudeContext.js +588 -0
- package/dist/generators/claudeContext.js.map +1 -0
- package/dist/generators/githooks.js +446 -0
- package/dist/generators/githooks.js.map +1 -0
- package/dist/generators/harnessConfig.js +632 -0
- package/dist/generators/harnessConfig.js.map +1 -0
- package/dist/generators/notifications.js +191 -0
- package/dist/generators/notifications.js.map +1 -0
- package/dist/generators/outerLoopScripts.js +165 -0
- package/dist/generators/outerLoopScripts.js.map +1 -0
- package/dist/generators/permissionProfile.js +1172 -0
- package/dist/generators/permissionProfile.js.map +1 -0
- package/dist/generators/projectSettings.js +322 -0
- package/dist/generators/projectSettings.js.map +1 -0
- package/dist/generators/repoRoot.js +417 -0
- package/dist/generators/repoRoot.js.map +1 -0
- package/dist/generators/scripts.js +557 -0
- package/dist/generators/scripts.js.map +1 -0
- package/dist/generators/stateDir.js +221 -0
- package/dist/generators/stateDir.js.map +1 -0
- package/dist/machine/paths.js +111 -0
- package/dist/machine/paths.js.map +1 -0
- package/dist/machine/plugins.js +224 -0
- package/dist/machine/plugins.js.map +1 -0
- package/dist/machine/registry.js +330 -0
- package/dist/machine/registry.js.map +1 -0
- package/package.json +23 -0
- package/scripts/README.md +13 -0
- package/scripts/daemon/launchd.plist.template +59 -0
- package/scripts/daemon/systemd.service.template +58 -0
- package/templates/README.md +15 -0
- package/templates/claude/CLAUDE.md +54 -0
- package/templates/claude/README.md +5 -0
- package/templates/claude/context/api.md +29 -0
- package/templates/claude/context/conventions.md +23 -0
- package/templates/claude/context/data-layer.md +28 -0
- package/templates/claude/context/data-storage.md +29 -0
- package/templates/claude/context/docs-catalog.md +29 -0
- package/templates/claude/context/domain.md +28 -0
- package/templates/claude/context/layer.md +20 -0
- package/templates/claude/context/module.md +30 -0
- package/templates/claude/context/package.md +29 -0
- package/templates/claude/context/presentation.md +32 -0
- package/templates/claude/context/state-slices.md +28 -0
- package/templates/claude/context/tests.md +28 -0
- package/templates/claude/harness-task-offer.md +58 -0
- package/templates/claude/push-notify.env.example +21 -0
- package/templates/claude/qa-accounts.env.example +38 -0
- package/templates/claude/qa_test_scenarios.md +110 -0
- package/templates/claude/settings.autonomous.json +93 -0
- package/templates/claude/settings.autonomous.qa.json +36 -0
- package/templates/githooks/README.md +3 -0
- package/templates/githooks/pre-push +72 -0
- package/templates/repo/README.md +3 -0
- package/templates/repo/gitattributes +16 -0
- package/templates/repo/gitignore +61 -0
- package/templates/repo/gitignore.qa +25 -0
- package/templates/repo/mcp.json +17 -0
- package/templates/scripts/README.md +5 -0
- package/templates/scripts/autonomous-format-stream.sh +95 -0
- package/templates/scripts/autonomous-notify.sh +337 -0
- package/templates/scripts/autonomous-watcher.sh +3087 -0
- package/templates/scripts/cleanup-merged-worktrees.sh +327 -0
- package/templates/scripts/commit-on-branch.sh +288 -0
- package/templates/scripts/create-worktree.sh +360 -0
- package/templates/scripts/deploy.sh +47 -0
- package/templates/scripts/lib/harness-run-lib.sh +1481 -0
- package/templates/scripts/push-branch.sh +140 -0
- package/templates/scripts/refresh-branch.sh +244 -0
- package/templates/scripts/restart-watcher.sh +401 -0
- package/templates/scripts/scratch-run.sh +302 -0
- package/templates/scripts/setup-worktree.sh +262 -0
- package/templates/scripts/start-dev-server.sh +99 -0
- package/templates/scripts/test.sh +50 -0
- package/templates/scripts/typecheck.sh +50 -0
- package/templates/state-dir/README-root.md +13 -0
- package/templates/state-dir/README.md +9 -0
- package/templates/state-dir/architecture_branch_review_point_reviews/README.md +9 -0
- package/templates/state-dir/architecture_branch_reviews/README.md +9 -0
- package/templates/state-dir/architecture_reviews/README.md +9 -0
- package/templates/state-dir/architecture_user_review_reviews/README.md +9 -0
- package/templates/state-dir/autonomous_inbox/README.md +9 -0
- package/templates/state-dir/autonomous_logs/README.md +9 -0
- package/templates/state-dir/branch_statistics/README.md +9 -0
- package/templates/state-dir/business_parity_branch_review_point_reviews/README.md +9 -0
- package/templates/state-dir/business_parity_branch_reviews/README.md +9 -0
- package/templates/state-dir/business_parity_reviews/README.md +9 -0
- package/templates/state-dir/business_parity_user_review_reviews/README.md +9 -0
- package/templates/state-dir/clarification_digests/README.md +9 -0
- package/templates/state-dir/clarifications/README.md +9 -0
- package/templates/state-dir/code_reviews/README.md +9 -0
- package/templates/state-dir/dispatch_additions/README.md +19 -0
- package/templates/state-dir/docs_catalog/README.md +9 -0
- package/templates/state-dir/flow_progress/README.md +9 -0
- package/templates/state-dir/improvement_observations/README.md +19 -0
- package/templates/state-dir/improvement_suggestions.md +29 -0
- package/templates/state-dir/lessons.md +23 -0
- package/templates/state-dir/qa_review_point_reviews/README.md +9 -0
- package/templates/state-dir/qa_reviews/README.md +9 -0
- package/templates/state-dir/review_plan_point_reviews/README.md +9 -0
- package/templates/state-dir/review_plan_reviews/README.md +9 -0
- package/templates/state-dir/scratch/README.md +11 -0
- package/templates/state-dir/skeptic_review_plan_reviews/README.md +9 -0
- package/templates/state-dir/skeptic_review_point_reviews/README.md +9 -0
- package/templates/state-dir/skeptic_reviews/README.md +9 -0
- package/templates/state-dir/story_plans/README.md +9 -0
- package/templates/state-dir/task_plan_point_reviews/README.md +9 -0
- package/templates/state-dir/task_plan_reviews/README.md +9 -0
- package/templates/state-dir/task_plans/README.md +9 -0
- package/templates/state-dir/task_prompts/README.md +9 -0
- package/templates/state-dir/ui_test_plan_reviews/README.md +9 -0
- package/templates/state-dir/ui_test_plans/README.md +9 -0
- package/templates/state-dir/user_review_fix_plan_point_reviews/README.md +9 -0
- package/templates/state-dir/user_reviews/README.md +9 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# cleanup-merged-worktrees.sh — housekeeping: remove the sibling working copy and
|
|
3
|
+
# the local branch of a run whose pull request was merged and whose remote branch
|
|
4
|
+
# was then deleted.
|
|
5
|
+
#
|
|
6
|
+
# THE TRIGGER IS AN UPSTREAM THAT IS GONE. After `git fetch --prune`, git marks a
|
|
7
|
+
# local branch whose remote counterpart no longer exists as `[gone]` — visible as
|
|
8
|
+
# `git for-each-ref --format='%(refname:short) %(upstream:track)' refs/heads/`.
|
|
9
|
+
# That is exactly the merge-the-pull-request-then-delete-the-remote-branch flow,
|
|
10
|
+
# so it is the signal this sweep acts on, and it is the only one. For each such
|
|
11
|
+
# branch it
|
|
12
|
+
#
|
|
13
|
+
# 1. removes the working copy checked out on it (`git worktree remove --force`;
|
|
14
|
+
# a failure is reported and the sweep continues, because a directory left
|
|
15
|
+
# behind is a smaller problem than a sweep that stops half-way through the
|
|
16
|
+
# set), then
|
|
17
|
+
# 2. force-deletes the local branch (`git branch -D`),
|
|
18
|
+
#
|
|
19
|
+
# and then runs `git worktree prune` to drop the administrative records of
|
|
20
|
+
# working copies whose directories are already gone.
|
|
21
|
+
#
|
|
22
|
+
# WHERE IT RUNS. In the MAIN checkout, always — that is where every working copy
|
|
23
|
+
# and every branch of this repository is administered, and where the run registry
|
|
24
|
+
# and the configuration live. The script derives that anchor rather than assuming
|
|
25
|
+
# it, so it behaves identically whether it is started from the main checkout or
|
|
26
|
+
# from a sibling working copy that is about to be swept away.
|
|
27
|
+
#
|
|
28
|
+
# THE THREE REFUSALS. It never touches
|
|
29
|
+
#
|
|
30
|
+
# * a PROTECTED branch — the set resolved at run time from this repository's
|
|
31
|
+
# `harness.config.json` (`protectedBranches`, or that key's schema default,
|
|
32
|
+
# unioned with `defaultBranch`), through lib/harness-run-lib.sh. There is no
|
|
33
|
+
# list of branch names in this file: one written here would delete branches
|
|
34
|
+
# an adopter had protected, the moment their configuration named anything
|
|
35
|
+
# else;
|
|
36
|
+
# * the branch the MAIN checkout itself has checked out, nor the main
|
|
37
|
+
# checkout's own directory;
|
|
38
|
+
# * a branch with an ACTIVE RUN — one whose record in the run registry
|
|
39
|
+
# (`<state_dir>/autonomous_logs/registry.json`, shaped
|
|
40
|
+
# `{"runs": {"<branch>": {"status": …}}}`) says `running`, `parked` or
|
|
41
|
+
# `paused`. All three own a working copy the watcher will come back to: a
|
|
42
|
+
# parked run is waiting for a clarification answer and a paused one for a
|
|
43
|
+
# RESUME sentinel, and `worktree remove --force` would discard the pause
|
|
44
|
+
# note the resumed engine is pointed at.
|
|
45
|
+
#
|
|
46
|
+
# CAVEAT: `: gone` + force-delete also catches an ABANDONED branch whose remote
|
|
47
|
+
# was deleted WITHOUT merging — its unmerged local commits would be lost. For a
|
|
48
|
+
# strict merge-then-delete-remote flow this is a non-issue. Use --dry-run to
|
|
49
|
+
# preview.
|
|
50
|
+
#
|
|
51
|
+
# `--dry-run` IS THE PREVIEW, AND IT DELETES NOTHING. It reports the branch (and
|
|
52
|
+
# the working copy) each decision would remove; no branch is deleted, no working
|
|
53
|
+
# copy removed, and it does not even `worktree prune`. It is NOT read-only: the
|
|
54
|
+
# `fetch --prune` that establishes which upstreams are gone runs first either
|
|
55
|
+
# way, so remote-tracking refs for deleted upstreams are removed and FETCH_HEAD
|
|
56
|
+
# is written. Preview the decisions, not the repository's ref state.
|
|
57
|
+
#
|
|
58
|
+
# FAIL CLOSED, AND HERE THAT MEANS DELETING NOTHING. This is the most destructive
|
|
59
|
+
# script in the set — `git branch -D` and `git worktree remove --force` are not
|
|
60
|
+
# recoverable from this side — so every input it cannot resolve ends the run
|
|
61
|
+
# instead of being worked around:
|
|
62
|
+
#
|
|
63
|
+
# * the shared library, or `<repo_root>/harness.config.json`, cannot be read
|
|
64
|
+
# (absent, unreadable, invalid JSON, more than one document, no
|
|
65
|
+
# `defaultBranch`, or `jq` missing or older than 1.5) — it cannot prove any
|
|
66
|
+
# branch is unprotected;
|
|
67
|
+
# * the registry file is present and cannot be read as a registry (unreadable,
|
|
68
|
+
# invalid JSON, or no `.runs` wrapper) — it cannot prove any branch is idle.
|
|
69
|
+
# A registry that is simply ABSENT is a different answer and not an error: a
|
|
70
|
+
# repository the watcher has never run in has no registry, and no active run
|
|
71
|
+
# either. But a registry that is there in a shape this cannot enumerate is
|
|
72
|
+
# refused rather than read as "nothing is running", because that misreading
|
|
73
|
+
# is a deleted branch with a run still writing to it;
|
|
74
|
+
# * the protected-branch answer for a specific branch comes back unresolvable
|
|
75
|
+
# mid-sweep.
|
|
76
|
+
#
|
|
77
|
+
# Each of those prints one line and exits 0 having deleted nothing. Exit 0
|
|
78
|
+
# because this sweep is housekeeping invoked from a loop that must keep running,
|
|
79
|
+
# and a refusal is a successful refusal.
|
|
80
|
+
#
|
|
81
|
+
# WHO RUNS IT. The watcher, on its own schedule, and a person by hand. NEVER a
|
|
82
|
+
# dispatched agent: the script-allowlist guard withholds the permit rather than
|
|
83
|
+
# granting one (its basename is on that guard's deny list, and being on it means
|
|
84
|
+
# the guard stays silent), and the generated permission profile emits no rule for
|
|
85
|
+
# this script either — so an agent that tries to run it gets a prompt it cannot
|
|
86
|
+
# answer, never a permit. Nothing about that is negotiable — an agent asked to
|
|
87
|
+
# change one file has no business deleting branches.
|
|
88
|
+
#
|
|
89
|
+
# Usage: cleanup-merged-worktrees.sh [--dry-run]
|
|
90
|
+
# --dry-run report what would be removed; remove nothing
|
|
91
|
+
#
|
|
92
|
+
# Exit map a caller can switch on:
|
|
93
|
+
#
|
|
94
|
+
# 0 the sweep ran, or refused and deleted nothing (the message says which)
|
|
95
|
+
# 1 usage error — an unrecognized or extra argument. Nothing was read and
|
|
96
|
+
# nothing was deleted; in particular a misspelled `--dry-run` is refused
|
|
97
|
+
# here rather than silently becoming a real sweep
|
|
98
|
+
#
|
|
99
|
+
# REPRO — reproduce any decision by hand, against a throwaway fixture:
|
|
100
|
+
#
|
|
101
|
+
# w=$(mktemp -d); b="$w/origin.git"; git init -q --bare "$b"
|
|
102
|
+
# d="$w/demo"; git init -q -b trunk "$d"; git -C "$d" remote add origin "$b"
|
|
103
|
+
# printf '%s' '{"version":1,"projectName":"demo","defaultBranch":"trunk","protectedBranches":["trunk","release/*"],"stateDir":"sdlc-harness/","layers":[],"commands":{}}' > "$d/harness.config.json"
|
|
104
|
+
# mkdir -p "$d/scripts/lib" # copy this script + lib/ there
|
|
105
|
+
# git -C "$d" add -A && git -C "$d" commit -qm seed
|
|
106
|
+
# git -C "$d" push -q -u origin trunk
|
|
107
|
+
# for r in feat_gone feat_live release/1.0; do git -C "$d" branch "$r"; \
|
|
108
|
+
# git -C "$d" push -q -u origin "$r"; done
|
|
109
|
+
# git -C "$d" worktree add -q "$w/demo-feat_gone" feat_gone
|
|
110
|
+
# git -C "$b" branch -q -D feat_gone; git -C "$b" branch -q -D release/1.0
|
|
111
|
+
#
|
|
112
|
+
# preview bash "$d/scripts/cleanup-merged-worktrees.sh" --dry-run
|
|
113
|
+
# -> names feat_gone and its working copy; `git -C "$d" branch`
|
|
114
|
+
# is unchanged and "$w/demo-feat_gone" is still there
|
|
115
|
+
# sweep bash "$d/scripts/cleanup-merged-worktrees.sh"
|
|
116
|
+
# -> "$w/demo-feat_gone" removed and feat_gone deleted;
|
|
117
|
+
# feat_live untouched (its upstream is not gone) and
|
|
118
|
+
# release/1.0 untouched (protected, though it IS gone)
|
|
119
|
+
# active run mkdir -p "$d/sdlc-harness/autonomous_logs"
|
|
120
|
+
# printf '%s' '{"runs":{"feat_gone":{"status":"running"}}}' \
|
|
121
|
+
# > "$d/sdlc-harness/autonomous_logs/registry.json"
|
|
122
|
+
# -> the skip line for feat_gone; nothing deleted (same with
|
|
123
|
+
# "parked" and "paused")
|
|
124
|
+
# unresolvable printf 'x' > "$d/harness.config.json"
|
|
125
|
+
# -> one line, exit 0, nothing deleted
|
|
126
|
+
# offline git -C "$d" remote set-url origin /nonexistent.git
|
|
127
|
+
# -> the fetch message, exit 0, nothing deleted
|
|
128
|
+
|
|
129
|
+
set -uo pipefail
|
|
130
|
+
|
|
131
|
+
# The library is reached by a path computed from this script's own location — no
|
|
132
|
+
# session root and no runtime-substituted token is assumed. Not finding it is a
|
|
133
|
+
# refusal: without it there is no protected set to check a branch against.
|
|
134
|
+
hr_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/harness-run-lib.sh"
|
|
135
|
+
if [ ! -r "$hr_lib" ]; then
|
|
136
|
+
echo "cleanup-merged-worktrees.sh: cannot read '$hr_lib' — refusing to delete anything"
|
|
137
|
+
exit 0
|
|
138
|
+
fi
|
|
139
|
+
# shellcheck source=lib/harness-run-lib.sh
|
|
140
|
+
. "$hr_lib"
|
|
141
|
+
|
|
142
|
+
dry_run=0
|
|
143
|
+
case "${1:-}" in
|
|
144
|
+
--dry-run)
|
|
145
|
+
dry_run=1
|
|
146
|
+
shift
|
|
147
|
+
;;
|
|
148
|
+
'') ;;
|
|
149
|
+
*)
|
|
150
|
+
echo "cleanup-merged-worktrees.sh: unrecognized argument '$1'" >&2
|
|
151
|
+
echo " usage: cleanup-merged-worktrees.sh [--dry-run]" >&2
|
|
152
|
+
exit 1
|
|
153
|
+
;;
|
|
154
|
+
esac
|
|
155
|
+
if [ "$#" -gt 0 ]; then
|
|
156
|
+
echo "cleanup-merged-worktrees.sh: unexpected extra arguments" >&2
|
|
157
|
+
echo " usage: cleanup-merged-worktrees.sh [--dry-run]" >&2
|
|
158
|
+
exit 1
|
|
159
|
+
fi
|
|
160
|
+
|
|
161
|
+
# --- Anchors. Derived, never remembered: this copy of the script may be sitting
|
|
162
|
+
# in the main checkout or in a sibling working copy of the same repository.
|
|
163
|
+
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
164
|
+
top="$(hr_repo_root "$script_dir")" || top=""
|
|
165
|
+
if [ -z "$top" ]; then
|
|
166
|
+
echo "cleanup-merged-worktrees.sh: '$script_dir' is not inside a git repository — refusing to delete anything"
|
|
167
|
+
exit 0
|
|
168
|
+
fi
|
|
169
|
+
|
|
170
|
+
# The sweep administers working copies and branches, so it runs in the MAIN
|
|
171
|
+
# checkout. Unlike its siblings this one does NOT fall back to the checkout it
|
|
172
|
+
# was started from: if the main-checkout probe does not answer, the working-copy
|
|
173
|
+
# enumeration below cannot be trusted either, and the closed outcome is to stop.
|
|
174
|
+
main_repo="$(hr_main_repo "$top")" || main_repo=""
|
|
175
|
+
if [ -z "$main_repo" ]; then
|
|
176
|
+
echo "cleanup-merged-worktrees.sh: could not determine the main checkout of '$top' — refusing to delete anything"
|
|
177
|
+
exit 0
|
|
178
|
+
fi
|
|
179
|
+
|
|
180
|
+
# Warm the library's per-process cache once, unsubstituted, and make the
|
|
181
|
+
# fail-closed decision here rather than letting each reader fail separately.
|
|
182
|
+
cfg_status=0
|
|
183
|
+
hr_config_load "$main_repo" || cfg_status=$?
|
|
184
|
+
if [ "$cfg_status" -ne 0 ]; then
|
|
185
|
+
echo "cleanup-merged-worktrees.sh: cannot resolve '$main_repo/harness.config.json' — refusing to delete anything"
|
|
186
|
+
echo " (no configuration there, invalid JSON, more than one document, no defaultBranch, or jq missing/older than 1.5)"
|
|
187
|
+
exit 0
|
|
188
|
+
fi
|
|
189
|
+
|
|
190
|
+
# --- The active-run set. A HARD PRECONDITION, not a best-effort read: the
|
|
191
|
+
# question it answers is whether a branch about to be force-deleted has a run
|
|
192
|
+
# writing to it.
|
|
193
|
+
registry="$(hr_state_path "$main_repo" autonomous_logs/registry.json)" || registry=""
|
|
194
|
+
if [ -z "$registry" ]; then
|
|
195
|
+
echo "cleanup-merged-worktrees.sh: could not derive the run registry path — refusing to delete anything"
|
|
196
|
+
exit 0
|
|
197
|
+
fi
|
|
198
|
+
|
|
199
|
+
active=""
|
|
200
|
+
if [ -e "$registry" ]; then
|
|
201
|
+
if [ ! -r "$registry" ]; then
|
|
202
|
+
echo "cleanup-merged-worktrees.sh: '$registry' is not readable — refusing to delete anything"
|
|
203
|
+
exit 0
|
|
204
|
+
fi
|
|
205
|
+
# Enumerated through the `.runs` wrapper the whole flow writes and reads. A
|
|
206
|
+
# document without it is a registry this cannot enumerate, so `jq` fails and
|
|
207
|
+
# the refusal below fires — which is the point: reading such a file as "no
|
|
208
|
+
# active runs" would delete a branch a run is still working on.
|
|
209
|
+
if ! active="$(jq -r '.runs | to_entries[] | select(.value.status == "running" or .value.status == "parked" or .value.status == "paused") | .key' "$registry" 2>/dev/null)"; then
|
|
210
|
+
echo "cleanup-merged-worktrees.sh: '$registry' could not be read as a run registry — refusing to delete anything"
|
|
211
|
+
exit 0
|
|
212
|
+
fi
|
|
213
|
+
fi
|
|
214
|
+
|
|
215
|
+
# Refresh remote-tracking refs so a deleted upstream becomes `[gone]`. A failed
|
|
216
|
+
# fetch (offline, or an unreachable remote) skips this round rather than sweeping
|
|
217
|
+
# on stale tracking information, which would report every branch as gone.
|
|
218
|
+
if ! git -C "$main_repo" fetch --prune --quiet 2>/dev/null; then
|
|
219
|
+
echo "cleanup-merged-worktrees.sh: fetch --prune failed (offline?); skipping this round"
|
|
220
|
+
exit 0
|
|
221
|
+
fi
|
|
222
|
+
|
|
223
|
+
current_branch="$(hr_current_branch "$main_repo")"
|
|
224
|
+
|
|
225
|
+
# The working copy checked out on <branch>, from the porcelain listing. Parsed
|
|
226
|
+
# line-wise rather than by whitespace fields, because a working copy's path may
|
|
227
|
+
# contain spaces. Prints nothing when there is none.
|
|
228
|
+
worktree_for_branch() {
|
|
229
|
+
local branch="${1-}" list line current=""
|
|
230
|
+
[ -n "$branch" ] || return 0
|
|
231
|
+
list="$(git -C "$main_repo" worktree list --porcelain 2>/dev/null)" || return 0
|
|
232
|
+
while IFS= read -r line; do
|
|
233
|
+
case "$line" in
|
|
234
|
+
"worktree "*) current="${line#worktree }" ;;
|
|
235
|
+
"branch refs/heads/$branch")
|
|
236
|
+
printf '%s\n' "$current"
|
|
237
|
+
return 0
|
|
238
|
+
;;
|
|
239
|
+
esac
|
|
240
|
+
done <<EOF
|
|
241
|
+
$list
|
|
242
|
+
EOF
|
|
243
|
+
return 0
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
# One record per local branch as `<name><TAB><upstream track>`; the tab keeps the
|
|
247
|
+
# two apart without splitting on whitespace.
|
|
248
|
+
gone_list="$(git -C "$main_repo" for-each-ref --format='%(refname:short)%09%(upstream:track)' refs/heads/ 2>/dev/null)" || gone_list=""
|
|
249
|
+
|
|
250
|
+
nl="
|
|
251
|
+
"
|
|
252
|
+
tab=$'\t'
|
|
253
|
+
cleaned=0
|
|
254
|
+
|
|
255
|
+
# Fed by a here-document rather than a pipeline, so `cleaned` is counted in THIS
|
|
256
|
+
# shell — a `|` would run the loop in a subshell and the total would always be 0.
|
|
257
|
+
while IFS= read -r record; do
|
|
258
|
+
[ -n "$record" ] || continue
|
|
259
|
+
branch="${record%%"$tab"*}"
|
|
260
|
+
track="${record#*"$tab"}"
|
|
261
|
+
[ -n "$branch" ] || continue
|
|
262
|
+
[ "$track" = "[gone]" ] || continue
|
|
263
|
+
|
|
264
|
+
hr_branch_is_protected "$main_repo" "$branch"
|
|
265
|
+
protected=$?
|
|
266
|
+
case "$protected" in
|
|
267
|
+
0) continue ;;
|
|
268
|
+
2)
|
|
269
|
+
# The configuration resolved a moment ago, so this is it changing under a
|
|
270
|
+
# running sweep. Stop; do not fall through to a delete.
|
|
271
|
+
echo "cleanup-merged-worktrees.sh: cannot tell whether '$branch' is protected — stopping; nothing further deleted"
|
|
272
|
+
exit 0
|
|
273
|
+
;;
|
|
274
|
+
esac
|
|
275
|
+
|
|
276
|
+
if [ "$branch" = "$current_branch" ]; then
|
|
277
|
+
continue
|
|
278
|
+
fi
|
|
279
|
+
|
|
280
|
+
# Newline-delimited membership: the registry enumeration returns one branch per
|
|
281
|
+
# line, so both the haystack and the needle are wrapped in newlines. Matching
|
|
282
|
+
# on spaces instead would only ever recognize the first and last entry.
|
|
283
|
+
case "$nl$active$nl" in
|
|
284
|
+
*"$nl$branch$nl"*)
|
|
285
|
+
echo "cleanup-merged-worktrees.sh: skip '$branch' (active run)"
|
|
286
|
+
continue
|
|
287
|
+
;;
|
|
288
|
+
esac
|
|
289
|
+
|
|
290
|
+
wt="$(worktree_for_branch "$branch")"
|
|
291
|
+
|
|
292
|
+
if [ "$dry_run" -eq 1 ]; then
|
|
293
|
+
if [ -n "$wt" ]; then
|
|
294
|
+
echo "cleanup-merged-worktrees.sh: [dry-run] would delete branch '$branch' and remove worktree $wt"
|
|
295
|
+
else
|
|
296
|
+
echo "cleanup-merged-worktrees.sh: [dry-run] would delete branch '$branch'"
|
|
297
|
+
fi
|
|
298
|
+
continue
|
|
299
|
+
fi
|
|
300
|
+
|
|
301
|
+
if [ -n "$wt" ] && [ "$wt" != "$main_repo" ]; then
|
|
302
|
+
# git's own message is captured and reported rather than printed raw, so a
|
|
303
|
+
# failure names the directory a person now has to remove by hand.
|
|
304
|
+
if err="$(git -C "$main_repo" worktree remove --force "$wt" 2>&1 >/dev/null)"; then
|
|
305
|
+
echo "cleanup-merged-worktrees.sh: removed worktree $wt"
|
|
306
|
+
else
|
|
307
|
+
echo "cleanup-merged-worktrees.sh: FAILED to remove worktree $wt: ${err:-no error output} — the directory may be left behind; remove it by hand"
|
|
308
|
+
fi
|
|
309
|
+
fi
|
|
310
|
+
|
|
311
|
+
if git -C "$main_repo" branch -D "$branch" >/dev/null 2>&1; then
|
|
312
|
+
echo "cleanup-merged-worktrees.sh: deleted merged branch '$branch'"
|
|
313
|
+
cleaned=$((cleaned + 1))
|
|
314
|
+
else
|
|
315
|
+
echo "cleanup-merged-worktrees.sh: could not delete branch '$branch' — left in place"
|
|
316
|
+
fi
|
|
317
|
+
done <<EOF
|
|
318
|
+
$gone_list
|
|
319
|
+
EOF
|
|
320
|
+
|
|
321
|
+
if [ "$dry_run" -eq 0 ]; then
|
|
322
|
+
git -C "$main_repo" worktree prune 2>/dev/null || true
|
|
323
|
+
if [ "$cleaned" -gt 0 ]; then
|
|
324
|
+
echo "cleanup-merged-worktrees.sh: $cleaned merged branch(es) removed"
|
|
325
|
+
fi
|
|
326
|
+
fi
|
|
327
|
+
exit 0
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# commit-on-branch.sh — the deterministic commit wrapper every unattended commit
|
|
3
|
+
# point calls: stage exactly the paths it was given, refuse a branch this
|
|
4
|
+
# repository's own configuration protects, and commit with the message supplied
|
|
5
|
+
# as ordinary positional arguments.
|
|
6
|
+
#
|
|
7
|
+
# WHY A WRAPPER EXISTS AT ALL. An unattended run cannot answer a permission
|
|
8
|
+
# prompt, so a command that draws one is a silent stall rather than a refusal.
|
|
9
|
+
# `git commit -m "$(…)"` draws one twice over: a command carrying a shell
|
|
10
|
+
# expansion (`$VAR` / `${…}` / `$(…)`) cannot be matched statically, so it
|
|
11
|
+
# reaches the commit branch-guard (`plugin/hooks/git-commit-branch-guard.sh`),
|
|
12
|
+
# which is fail-closed and answers `ask` for anything it could not read — and a
|
|
13
|
+
# `$(…)` in the message defeats a literal allow entry as well. An invocation of
|
|
14
|
+
# a SCRIPT PATH has neither problem: `bash <scripts_dir>/commit-on-branch.sh …`
|
|
15
|
+
# is allow-listed in three literal forms by the generated permission profile,
|
|
16
|
+
# is auto-allowed by the script-allowlist guard (a script resolving under the
|
|
17
|
+
# configured scripts directory whose basename is not on that guard's deny list,
|
|
18
|
+
# in a command carrying none of `$(…)`, a backtick, `|`, `<`, a braced form
|
|
19
|
+
# other than a bare `${IDENT}`, or a `>` that is neither a descriptor
|
|
20
|
+
# duplication (`2>&1`, `>&2`, `2>&-`) nor a redirection to the literal
|
|
21
|
+
# `/dev/null` — which is what the rule below enforces),
|
|
22
|
+
# and is never matched by the commit guard's parser — even when the path or the
|
|
23
|
+
# arguments carry expansions. So the caller's command line carries NO heredoc
|
|
24
|
+
# and NO `$(…)`: the subject and every body paragraph are ordinary positional
|
|
25
|
+
# arguments and this script builds the commit from repeated `-m` flags itself.
|
|
26
|
+
#
|
|
27
|
+
# SAFETY-CRITICAL CONSEQUENCE. Not reaching the commit branch-guard also means
|
|
28
|
+
# not reaching its protected-branch refusal, so this script MUST make that
|
|
29
|
+
# decision itself — and it makes it from `<repo_root>/harness.config.json` at
|
|
30
|
+
# run time, through `lib/harness-run-lib.sh`, never from a list of branch names
|
|
31
|
+
# written into this file. A remembered list enforces the wrong set the moment
|
|
32
|
+
# `protectedBranches` changes, and enforces nothing at all in a repository whose
|
|
33
|
+
# integration branch is named something else. The library answers three states —
|
|
34
|
+
# protected, not protected, unresolvable — and all three are closed here.
|
|
35
|
+
#
|
|
36
|
+
# LOUD-FAILURE CONTRACT (the KEY difference from push-branch.sh). push-branch.sh
|
|
37
|
+
# makes every failure non-fatal because a stale remote is tolerable. A commit
|
|
38
|
+
# caller cannot tolerate that ambiguity: if a commit did NOT land, the caller
|
|
39
|
+
# must NOT proceed as if it did. A non-git directory, a detached HEAD, a
|
|
40
|
+
# configuration that cannot be resolved, a protected-branch refusal and a
|
|
41
|
+
# git-level hook failure are therefore all non-zero exits. Exit-code map a
|
|
42
|
+
# caller can switch on:
|
|
43
|
+
#
|
|
44
|
+
# 0 commit landed (the new short SHA is printed to stdout)
|
|
45
|
+
# 1 usage error / non-git directory / detached HEAD / the configuration could
|
|
46
|
+
# not be resolved / `git commit` failed (a hook, or any commit-time check)
|
|
47
|
+
# 2 protected-branch refusal (no staging, no commit)
|
|
48
|
+
# 3 nothing to commit (the paths staged cleanly and produced no staged
|
|
49
|
+
# change) — a DISTINCT, non-crashing status so a caller can tell "nothing
|
|
50
|
+
# staged" apart from a protected-branch refusal or a hook failure
|
|
51
|
+
#
|
|
52
|
+
# WHAT IT NEVER DOES. It never pushes (that is push-branch.sh, called as a
|
|
53
|
+
# SEPARATE statement — an `if …; then push; fi` block is a compound the guards
|
|
54
|
+
# do not cover and stalls an unattended run). It never flips a plan checkbox:
|
|
55
|
+
# callers own that. It never stages anything it was not given — no `git add -A`,
|
|
56
|
+
# no `git add .`. It never passes `--no-verify` or any other hook-skip flag, and
|
|
57
|
+
# it never retries a failed commit.
|
|
58
|
+
#
|
|
59
|
+
# STAGING POLICY, AND THE ONE TRAP IN IT. Only the explicit paths passed before
|
|
60
|
+
# `--` are staged, one `git add -- <path>` each. A path that does not exist is
|
|
61
|
+
# skip-with-warning rather than a hard error, and that tolerance is what lets
|
|
62
|
+
# callers pass their paths UNCONDITIONALLY: an optional-but-absent one (an empty
|
|
63
|
+
# per-finding folder, say) must not fail the commit, and a caller-side
|
|
64
|
+
# `if [ -d … ]; then` existence check would reintroduce the stall described
|
|
65
|
+
# under WHY A WRAPPER EXISTS AT ALL — a shell conditional cannot be matched
|
|
66
|
+
# statically, so it reaches the fail-closed guards and an unattended run cannot
|
|
67
|
+
# answer the prompt it draws. Callers were deliberately stripped of those
|
|
68
|
+
# checks; do not restore one. THE TRAP: a path that was DELETED does not
|
|
69
|
+
# exist either, so passing the exact deleted path stages nothing and the
|
|
70
|
+
# deletion is silently left out of the commit. Pass the PARENT DIRECTORY of a
|
|
71
|
+
# deletion instead — `git add -- <dir>` stages the removal. This is source
|
|
72
|
+
# behaviour carried across deliberately rather than fixed: the existence check
|
|
73
|
+
# is what keeps an absent optional path from failing a commit, and it is checked
|
|
74
|
+
# against `<repo_top>/<path>` — the same base `git -C <top> add` resolves
|
|
75
|
+
# against — so a path is staged if and only if it is the one git would resolve.
|
|
76
|
+
#
|
|
77
|
+
# MESSAGE ARGUMENTS, AND THE BYTES ONE MAY NOT CARRY. The subject and every body
|
|
78
|
+
# line arrive as ordinary shell arguments, so none of them may carry `$(…)`, a
|
|
79
|
+
# backtick, `|`, `>`, `<`, or a braced expansion other than a bare `${IDENT}`.
|
|
80
|
+
# Two independent reasons, either one fatal on its own. THE SHELL: inside the
|
|
81
|
+
# caller's double quotes `$(…)` and a backtick are substituted before this
|
|
82
|
+
# script is reached, and any `$` spelling is expanded — so a `$`, a backtick or
|
|
83
|
+
# a placeholder meant as TEXT is not text by the time it lands. THE GUARD: the
|
|
84
|
+
# script-allowlist guard (`autonomous-script-allowlist-guard.sh`, shipped with the
|
|
85
|
+
# plugin)
|
|
86
|
+
# scans the WHOLE command string for `$(…)`, a backtick, `|`, `<`, a braced form
|
|
87
|
+
# other than a bare `${IDENT}`, and a `>` that is neither a descriptor
|
|
88
|
+
# duplication (`2>&1`, `>&2`, `2>&-`) nor a redirection to the literal
|
|
89
|
+
# `/dev/null`, and silences the invocation on a match, which is the permission
|
|
90
|
+
# prompt described under WHY A WRAPPER EXISTS AT ALL and, unattended, a stall.
|
|
91
|
+
# That scanned set is the set above minus the two `>` shapes a MESSAGE cannot
|
|
92
|
+
# be, which is why the rule here stays whole-byte for `>`. The guard reads the
|
|
93
|
+
# raw string, so quoting and escaping rescue nothing: `|`, `<` and a prose `>`
|
|
94
|
+
# are inert to the shell inside quotes and still silence the command, and an
|
|
95
|
+
# escaped backtick matches exactly as a bare one does. The ONE admitted
|
|
96
|
+
# expansion is a bare `${IDENT}`,
|
|
97
|
+
# bought back by the guard's narrowed `${` arm because the corpus's canonical
|
|
98
|
+
# subject spells its branch token that way — it is allowed AND expanded, so pass
|
|
99
|
+
# it only when expansion is what you want. THIS SCRIPT ENFORCES NONE OF THIS: it
|
|
100
|
+
# validates no message, rewrites no message, and builds the commit from repeated
|
|
101
|
+
# `-m` flags exactly as given. The constraint binds the CALLER, and the caller's
|
|
102
|
+
# own contract states what to do with a title that cannot satisfy it.
|
|
103
|
+
#
|
|
104
|
+
# Usage: commit-on-branch.sh [--repo <repo-dir>] <path>... -- <subject> [<body-line>...]
|
|
105
|
+
# --repo <repo-dir> worktree/repository to operate in (default: $PWD)
|
|
106
|
+
# <path>... explicit paths to stage (before `--`); missing paths warn+skip
|
|
107
|
+
# -- separates the stage-path list from the message lines
|
|
108
|
+
# <subject> first token after `--` — the commit subject (`-m`); see
|
|
109
|
+
# MESSAGE ARGUMENTS for the bytes it may not carry
|
|
110
|
+
# <body-line>... each remaining token — one additional `-m` body paragraph,
|
|
111
|
+
# under that same constraint
|
|
112
|
+
#
|
|
113
|
+
# REPRO — reproduce any decision by hand, against a throwaway fixture:
|
|
114
|
+
#
|
|
115
|
+
# d=$(mktemp -d); git -C "$d" init -q -b feat_x
|
|
116
|
+
# printf '%s' '{"version":1,"defaultBranch":"trunk","protectedBranches":["trunk","release/*"],"stateDir":"sdlc-harness/","layers":[],"commands":{}}' > "$d/harness.config.json"
|
|
117
|
+
# printf 'x\n' > "$d/f.txt"
|
|
118
|
+
#
|
|
119
|
+
# ordinary branch commit-on-branch.sh --repo "$d" f.txt -- "feat: x" "body"
|
|
120
|
+
# -> exit 0, prints the new short SHA
|
|
121
|
+
# re-run with nothing changed -> exit 3
|
|
122
|
+
# protected branch git -C "$d" checkout -q -b trunk; same call -> exit 2,
|
|
123
|
+
# and `git -C "$d" status --porcelain` is unchanged
|
|
124
|
+
# not in the set git -C "$d" checkout -q -b main; same call -> exit 0
|
|
125
|
+
# (the set is configured, not remembered)
|
|
126
|
+
# unresolvable printf 'x' > "$d/harness.config.json"; same call -> exit 1,
|
|
127
|
+
# no commit
|
|
128
|
+
# deleted path git -C "$d" rm -q f.txt; passing `f.txt` -> the deletion is
|
|
129
|
+
# NOT staged; pass its parent directory instead
|
|
130
|
+
|
|
131
|
+
set -uo pipefail
|
|
132
|
+
|
|
133
|
+
# The library is reached by a path computed from this script's own location, so
|
|
134
|
+
# a copy that was moved with its `lib/` still finds it, and no session root or
|
|
135
|
+
# runtime-substituted token is assumed.
|
|
136
|
+
hr_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/harness-run-lib.sh"
|
|
137
|
+
if [ ! -r "$hr_lib" ]; then
|
|
138
|
+
echo "commit-on-branch.sh: cannot read '$hr_lib' — refusing to commit" >&2
|
|
139
|
+
exit 1
|
|
140
|
+
fi
|
|
141
|
+
# shellcheck source=lib/harness-run-lib.sh
|
|
142
|
+
. "$hr_lib"
|
|
143
|
+
|
|
144
|
+
repo_dir="$PWD"
|
|
145
|
+
|
|
146
|
+
# --- Parse args: optional --repo, then <path>... up to `--`, then message lines.
|
|
147
|
+
paths=()
|
|
148
|
+
msgs=()
|
|
149
|
+
saw_sep=0
|
|
150
|
+
|
|
151
|
+
while [ "$#" -gt 0 ]; do
|
|
152
|
+
case "$1" in
|
|
153
|
+
--repo)
|
|
154
|
+
if [ "$#" -lt 2 ]; then
|
|
155
|
+
echo "commit-on-branch.sh: --repo requires a directory argument" >&2
|
|
156
|
+
exit 1
|
|
157
|
+
fi
|
|
158
|
+
repo_dir="$2"
|
|
159
|
+
shift 2
|
|
160
|
+
;;
|
|
161
|
+
--)
|
|
162
|
+
saw_sep=1
|
|
163
|
+
shift
|
|
164
|
+
break
|
|
165
|
+
;;
|
|
166
|
+
*)
|
|
167
|
+
paths+=("$1")
|
|
168
|
+
shift
|
|
169
|
+
;;
|
|
170
|
+
esac
|
|
171
|
+
done
|
|
172
|
+
|
|
173
|
+
if [ "$saw_sep" -ne 1 ]; then
|
|
174
|
+
echo "commit-on-branch.sh: missing '--' separator before the commit message" >&2
|
|
175
|
+
echo " usage: commit-on-branch.sh [--repo <dir>] <path>... -- <subject> [<body-line>...]" >&2
|
|
176
|
+
exit 1
|
|
177
|
+
fi
|
|
178
|
+
|
|
179
|
+
# Everything after `--` is the message: the first token is the subject, the rest
|
|
180
|
+
# are additional body paragraphs.
|
|
181
|
+
while [ "$#" -gt 0 ]; do
|
|
182
|
+
msgs+=("$1")
|
|
183
|
+
shift
|
|
184
|
+
done
|
|
185
|
+
|
|
186
|
+
if [ "${#msgs[@]}" -eq 0 ]; then
|
|
187
|
+
echo "commit-on-branch.sh: no commit subject after '--'" >&2
|
|
188
|
+
exit 1
|
|
189
|
+
fi
|
|
190
|
+
|
|
191
|
+
# Resolve the repository top. A non-git directory is a loud non-zero error (a
|
|
192
|
+
# commit caller cannot tolerate "not a repository" the way a best-effort push
|
|
193
|
+
# can).
|
|
194
|
+
top="$(hr_repo_root "$repo_dir")"
|
|
195
|
+
if [ -z "$top" ]; then
|
|
196
|
+
echo "commit-on-branch.sh: '$repo_dir' is not a git repository — refusing to commit" >&2
|
|
197
|
+
exit 1
|
|
198
|
+
fi
|
|
199
|
+
|
|
200
|
+
# Current branch. Empty => detached HEAD => loud refusal, no staging, no commit.
|
|
201
|
+
branch="$(hr_current_branch "$top")"
|
|
202
|
+
if [ -z "$branch" ]; then
|
|
203
|
+
echo "commit-on-branch.sh: detached HEAD — refusing to commit" >&2
|
|
204
|
+
exit 1
|
|
205
|
+
fi
|
|
206
|
+
|
|
207
|
+
# The protected-branch decision, from this repository's configuration. Called
|
|
208
|
+
# unsubstituted so the library's per-process cache is warmed here and the
|
|
209
|
+
# `$(hr_protected_patterns …)` in the refusal message below re-reads nothing.
|
|
210
|
+
hr_branch_is_protected "$top" "$branch"
|
|
211
|
+
protected=$?
|
|
212
|
+
case "$protected" in
|
|
213
|
+
0)
|
|
214
|
+
# Name the resolved set as well as the branch: the adopter configured it, so
|
|
215
|
+
# a refusal that only names the branch cannot be acted on.
|
|
216
|
+
patterns="$(hr_protected_patterns "$top")" || patterns=""
|
|
217
|
+
protected_set=""
|
|
218
|
+
while IFS= read -r pattern; do
|
|
219
|
+
[ -n "$pattern" ] || continue
|
|
220
|
+
if [ -z "$protected_set" ]; then
|
|
221
|
+
protected_set="$pattern"
|
|
222
|
+
else
|
|
223
|
+
protected_set="$protected_set $pattern"
|
|
224
|
+
fi
|
|
225
|
+
done <<EOF
|
|
226
|
+
$patterns
|
|
227
|
+
EOF
|
|
228
|
+
echo "commit-on-branch.sh: branch '$branch' is protected — refusing to commit" >&2
|
|
229
|
+
echo " protected set (from harness.config.json): $protected_set" >&2
|
|
230
|
+
exit 2
|
|
231
|
+
;;
|
|
232
|
+
2)
|
|
233
|
+
# Fail closed. The configuration is what says which branches are protected,
|
|
234
|
+
# so a configuration that cannot be read is a branch that cannot be judged —
|
|
235
|
+
# and committing on it would be exactly the mutation this wrapper exists to
|
|
236
|
+
# gate.
|
|
237
|
+
echo "commit-on-branch.sh: cannot resolve '$top/harness.config.json' — refusing to commit" >&2
|
|
238
|
+
echo " (no configuration there, invalid JSON, more than one document, no defaultBranch, or jq missing/older than 1.5)" >&2
|
|
239
|
+
exit 1
|
|
240
|
+
;;
|
|
241
|
+
esac
|
|
242
|
+
|
|
243
|
+
# Stage ONLY the explicit paths — never `git add -A` / `git add .`. A missing
|
|
244
|
+
# path is skip-with-warning, not a hard error. The count guard is required: on
|
|
245
|
+
# bash 3.2 (the floor) `"${paths[@]}"` on an EMPTY array under `set -u` raises
|
|
246
|
+
# `unbound variable` instead of expanding to nothing, which would abort a legal
|
|
247
|
+
# zero-path call before it could reach the nothing-to-commit exit 3.
|
|
248
|
+
if [ "${#paths[@]}" -gt 0 ]; then
|
|
249
|
+
for p in "${paths[@]}"; do
|
|
250
|
+
# The existence check MUST use the same base as the staging command below
|
|
251
|
+
# (`git -C "$top" add` resolves $p relative to $top, not the caller's cwd).
|
|
252
|
+
# Checking `$top/$p` only — never a cwd-relative $p — guarantees a path is
|
|
253
|
+
# staged if and only if it is the same path `git add` will resolve.
|
|
254
|
+
if [ ! -e "$top/$p" ]; then
|
|
255
|
+
echo "commit-on-branch.sh: path '$p' does not exist under '$top' — skipping" >&2
|
|
256
|
+
continue
|
|
257
|
+
fi
|
|
258
|
+
if ! git -C "$top" add -- "$p"; then
|
|
259
|
+
echo "commit-on-branch.sh: 'git add -- $p' failed — no commit made" >&2
|
|
260
|
+
exit 1
|
|
261
|
+
fi
|
|
262
|
+
done
|
|
263
|
+
fi
|
|
264
|
+
|
|
265
|
+
# Nothing staged => a distinct, dedicated non-zero status, so a caller can tell
|
|
266
|
+
# this apart from a protected-branch refusal or a hook failure.
|
|
267
|
+
if git -C "$top" diff --cached --quiet; then
|
|
268
|
+
echo "commit-on-branch.sh: nothing to commit" >&2
|
|
269
|
+
exit 3
|
|
270
|
+
fi
|
|
271
|
+
|
|
272
|
+
# Build the commit from repeated -m flags, WITHOUT `--no-verify` or any other
|
|
273
|
+
# hook-skip flag, so the repository's own commit hooks still run.
|
|
274
|
+
commit_args=()
|
|
275
|
+
for m in "${msgs[@]}"; do
|
|
276
|
+
commit_args+=(-m "$m")
|
|
277
|
+
done
|
|
278
|
+
|
|
279
|
+
if ! git -C "$top" commit "${commit_args[@]}"; then
|
|
280
|
+
# A hook (or any other commit-time check) failed. Surface it loudly; do NOT
|
|
281
|
+
# retry, do NOT add --no-verify, do NOT leave a partial or bypassing commit.
|
|
282
|
+
echo "commit-on-branch.sh: 'git commit' failed (see output above) — no commit made" >&2
|
|
283
|
+
exit 1
|
|
284
|
+
fi
|
|
285
|
+
|
|
286
|
+
sha="$(git -C "$top" rev-parse --short HEAD)"
|
|
287
|
+
echo "commit-on-branch.sh: committed $sha on $branch"
|
|
288
|
+
exit 0
|