Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,33 @@ This applies to:
- Commit message trailers referenced in PR descriptions
- Any auto-generated changelogs or release notes

## Repo Topology & Vendored Code (Read Before Editing)

This repo **distributes** RepoMan; it is not where the engine is developed.

- Engine scripts are developed and tested in
[rossoctl/automation](https://github.com/rossoctl/automation) `scripts/`.
Engine changes are made there, then vendored here.
- Everything under `skills/repoman/scripts/` — the shell scripts and the
non-shell extras (e.g. `doc-drift-check.py`) — is **vendored output**,
provenance-stamped in `skills/repoman/scripts/_vendor.json`. **Never edit
anything here in place.** To update it, run
`scripts/vendor-engine.sh --source <automation-checkout> --dest skills/repoman/scripts`
from a clean automation checkout that has your fix committed (the script
rejects a dirty source unless `--allow-dirty` is passed).
- RepoMan-specific skills are canonical **here**: their `SKILL.md`s are
authored in this repo.
[rossoctl/agent-skills](https://github.com/rossoctl/agent-skills) hosts
skills designed to work standalone as well as with RepoMan — do not port
RepoMan skill docs there.
- The per-program `skills/<program>/scripts/` copies are known-stale relative

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's about time to fix #5 before releasing v0.1.3. I have assigned it to my local Claude, but with the instruction/understanding that merging a PR for it is non-blocking for this one.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: #5 cannot land until this one merges because the mechanistic part of it to be done on repoman would also change the same set of files that this PR does.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — sequencing noted: this merges first, then #5 (its mechanistic repoman-side changes touch the same files as this PR). No action here.

Assisted-By: Claude Code

to automation's engine (resync tracked in
[#5](https://github.com/rossoctl/repoman/issues/5)).
Do not treat either side as authoritative or copy between them piecemeal.
- The full contributor workflow (placement, testing, dry runs, vendoring, PR
rules) is in the README's `## Development` section — read it before
changing anything here.

## Scope Discipline

Only commit files directly related to the PR's stated purpose. Do not bundle
Expand Down
66 changes: 61 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,68 @@ adding the marketplace to browse the authoritative list.

Individual programs may have additional requirements documented in their `SKILL.md`.

## How it is built
## Development

RepoMan is developed across three repositories:

| Repository | Role |
|------------|------|
| [rossoctl/automation](https://github.com/rossoctl/automation) | Engine source of truth: the flat scripts in `scripts/`, plus tests and specs. All engine changes are made and tested there first. |
| [rossoctl/agent-skills](https://github.com/rossoctl/agent-skills) | Skills designed to work standalone as well as with RepoMan. |
| [rossoctl/repoman](https://github.com/rossoctl/repoman) (this repo) | Distribution (`rossoctl-repoman` marketplace) and the canonical home for RepoMan-specific `SKILL.md`s. The engine is not authored here. |

See the `[CHANGELOG.md](https://github.com/rossoctl/repoman/blob/main/CHANGELOG.md)` for release history.

### Contributor workflow

1. **Place the work.** A skill that is useful on its own → author it in
agent-skills. A RepoMan-specific program (keeps state under `~/.repoman`) →
deterministic scripts in automation, `SKILL.md` in this repo. New programs

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Step 1 covers the deterministic script and the SKILL.md, but a new program also needs repoman-side wiring that isn't engine code — #12 needed all of it:

  • an entry in .claude-plugin/marketplace.json (without it the program can't be installed at all);
  • a row in the README ## Programs table;
  • the program → skill mapping in skills/repoman/SKILL.md (the _PROGRAM_TABLE/KNOWN_PROGRAMS registries are engine-side, so automation covers those).

As written, a contributor can follow this to the letter and still ship a skill nobody can install or a program /repoman cannot resolve. Worth naming those three explicitly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2a0d0d3. Step 1 now names all three: the .claude-plugin/marketplace.json entry, the README ## Programs table row, and the program → skill mapping in skills/repoman/SKILL.md — with a note that these are repoman-side wiring, not engine code.

Assisted-By: Claude Code

start from automation's `docs/program-template.md`. A new program also
needs repoman-side wiring that is not engine code: a
`.claude-plugin/marketplace.json` entry, a row in the README
`## Programs` table, and the program → skill mapping in
`skills/repoman/SKILL.md`.
2. **Test in automation**, next to the existing tests in `tests/`. Bash 3.2
only: no `mapfile`, no associative arrays, BSD fallbacks for GNU-only
flags. If the skill writes to `~/.repoman`, give its `SKILL.md` a
machine-readable `### Requirements` block so `repoman-check.sh` can gate
the run.
3. **Keep real state out of test runs.** Point `REPOMAN_CONFIG_FILE` /
`REPOMAN_REPOS_FILE` at scratch files; never hand-edit `~/.repoman`
(`repoman-setup.sh` is its only writer); drive first runs through
`repoman-run.sh <program> owner/repo --dry-run` and drop `--dry-run` only
once the output looks right.
4. **Vendor the engine** into this repo (below) — a new engine script must be
added to both the vendor manifest and its test fixture in
`tests/test-vendor-engine.sh`, including the expected counts — then run
that test. To try the plugin without reinstalling from the marketplace:
`claude plugin marketplace add <this-repo>` then
`claude plugin install repoman@rossoctl-repoman`.
5. **Before the PR**: `git config core.hooksPath scripts/hooks`, sign off
with `git commit -s` (DCO), use a conventional title, one purpose per PR.

### Vendoring the engine

`skills/repoman/scripts/` is **generated output** — a vendored copy of the
automation engine, stamped with its source commit in
[`_vendor.json`](skills/repoman/scripts/_vendor.json). Never edit those files
directly: an in-place fix bypasses automation's tests and is silently
overwritten on the next vendor. To pick up engine changes, run it from this
repo against a **clean automation checkout** — the stamp records the source
HEAD SHA, so vendoring uncommitted changes would produce a stamp that does not
match the copied bytes (the script rejects a dirty source unless
`--allow-dirty` is passed):

```bash

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will likely need to change during the development of #5 to include vendoring the scripts to the individual skills, too. Non-blocking comment; simply an observation after a recent investigation on the ordering of this repo vs #5 work.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense — this section describes the current single-dest vendoring, and #5 will extend it to the per-program skills/<program>/scripts/ copies when it lands. Leaving as-is for this PR.

Assisted-By: Claude Code

scripts/vendor-engine.sh --source <path-to-automation-checkout> \
--dest skills/repoman/scripts
```

The engine scripts are vendored from [rossoctl/automation](https://github.com/rossoctl/automation)
(the development source of truth) into this distribution repo, with a provenance stamp
(`skills/repoman/scripts/_vendor.json`) recording the exact source commit. See the
`CHANGELOG.md` for release history.
The script copies an explicit manifest (never a glob, so it cannot touch the
standalone skills' own `scripts/`), pre-validates the whole manifest before
copying anything, and rewrites `_vendor.json` with the automation HEAD SHA it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

vendor-engine.sh stamps git -C "$SRC" rev-parse HEAD with no dirty-tree check (scripts/vendor-engine.sh:65), so vendoring an automation checkout with uncommitted changes — which CLAUDE.md:59 actively invites with "a checkout that has your fix" — records a source_sha that does not contain the vendored bytes. Since this section is specifically about provenance, add "from a clean checkout" next to the invocation (or have the script reject a dirty source; that fix belongs in automation).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2a0d0d3 — docs side: the README invocation and the CLAUDE.md bullet now both require a clean automation checkout (fix committed), with the reason stated (the stamp records the source HEAD SHA, so a dirty tree stamps a SHA that does not contain the vendored bytes). Agreed the stronger fix — vendor-engine.sh rejecting a dirty source — belongs in automation; want me to file that as an issue there?

Assisted-By: Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filed as #20. One correction to the review's note: vendor-engine.sh lives in this repo (scripts/vendor-engine.sh), not in automation — it is not part of the engine manifest — so the fix lands here and the issue is filed on rossoctl/repoman.

Assisted-By: Claude Code

vendored from. `tests/test-vendor-engine.sh` is its regression test.

## License

Expand Down
21 changes: 20 additions & 1 deletion scripts/vendor-engine.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,36 @@ set -uo pipefail
# Part of the RepoMan v0.1.0 packaged release (epic rossoctl/automation#72).
#
# vendor-engine.sh --source <automation-repo> --dest <repoman-scripts-dir>
# [--allow-dirty]

SRC="" DEST=""
SRC="" DEST="" ALLOW_DIRTY=false
while [ $# -gt 0 ]; do
case "$1" in
--source) SRC="${2:-}"; shift 2 ;;
--dest) DEST="${2:-}"; shift 2 ;;
--allow-dirty) ALLOW_DIRTY=true; shift ;;
*) echo "ERROR: unknown arg: $1" >&2; exit 2 ;;
esac
done
[ -n "$SRC" ] && [ -n "$DEST" ] || { echo "ERROR: --source and --dest are required" >&2; exit 2; }

# Fail closed: if $SRC cannot be inspected at all (not a git checkout — e.g.
# a tarball extract — or git missing), the dirty check below would read the
# resulting empty output as "clean", the run would only die at the stamp
# step, and the dest would be left half-vendored with a stale _vendor.json.
# Unconditional: the stamp needs a source HEAD even with --allow-dirty.
if ! git -C "$SRC" rev-parse --git-dir >/dev/null 2>&1; then
echo "ERROR: cannot inspect $SRC (not a git checkout?); the provenance stamp requires a source HEAD" >&2; exit 1
fi

# Provenance guard: the stamp records the source HEAD SHA, so vendoring a
# dirty tree would stamp a commit that does not contain the copied bytes
# (see repoman#20). Reject a dirty source unless --allow-dirty is passed
# (scratch experiments only; the stamp then knowingly lies about the bytes).
if [ "$ALLOW_DIRTY" = false ] && [ -n "$(git -C "$SRC" status --porcelain 2>/dev/null)" ]; then

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion — the guard fails open: git -C "$SRC" status --porcelain 2>/dev/null produces empty output both when the tree is clean and when git cannot inspect it at all (not a git checkout, git missing, $SRC unresolvable). In that second case the guard passes and the run proceeds: the manifest pre-validation passes, every file is copied into $DEST, and only then does git rev-parse HEAD fail — leaving $DEST freshly overwritten with a stale or absent _vendor.json. That is the stamp/bytes mismatch this PR exists to prevent, reachable from a source path that isn't a git checkout (e.g. an exported tarball).

Since the stamp requires HEAD anyway, failing early on inspection failure is cheap:

if [ "$ALLOW_DIRTY" = false ]; then
  if ! DIRTY=$(git -C "$SRC" status --porcelain 2>&1); then
    echo "ERROR: cannot inspect $SRC for uncommitted changes (not a git checkout?)" >&2; exit 1
  fi
  [ -z "$DIRTY" ] || { echo "ERROR: source $SRC has uncommitted changes; commit or stash them so the provenance stamp matches the vendored bytes (or pass --allow-dirty)" >&2; exit 1; }
fi

Worth a regression case too: a source directory with every manifest file but no .git should fail before copying. Not blocking — the documented workflow points at a real checkout.

@rubambiza rubambiza Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jimcadden As the AI agents like to say "belt-and-suspenders". Worth fixing.

@jimcadden jimcadden Nov 6, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1606d1d. Went with the fail-early shape from the sibling thread (an up-front rev-parse --git-dir check before any copy) rather than tightening the status capture itself — once rev-parse succeeds, the existing --porcelain 2>/dev/null empty-output case can only mean clean, and the up-front form also covers --allow-dirty (the stamp needs a source HEAD regardless).

Your regression case is in: tests/test-vendor-engine.sh now builds a source with every manifest file but no .git and asserts it fails before copying (dest left uncreated), both with and without --allow-dirty.

Assisted-By: Claude Code

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): the guard swallows git -C "$SRC" status --porcelain 2>/dev/null, so a non-git --source (a wrong path that exists, or a tarball extract) yields empty output and is treated as clean — the guard passes. The copy loop then writes the full engine into --dest, and only the later git rev-parse HEAD fails loud, leaving a half-vendored dest with no _vendor.json. Verified by running with a non-git source: 26 scripts copied, then ERROR: cannot read source HEAD sha, dest left behind.

Since the guard exists to keep the stamp honest, it should fail-closed when $SRC is not a git repo — e.g. an up-front git -C "$SRC" rev-parse --git-dir >/dev/null 2>&1 || { echo ... ; exit 1; } before any copy — rather than letting "can't determine cleanliness" read as "clean". Normal use (an automation checkout) is unaffected.

@jimcadden jimcadden Nov 6, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1606d1d, along the lines of your suggestion: an up-front git -C "$SRC" rev-parse --git-dir check now rejects an uninspectable source before anything is copied. It is deliberately unconditional — outside the --allow-dirty path — since the stamp needs a source HEAD either way, so --allow-dirty no longer postpones the same failure to after the copy loop.

Also added the regression case clawgenti asked for on the sibling thread: a source holding the full manifest but no .git fails (with and without --allow-dirty) leaving the dest uncreated. Verified your repro end-to-end: immediate error, 0 files copied.

Assisted-By: Claude Code

echo "ERROR: source $SRC has uncommitted changes; commit or stash them so the provenance stamp matches the vendored bytes (or pass --allow-dirty)" >&2; exit 1
fi

# The 26 engine shell scripts. Flat set: nearly all source program-lib.sh,
# which sources core.sh/github-api.sh/fork.sh/org.sh/repoman-requirements.sh.
MANIFEST="automation-health-dashboard core dep-bump-fixer dep-bump-scanner \
Expand Down
33 changes: 33 additions & 0 deletions tests/test-vendor-engine.sh
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,20 @@ bash "$VENDOR" --source "$SRC" --dest "$DEST" >/dev/null 2>&1
got2=$(jq -r '.source_sha' "$DEST/_vendor.json" 2>/dev/null)
if [ "$got2" = "$want" ]; then echo "PASS: idempotent stamp sha"; else echo "FAIL: idempotency $got2 != $want"; fail=1; fi

# --- Dirty source: rejected before any copy, dest left uncreated ---
DIRTYDEST="$TMP/dirtydest"
echo x >> "$SRC/scripts/org.sh" # uncommitted modification
if bash "$VENDOR" --source "$SRC" --dest "$DIRTYDEST" >/dev/null 2>&1; then
echo "FAIL: dirty source was not rejected"; fail=1
else echo "PASS: dirty source rejected"; fi
if [ ! -d "$DIRTYDEST" ]; then echo "PASS: dirty rejection left dest uncreated"; else echo "FAIL: dirty rejection still created dest"; fail=1; fi
# --- Dirty source with --allow-dirty: override works, stamp still stamped ---
if bash "$VENDOR" --source "$SRC" --dest "$DIRTYDEST" --allow-dirty >/dev/null 2>&1; then
echo "PASS: --allow-dirty overrides the guard"
else echo "FAIL: --allow-dirty did not override"; fail=1; fi
if [ -f "$DIRTYDEST/org.sh" ]; then echo "PASS: --allow-dirty run copied files"; else echo "FAIL: --allow-dirty run copied nothing"; fail=1; fi
git -C "$SRC" checkout -- scripts/org.sh # restore for the tests below

# --- Missing source fails loud AND leaves the dest empty (no half-vendored dir) ---
BADDEST="$TMP/baddest"
missing_source_fails() (
Expand All @@ -71,4 +85,23 @@ if ! missing_source_fails; then echo "PASS: missing source fails loud"; else ech
copied=$(find "$BADDEST" -maxdepth 1 -name '*.sh' 2>/dev/null | wc -l | tr -d ' ')
if [ "${copied:-0}" = "0" ]; then echo "PASS: no half-vendored dir (dest empty on failure)"; else echo "FAIL: $copied files copied before the abort (half-vendored)"; fail=1; fi

# --- Non-git source (full manifest, no .git): rejected before any copy ---
# The dirty guard's `git status --porcelain` is empty for a non-git dir, which
# used to read as "clean"; the run then died only at the stamp step, after
# overwriting the dest. Must fail closed — with and without --allow-dirty
# (the stamp needs a source HEAD either way).
NOGITDEST="$TMP/nogitdest"
NOGIT="$TMP/nogit"; mkdir -p "$NOGIT/scripts"
# shellcheck disable=SC2086
for f in $MANIFEST; do echo x > "$NOGIT/scripts/$f.sh"; done
# shellcheck disable=SC2086
for f in $EXTRA_FILES; do echo x > "$NOGIT/scripts/$f"; done
if bash "$VENDOR" --source "$NOGIT" --dest "$NOGITDEST" >/dev/null 2>&1; then
echo "FAIL: non-git source was not rejected"; fail=1
else echo "PASS: non-git source rejected"; fi
if [ ! -d "$NOGITDEST" ]; then echo "PASS: non-git rejection left dest uncreated"; else echo "FAIL: non-git rejection still created dest"; fail=1; fi
if bash "$VENDOR" --source "$NOGIT" --dest "$NOGITDEST" --allow-dirty >/dev/null 2>&1; then
echo "FAIL: non-git source accepted with --allow-dirty"; fail=1
else echo "PASS: non-git source rejected even with --allow-dirty"; fi

[ "$fail" -eq 0 ] && echo "PASS" || { echo "FAILURES"; exit 1; }
Loading