rollout-cli 0.3.2__tar.gz → 0.4.1__tar.gz
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.
- rollout_cli-0.4.1/.claude/skills/mass-update/SKILL.md +109 -0
- rollout_cli-0.4.1/.claude/skills/mass-update/scripts/mass-update.sh +410 -0
- rollout_cli-0.4.1/.devague/frames/mass-update-now-clones-each-target-repo-into-a-git.json +214 -0
- rollout_cli-0.4.1/.devague/plans/mass-update-now-clones-each-target-repo-into-a-git.json +255 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.gitignore +3 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/CHANGELOG.md +23 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/CLAUDE.md +12 -8
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/PKG-INFO +1 -1
- rollout_cli-0.4.1/docs/plans/2026-06-12-mass-update-now-clones-each-target-repo-into-a-git.md +72 -0
- rollout_cli-0.4.1/docs/specs/2026-06-12-mass-update-now-clones-each-target-repo-into-a-git.md +54 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/pyproject.toml +1 -1
- rollout_cli-0.4.1/tests/test_mass_update.py +489 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/uv.lock +1 -1
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/agent-config/SKILL.md +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/agent-config/data/backend-fingerprints.yaml +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/agent-config/scripts/show.sh +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/ask-colleague/SKILL.md +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/ask-colleague/prompts/explore.md +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/ask-colleague/prompts/review.md +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/ask-colleague/prompts/write.md +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/ask-colleague/scripts/ask-colleague.sh +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/assign-to-workforce/SKILL.md +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/assign-to-workforce/scripts/assign-to-workforce.sh +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/cicd/SKILL.md +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/cicd/scripts/_resolve-nick.sh +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/cicd/scripts/portability-lint.sh +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/cicd/scripts/pr-reply.sh +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/cicd/scripts/pr-status.sh +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/cicd/scripts/workflow.sh +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/communicate/SKILL.md +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/communicate/scripts/fetch-issues.sh +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/communicate/scripts/mesh-message.sh +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/communicate/scripts/post-comment.sh +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/communicate/scripts/post-issue.sh +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/communicate/scripts/templates/skill-new-brief.md +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/communicate/scripts/templates/skill-update-brief.md +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/doc-test-alignment/SKILL.md +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/doc-test-alignment/scripts/check.sh +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/pypi-maintainer/SKILL.md +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/pypi-maintainer/scripts/switch-source.sh +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/run-tests/SKILL.md +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/run-tests/scripts/test.sh +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/sonarclaude/SKILL.md +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/sonarclaude/scripts/sonar.sh +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/spec-to-plan/SKILL.md +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/spec-to-plan/scripts/spec-to-plan.sh +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/think/SKILL.md +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/think/scripts/think.sh +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/version-bump/SKILL.md +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills/version-bump/scripts/bump.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.claude/skills.local.yaml.example +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.devague/frames/rollout-cli-ships-rollout-define-an-org-wide-chang.json +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.devague/plans/rollout-cli-ships-rollout-define-an-org-wide-chang.json +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.flake8 +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.github/workflows/publish.yml +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.github/workflows/tests.yml +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/.markdownlint-cli2.yaml +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/AGENTS.colleague.md +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/LICENSE +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/README.md +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/culture.yaml +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/docs/plans/2026-06-06-rollout-cli-ships-rollout-define-an-org-wide-chang.md +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/docs/skill-sources.md +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/docs/specs/2026-06-06-rollout-cli-ships-rollout-define-an-org-wide-chang.md +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/recipes/apache-relicense/apply.sh +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/recipes/apache-relicense/check.sh +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/recipes/apache-relicense/files/LICENSE +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/recipes/apache-relicense/manifest.toml +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/recipes/ask-colleague-revendor/README.md +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/recipes/ask-colleague-revendor/apply.sh +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/recipes/ask-colleague-revendor/check.sh +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/recipes/ask-colleague-revendor/files/SKILL.md.tmpl +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/recipes/ask-colleague-revendor/files/prompts/explore.md +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/recipes/ask-colleague-revendor/files/prompts/review.md +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/recipes/ask-colleague-revendor/files/prompts/write.md +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/recipes/ask-colleague-revendor/files/scripts/ask-colleague.sh +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/recipes/ask-colleague-revendor/manifest.toml +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/__init__.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/__main__.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/cli/__init__.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/cli/_commands/__init__.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/cli/_commands/apply.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/cli/_commands/cli.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/cli/_commands/doctor.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/cli/_commands/explain.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/cli/_commands/learn.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/cli/_commands/overview.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/cli/_commands/plan.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/cli/_commands/whoami.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/cli/_errors.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/cli/_output.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/core/__init__.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/core/bump.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/core/devex_pr.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/core/gitops.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/core/orchestrate.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/core/preflight.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/core/recipe.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/core/report.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/core/targets.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/explain/__init__.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/rollout/explain/catalog.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/sonar-project.properties +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/__init__.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/test_apache_relicense.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/test_apply_cmd.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/test_bump.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/test_cli.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/test_cli_introspection.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/test_cli_wiring.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/test_devex_pr.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/test_e2e_apache_relicense.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/test_explain_catalog.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/test_gitops.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/test_orchestrate.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/test_plan_cmd.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/test_preflight.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/test_recipe.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/test_report.py +0 -0
- {rollout_cli-0.3.2 → rollout_cli-0.4.1}/tests/test_targets.py +0 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mass-update
|
|
3
|
+
type: command
|
|
4
|
+
description: >
|
|
5
|
+
Propagate ONE deterministic change across many GitHub repos at once. Targets
|
|
6
|
+
are GitHub slugs (`owner/repo`), not local paths. For each target, mass-update
|
|
7
|
+
clones fresh into a scratch dir, applies a verbatim file drop (`--file`) or
|
|
8
|
+
anchored edit (`--edit`), and cleans up on exit. Default is PREVIEW (zero
|
|
9
|
+
outward effect); `--pr` pushes a branch, opens a PR, and files a companion
|
|
10
|
+
tracking issue. No LLM, no ask-colleague — increment 1 is deterministic only.
|
|
11
|
+
Triggers when the user says "mass update", "propagate this change across
|
|
12
|
+
repos", "apply X to all these repos", "roll this out to agentculture/foo", or
|
|
13
|
+
"relicense these repos". One call = one PR per repo; run it several times for
|
|
14
|
+
independent changes.
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# mass-update — one deterministic change, many repos
|
|
18
|
+
|
|
19
|
+
`mass-update` is a **deterministic** cross-repo propagator. It clones each target
|
|
20
|
+
repo fresh into a scratch directory, applies verbatim file drops or anchored
|
|
21
|
+
edits, and cleans up on exit. No LLM, no `ask-colleague`.
|
|
22
|
+
|
|
23
|
+
## What changed
|
|
24
|
+
|
|
25
|
+
mass-update previously drove `ask-colleague` against sibling local checkouts
|
|
26
|
+
(worktrees, `colleague/<id>` branches, `--apply`/`--pr` landing). That flow is
|
|
27
|
+
gone. The new mechanism:
|
|
28
|
+
|
|
29
|
+
- **Targets are GitHub slugs** (`owner/repo`), not local paths. A local path is
|
|
30
|
+
rejected.
|
|
31
|
+
- For each target it **clones fresh** into a gitignored scratch dir
|
|
32
|
+
(`.mass-update/clones/<owner>-<repo>/`) inside rollout-cli, branches off the
|
|
33
|
+
default branch, applies a deterministic change in the clone, and cleans the
|
|
34
|
+
clone up on exit (success or failure). `--keep-clone` retains it for debugging.
|
|
35
|
+
- The cardinal rule: **rollout-cli never reaches beyond its own repo.** The
|
|
36
|
+
entire on-disk footprint stays inside rollout-cli; the only outward effects
|
|
37
|
+
are `git push` (the PR) and `/communicate` (the issue).
|
|
38
|
+
- Increment 1 supports only deterministic ops: `--file` (verbatim) and
|
|
39
|
+
`--edit` (anchored unique-occurrence replace). Free-text described changes are
|
|
40
|
+
a deferred follow-up.
|
|
41
|
+
|
|
42
|
+
## How to run
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
bash .claude/skills/mass-update/scripts/mass-update.sh \
|
|
46
|
+
--repos <owner/repo[,owner/repo...]> <ops> [--pr] [--keep-clone]
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Run it from the rollout-cli repo root. `--repos` takes comma-separated GitHub
|
|
50
|
+
slugs (e.g. `agentculture/widget-cli`).
|
|
51
|
+
|
|
52
|
+
## Op shapes (combinable in one call)
|
|
53
|
+
|
|
54
|
+
| Op | What it does |
|
|
55
|
+
|----|--------------|
|
|
56
|
+
| `--file DEST --from SRC` | Create/replace `DEST` in each clone with **exactly** `SRC`'s bytes (verbatim). Use this for content the model shouldn't regenerate — a `LICENSE`, a CI workflow, a config file. |
|
|
57
|
+
| `--file DEST --content '...'` | Same, but the verbatim content is given inline. |
|
|
58
|
+
| `--edit 'FILE: FIND => REPLACE'` | Replace the **unique** occurrence of `FIND` with `REPLACE` in `FILE`. Errors on 0 or >1 matches. Repeatable. |
|
|
59
|
+
|
|
60
|
+
All ops given in one call are applied to each clone. At least one op is required.
|
|
61
|
+
|
|
62
|
+
## Landing — preview or PR
|
|
63
|
+
|
|
64
|
+
| Flag | Effect |
|
|
65
|
+
|------|--------|
|
|
66
|
+
| *(none)* | **PREVIEW.** Clone, apply ops, print diff, cleanup. **Zero outward effect.** Always start here. |
|
|
67
|
+
| `--pr` | Clone, apply, commit, push, open PR, file a companion tracking issue via `communicate/scripts/post-issue.sh`, cleanup. Both the PR URL and issue URL are printed. |
|
|
68
|
+
| `--keep-clone` | Retain the scratch clone on exit (debug). |
|
|
69
|
+
|
|
70
|
+
## Worked example — propagate a LICENSE to a sibling repo
|
|
71
|
+
|
|
72
|
+
Replace `agentculture/widget-cli`'s `LICENSE` with rollout-cli's own `LICENSE`,
|
|
73
|
+
then open a PR and file a tracking issue:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
bash .claude/skills/mass-update/scripts/mass-update.sh \
|
|
77
|
+
--repos agentculture/widget-cli \
|
|
78
|
+
--file LICENSE --from ./LICENSE \
|
|
79
|
+
--pr
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Run it **without `--pr` first** to inspect the preview diff; re-run with `--pr`
|
|
83
|
+
once the diff is correct.
|
|
84
|
+
|
|
85
|
+
## Where it fits
|
|
86
|
+
|
|
87
|
+
- **`rollout` engine** — deterministic recipes, reproducible, no model. Reach for
|
|
88
|
+
it for org-wide fan-out (`--org`) and exact, scriptable transforms.
|
|
89
|
+
- **`mass-update`** — *this skill*: deterministic cross-repo propagation via
|
|
90
|
+
fresh clones, for verbatim file drops and anchored edits across a handful of
|
|
91
|
+
GitHub repos.
|
|
92
|
+
|
|
93
|
+
## Honest limits
|
|
94
|
+
|
|
95
|
+
- **Deterministic only (increment 1).** Free-text described changes are NOT
|
|
96
|
+
supported — they are a deferred follow-up that needs an `ask-colleague`
|
|
97
|
+
edit-in-place mode.
|
|
98
|
+
- **One drive per target.** Each target succeeds or fails independently; a
|
|
99
|
+
non-zero exit means at least one target failed (others may have landed).
|
|
100
|
+
Re-run for the failures.
|
|
101
|
+
- **Requires `gh` and `git`.** Both must be on PATH and authenticated
|
|
102
|
+
(`gh auth status` must succeed) before any clone begins.
|
|
103
|
+
- **`--keep-clone`** retains the scratch directory for debugging; without it,
|
|
104
|
+
clones are cleaned up on both success and failure.
|
|
105
|
+
|
|
106
|
+
## Provenance
|
|
107
|
+
|
|
108
|
+
mass-update is a rollout-cli-local skill. The `cite, don't import` policy holds:
|
|
109
|
+
downstream repos copy it, they don't symlink or depend on it.
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# mass-update — deterministic mass-update across GitHub repos.
|
|
4
|
+
#
|
|
5
|
+
# Clones each target repo into a scratch dir, applies deterministic ops,
|
|
6
|
+
# and optionally opens a PR + tracking issue. No LLM, no ask-colleague.
|
|
7
|
+
#
|
|
8
|
+
set -euo pipefail
|
|
9
|
+
|
|
10
|
+
usage() {
|
|
11
|
+
cat <<'EOF'
|
|
12
|
+
mass-update — deterministic mass-update across GitHub repos.
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
mass-update --repos OWNER/REPO[,OWNER/REPO...] <ops> [--pr] [--keep-clone] [-h|--help]
|
|
16
|
+
|
|
17
|
+
Ops (at least one required):
|
|
18
|
+
--file DEST --from SRC Write DEST with verbatim bytes of SRC (relative to cwd).
|
|
19
|
+
--file DEST --content TEXT Write DEST with verbatim TEXT.
|
|
20
|
+
--edit 'FILE: FIND => REPLACE' Replace UNIQUE occurrence of FIND with REPLACE in FILE.
|
|
21
|
+
|
|
22
|
+
Landing:
|
|
23
|
+
(default) PREVIEW: clone, apply ops, print diff, cleanup. No push/PR/issue.
|
|
24
|
+
--pr clone, apply, commit, push, open PR, file tracking issue, cleanup.
|
|
25
|
+
--keep-clone Retain the scratch clone on exit (debug).
|
|
26
|
+
|
|
27
|
+
Targets:
|
|
28
|
+
--repos a/b,c/d Comma-separated GitHub slugs (owner/repo). Required.
|
|
29
|
+
|
|
30
|
+
Other:
|
|
31
|
+
-h, --help Show this help.
|
|
32
|
+
EOF
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# Guard a value-taking flag.
|
|
36
|
+
need_value() {
|
|
37
|
+
[[ "$1" -ge 2 ]] || { echo "error: $2 requires a value" >&2; echo "hint: run 'mass-update --help'" >&2; exit 2; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# ── defaults + accumulators ──────────────────────────────────────────────────
|
|
41
|
+
REPOS_CSV=""
|
|
42
|
+
FILE_DESTS=()
|
|
43
|
+
FILE_KINDS=()
|
|
44
|
+
FILE_SRCS=()
|
|
45
|
+
FILE_CONTENTS=()
|
|
46
|
+
EDITS=()
|
|
47
|
+
DO_PR=0
|
|
48
|
+
KEEP_CLONE=0
|
|
49
|
+
PENDING_FILE=""
|
|
50
|
+
|
|
51
|
+
_push_file() {
|
|
52
|
+
[[ -n "$PENDING_FILE" ]] || { echo "error: $([[ "$1" == "from" ]] && echo --from || echo --content) given before any --file DEST" >&2; exit 2; }
|
|
53
|
+
FILE_DESTS+=("$PENDING_FILE")
|
|
54
|
+
FILE_KINDS+=("$1")
|
|
55
|
+
if [[ "$1" == "from" ]]; then
|
|
56
|
+
FILE_SRCS+=("$2")
|
|
57
|
+
FILE_CONTENTS+=("")
|
|
58
|
+
else
|
|
59
|
+
FILE_SRCS+=("")
|
|
60
|
+
FILE_CONTENTS+=("$2")
|
|
61
|
+
fi
|
|
62
|
+
PENDING_FILE=""
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# ── parse arguments ─────────────────────────────────────────────────────────
|
|
66
|
+
POSITIONAL_ARGS=()
|
|
67
|
+
|
|
68
|
+
while [[ $# -gt 0 ]]; do
|
|
69
|
+
case "$1" in
|
|
70
|
+
--repos)
|
|
71
|
+
need_value "$#" "$1"
|
|
72
|
+
REPOS_CSV="$2"
|
|
73
|
+
shift 2
|
|
74
|
+
;;
|
|
75
|
+
--file)
|
|
76
|
+
need_value "$#" "$1"
|
|
77
|
+
[[ -z "$PENDING_FILE" ]] || { echo "error: --file $PENDING_FILE has no --from/--content before the next --file" >&2; exit 2; }
|
|
78
|
+
PENDING_FILE="$2"
|
|
79
|
+
shift 2
|
|
80
|
+
;;
|
|
81
|
+
--from)
|
|
82
|
+
need_value "$#" "$1"
|
|
83
|
+
_push_file from "$2"
|
|
84
|
+
shift 2
|
|
85
|
+
;;
|
|
86
|
+
--content)
|
|
87
|
+
need_value "$#" "$1"
|
|
88
|
+
_push_file content "$2"
|
|
89
|
+
shift 2
|
|
90
|
+
;;
|
|
91
|
+
--edit)
|
|
92
|
+
need_value "$#" "$1"
|
|
93
|
+
EDITS+=("$2")
|
|
94
|
+
shift 2
|
|
95
|
+
;;
|
|
96
|
+
--pr)
|
|
97
|
+
DO_PR=1
|
|
98
|
+
shift
|
|
99
|
+
;;
|
|
100
|
+
--keep-clone)
|
|
101
|
+
KEEP_CLONE=1
|
|
102
|
+
shift
|
|
103
|
+
;;
|
|
104
|
+
-h|--help)
|
|
105
|
+
usage
|
|
106
|
+
exit 0
|
|
107
|
+
;;
|
|
108
|
+
-*)
|
|
109
|
+
echo "error: unknown option '$1'" >&2
|
|
110
|
+
echo "hint: run 'mass-update --help'" >&2
|
|
111
|
+
exit 2
|
|
112
|
+
;;
|
|
113
|
+
*)
|
|
114
|
+
POSITIONAL_ARGS+=("$1")
|
|
115
|
+
shift
|
|
116
|
+
;;
|
|
117
|
+
esac
|
|
118
|
+
done
|
|
119
|
+
|
|
120
|
+
[[ -z "$PENDING_FILE" ]] || { echo "error: --file $PENDING_FILE is missing its --from SRC or --content '...'" >&2; exit 2; }
|
|
121
|
+
|
|
122
|
+
# Reject positional free-text — it's a deferred follow-up.
|
|
123
|
+
if [[ ${#POSITIONAL_ARGS[@]} -gt 0 ]]; then
|
|
124
|
+
echo "error: positional free-text is not supported in this version" >&2
|
|
125
|
+
echo "hint: free-text is a deferred follow-up; use --file or --edit for deterministic ops" >&2
|
|
126
|
+
exit 2
|
|
127
|
+
fi
|
|
128
|
+
|
|
129
|
+
# Require at least one op.
|
|
130
|
+
if [[ ${#FILE_DESTS[@]} -eq 0 && ${#EDITS[@]} -eq 0 ]]; then
|
|
131
|
+
echo "error: no change requested — pass at least one --file or --edit" >&2
|
|
132
|
+
echo "hint: run 'mass-update --help'" >&2
|
|
133
|
+
exit 2
|
|
134
|
+
fi
|
|
135
|
+
|
|
136
|
+
# ── validate slug format ────────────────────────────────────────────────────
|
|
137
|
+
validate_slug() {
|
|
138
|
+
local slug="$1"
|
|
139
|
+
# Must match OWNER/REPO pattern with valid chars
|
|
140
|
+
if [[ ! "$slug" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]]; then
|
|
141
|
+
echo "error: '$slug' is not a valid GitHub slug (owner/repo)" >&2
|
|
142
|
+
echo "hint: slugs must match OWNER/REPO format, e.g. 'agentculture/widget-cli'" >&2
|
|
143
|
+
return 1
|
|
144
|
+
fi
|
|
145
|
+
# Reject local paths that happen to contain a slash
|
|
146
|
+
if [[ "$slug" == ../* || "$slug" == ./* || "$slug" == /* ]]; then
|
|
147
|
+
echo "error: '$slug' is not a valid GitHub slug — looks like a local path" >&2
|
|
148
|
+
echo "hint: slugs must be GitHub OWNER/REPO, not filesystem paths" >&2
|
|
149
|
+
return 1
|
|
150
|
+
fi
|
|
151
|
+
return 0
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# Reject a DEST/FILE path that could escape the scratch clone — absolute paths
|
|
155
|
+
# or any '..' path segment. This enforces the cardinal rule: every write stays
|
|
156
|
+
# inside the clone, never reaching beyond the repo.
|
|
157
|
+
validate_relpath() {
|
|
158
|
+
local p="$1" what="$2"
|
|
159
|
+
if [[ "$p" == /* ]]; then
|
|
160
|
+
echo "error: $what '$p' must be a repo-relative path, not absolute" >&2
|
|
161
|
+
echo "hint: targets must stay inside the scratch clone" >&2
|
|
162
|
+
return 1
|
|
163
|
+
fi
|
|
164
|
+
if [[ "$p" == ".." || "$p" == "../"* || "$p" == *"/.." || "$p" == *"/../"* ]]; then
|
|
165
|
+
echo "error: $what '$p' may not contain a '..' path segment" >&2
|
|
166
|
+
echo "hint: targets must stay inside the scratch clone" >&2
|
|
167
|
+
return 1
|
|
168
|
+
fi
|
|
169
|
+
return 0
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
# ── check preconditions ─────────────────────────────────────────────────────
|
|
173
|
+
if ! command -v gh >/dev/null 2>&1; then
|
|
174
|
+
echo "error: gh not found on PATH" >&2
|
|
175
|
+
echo "hint: install the GitHub CLI (gh) to use mass-update" >&2
|
|
176
|
+
exit 1
|
|
177
|
+
fi
|
|
178
|
+
|
|
179
|
+
if ! command -v git >/dev/null 2>&1; then
|
|
180
|
+
echo "error: git not found on PATH" >&2
|
|
181
|
+
echo "hint: install git to use mass-update" >&2
|
|
182
|
+
exit 1
|
|
183
|
+
fi
|
|
184
|
+
|
|
185
|
+
# ── parse repos ─────────────────────────────────────────────────────────────
|
|
186
|
+
IFS=',' read -r -a TARGETS <<< "$REPOS_CSV"
|
|
187
|
+
|
|
188
|
+
# Require at least one non-empty slug — an absent/blank --repos is an error,
|
|
189
|
+
# not a silent no-op that exits 0.
|
|
190
|
+
nonempty_targets=0
|
|
191
|
+
for raw in ${TARGETS[@]+"${TARGETS[@]}"}; do
|
|
192
|
+
t="${raw#"${raw%%[![:space:]]*}"}"
|
|
193
|
+
t="${t%"${t##*[![:space:]]}"}"
|
|
194
|
+
[[ -n "$t" ]] && nonempty_targets=$((nonempty_targets + 1))
|
|
195
|
+
done
|
|
196
|
+
if [[ "$nonempty_targets" -eq 0 ]]; then
|
|
197
|
+
echo "error: no target repos — pass --repos OWNER/REPO[,...]" >&2
|
|
198
|
+
echo "hint: at least one GitHub slug is required" >&2
|
|
199
|
+
exit 2
|
|
200
|
+
fi
|
|
201
|
+
|
|
202
|
+
# Validate all slugs first
|
|
203
|
+
for raw in "${TARGETS[@]}"; do
|
|
204
|
+
target="${raw#"${raw%%[![:space:]]*}"}"
|
|
205
|
+
target="${target%"${target##*[![:space:]]}"}"
|
|
206
|
+
[[ -n "$target" ]] || continue
|
|
207
|
+
if ! validate_slug "$target"; then
|
|
208
|
+
exit 2
|
|
209
|
+
fi
|
|
210
|
+
done
|
|
211
|
+
|
|
212
|
+
# Validate every destination/target path BEFORE any clone, so a traversal
|
|
213
|
+
# attempt never even creates the scratch dir.
|
|
214
|
+
for ((i=0; i<${#FILE_DESTS[@]}; i++)); do
|
|
215
|
+
validate_relpath "${FILE_DESTS[$i]}" "--file DEST" || exit 2
|
|
216
|
+
done
|
|
217
|
+
for ((i=0; i<${#EDITS[@]}; i++)); do
|
|
218
|
+
edit_file="${EDITS[$i]%%: *}"
|
|
219
|
+
validate_relpath "$edit_file" "--edit FILE" || exit 2
|
|
220
|
+
done
|
|
221
|
+
|
|
222
|
+
# ── scratch dir setup ───────────────────────────────────────────────────────
|
|
223
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
224
|
+
# Root the scratch dir at the git toplevel, not the (arbitrary) cwd — so a run
|
|
225
|
+
# from a subdir still confines and cleans up under <repo-root>/.mass-update.
|
|
226
|
+
if ! REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"; then
|
|
227
|
+
echo "error: not inside a git repository" >&2
|
|
228
|
+
echo "hint: run mass-update from within the rollout-cli repo" >&2
|
|
229
|
+
exit 1
|
|
230
|
+
fi
|
|
231
|
+
SCRATCH_DIR="$REPO_ROOT/.mass-update"
|
|
232
|
+
CLONES_DIR="$SCRATCH_DIR/clones"
|
|
233
|
+
|
|
234
|
+
# Track which scratch dirs to clean up
|
|
235
|
+
CLEANUP_DIRS=()
|
|
236
|
+
cleanup() {
|
|
237
|
+
for d in "${CLEANUP_DIRS[@]}"; do
|
|
238
|
+
if [[ -d "$d" ]]; then
|
|
239
|
+
rm -rf "$d"
|
|
240
|
+
fi
|
|
241
|
+
done
|
|
242
|
+
# Also remove the .mass-update dir itself if empty or if not keep-clone
|
|
243
|
+
if [[ "$KEEP_CLONE" -eq 0 ]]; then
|
|
244
|
+
rm -rf "$SCRATCH_DIR"
|
|
245
|
+
fi
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if [[ "$KEEP_CLONE" -eq 0 ]]; then
|
|
249
|
+
trap cleanup EXIT
|
|
250
|
+
fi
|
|
251
|
+
|
|
252
|
+
# ── apply ops to a clone ────────────────────────────────────────────────────
|
|
253
|
+
apply_file_ops() {
|
|
254
|
+
local clone_dir="$1"
|
|
255
|
+
local i dest
|
|
256
|
+
|
|
257
|
+
for ((i=0; i<${#FILE_DESTS[@]}; i++)); do
|
|
258
|
+
dest="${FILE_DESTS[$i]}"
|
|
259
|
+
# Ensure parent dir exists
|
|
260
|
+
mkdir -p "$(dirname "$clone_dir/$dest")"
|
|
261
|
+
if [[ "${FILE_KINDS[$i]}" == "from" ]]; then
|
|
262
|
+
# Byte-verbatim copy — `$(cat)` would strip trailing newlines.
|
|
263
|
+
cp -- "${FILE_SRCS[$i]}" "$clone_dir/$dest"
|
|
264
|
+
else
|
|
265
|
+
printf '%s' "${FILE_CONTENTS[$i]}" > "$clone_dir/$dest"
|
|
266
|
+
fi
|
|
267
|
+
done
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
apply_edit_ops() {
|
|
271
|
+
local clone_dir="$1"
|
|
272
|
+
local edit file_part find_part replace_part
|
|
273
|
+
|
|
274
|
+
for edit in "${EDITS[@]}"; do
|
|
275
|
+
# Parse 'FILE: FIND => REPLACE'
|
|
276
|
+
# Split on first ': ' to get file and the rest
|
|
277
|
+
file_part="${edit%%: *}"
|
|
278
|
+
rest="${edit#*: }"
|
|
279
|
+
# Split on ' => ' to get find and replace
|
|
280
|
+
find_part="${rest%% => *}"
|
|
281
|
+
replace_part="${rest#* => }"
|
|
282
|
+
|
|
283
|
+
local target_file="$clone_dir/$file_part"
|
|
284
|
+
if [[ ! -f "$target_file" ]]; then
|
|
285
|
+
echo "error: file '$file_part' not found in clone" >&2
|
|
286
|
+
return 1
|
|
287
|
+
fi
|
|
288
|
+
|
|
289
|
+
# Use Python for unique-replace check
|
|
290
|
+
python3 -c "
|
|
291
|
+
import sys
|
|
292
|
+
content = open(sys.argv[1]).read()
|
|
293
|
+
find = sys.argv[2]
|
|
294
|
+
replace = sys.argv[3]
|
|
295
|
+
count = content.count(find)
|
|
296
|
+
if count != 1:
|
|
297
|
+
print(f\"error: '{find}' occurs {count} times (need exactly 1)\", file=sys.stderr)
|
|
298
|
+
sys.exit(1)
|
|
299
|
+
content = content.replace(find, replace)
|
|
300
|
+
open(sys.argv[1], 'w').write(content)
|
|
301
|
+
" "$target_file" "$find_part" "$replace_part" || return 1
|
|
302
|
+
done
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
# ── process each repo ───────────────────────────────────────────────────────
|
|
306
|
+
overall_rc=0
|
|
307
|
+
|
|
308
|
+
for raw in "${TARGETS[@]}"; do
|
|
309
|
+
target="${raw#"${raw%%[![:space:]]*}"}"
|
|
310
|
+
target="${target%"${target##*[![:space:]]}"}"
|
|
311
|
+
[[ -n "$target" ]] || continue
|
|
312
|
+
|
|
313
|
+
owner="${target%%/*}"
|
|
314
|
+
repo="${target#*/}"
|
|
315
|
+
clone_name="${owner}-${repo}"
|
|
316
|
+
clone_dir="$CLONES_DIR/$clone_name"
|
|
317
|
+
|
|
318
|
+
# Check auth before cloning
|
|
319
|
+
if ! gh auth status >/dev/null 2>&1; then
|
|
320
|
+
echo "error: gh auth status failed" >&2
|
|
321
|
+
echo "hint: run 'gh auth login' to authenticate with GitHub" >&2
|
|
322
|
+
exit 1
|
|
323
|
+
fi
|
|
324
|
+
|
|
325
|
+
# Create scratch dirs
|
|
326
|
+
mkdir -p "$clone_dir"
|
|
327
|
+
CLEANUP_DIRS+=("$clone_dir")
|
|
328
|
+
|
|
329
|
+
# Clone the repo; fail this target if it didn't produce a git work tree
|
|
330
|
+
# (don't let a failed clone silently fall through to confusing op errors).
|
|
331
|
+
if ! gh repo clone "$target" "$clone_dir" 2>/dev/null \
|
|
332
|
+
|| ! git -C "$clone_dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
333
|
+
echo "error: failed to clone $target" >&2
|
|
334
|
+
echo "hint: check the slug, your gh auth, and network access" >&2
|
|
335
|
+
overall_rc=1
|
|
336
|
+
continue
|
|
337
|
+
fi
|
|
338
|
+
|
|
339
|
+
# Create work branch
|
|
340
|
+
git -C "$clone_dir" checkout -b "mass-update/$clone_name" 2>/dev/null || \
|
|
341
|
+
git -C "$clone_dir" switch -c "mass-update/$clone_name" 2>/dev/null || true
|
|
342
|
+
|
|
343
|
+
# Apply file ops
|
|
344
|
+
apply_file_ops "$clone_dir" || { overall_rc=1; continue; }
|
|
345
|
+
|
|
346
|
+
# Apply edit ops
|
|
347
|
+
apply_edit_ops "$clone_dir" || { overall_rc=1; continue; }
|
|
348
|
+
|
|
349
|
+
if [[ "$DO_PR" -eq 1 ]]; then
|
|
350
|
+
# Issues are filed ONLY via communicate's post-issue.sh — no direct
|
|
351
|
+
# `agtag` fallback (that would bypass the /communicate channel).
|
|
352
|
+
post_issue_script="$SCRIPT_DIR/../../communicate/scripts/post-issue.sh"
|
|
353
|
+
if [[ ! -f "$post_issue_script" ]]; then
|
|
354
|
+
echo "error: communicate post-issue.sh not found — cannot file tracking issue" >&2
|
|
355
|
+
echo "hint: issues must be filed via /communicate (post-issue.sh); install the communicate skill" >&2
|
|
356
|
+
overall_rc=1
|
|
357
|
+
continue
|
|
358
|
+
fi
|
|
359
|
+
|
|
360
|
+
# Stage + commit (fail this target loudly on error, never mask it).
|
|
361
|
+
git -C "$clone_dir" add -A
|
|
362
|
+
if ! git -C "$clone_dir" commit -m "mass-update: deterministic update"; then
|
|
363
|
+
echo "error: commit failed for $target" >&2
|
|
364
|
+
overall_rc=1
|
|
365
|
+
continue
|
|
366
|
+
fi
|
|
367
|
+
|
|
368
|
+
# Push work branch — a failed push must not be swallowed.
|
|
369
|
+
if ! git -C "$clone_dir" push origin "mass-update/$clone_name"; then
|
|
370
|
+
echo "error: push failed for $target" >&2
|
|
371
|
+
overall_rc=1
|
|
372
|
+
continue
|
|
373
|
+
fi
|
|
374
|
+
|
|
375
|
+
# Create PR — capture failure rather than printing an empty URL.
|
|
376
|
+
if ! pr_url="$(gh pr create \
|
|
377
|
+
--repo "$target" \
|
|
378
|
+
--title "mass-update: deterministic update" \
|
|
379
|
+
--body "Automated update via mass-update skill." \
|
|
380
|
+
--head "mass-update/$clone_name")" || [[ -z "$pr_url" ]]; then
|
|
381
|
+
echo "error: gh pr create failed for $target" >&2
|
|
382
|
+
overall_rc=1
|
|
383
|
+
continue
|
|
384
|
+
fi
|
|
385
|
+
echo "PR_URL: $pr_url"
|
|
386
|
+
|
|
387
|
+
# File the companion tracking issue. Keep the body file INSIDE the
|
|
388
|
+
# scratch dir — never $TMPDIR — so no write lands outside the repo.
|
|
389
|
+
tmp_body="$(mktemp -p "$SCRATCH_DIR")"
|
|
390
|
+
printf 'PR: %s\n' "$pr_url" > "$tmp_body"
|
|
391
|
+
if ! issue_url="$(bash "$post_issue_script" \
|
|
392
|
+
--repo "$target" \
|
|
393
|
+
--title "mass-update: deterministic update" \
|
|
394
|
+
--body-file "$tmp_body")" || [[ -z "$issue_url" ]]; then
|
|
395
|
+
echo "error: filing tracking issue failed for $target" >&2
|
|
396
|
+
rm -f "$tmp_body"
|
|
397
|
+
overall_rc=1
|
|
398
|
+
continue
|
|
399
|
+
fi
|
|
400
|
+
rm -f "$tmp_body"
|
|
401
|
+
echo "ISSUE_URL: $issue_url"
|
|
402
|
+
else
|
|
403
|
+
# Preview mode: stage and show diff
|
|
404
|
+
git -C "$clone_dir" add -A
|
|
405
|
+
echo "=== Diff for $target ==="
|
|
406
|
+
git -C "$clone_dir" diff --cached
|
|
407
|
+
fi
|
|
408
|
+
done
|
|
409
|
+
|
|
410
|
+
exit "$overall_rc"
|