diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c3e404..2d8a841 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,9 @@ summary: Chronological history of repository and skill changes. ## 2026-07-21 — Consolidated carve-changesets CLI and live contract +- feat: package the carve-changesets skill - docs: define carve-changesets suite handoffs + (`2df5136e2a7226666bc136e30905c2442a579c78`) - feat: add stateless changeset merge and propagation (`925affa807c203824127a0fe5e0fb084f14f378d`) - refactor: make strict apply use one proof @@ -170,7 +172,7 @@ summary: Chronological history of repository and skill changes. (797e56fcb2bc41fd8e84491866c86a2af1dd31f9) - fix: CI agentskills install and changelog workflow rules (460a81780211264cdc568e42e3f8e4b73ca2bcea) -- feat: AGENTS-aware test command discovery for prepare-changesets +- feat: add AGENTS-aware test command discovery (1730fb654885f4ea1a5448e18bab1f558b5063ad) - chore: initialize agent-scripts monorepo (420d1cdacb2855d3d9c494e57447954995043c42) diff --git a/README.md b/README.md index 2659965..559a3aa 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,6 @@ Current reusable agent skills: delegate each published PR lifecycle to `babysit-pr` - `skills/review-code-change` — orchestrate the repository-owned review lenses into one evidence-bound, deduplicated verdict -- `skills/prepare-changesets` — decompose a large, review-ready branch into a - deterministic chain of smaller, reviewable changesets and GitHub PRs - `skills/review-correctness` — find material behavioral, security, compatibility, data-integrity, and validation failures in a code change - `skills/review-solution-simplicity` — challenge whole-solution machinery that @@ -77,7 +75,7 @@ just check Run skill-specific tests: ```bash -just test-prepare-changesets +just test-carve-changesets just test-review-suite just test-babysit-pr just test-implement-ticket @@ -94,7 +92,7 @@ python3 review-suite/scripts/validate.py pair packet.json result.json Run deterministic local evals without an agent runtime: ```bash -just eval-prepare-changesets +just eval-carve-changesets just eval-implement-ticket ``` diff --git a/justfile b/justfile index c65f7b2..bec2656 100644 --- a/justfile +++ b/justfile @@ -61,14 +61,11 @@ test-implement-epic: test-carve-changesets: python3 -m unittest discover -s {{skills_dir}}/carve-changesets/scripts/tests -p 'test_*.py' -test-prepare-changesets: - python3 -m unittest discover -s {{skills_dir}}/prepare-changesets/scripts/tests -p 'test_*.py' +eval-carve-changesets: + {{skills_dir}}/carve-changesets/scripts/evals/runner.py --skip-codex -eval-prepare-changesets: - {{skills_dir}}/prepare-changesets/scripts/evals/runner.py --skip-codex - -eval-prepare-changesets-codex: - {{skills_dir}}/prepare-changesets/scripts/evals/runner.py +eval-carve-changesets-codex: + {{skills_dir}}/carve-changesets/scripts/evals/runner.py validate-skills: lint-skills diff --git a/skills/carve-changesets/SKILL.md b/skills/carve-changesets/SKILL.md new file mode 100644 index 0000000..eeb9972 --- /dev/null +++ b/skills/carve-changesets/SKILL.md @@ -0,0 +1,208 @@ +--- +name: carve-changesets +description: Recompose a large review-ready source branch into an ordered chain of intentional, independently reviewable changesets while preserving its final behavior. Use when asked to split, carve, decompose, publish, review, merge, or propagate an oversized branch as stacked GitHub pull requests; keeps proposal state ephemeral, promotes truth into git and GitHub, delegates repository review and PR lifecycle work, and requires explicit authority for every remote mutation. +--- + +# Carve Changesets + +Turn one existing review-ready source branch into a plain-git, plain-GitHub +changeset chain. Preserve the source branch, keep each intermediate result safe +and reviewable, and prove that the fully merged chain is equivalent to the +source candidate. + +This skill owns decomposition, truth promotion, chain mechanics, whole-chain +equivalence, and downstream propagation. It delegates per-changeset review to +`review-code-change` and a published PR's lifecycle to `babysit-pr` without +copying either skill's workflow. + +## Load the references + +- Always read [the normative contract](references/SPEC.md) before planning or + mutating a chain. +- Read [the plan schema](references/plan-schema.md) before creating or editing + `.carve-changesets/plan.json`. +- Read [the CLI reference](references/cli.md) before invoking a subcommand or + selecting flags. +- Read [the suite handoffs](references/suite-handoffs.md) before reviewing a + changeset, publishing PRs, or delegating a PR lifecycle. + +Use `scripts/cli.py` as the single command surface. Resolve script and reference +paths from this skill's root, not from a repository-relative installation +assumption. + +## Require compatible capabilities + +Require a runtime that can: + +- resolve exact git refs, inspect ancestry and trees, and create isolated local + branches or worktrees without mutating the source branch; +- run Python 3 and separately approved repository validation commands; +- reach GitHub over the network and use an authenticated `gh` session whenever + publication, live PR state, review, merge, or propagation is in scope; +- load repository-owned `review-code-change` for required per-changeset review; +- load repository-owned `babysit-pr` and retain task ownership while waiting + whenever a published PR lifecycle is delegated; and +- read current checks, reviews, comments, reactions, and resolved-thread state + before a readiness or merge claim. + +Return `blocked` before the affected mutation when a required capability is +missing. Do not download a substitute workflow, bypass review, publish a PR that +has no lifecycle owner, or treat partial GitHub data as clean. + +## Resolve the operating contract + +Before mutation, discover or receive and verify: + +- repository identity, current checkout, worktree state, and applicable + repository instructions; +- exact source and base branches and SHAs, their merge base, source freshness, + and the complete candidate diff; +- the immutable source outcome and the behavior, schema, constraints, public + interfaces, migrations, and rollout properties the final chain must preserve; +- an explicitly approved test command and any required database, build, + integration, or manual validation commands; +- cognitive-load guardrails, acceptable intermediate states, decomposition + order, feature-flag policy, and database-migration requirements from the + normative contract; +- the requested terminal boundary: proposal only, local chain, published PRs, or + fully merged chain; and +- authority for local decomposition, validation execution, publication, + candidate repair, review communication, merge, propagation, and cleanup. + +Treat discovered validation commands as proposals until the user explicitly +approves them. The source branch is immutable throughout the workflow. Stop if +the source is behind the base unless the contract's explicit override and +confirmation are both present. + +### Authority levels + +- **Decompose-only** permits the ephemeral plan, local changeset branches and + commits, required trailers, and separately approved validation. It forbids + every remote write. +- **Publish** additionally permits pushing exact changeset branches, opening or + updating their correctly based PRs, and delegating each PR to `babysit-pr` + with `ready_to_merge`. Merge and force-push authority remain withheld. +- **Merge-and-propagate** additionally permits `merge_when_ready`, sequential + changeset merges after all gates pass, and exact-lease downstream propagation. + It never permits force-pushing the base, source, merged upstream, or an + unowned branch. + +Pass authority to delegated skills without expansion. Reply and thread +resolution authority remain separate from branch mutation and merge authority. + +## Execute the phase workflow + +### 1. Propose + +Run `preflight` against the exact source and base with the approved test +command. Use `init-plan` to create `.carve-changesets/plan.json`, then replace +every placeholder with cohesive boundaries, ordering, intent, extraction +selectors, validation, and intentional incompleteness. Use `hunk-preview` when +textual hunk selection needs inspection, and require `validate --strict` before +promotion. + +At this phase the plan is the only writable truth. Do not create changeset refs +or perform remote operations. Return `plan_ready` when proposal is the requested +boundary. + +### 2. Materialize and prove equivalence + +Use `create-chain` to create append-only `-N` branches and stamped +commits. Run `validate-chain` with approved validation, `compare` for the +reconstructed tree, and the applicable `squash-check` or `db-compare` evidence. +Use `status --local-only` to inspect live local truth without GitHub. + +Construct and run the required `review-code-change` packet for every exact +changeset candidate. The review suite is read-only; this skill applies accepted +fixes and rebuilds invalidated validation and review evidence. Return +`chain_ready` only after every local candidate and the full chain satisfy the +contract. + +### 3. Publish + +Require publish authority before any remote mutation. `push-chain` and +`pr-create` are dry-run by default; use their execution flag only after +reverifying the exact remote, branches, heads, predecessor bases, metadata, and +exclusive ownership. Use `status` to reconstruct published truth from live git +and GitHub rather than a local cache. + +Delegate each exact PR to `babysit-pr` using the policy and evidence in the +suite handoff reference. While delegated, do not run a competing CI, feedback, +review, or mutation loop. Return `prs_open` only when every applicable non-merge +gate at the requested boundary passes and merge is withheld. + +### 4. Merge and propagate + +Require merge-and-propagate authority. When `babysit-pr` returns `merged`, +independently verify the exact merged candidate on the live base, rehydrate the +chain, use `propagate` to rewrite only the downstream suffix with exact leases, +then hand the next exact PR back to `babysit-pr`. + +Use `merge-propagate` only when the resolved workflow explicitly assigns the +direct merge to this CLI and no delegated owner controls that PR. Both execution +paths are dry-run by default and require the merge-and-propagate acknowledgement +before mutation. Resume interrupted work from live git and GitHub state, not +from the plan or a cache. + +Return `all_merged` only after every PR, propagation step, final equivalence +check, required validation, and authorized cleanup has been verified. + +## Preserve safety and truth + +- Keep `.carve-changesets/` ignored and out of commits and PRs. +- Resolve every mutation target to an exact repository, ref, SHA, PR, and + worktree immediately before acting. +- Keep remote mutation dry-run by default and use explicit refspecs and exact + leases where the contract permits force-push. +- Never use a plan edit or cached head to override materialized, published, or + merged truth. +- Never reset away, overwrite, or delete user work, credentials, environment + files, databases, or non-reproducible artifacts. +- Rebuild candidate-bound validation, review, CI, and feedback evidence after a + head change; retain evidence across base-only drift only with the documented + proof. + +## Stop conditions + +Return `blocked` without widening scope when: + +- source, base, repository, candidate, chain, PR, or ownership identity is + ambiguous, stale, conflicting, or changes unexpectedly; +- the source is dirty, incomplete, mutable, or behind the base without the + explicit two-part override; +- a proposed changeset cannot remain cohesive, independently understandable, + safely intermediate, or mergeable in sequence; +- required validation, review, equivalence, migration, or GitHub evidence is + missing or fails; +- stronger live truth conflicts with the plan or other weaker records; +- safe progress would require rewriting the source, base, merged upstream, or an + unowned branch; +- required authority, capability, infrastructure, or exclusive ownership is + absent; or +- a material product, data, architecture, migration, or rollout decision is + unresolved. + +Ordinary CI wait time, difficult decomposition, or an independently ready later +changeset is not a blocker. + +## Return one terminal handoff + +Return exactly one terminal state with evidence bound to the current source, +base, chain, and PR candidates: + +- `plan_ready`: exact source/base identity, complete validated plan and proposed + validation, with no materialized branch or remote mutation. +- `chain_ready`: exact local branch heads, trailers, ancestry, per-changeset + validation and clean review, whole-chain equivalence, and no new publication. +- `prs_open`: all `chain_ready` evidence plus exact remote heads, correctly + based open PRs, current metadata, applicable non-merge gates, and merge + withheld. +- `all_merged`: every exact PR verified merged on the base, propagation and + final equivalence verified, required validation passing, and cleanup complete + or precisely limited. +- `blocked`: one concrete blocker, exact phase and identities reached, preserved + partial artifacts and last trustworthy evidence, and one action or decision + needed to resume. + +An open PR, green check, local diff, stale review, or plan alone is never enough +to claim a later terminal state. diff --git a/skills/carve-changesets/agents/openai.yaml b/skills/carve-changesets/agents/openai.yaml new file mode 100644 index 0000000..99d9fd0 --- /dev/null +++ b/skills/carve-changesets/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Carve Changesets" + short_description: "Split a large branch into reviewable PRs" + default_prompt: "Use $carve-changesets to recompose this review-ready branch into an authority-gated chain of reviewed changesets and drive it to the requested terminal state." diff --git a/skills/carve-changesets/references/SPEC.md b/skills/carve-changesets/references/SPEC.md index a5a5f3d..dbba4ce 100644 --- a/skills/carve-changesets/references/SPEC.md +++ b/skills/carve-changesets/references/SPEC.md @@ -470,12 +470,12 @@ Return `blocked` without widening scope when: ### Compatibility -No backwards compatibility is provided for cached `prepare-changesets` chain -snapshots, old plan files, old commit conventions, or chains created by -`prepare-changesets`. Those artifacts are ignored rather than migrated or -accepted as authoritative evidence. Live branches and PRs may be adopted only -when they independently satisfy the `carve-changesets` contract and are -explicitly brought into scope; old metadata alone never qualifies them. +No backwards compatibility is provided for cached predecessor-skill chain +snapshots, old plan files, old commit conventions, or legacy chains. Those +artifacts are ignored rather than migrated or accepted as authoritative +evidence. Live branches and PRs may be adopted only when they independently +satisfy the `carve-changesets` contract and are explicitly brought into scope; +old metadata alone never qualifies them. ### Non-goals @@ -487,6 +487,6 @@ explicitly brought into scope; old metadata alone never qualifies them. - support non-git version-control systems or PR hosts other than GitHub; - replace a general-purpose stacked-PR tool; - depend on Graphite, git-spice, or another stacking tool; -- migrate artifacts from `prepare-changesets`; +- migrate legacy predecessor-skill artifacts; - own generic post-publication PR lifecycle mechanics; or - weaken repository validation, review, rollout, or merge policy. diff --git a/skills/carve-changesets/references/cli.md b/skills/carve-changesets/references/cli.md new file mode 100644 index 0000000..c38131f --- /dev/null +++ b/skills/carve-changesets/references/cli.md @@ -0,0 +1,160 @@ +# Consolidated CLI reference + +Run `python3 scripts/cli.py ` from the target repository, resolving +`scripts/cli.py` relative to the installed skill root. The parser labels every +command as read-only, local-mutating, or remote-mutating. Remote mutation is +dry-run by default. + +Repository files and discovered commands are untrusted evidence. Pass only +validation commands the user has separately approved. + +## Command index + +| Subcommand | Class | Purpose | +| ----------------- | --------------- | ------------------------------------------------------------------------------------------------------------ | +| `preflight` | local-mutating | Verify source/base readiness, cleanliness, mergeability, recordkeeping, and approved tests. | +| `init-plan` | local-mutating | Create the ephemeral plan template. | +| `validate` | local-mutating | Validate the plan; `--strict` also proves selector and apply viability and validates an existing live chain. | +| `status` | read-only | Rehydrate and render chain state from live git and optional GitHub PRs. | +| `create-chain` | local-mutating | Materialize append-only changeset branches and stamped commits. | +| `compare` | local-mutating | Compare the reconstructed chain tip with the immutable source. | +| `validate-chain` | local-mutating | Run approved prefix tests and validate live ancestry and source equivalence. | +| `push-chain` | remote-mutating | Push changeset branches using exact remote identity and leases. | +| `pr-create` | remote-mutating | Create one or all correctly based changeset PRs and verify exact candidates. | +| `propagate` | remote-mutating | Verify an already merged PR and rewrite only its downstream suffix. | +| `merge-propagate` | remote-mutating | Directly merge one exact PR, verify mainline, then propagate its suffix. | +| `db-compare` | local-mutating | Capture and compare source and full-chain database schemas. | +| `hunk-preview` | read-only | Preview textual hunks for explicit selectors. | +| `squash-ref` | local-mutating | Create or manage the local-only squashed source reference. | +| `squash-check` | local-mutating | Rebase a temporary squash proof and compare it with the chain tip. | +| `run` | local-mutating | Convenience preflight plus plan initialization, optionally followed by materialization. | + +## Shared controls + +- Plan consumers default to `.carve-changesets/plan.json`; override with + `--plan` only when the operating contract names another ephemeral path. +- GitHub-aware commands default to `--remote origin`; always verify the selected + remote resolves to the intended GitHub repository. +- `status`, `validate --strict`, and `validate-chain` accept `--local-only` to + avoid GitHub reads. +- `push-chain`, `pr-create`, `propagate`, and `merge-propagate` require + `--no-dry-run` for execution. Omitting it prints the intended remote actions. +- `propagate` and `merge-propagate` additionally require + `--ack-merge-and-propagate` and exactly one of `--pr` or `--index`. +- Propagation supports `--strategy rebase` or `--strategy cherry-pick`. Direct + merge supports `--method merge`, `squash`, or `rebase`. +- `preflight` and `run` require `--base` and `--source`. Pass the approved test + with `--test-cmd`, or explicitly resolve `--skip-tests` before execution. +- A source-behind-base exception requires both `--allow-source-behind-base` and + `--confirm-source-behind-base`; either flag alone fails closed. + +## Proposal and materialization walkthrough + +First establish readiness and create the plan: + +```bash +python3 scripts/cli.py preflight \ + --base main \ + --source feature/large-change \ + --test-cmd "just test" + +python3 scripts/cli.py init-plan \ + --base main \ + --source feature/large-change \ + --title "Large change" \ + --changesets 3 \ + --test-cmd "just test" +``` + +Edit the plan using [the plan schema](plan-schema.md), then validate and +materialize it: + +```bash +python3 scripts/cli.py validate --strict +python3 scripts/cli.py create-chain +python3 scripts/cli.py validate-chain --test-cmd "just test" --local-only +python3 scripts/cli.py compare +``` + +Use `hunk-preview --file ` before strict validation when a `hunks` +selector needs an exact range or occurrence. Use `squash-ref` and `squash-check` +only for local equivalence evidence; their refs never become workflow truth. + +For database changes, provide resettable source and chain schema commands: + +```bash +python3 scripts/cli.py db-compare \ + --source-cmd "./scripts/schema-source" \ + --chain-cmd "./scripts/schema-chain" +``` + +## Publication walkthrough + +Preview both remote operations first: + +```bash +python3 scripts/cli.py push-chain +python3 scripts/cli.py pr-create +``` + +After publish authority and exact identities are reverified, execute them: + +```bash +python3 scripts/cli.py push-chain --no-dry-run +python3 scripts/cli.py pr-create --no-dry-run +``` + +Use `pr-create --index N` to publish one position. After publication, status no +longer depends on the plan: + +```bash +python3 scripts/cli.py status \ + --source feature/large-change \ + --base main +``` + +Build the per-changeset review packet and delegate the PR lifecycle as defined +in [the suite handoffs](suite-handoffs.md). + +## Merge and propagation walkthrough + +When a delegated babysitter returns a verified merged PR, preview downstream +propagation from live state: + +```bash +python3 scripts/cli.py propagate \ + --source feature/large-change \ + --base main \ + --pr 123 +``` + +After merge-and-propagate authority and every downstream identity are freshly +verified, execute with the required acknowledgement: + +```bash +python3 scripts/cli.py propagate \ + --source feature/large-change \ + --base main \ + --pr 123 \ + --strategy rebase \ + --no-dry-run \ + --ack-merge-and-propagate +``` + +Use `merge-propagate` instead only when the resolved workflow assigns direct +merge ownership to the CLI and no babysitter owns the PR: + +```bash +python3 scripts/cli.py merge-propagate \ + --source feature/large-change \ + --base main \ + --pr 123 \ + --method merge \ + --strategy rebase \ + --no-dry-run \ + --ack-merge-and-propagate +``` + +After every operation, rerun `status` and the required live validation. Resume +an interrupted sequence by selecting the exact PR or stable changeset index from +rehydrated git and GitHub evidence. diff --git a/skills/carve-changesets/references/plan-schema.md b/skills/carve-changesets/references/plan-schema.md new file mode 100644 index 0000000..6d4b1cd --- /dev/null +++ b/skills/carve-changesets/references/plan-schema.md @@ -0,0 +1,106 @@ +# Decomposition plan schema + +The proposal phase stores one ephemeral JSON plan at +`.carve-changesets/plan.json`. Keep `.carve-changesets/` ignored and out of git. +The plan is authoritative only for changesets that have not been materialized; +live commit trailers, PR metadata, and mainline replace it in later phases. + +## Minimal example + +```json +{ + "feature_title": "Cloud host migration", + "base_branch": "main", + "source_branch": "feature/cloud-host-migration", + "test_command": "just test", + "changesets": [ + { + "slug": "rename-config-types", + "description": "Rename config types without behavior changes.", + "mode": "paths", + "include_paths": ["src/config/**", "src/types/**"], + "exclude_paths": [], + "allow_partial_files": false, + "commit_message": "refactor: rename config types", + "pr_notes": ["No behavior changes."] + }, + { + "slug": "add-dual-write-path", + "description": "Add the flag-guarded dual-write path.", + "mode": "hunks", + "include_paths": ["src/migration/**"], + "exclude_paths": [], + "allow_partial_files": true, + "hunk_selectors": [ + { + "file": "src/migration/write.ts", + "contains": ["enableDualWrite"], + "occurrence": 1 + } + ], + "commit_message": "feat: add dual-write path behind flag", + "pr_notes": [ + "Introduces temporary flag enableDualWrite.", + "A later changeset removes the accommodation." + ] + } + ] +} +``` + +## Top-level fields + +- `feature_title` (required string): shared title stem for the changeset PRs. +- `base_branch` (required string): mainline branch that precedes changeset 1. +- `source_branch` (required string): immutable review-ready branch to recompose. +- `test_command` (optional string): separately approved validation command. +- `changesets` (required non-empty array): ordered proposed changesets. + +## Changeset fields + +- `slug` (required string): stable concise intent identifier carried into commit + and PR metadata. +- `description` (required string): independently reviewable goal and scope. +- `mode` (optional string): `paths` by default, `patch`, or `hunks`. +- `include_paths` (string array): glob patterns included by `paths`, or a coarse + filter for `hunks`; required and non-empty for `paths`. +- `exclude_paths` (optional string array): glob patterns removed from the + selected paths. +- `allow_partial_files` (optional boolean): whether a changeset may select only + some textual hunks from a file; defaults to true. +- `patch_file` (required string for `patch`): a non-empty patch path, normally + under `.carve-changesets/patches/`. +- `hunk_selectors` (required non-empty object array for `hunks`): explicit + textual hunk selectors described below. +- `commit_message` (optional string): changeset commit subject/body before the + required metadata trailers are added. +- `pr_notes` (optional string array): scaffolding, flags, intentional + incompleteness, and later changeset ownership shown in the PR body. + +## Hunk selector fields + +- `file` (required string): repository-relative old or new path; prefer the new + path after a rename. +- `range` (optional string): exact unified-diff hunk header. +- `contains` (optional string array): every string must appear in the hunk body. +- `excludes` (optional string array): none of the strings may appear. +- `occurrence` (optional positive integer): select one match when the other + filters match more than one hunk. +- `all` (optional boolean): select every textual hunk in the file. + +Selectors must resolve unambiguously. Binary changes use `patch`, not `hunks`. +Pure renames should use `paths` or `patch` so rename intent is preserved. + +## Authoring rules + +- Keep one cohesive intent per changeset and prefer additive foundations before + consumers, cutovers, or removals. +- Make changesets append-only once validated; do not reorder or renumber an + existing materialized position. +- Document temporary flags and incomplete states in `pr_notes`, including the + later changeset that removes them. +- Use `validate --strict` before materialization. Placeholder warnings, missing + files, unmatched selectors, ambiguous hunks, or unappliable patches must be + resolved rather than ignored. +- After materialization, do not use plan edits to change the meaning or identity + of an existing changeset. Create a new candidate commit and renew evidence. diff --git a/skills/prepare-changesets/.gitignore b/skills/prepare-changesets/.gitignore deleted file mode 100644 index 71371d5..0000000 --- a/skills/prepare-changesets/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -.prepare-changesets/ -pcs-temp-compare-* -evals/out/ diff --git a/skills/prepare-changesets/SKILL.md b/skills/prepare-changesets/SKILL.md deleted file mode 100644 index 59f4f62..0000000 --- a/skills/prepare-changesets/SKILL.md +++ /dev/null @@ -1,392 +0,0 @@ ---- -name: prepare-changesets -description: >- - GitHub-specific workflow to recompose a large, review-ready source branch - into an ordered chain of smaller changesets (stacked branches + GitHub PRs) - that preserve behavior and improve reviewability. Use when working with - GitHub PR stacks via the gh CLI to split monolithic branches, create stacked - PRs, merge reviewed changesets, and propagate/update downstream PR bases after - merges. -compatibility: >- - Requires Git and the GitHub CLI (gh) authenticated to a GitHub repo, plus - network access to GitHub for PR creation, merging, and base updates. -license: MIT -metadata: - provider: github - vcs: git - pr_cli: gh - workflow: stacked-prs - phase_model: two-phase-incremental - plan_file: .prepare-changesets/plan.json - dry_run_default: 'true' ---- - -# Prepare Changesets - -Follow the spec and keep the source branch immutable. Use an incremental, -append-only planning approach: propose the next changeset, validate it, then -append the plan. - -Always load: - -- `references/SPEC.md` -- `references/PLAN_SCHEMA.md` - -Use deterministic helpers: - -- `scripts/preflight.py` -- `scripts/squash_ref.py` -- `scripts/init_plan.py` -- `scripts/validate.py` -- `scripts/status.py` -- `scripts/create_chain.py` -- `scripts/compare.py` -- `scripts/squash_check.py` -- `scripts/validate_chain.py` -- `scripts/hunk_preview.py` -- `scripts/run.py` -- `scripts/pr_create.py` -- `scripts/merge_propagate.py` -- `scripts/propagate.py` -- `scripts/push_chain.py` -- `scripts/db_compare.py` - -## Phase 0: Preflight - -Require a clean working tree and valid base/source branches. Treat the source -branch as immutable reference state. Never modify, rebase, or rewrite it as part -of this process. Preflight simulates merging the source into the base on a -temporary branch and runs the test command on the source branch. The source -branch must include the current base HEAD; if it is behind, stop unless the user -explicitly approves proceeding. - -Resolve the test command in this order: - -- Prefer the repository’s root `AGENTS.md` if it specifies a test command. -- If missing, unclear, or ambiguous, ask once. -- If still unknown, proceed with `--skip-tests` and record that explicitly in - the plan. - -When discovery is unclear, suggest likely commands by inspecting `.github` -workflows, `pyproject.toml`, `package.json` scripts, `justfile`, `Makefile`, and -other standard project entrypoints. - -Run: - -```bash -skills/prepare-changesets/scripts/preflight.py \ - --base main \ - --source feature/my-large-branch -``` - -To override the source-behind-base gate (not recommended): - -```bash -skills/prepare-changesets/scripts/preflight.py \ - --base main \ - --source feature/my-large-branch \ - --allow-source-behind-base -``` - -If a specific test command is known or provided by the user, pass it with -`--test-cmd ""`. - -Create a local squashed reference for cleaner comparisons: - -```bash -skills/prepare-changesets/scripts/squash_ref.py \ - --base main \ - --source feature/my-large-branch -``` - -This creates `-squashed`. Never push it. - -If the squashed branch already exists, ask whether to reuse it. If the user -declines reuse, stop. If reuse is approved, re-run with `--reuse-existing`. If -it must be rebuilt, re-run with `--recreate`. - -For a guided end-to-end starter: - -```bash -skills/prepare-changesets/scripts/run.py \ - --base main \ - --source feature/my-large-branch \ - --title "My feature title" -``` - -## Phase 1: Incremental Decompose And Validate - -Phase 1 is incremental and append-only. It is valid to propose the next -changeset, create it, validate it, and append the plan in cycles. - -1. Inspect the change surface. - -Use: - -```bash -git diff --stat main..feature/my-large-branch -git diff --name-status main..feature/my-large-branch -``` - -Use `rg` to find refactors, renames, and flaggable boundaries. Focus on semantic -and conceptual boundaries of change, not just file counts or diff size. - -2. Propose an ordered changeset chain. - -Honor the decomposition preferences in `references/SPEC.md`, especially: - -- separate renames from behavioral changes -- prefer additive-first changesets -- defer user-visible or API-exposed changes -- keep intent cohesive -- do not propose changesets that require future changesets to be partially - present in order to be understandable or reviewable -- prefer rename-first changesets when they reduce diff noise or stabilize paths - -Optimize for reviewer cognitive load (explicit guardrails). These are -rules-of-thumb; ask the user up front whether to accept them or adjust the -thresholds (or replace them with a different litmus, such as strict subsystem -separation). - -- Target size: ~200–400 lines of *actual code changes* is the sweet spot for a - 30-minute review. If a changeset exceeds ~800 lines total (including tests and - docs), split it unless the user explicitly approves the size. -- If a changeset spans multiple subsystems (for example: activities + workflows - - worker wiring), split by subsystem even if the theme is coherent. -- Keep tests with their closest production changeset, but still split by - subsystem so each PR has matching tests. -- If a reviewer could not reasonably review the diff in ~30 minutes, it is too - large; split it. -- When a changeset violates any guardrail, stop and ask the user whether to - exceed it, then record the approval. -- Exception: allow very large *purely mechanical* changesets (for example, a - function rename applied uniformly or a global reformat), but only when the - changeset is exclusively mechanical and clearly documented with guidance for - spot checks or verification. Do not mix mechanical changes with behavioral - changes in the same PR. - -3. Initialize the plan. - -Create a plan template: - -```bash -skills/prepare-changesets/scripts/init_plan.py \ - --base main \ - --source feature/my-large-branch \ - --title "My feature title" \ - --changesets 4 -``` - -Then edit `.prepare-changesets/plan.json`. The plan is append-only: - -- define cohesive `slug` and `description` per changeset -- choose a `mode` per changeset: `paths`, `patch`, or `hunks` -- for `paths`, use `include_paths` to pull in relevant files -- for `hunks`, use `hunk_selectors` with `file` + `contains`/`range` -- use `all: true` in a selector to include all hunks in a file -- set `allow_partial_files=false` when a file must be fully included -- for `patch`, set `patch_file` under `.prepare-changesets/patches/` -- use `exclude_paths` as a coarse filter to prevent accidental overlap -- document scaffolding, flags, and intentional incompleteness in `pr_notes` -- append new changesets at the end as you learn more -- do not renumber or reorder validated changesets - -Validate: - -```bash -skills/prepare-changesets/scripts/validate.py -``` - -For stricter checks (ambiguous selectors, missing patch files, placeholders): - -```bash -skills/prepare-changesets/scripts/validate.py --strict -``` - -`--strict` also warns if `.prepare-changesets/state.json` indicates prior -changeset branch heads have drifted. It runs patch/hunk apply checks on a -temporary branch, so the working tree must be clean. - -After a changeset is validated and accepted, treat it as locked. Do not revise, -reinterpret, or reorder earlier validated changesets without explicit user -approval. - -4. Create or extend the branch chain. - -```bash -skills/prepare-changesets/scripts/create_chain.py -``` - -Branch names are append-only: - -- `-1` -- `-2` -- ... - -`create_chain.py` is append-only. It reuses an existing prefix of changeset -branches and creates only the missing suffix. Delete a branch explicitly if it -must be recreated. - -`create_chain.py` also records `.prepare-changesets/state.json` with source and -changeset branch heads for drift detection. - -5. Validate equivalence and progress. - -```bash -skills/prepare-changesets/scripts/compare.py -skills/prepare-changesets/scripts/squash_check.py -``` - -`compare.py` checks equivalence by merging the chain. `squash_check.py` rebases -the squashed reference onto the chain tip using a temporary branch -`pcs-temp-squash-check-*` to show what remains. - -To preview candidate hunks for selectors: - -```bash -skills/prepare-changesets/scripts/hunk_preview.py --file path/to/file.ts -``` - -6. Review each changeset branch. - -For each changeset branch: - -- review the diff relative to its intended base -- run repo-specific tests when practical -- adjust commits to keep the changeset cohesive - -7. Validate incremental mergeability. - -Run tests after each changeset merge: - -```bash -skills/prepare-changesets/scripts/validate_chain.py --test-cmd "" -``` - -To run skill unit tests locally: - -```bash -python3 -m unittest discover -s skills/prepare-changesets/scripts/tests -p 'test_*.py' -``` - -8. Open stacked PRs with correct bases. - -Before opening PRs, ensure the changeset branches are pushed to the remote. If -the branches are not pushed, `gh pr create` fails with “Head sha can’t be blank” -or “Head ref must be a branch.” - -Push the chain (defaults to `origin`): - -```bash -skills/prepare-changesets/scripts/push_chain.py -``` - -Base rules: - -- changeset 1 base: `base_branch` -- changeset i>1 base: previous changeset branch - -Title rule: - -- ` (i of N)` -- `N` is derived from the current plan length at PR creation time - -Body requirements: - -- summarize the overall feature first -- explain what this changeset provides -- call out temporary scaffolding, flags, and intentional incompleteness -- document intent, scope, and temporary compromises only -- do not use PR bodies for marketing, justification, or speculative discussion - -If a changeset is primarily renames, add a PR note: - -- rename-only / mechanical; no behavior change intended - -If a changeset mixes renames and behavior, add a PR note: - -- includes rename(s) X → Y plus minimal behavior changes needed to keep the code - coherent - -Use the helper to generate `gh` commands and PR bodies: - -```bash -skills/prepare-changesets/scripts/pr_create.py -``` - -This uses `gh pr create` under the hood and defaults to dry-run. Execute for -real: - -```bash -skills/prepare-changesets/scripts/pr_create.py --no-dry-run -``` - -If `gh` is not authenticated, run `gh auth login` once, then retry. - -## Phase 2: Merge And Propagate - -Merge in order and propagate forward. Do not renumber or reorder validated -changesets. - -Use one of these explicit workflows. - -1. Merge a reviewed changeset PR, then propagate and update PR bases. - -Dry-run first: - -```bash -skills/prepare-changesets/scripts/merge_propagate.py --index i -``` - -Execute for real: - -```bash -skills/prepare-changesets/scripts/merge_propagate.py --index i --no-dry-run -``` - -By default, the merge uses the repository's default method. Pass -`--method merge|squash|rebase` to override. - -If the default merge method cannot be determined (for example, gh cannot read -repo settings), ask the user which merge method to use and re-run with -`--method`. - -2. If the PR was merged separately, propagate and clean up downstream PRs. - -If you have already synced the local base branch to include the merged -changeset, skip the local merge simulation: - -```bash -skills/prepare-changesets/scripts/propagate.py \ - --merged-index i \ - --skip-local-merge \ - --no-dry-run -``` - -By default, propagation updates downstream PR bases with `gh pr edit --base`. -Disable that with `--no-update-pr-bases`. Add `--push` to push updated branches -with `--force-with-lease` (remote defaults to `origin`). - -Propagation strategy: - -- `--strategy rebase` (default) rewrites downstream history by rebasing onto the - new base. -- `--strategy cherry-pick` preserves downstream history and applies the merged - changeset commits onto each downstream branch. Each downstream branch - cherry-picks from the original merged changeset branch (not from another - downstream branch), so conflict resolutions do not cascade. - -## Operational Notes - -- Prefer explicit plan edits over clever automation. -- Keep `.prepare-changesets/` ignored in the repo (add it to `.gitignore` or - `.git/info/exclude`); preflight stops unless you override with - `--allow-recordkeeping-tracked`. -- Keep `.prepare-changesets/plan.json` out of PRs. -- Use the script for mechanical steps and Git for judgment calls. -- For database migrations, follow the validation guidance in - `references/SPEC.md`. -- Use `db-compare` to run schema dump commands on the source branch and the - fully merged chain, then diff the outputs. -- If judgment or new information conflicts with the validated plan, stop and - escalate rather than improvising. diff --git a/skills/prepare-changesets/evals/prompts.csv b/skills/prepare-changesets/evals/prompts.csv deleted file mode 100644 index 64a3d8d..0000000 --- a/skills/prepare-changesets/evals/prompts.csv +++ /dev/null @@ -1,3 +0,0 @@ -id,prompt -chain-basic,"You are in a git repo with base branch main, source branch feature/test, and a validated plan at .prepare-changesets/plan.json. Using the prepare-changesets skill, run preflight, create the changeset chain, validate mergeability with tests using python3 -c \"print('ok')\", and compare the fully merged chain to the source branch. Do not modify the source branch. Use scripts/*.py directly." -chain-compare,"Follow the prepare-changesets skill strictly. The plan is already validated at .prepare-changesets/plan.json for base main and source feature/test. Create the changeset chain, run validate-chain with python3 -c \"print('ok')\", and run compare. Keep the source branch immutable." diff --git a/skills/prepare-changesets/references/PLAN_SCHEMA.md b/skills/prepare-changesets/references/PLAN_SCHEMA.md deleted file mode 100644 index 98eab75..0000000 --- a/skills/prepare-changesets/references/PLAN_SCHEMA.md +++ /dev/null @@ -1,111 +0,0 @@ -# Prepare Changesets Plan Schema - -Use a single JSON plan file to define the ordered changeset chain and make the -mechanical steps deterministic. Store it under `.prepare-changesets/plan.json` -and keep it out of PRs. - -The plan is append-only. You can add new changesets at the end as you learn -more, but do not renumber or reorder validated changesets. - -## Minimal Example - -```json -{ - "feature_title": "Cloud host migration", - "base_branch": "main", - "source_branch": "feature/cloud-host-migration", - "test_command": "npm test", - "changesets": [ - { - "slug": "rename-config-types", - "description": "Rename config types without behavior changes.", - "mode": "paths", - "include_paths": [ - "src/config/**", - "src/types/**" - ], - "exclude_paths": [], - "commit_message": "refactor: rename config types", - "pr_notes": [ - "No behavior changes.", - "Separates renames from functional changes." - ] - }, - { - "slug": "add-dual-write-path", - "description": "Add additive dual-write path guarded by a flag.", - "mode": "paths", - "include_paths": [ - "src/migration/**", - "src/flags/**" - ], - "exclude_paths": [ - "src/migration/cleanup/**" - ], - "commit_message": "feat: add dual-write path behind flag", - "pr_notes": [ - "Introduces temporary flag: enableDualWrite.", - "Cleanup happens in the final changeset." - ] - } - ] -} -``` - -## Field Reference - -Top-level fields: - -- `feature_title` (string, required): The shared PR title base. -- `base_branch` (string, required): Usually `main`. -- `source_branch` (string, required): The review-ready branch to decompose. -- `test_command` (string, optional): A repo-specific validation command. -- `changesets` (array, required): Ordered changesets. - -Changeset fields: - -- `slug` (string, required): Short identifier used in logs and plan review. -- `description` (string, required): Intent and scope of the changeset. -- `mode` (string, optional): One of `"paths"` (default), `"patch"`, or - `"hunks"`. -- `include_paths` (array of strings, optional): Glob patterns matched with - Python `fnmatch` against repo-relative paths. Required for `mode="paths"`. - When `mode="hunks"`, they act as a coarse filter for selectors. -- `exclude_paths` (array of strings, optional): Patterns removed from the - include set (also matched with `fnmatch`). -- `patch_file` (string, optional): Required for `mode="patch"`. Path to a patch - file (recommended under `.prepare-changesets/patches/`). -- `hunk_selectors` (array, optional): Required for `mode="hunks"`. Each selector - is an object with: - - `file` (string, required): Repo-relative path for the diff file. When a file - is renamed, this may refer to either the old or new path. Prefer the new - path for stability. - - `range` (string, optional): Exact hunk header to match (e.g., - `"@@ -120,7 +120,18 @@"`). - - `contains` (array of strings, optional): All strings must appear in the hunk - body. - - `excludes` (array of strings, optional): None of the strings may appear in - the hunk body. - - `occurrence` (integer, optional): Pick the Nth matching hunk when multiple - matches exist (1-based). - - `all` (boolean, optional): When true, select all hunks for the file and - ignore other selector fields. -- `allow_partial_files` (boolean, optional): Defaults to `true`. When `false`, - all hunks in a selected file must be included. - -Note: `mode="hunks"` requires textual hunks. Pure rename-only diffs should use -`mode="paths"` or `mode="patch"` instead. - -- `commit_message` (string, optional): Defaults to `"changeset N: "`. -- `pr_notes` (array of strings, optional): Bullets describing scaffolding, - flags, and intentional incompleteness for the PR body. - -## Important Constraints - -- Keep changesets cohesive by intent, not by line count. -- Prefer additive-first changesets and defer user-visible changes. -- Separate renames from behavioral changes when possible. -- Treat the plan as append-only output. Do not renumber or reorder validated - changesets after creation. - -See `references/SPEC.md` for the full behavioral rules and invariants. diff --git a/skills/prepare-changesets/references/SPEC.md b/skills/prepare-changesets/references/SPEC.md deleted file mode 100644 index 6380d4b..0000000 --- a/skills/prepare-changesets/references/SPEC.md +++ /dev/null @@ -1,473 +0,0 @@ -# prepare-changesets - -## Skill Specification - -### Purpose - -The `prepare-changesets` skill takes an **existing, review-ready branch of -work** and deconstructs and recomposes it into an **ordered sequence of -intentional, reviewable changesets**, preserving behavior while improving -reviewability, with each changeset represented as its own branch and pull -request. - -The goal is to transform a large, monolithic branch into a **set of cohesive, -incrementally mergeable deliverables** that: - -- reduce reviewer cognitive load, -- preserve correctness at every step, -- allow incremental integration where possible, -- and, when fully merged, are functionally equivalent to the original branch. - -This skill works *backwards* from an already-implemented solution, capturing the -invariants required for trust-first, agent-assisted development. - -______________________________________________________________________ - -## Inputs and Preconditions - -The skill operates on: - -- a **source branch**: - - contains all intended changes, - - believed to be logically correct and complete, - - may span many commits; -- a **base branch** (typically `main`); -- a clean working tree. - -Preconditions: - -- The source branch must be rebased or mergeable onto the base branch. -- The source branch must include the current base HEAD (not be behind mainline). -- The source branch must build and pass tests (to the extent applicable). -- The working tree must be clean before starting. - -______________________________________________________________________ - -## Changeset Model - -A **changeset** is: - -- a cohesive, intentional unit of work, -- designed to be reviewed and reasoned about independently, -- ordered relative to other changesets, -- mergeable once all prior changesets have been merged. - -Each changeset corresponds to: - -- one Git branch, -- one pull request, -- one reviewable deliverable. - -Changesets may be assembled from **paths**, **patches**, or **hunks**. This -allows multiple changesets to touch the same file without forcing file-level -exclusivity, as long as each changeset remains independently reviewable. - -______________________________________________________________________ - -## Changeset Extraction Modes - -The skill supports three deterministic extraction modes: - -- **Paths**: check out whole file paths from the source branch (legacy mode). -- **Patch**: apply a precomputed patch file onto the changeset branch. -- **Hunks**: compute `base..source` diffs, select specific hunks, and apply them - using `git apply --index --3way` for resilience to context shifts. - -Hunk selection must be driven by explicit selectors (file + range/contains) that -can be validated for ambiguity. When `allow_partial_files` is false, a changeset -must include all hunks for any selected file. Use `all: true` in a selector to -include every hunk in a file. - -Hunk mode requires textual hunks. Pure rename-only diffs should be handled via -`paths` or `patch` mode. - -______________________________________________________________________ - -## Rule-of-Thumb for Changeset Size - -Changesets should be sized to minimize **cognitive overhead**, not to satisfy -arbitrary metrics. - -Guidelines: - -- A few hundred *new or changed* lines is ideal. -- Raw deletions typically do **not** introduce significant cognitive overhead, - provided: - - it is clear *why* the code is being removed, - - and what replaces or obsoletes it. - -Exceptions are acceptable for: - -- purely mechanical refactors, -- systematic renaming, -- low-level changes with minimal semantic impact. - -The primary focus is: - -- **cohesiveness of intent**, and -- **reviewability**, not raw line counts. - -______________________________________________________________________ - -## Decomposition Preferences - -When creating changesets, the skill should prefer: - -- separating **renames** from **behavioral changes** into distinct changesets; -- introducing **additive changes first**, before modifying or removing existing - behavior; -- deferring user-visible or API-exposed changes until later changesets; -- avoiding changesets that mix unrelated concerns. - -Wide refactors should either: - -- be isolated into an early changeset, or -- be deferred entirely. - -Renames may be mixed with behavioral changes when splitting would increase -cognitive load or create an awkward intermediate state. When renames dominate or -meaningfully reduce diff noise, prefer a **rename-first** changeset that -stabilizes file paths before behavior changes. - -______________________________________________________________________ - -## Branch Creation and Naming Strategy - -Given: - -- base branch `B`, -- source branch `S`, - -the skill must: - -1. Create a new ordered sequence of changeset branches. -2. Each changeset branch must: - - be branched from its immediate predecessor (or from `B` for the first - changeset), - - contain only that changeset’s work, - - exclude work intended for later changesets. - -### Branch Naming Convention - -Each changeset branch name must: - -- retain the original source branch name, and -- append a suffix of the form: - -``` -- -``` - -Where: - -- `x` is the 1-based index of the changeset. - -Example: - -``` -feature/cloud-host-migration-1 -feature/cloud-host-migration-2 -feature/cloud-host-migration-3 -feature/cloud-host-migration-4 -``` - -This naming is mandatory and append-only. Do not renumber existing changesets -after they have been validated or reviewed. - -______________________________________________________________________ - -## Pull Request Requirements - -Each changeset must have a corresponding pull request. - -### PR Titles - -- All changeset PRs must share **the same base title**, summarizing the overall - feature. -- The title must append: - -``` -(x of y) -``` - -Example: - -``` -Cloud host migration (2 of 4) -``` - -This reinforces that each PR is part of a unified whole. The total (`y`) is -derived from the current plan length at PR creation time. If additional -changesets are appended later, update PR titles to reflect the new total when -appropriate. - -______________________________________________________________________ - -### PR Bodies - -Each PR body must: - -1. **Summarize the overall feature** first. -2. Clearly explain **what part of the feature this changeset provides**. -3. Explicitly document: - - any temporary scaffolding, - - any feature flags, - - any intermediate correctness accommodations that are introduced to support - decomposition. - -The PR body must make it clear: - -- what is intentionally incomplete at this stage, -- and how later changesets will complete or clean up the work. - -This documentation is critical for reviewer trust. - -If a changeset is primarily renames, state explicitly: - -- rename-only / mechanical; no behavior change intended - -If a changeset mixes renames and behavior, state explicitly: - -- includes rename(s) X → Y plus the minimal behavior changes needed to keep the - code coherent - -______________________________________________________________________ - -## Incremental Mergeability Requirements - -Each changeset branch must be: - -- mergeable into the base branch **once all prior changesets are merged**; -- non-breaking to existing functionality at that point in the sequence. - -To achieve this, the skill may: - -- introduce feature flags, -- introduce runtime-configurable toggles, -- introduce deploy-time environment flags, -- introduce temporary scaffolding code. - -### Feature Flag Policy - -Feature flags should be used **only when necessary**. - -Preference order: - -1. Additive, non-exposed code paths -2. Runtime feature flags -3. Deploy-time environment variables -4. Code-level conditionals (last resort) - -Flags should be: - -- minimized, -- centralized, -- documented, -- and removed or fully enabled in the **final changeset** whenever possible. - -______________________________________________________________________ - -## Database Migration Guidelines - -Database migrations may be recomposed across **multiple changesets** to reduce -cognitive load and avoid large, coupled changes. - -Rules: - -- **Data integrity takes precedence over all other concerns.** -- Migrations in the source branch are **not** considered atomic or indivisible. -- Acceptable decomposition strategies include: - - introducing nullable columns first, - - backfilling or validating data, - - applying non-null constraints later, - - adding foreign key constraints as early as possible to enforce integrity. - -The final merged state must reflect the same schema and constraints as the -source branch, modulo ordering differences introduced by decomposition. - -### Validation Requirements - -The skill should: - -- use a resettable test database, -- apply migrations from: - - the source branch, and - - the fully merged changeset sequence, -- compare resulting schemas (e.g. via schema dumps), -- and validate that they are equivalent. - -______________________________________________________________________ - -## Recordkeeping and Temporary Artifacts - -The skill may introduce **recordkeeping files** to manage decomposition and -tracking, provided that: - -- they are clearly documented, -- `.prepare-changesets/` is ignored (add it to `.gitignore` or - `.git/info/exclude`); preflight stops unless explicitly overridden, -- either live under `.gitignore` *or* are explicitly marked as temporary, -- and are removed by the final changeset. - -These artifacts exist solely to support correctness during decomposition. - -______________________________________________________________________ - -## Equivalence Guarantee - -A critical invariant: - -> **After all changesets are merged in order, the resulting codebase must be -> functionally equivalent to the source branch.** - -Acceptable differences include: - -- commit history shape, -- additional commits for scaffolding or documentation, -- mechanical differences introduced by decomposition. - -Unacceptable differences include: - -- missing functionality, -- altered behavior, -- regressions not present in the source branch. - -______________________________________________________________________ - -## Verification and Comparison - -It is encouraged that the skill: - -- mirror the full changeset chain into a **temporary test base**, -- merge all changesets in order into that base, -- and compare the result against the source branch. - -This comparison exists to validate equivalence and catch decomposition errors -early. - -______________________________________________________________________ - -## Squashed Reference Workflow (Local-Only) - -The source branch must remain immutable. To make comparisons easier, the skill -may create a **local-only squashed reference branch**: - -- branch name: `-squashed` -- purpose: represent the source tree as a single commit reference target -- policy: never push this branch - -If the squashed branch already exists: - -- ask whether to reuse it, -- stop the process if the user declines reuse. - -### Squash-Check Strategy - -Use Git itself to measure how much of the source is captured by the current -changeset chain: - -1. Create a temporary branch from `-squashed`. -2. Rebase it onto the chain tip (for example, `pcs-temp-squash-check-*`). -3. Compare the temporary branch against the chain tip. - -Interpretation: - -- clean rebase with minimal diff indicates the chain closely matches the source - tree, -- rebase conflicts or large diffs indicate gaps or boundary mistakes in the - current changesets. - -This workflow is especially useful for large or messy source branches with -merges and long histories. - -______________________________________________________________________ - -## Pull Request Management Subskills - -The skill should include (or delegate to) subskills for: - -- creating GitHub pull requests with the correct base branch, -- merging an approved changeset, -- adjusting downstream changeset branch bases, -- propagating review changes forward, -- keeping downstream PRs alive and correctly diffed. - -This choreography must be handled mechanically and deterministically. - -______________________________________________________________________ - -## Two-Phase Incremental Execution Model - -The skill operates in **two phases**, with Phase 1 designed to be incremental -and append-only. - -### Phase 1: Incremental Decompose and Validate - -In this phase, it is valid to "chip away" at a large source branch: - -- propose the next changeset, -- justify why it is the next safe building block, -- create that changeset branch, -- run tests and comparison checks (including squash-check), -- validate hunk selectors or patch files before applying them, -- append the changeset to the plan, -- and repeat. - -Rules: - -- the source branch remains immutable, -- changesets are append-only, -- once a changeset is validated and accepted, do not renumber or reorder it, -- do not silently revise earlier validated changesets. - -Record state under `.prepare-changesets/state.json` to detect drift in the -source branch or earlier changeset branch heads. - -______________________________________________________________________ - -### Phase 2: Merge and Propagate - -- Merge changesets in order as they are approved. -- Propagate changes into downstream changesets. -- Update PR bases and content accordingly. - -______________________________________________________________________ - -## Non-Goals - -This skill does **not**: - -- plan brand-new work unrelated to the source branch, -- rewrite or mutate the source branch, -- renumber validated changesets, -- reorder or merge validated changesets without explicit user direction, -- push local-only scratch references like `-squashed`, -- support inverted merge strategies, -- optimize for the minimal number of changesets. - -The focus is **trust, reviewability, and correctness**, not cleverness. - -______________________________________________________________________ - -## Why This Skill Exists - -This skill exists to address the central asymmetry of agent-assisted -development: - -> **Implementation cost has collapsed; trust cost has not.** - -By working backwards from real, messy branches, this skill codifies what is -required to make agent-generated work *human-compatible*. - -It is a proving ground for the workflows Atelier is designed to support -natively. - -______________________________________________________________________ - -If you want, the next natural step would be to: - -- carve this into a formal `SKILL.md`, -- annotate which parts must be structured vs prose, -- or walk through a concrete example branch and pressure-test the spec. - -But at this point: **this is extremely solid, coherent, and implementable.** diff --git a/skills/prepare-changesets/scripts/chain.py b/skills/prepare-changesets/scripts/chain.py deleted file mode 100755 index f6c3e47..0000000 --- a/skills/prepare-changesets/scripts/chain.py +++ /dev/null @@ -1,399 +0,0 @@ -#!/usr/bin/env python3 -"""Changeset chain creation, comparison, and validation.""" - -from __future__ import annotations - -import fnmatch -import subprocess -from dataclasses import dataclass -from typing import Dict, Iterable, List, Optional, Sequence, Tuple - -from common import ( - CommandError, - branch_exists, - branch_name_for, - checkout_restore, - delete_branch, - diff_name_status, - diff_stat, - discover_test_command, - ensure_branches_exist, - ensure_clean_tree, - ensure_git_repo, - git, - record_state, - unique_temp_branch, -) -from patch_apply import ( - apply_patch_file, - apply_patch_text, - build_diff, - parse_hunk_selectors, - select_hunks_for_changeset, -) - - -@dataclass -class DiffEntry: - status: str - path: str - old_path: Optional[str] = None - - -def _matches_any(path: str, patterns: Iterable[str]) -> bool: - return any(fnmatch.fnmatch(path, pat) for pat in patterns) - - -def changed_files_between(base: str, source: str) -> List[DiffEntry]: - raw = diff_name_status(base, source) - entries: List[DiffEntry] = [] - if not raw: - return entries - - for line in raw.splitlines(): - parts = line.split("\t") - if not parts: - continue - status = parts[0] - code = status[0] - if code == "R" and len(parts) >= 3: - entries.append(DiffEntry(status=code, path=parts[2], old_path=parts[1])) - elif len(parts) >= 2: - entries.append(DiffEntry(status=code, path=parts[1])) - return entries - - -def select_entries( - entries: Sequence[DiffEntry], include: Sequence[str], exclude: Sequence[str] -) -> List[DiffEntry]: - if not include: - return [] - - selected = [ - e - for e in entries - if _matches_any(e.path, include) - or (e.old_path and _matches_any(e.old_path, include)) - ] - if not exclude: - return selected - - filtered: List[DiffEntry] = [] - for e in selected: - if _matches_any(e.path, exclude): - continue - if e.old_path and _matches_any(e.old_path, exclude): - continue - filtered.append(e) - return filtered - - -@dataclass -class ApplySummary: - mode: str - message: str - - -def _apply_changeset_paths( - *, base_branch: str, source_branch: str, index: int, changeset: Dict -) -> ApplySummary: - include = changeset.get("include_paths", []) - exclude = changeset.get("exclude_paths", []) - - diff_entries = changed_files_between(base_branch, source_branch) - selected = select_entries(diff_entries, include, exclude) - - if not selected: - print(f"[WARN] Changeset {index}: no files matched include/exclude rules.") - return ApplySummary(mode="paths", message="no paths matched") - - checkout_paths: List[str] = [] - delete_paths: List[str] = [] - - for entry in selected: - if entry.status == "D": - delete_paths.append(entry.path) - continue - if ( - entry.old_path - and entry.old_path != entry.path - and entry.old_path not in delete_paths - ): - delete_paths.append(entry.old_path) - checkout_paths.append(entry.path) - - for path in checkout_paths: - git("checkout", source_branch, "--", path) - - for path in delete_paths: - git("rm", "-f", "--ignore-unmatch", path) - - git("add", "-A") - git("reset", "-q", "--", ".prepare-changesets") - - diff_cached = git("diff", "--cached", "--quiet", check=False) - if diff_cached.returncode == 0: - print(f"[WARN] Changeset {index}: no staged changes after apply.") - return ApplySummary( - mode="paths", - message=( - f"{len(checkout_paths)} paths checked out, {len(delete_paths)} paths removed" - ), - ) - - commit_message = changeset.get("commit_message") - if not isinstance(commit_message, str) or not commit_message.strip(): - slug = str(changeset.get("slug", f"cs-{index}")).strip() or f"cs-{index}" - commit_message = f"changeset {index}: {slug}" - - git("commit", "-m", commit_message) - return ApplySummary( - mode="paths", - message=( - f"{len(checkout_paths)} paths checked out, {len(delete_paths)} paths removed" - ), - ) - - -def _apply_changeset_patch(*, index: int, changeset: Dict, label: str) -> ApplySummary: - patch_file = changeset.get("patch_file") - if not isinstance(patch_file, str) or not patch_file.strip(): - raise CommandError(f"{label}: patch_file must be a non-empty string.") - apply_patch_file(patch_file, label=label) - - diff_cached = git("diff", "--cached", "--quiet", check=False) - if diff_cached.returncode == 0: - print(f"[WARN] Changeset {index}: no staged changes after apply.") - return ApplySummary( - mode="patch", message="patch applied with no staged changes" - ) - - commit_message = changeset.get("commit_message") - if not isinstance(commit_message, str) or not commit_message.strip(): - slug = str(changeset.get("slug", f"cs-{index}")).strip() or f"cs-{index}" - commit_message = f"changeset {index}: {slug}" - - git("commit", "-m", commit_message) - return ApplySummary(mode="patch", message="patch applied and committed") - - -def _apply_changeset_hunks( - *, base_branch: str, source_branch: str, index: int, changeset: Dict, label: str -) -> ApplySummary: - selectors = changeset.get("hunk_selectors", []) - include = changeset.get("include_paths", []) - exclude = changeset.get("exclude_paths", []) - allow_partial = changeset.get("allow_partial_files", True) - - parsed = parse_hunk_selectors(selectors, changeset_label=label) - diff_files = build_diff(base_branch, source_branch) - selected = select_hunks_for_changeset( - diff_files, - parsed, - include_paths=include, - exclude_paths=exclude, - allow_partial_files=bool(allow_partial), - changeset_label=label, - ) - apply_patch_text(selected.text, label=label) - - diff_cached = git("diff", "--cached", "--quiet", check=False) - if diff_cached.returncode == 0: - print(f"[WARN] Changeset {index}: no staged changes after apply.") - return ApplySummary( - mode="hunks", - message=f"{selected.hunks} hunks selected in {selected.files} files", - ) - - commit_message = changeset.get("commit_message") - if not isinstance(commit_message, str) or not commit_message.strip(): - slug = str(changeset.get("slug", f"cs-{index}")).strip() or f"cs-{index}" - commit_message = f"changeset {index}: {slug}" - - git("commit", "-m", commit_message) - return ApplySummary( - mode="hunks", - message=f"{selected.hunks} hunks selected in {selected.files} files", - ) - - -def apply_changeset( - *, base_branch: str, source_branch: str, index: int, total: int, changeset: Dict -) -> ApplySummary: - mode = str(changeset.get("mode", "paths")).strip() or "paths" - label = f"Changeset {index}" - if mode == "paths": - return _apply_changeset_paths( - base_branch=base_branch, - source_branch=source_branch, - index=index, - changeset=changeset, - ) - if mode == "patch": - return _apply_changeset_patch(index=index, changeset=changeset, label=label) - if mode == "hunks": - return _apply_changeset_hunks( - base_branch=base_branch, - source_branch=source_branch, - index=index, - changeset=changeset, - label=label, - ) - raise CommandError( - f"{label}: unsupported mode '{mode}'. Use 'paths', 'patch', or 'hunks'." - ) - - -def create_chain(plan: Dict) -> List[str]: - ensure_git_repo() - ensure_clean_tree() - - base = plan["base_branch"] - source = plan["source_branch"] - changesets = plan["changesets"] - total = len(changesets) - - chain = [branch_name_for(source, i) for i in range(1, total + 1)] - ensure_branches_exist([base, source]) - - existing_prefix = 0 - for idx, name in enumerate(chain, start=1): - exists = branch_exists(name) - if exists and idx == existing_prefix + 1: - existing_prefix = idx - continue - if exists and idx > existing_prefix + 1: - missing = branch_name_for(source, existing_prefix + 1) - raise CommandError( - f"Found existing branch {name} but missing earlier branch {missing}." - ) - - start_index = existing_prefix + 1 - if existing_prefix > 0: - print( - f"[INFO] Reusing existing changeset branches through index {existing_prefix}." - ) - print( - "[INFO] create-chain is append-only; delete a branch explicitly if it must be recreated." - ) - - with checkout_restore() as original: - print(f"[INFO] Starting from current branch: {original}") - prev_branch = base if existing_prefix == 0 else chain[existing_prefix - 1] - for idx in range(start_index, total + 1): - cs = changesets[idx - 1] - name = chain[idx - 1] - print(f"\n[STEP] Creating {name} from {prev_branch}") - git("checkout", "-B", name, prev_branch) - - summary = apply_changeset( - base_branch=base, - source_branch=source, - index=idx, - total=total, - changeset=cs, - ) - print(f"[OK] Applied changeset {idx} ({summary.mode}): {summary.message}") - prev_branch = name - - record_state(plan, chain) - print("[OK] Changeset branch chain created.") - return chain - - -def compare_chain(plan: Dict) -> Tuple[str, str]: - ensure_git_repo() - ensure_clean_tree() - - base = plan["base_branch"] - source = plan["source_branch"] - total = len(plan["changesets"]) - - chain = [branch_name_for(source, i) for i in range(1, total + 1)] - ensure_branches_exist([base, source, *chain]) - - temp_branch = unique_temp_branch("pcs-temp-compare") - print(f"[INFO] Creating temporary comparison branch: {temp_branch}") - - with checkout_restore() as original: - try: - git("checkout", "-B", temp_branch, base) - for name in chain: - print(f"[STEP] Merging {name} into {temp_branch}") - git("merge", "--no-ff", "--no-edit", name) - - diffstat = diff_stat(temp_branch, source) - namestatus = diff_name_status(temp_branch, source) - finally: - git("checkout", original) - delete_branch(temp_branch) - print(f"\n[INFO] Restored original branch: {original}") - - return diffstat, namestatus - - -def validate_chain(plan: Dict, *, test_cmd: str) -> None: - """Merge changesets in order into a temp branch and run tests after each merge.""" - effective_test_cmd = test_cmd.strip() - if not effective_test_cmd: - discovery = discover_test_command("") - discovered = str(discovery.get("command") or "").strip() - if discovered: - effective_test_cmd = discovered - print(f"[INFO] Using test command from AGENTS.md: {effective_test_cmd}") - else: - reason = str(discovery.get("reason", "unknown")) - if reason == "agents-missing": - print("[WARN] No AGENTS.md found at repo root.") - elif reason == "agents-no-test-command": - print("[WARN] AGENTS.md found but no clear test command was detected.") - elif reason == "agents-ambiguous": - candidates = list(discovery.get("candidates", [])) - if candidates: - print("[WARN] Multiple test commands were detected in AGENTS.md:") - for cmd in candidates: - print(f" - {cmd}") - - suggestions = list(discovery.get("suggestions", [])) - if suggestions: - print("[HINT] Likely test commands to consider:") - for cmd in suggestions: - print(f" - {cmd}") - raise CommandError( - "validate-chain requires a non-empty --test-cmd or plan.test_command." - ) - - ensure_git_repo() - ensure_clean_tree() - - base = plan["base_branch"] - source = plan["source_branch"] - total = len(plan["changesets"]) - chain = [branch_name_for(source, i) for i in range(1, total + 1)] - ensure_branches_exist([base, source, *chain]) - - temp_branch = unique_temp_branch("pcs-temp-validate") - print(f"[INFO] Creating temporary validation branch: {temp_branch}") - - with checkout_restore() as original: - try: - git("checkout", "-B", temp_branch, base) - for idx, name in enumerate(chain, start=1): - print(f"\n[STEP] Merging {name} ({idx} of {total})") - git("merge", "--no-ff", "--no-edit", name) - print( - f"[STEP] Running tests after changeset {idx}: {effective_test_cmd}" - ) - if git("diff", "--quiet", check=False).returncode != 0: - raise CommandError( - "Working tree became dirty during validate-chain." - ) - result = subprocess.run(effective_test_cmd, shell=True) - if result.returncode != 0: - raise CommandError(f"Test command failed after changeset {idx}.") - finally: - git("checkout", original) - delete_branch(temp_branch) - print(f"\n[INFO] Restored original branch: {original}") - - print("[OK] validate-chain completed successfully.") diff --git a/skills/prepare-changesets/scripts/cli.py b/skills/prepare-changesets/scripts/cli.py deleted file mode 100755 index a04f0ad..0000000 --- a/skills/prepare-changesets/scripts/cli.py +++ /dev/null @@ -1,695 +0,0 @@ -#!/usr/bin/env python3 -"""CLI wiring for prepare-changesets helpers.""" - -from __future__ import annotations - -import argparse -from pathlib import Path -from typing import Dict, List, Optional, Sequence - -from chain import compare_chain, create_chain, validate_chain -from common import ( - DEFAULT_PLAN_PATH, - CommandError, - branch_exists, - branch_name_for, - discover_test_command, - ensure_clean_tree, - ensure_git_repo, - init_plan, - load_plan, - validate_plan, -) -from dbcompare import db_compare -from github import pr_create, pr_merge -from patch_apply import build_diff -from plan_checks import strict_apply_check, validate_plan_strict -from preflight import preflight -from propagate import propagate_downstream, push_chain - - -def load_and_validate(plan_path: Path) -> Dict: - plan = load_plan(plan_path) - valid, errors = validate_plan(plan) - if not valid: - for err in errors: - print(f"[ERROR] {err}") - raise CommandError("Plan validation failed.") - return plan - - -def cmd_preflight(args: argparse.Namespace) -> None: - preflight( - base=args.base, - source=args.source, - test_cmd=args.test_cmd, - skip_tests=args.skip_tests, - skip_merge_check=args.skip_merge_check, - allow_source_behind_base=args.allow_source_behind_base, - confirm_source_behind_base=args.confirm_source_behind_base, - allow_recordkeeping_tracked=args.allow_recordkeeping_tracked, - ) - - -def cmd_init_plan(args: argparse.Namespace) -> None: - plan_path = Path(args.plan) - test_cmd = str(args.test_cmd or "").strip() - if not test_cmd: - discovery = discover_test_command("") - discovered = str(discovery.get("command") or "").strip() - if discovered: - test_cmd = discovered - print(f"[INFO] Using test command from AGENTS.md: {test_cmd}") - else: - reason = str(discovery.get("reason", "unknown")) - if reason == "agents-missing": - print("[WARN] No AGENTS.md found at repo root.") - elif reason == "agents-no-test-command": - print("[WARN] AGENTS.md found but no clear test command was detected.") - elif reason == "agents-ambiguous": - candidates = list(discovery.get("candidates", [])) - if candidates: - print("[WARN] Multiple test commands were detected in AGENTS.md:") - for cmd in candidates: - print(f" - {cmd}") - - suggestions = list(discovery.get("suggestions", [])) - if suggestions: - print("[HINT] Likely test commands to consider:") - for cmd in suggestions: - print(f" - {cmd}") - print("[NEXT] Ask once for the desired test command and update the plan.") - - init_plan( - plan_path=plan_path, - base=args.base, - source=args.source, - title=args.title, - changesets=args.changesets, - test_cmd=test_cmd, - force=args.force, - ) - print(f"[OK] Wrote plan template: {plan_path}") - print( - "[NEXT] Edit the plan to reflect your Phase 1 decomposition before creating branches." - ) - - -def cmd_validate(args: argparse.Namespace) -> None: - plan = load_plan(Path(args.plan)) - valid, errors = validate_plan(plan) - if not valid: - print("[ERROR] Plan validation failed:") - for err in errors: - print(f" - {err}") - raise CommandError("Plan is invalid.") - if args.strict: - strict_ok, strict_errors, strict_warnings = validate_plan_strict(plan) - if strict_warnings: - print("[WARN] Strict validation warnings:") - for warn in strict_warnings: - print(f" - {warn}") - if not strict_ok: - print("[ERROR] Strict validation failed:") - for err in strict_errors: - print(f" - {err}") - raise CommandError("Plan is invalid under --strict.") - ensure_git_repo() - ensure_clean_tree() - strict_apply_check(plan) - print("[OK] Strict validation passed.") - return - print("[OK] Plan validation passed.") - - -def cmd_hunk_preview(args: argparse.Namespace) -> None: - plan_path = Path(args.plan) - base = args.base - source = args.source - if plan_path.exists() and (not base or not source): - plan = load_plan(plan_path) - base = base or plan.get("base_branch", "") - source = source or plan.get("source_branch", "") - if not base or not source: - raise CommandError("hunk-preview requires --base and --source or a plan file.") - - diff_files = build_diff(base, source) - target = args.file - matched = [df for df in diff_files if target in (df.new_path, df.old_path)] - if not matched: - raise CommandError(f"No diff hunks found for file: {target}") - - contains = args.contains or [] - excludes = args.excludes or [] - - for df in matched: - if df.old_path and df.new_path and df.old_path != df.new_path: - label = f"{df.old_path} -> {df.new_path}" - else: - label = df.new_path or df.old_path or "" - print(f"[FILE] {label}") - if df.is_binary: - print(" [BIN] Binary file; no textual hunks available.") - continue - if not df.hunks: - print(" [INFO] No hunks in diff.") - continue - for idx, hunk in enumerate(df.hunks, start=1): - body = hunk.body_text - if contains and not all(c in body for c in contains): - continue - if excludes and any(c in body for c in excludes): - continue - print(f"\n [HUNK {idx}] {hunk.header}") - for line in hunk.lines[1:]: - print(f" {line}") - - -def cmd_run(args: argparse.Namespace) -> None: - plan_path = Path(args.plan) - plan_exists = plan_path.exists() - - preflight( - base=args.base, - source=args.source, - test_cmd=args.test_cmd, - skip_tests=args.skip_tests, - skip_merge_check=args.skip_merge_check, - allow_source_behind_base=args.allow_source_behind_base, - confirm_source_behind_base=args.confirm_source_behind_base, - allow_recordkeeping_tracked=args.allow_recordkeeping_tracked, - ) - - if not plan_exists or args.force_init: - init_plan( - plan_path=plan_path, - base=args.base, - source=args.source, - title=args.title, - changesets=args.changesets, - test_cmd=str(args.test_cmd or "").strip(), - force=args.force_init, - ) - print(f"[OK] Wrote plan template: {plan_path}") - print("[NEXT] Edit the plan to define changesets and selectors.") - else: - print(f"[INFO] Plan already exists: {plan_path}") - - if args.create_chain: - plan = load_and_validate(plan_path) - create_chain(plan) - print("[NEXT] Review each changeset branch before creating PRs.") - else: - print("[NEXT] Run 'create-chain' after updating the plan.") - - -def cmd_status(args: argparse.Namespace) -> None: - plan_path = Path(args.plan) - plan = load_plan(plan_path) - valid, errors = validate_plan(plan) - if not valid: - print("[WARN] Plan is invalid; status may be misleading.") - for err in errors: - print(f" - {err}") - - base = plan["base_branch"] - source = plan["source_branch"] - total = len(plan.get("changesets", [])) - - print(f"Plan: {plan_path}") - print(f"Base: {base}") - print(f"Source: {source}") - print(f"Changesets: {total}") - - for i in range(1, total + 1): - name = branch_name_for(source, i) - exists = branch_exists(name) - marker = "[OK]" if exists else "[ ]" - print(f"{marker} {name}") - - -def cmd_create_chain(args: argparse.Namespace) -> None: - plan = load_and_validate(Path(args.plan)) - create_chain(plan) - print("[NEXT] Review each branch and refine commits as needed before opening PRs.") - - -def cmd_compare(args: argparse.Namespace) -> None: - plan = load_and_validate(Path(args.plan)) - diffstat, namestatus = compare_chain(plan) - - print("\n[INFO] Diffstat vs source branch:") - if diffstat: - print(diffstat) - else: - print("[OK] No diffstat differences detected.") - - print("\n[INFO] Name-status vs source branch:") - if namestatus: - print(namestatus) - else: - print("[OK] No name-status differences detected.") - - print("[OK] Comparison completed. Investigate any reported differences.") - - -def cmd_validate_chain(args: argparse.Namespace) -> None: - plan = load_and_validate(Path(args.plan)) - test_cmd = args.test_cmd or str(plan.get("test_command", "")).strip() - validate_chain(plan, test_cmd=test_cmd) - - -def cmd_pr_create(args: argparse.Namespace) -> None: - plan = load_and_validate(Path(args.plan)) - total = len(plan["changesets"]) - - indices: List[int] - if args.index is None: - indices = list(range(1, total + 1)) - else: - indices = [args.index] - - pr_create(plan, indices=indices, dry_run=args.dry_run) - - -def cmd_propagate(args: argparse.Namespace) -> None: - plan = load_and_validate(Path(args.plan)) - propagate_downstream( - plan=plan, - merged_index=args.merged_index, - dry_run=args.dry_run, - update_pr_bases=args.update_pr_bases, - skip_local_merge=args.skip_local_merge, - push=args.push, - remote=args.remote, - strategy=args.strategy, - ) - - -def cmd_merge_propagate(args: argparse.Namespace) -> None: - plan = load_and_validate(Path(args.plan)) - source = plan["source_branch"] - total = len(plan["changesets"]) - index = args.index - - if index < 1 or index > total: - raise CommandError(f"--index must be between 1 and {total}.") - - head_branch = branch_name_for(source, index) - pr_merge(head_branch, method=args.method, dry_run=args.dry_run) - - propagate_downstream( - plan=plan, - merged_index=index, - dry_run=args.dry_run, - update_pr_bases=args.update_pr_bases, - skip_local_merge=args.skip_local_merge, - push=args.push, - remote=args.remote, - strategy=args.strategy, - ) - - -def cmd_push_chain(args: argparse.Namespace) -> None: - plan = load_and_validate(Path(args.plan)) - push_chain(plan, remote=args.remote, dry_run=args.dry_run) - - -def cmd_db_compare(args: argparse.Namespace) -> None: - plan = load_and_validate(Path(args.plan)) - out_dir = Path(args.out_dir) - db_compare( - plan, source_cmd=args.source_cmd, chain_cmd=args.chain_cmd, out_dir=out_dir - ) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="Deterministic helpers for the prepare-changesets workflow." - ) - sub = parser.add_subparsers(dest="command", required=True) - - p_preflight = sub.add_parser( - "preflight", help="Validate repo state, mergeability, and optional tests." - ) - p_preflight.add_argument("--base", required=True, help="Base branch (e.g., main)") - p_preflight.add_argument( - "--source", required=True, help="Source branch to decompose" - ) - p_preflight.add_argument( - "--test-cmd", - default="", - help="Optional test/build command to run on the source branch", - ) - p_preflight.add_argument( - "--skip-tests", action="store_true", help="Skip running the test command" - ) - p_preflight.add_argument( - "--skip-merge-check", action="store_true", help="Skip mergeability simulation" - ) - p_preflight.add_argument( - "--allow-source-behind-base", - action="store_true", - help="Allow preflight to continue when source is behind base.", - ) - p_preflight.add_argument( - "--confirm-source-behind-base", - action="store_true", - help="Prompt for confirmation when source is behind base.", - ) - p_preflight.add_argument( - "--allow-recordkeeping-tracked", - action="store_true", - help="Allow preflight to continue when .prepare-changesets/ is not ignored.", - ) - p_preflight.set_defaults(func=cmd_preflight) - - p_init = sub.add_parser("init-plan", help="Create a plan template JSON file.") - p_init.add_argument( - "--plan", - default=str(DEFAULT_PLAN_PATH), - help="Plan path (default: .prepare-changesets/plan.json)", - ) - p_init.add_argument("--base", required=True, help="Base branch") - p_init.add_argument("--source", required=True, help="Source branch") - p_init.add_argument("--title", required=True, help="Shared PR title base") - p_init.add_argument( - "--changesets", type=int, default=3, help="Number of placeholder changesets" - ) - p_init.add_argument( - "--test-cmd", - default="", - help="Optional test/build command to store in the plan", - ) - p_init.add_argument("--force", action="store_true", help="Overwrite existing plan") - p_init.set_defaults(func=cmd_init_plan) - - p_validate = sub.add_parser("validate", help="Validate a plan file.") - p_validate.add_argument("--plan", default=str(DEFAULT_PLAN_PATH), help="Plan path") - p_validate.add_argument( - "--strict", - action="store_true", - help="Run strict validation (hunk matching, patch checks, placeholders).", - ) - p_validate.set_defaults(func=cmd_validate) - - p_status = sub.add_parser("status", help="Show plan and branch-chain status.") - p_status.add_argument("--plan", default=str(DEFAULT_PLAN_PATH), help="Plan path") - p_status.set_defaults(func=cmd_status) - - p_create = sub.add_parser( - "create-chain", help="Create the ordered changeset branch chain." - ) - p_create.add_argument("--plan", default=str(DEFAULT_PLAN_PATH), help="Plan path") - p_create.set_defaults(func=cmd_create_chain) - - p_compare = sub.add_parser( - "compare", help="Merge the chain into a temp branch and diff vs source." - ) - p_compare.add_argument("--plan", default=str(DEFAULT_PLAN_PATH), help="Plan path") - p_compare.set_defaults(func=cmd_compare) - - p_validate_chain = sub.add_parser( - "validate-chain", - help="Merge changesets in order into a temp branch and run tests after each merge.", - ) - p_validate_chain.add_argument( - "--plan", default=str(DEFAULT_PLAN_PATH), help="Plan path" - ) - p_validate_chain.add_argument( - "--test-cmd", - default="", - help="Test command to run after each merge (defaults to plan.test_command)", - ) - p_validate_chain.set_defaults(func=cmd_validate_chain) - - p_pr = sub.add_parser( - "pr-create", help="Create stacked PRs with gh based on the plan." - ) - p_pr.add_argument("--plan", default=str(DEFAULT_PLAN_PATH), help="Plan path") - p_pr.add_argument( - "--index", - type=int, - help="Create a PR for a single 1-based changeset index (default: all)", - ) - p_pr.add_argument( - "--dry-run", - dest="dry_run", - action="store_true", - help="Print gh commands without executing them (default).", - ) - p_pr.add_argument( - "--no-dry-run", - dest="dry_run", - action="store_false", - help="Execute gh commands.", - ) - p_pr.set_defaults(func=cmd_pr_create, dry_run=True) - - p_merge = sub.add_parser( - "merge-propagate", - help="Merge a reviewed changeset PR via gh, then propagate and update downstream PR bases.", - ) - p_merge.add_argument("--plan", default=str(DEFAULT_PLAN_PATH), help="Plan path") - p_merge.add_argument( - "--index", type=int, required=True, help="1-based changeset index to merge" - ) - p_merge.add_argument( - "--method", - choices=("default", "merge", "squash", "rebase"), - default="default", - help="gh pr merge strategy (default: repo default)", - ) - p_merge.add_argument( - "--update-pr-bases", - dest="update_pr_bases", - action="store_true", - help="Update downstream PR bases with gh (default).", - ) - p_merge.add_argument( - "--no-update-pr-bases", - dest="update_pr_bases", - action="store_false", - help="Skip gh PR base updates.", - ) - p_merge.add_argument( - "--skip-local-merge", - action="store_true", - help="Skip local base-branch merge simulation.", - ) - p_merge.add_argument( - "--strategy", - choices=("rebase", "cherry-pick"), - default="rebase", - help="Propagate via rebase (default) or cherry-picking the merged changeset commits.", - ) - p_merge.add_argument( - "--push", - action="store_true", - help="Push updated branches to the remote with --force-with-lease.", - ) - p_merge.add_argument( - "--remote", default="origin", help="Remote name for --push (default: origin)" - ) - p_merge.add_argument( - "--dry-run", - dest="dry_run", - action="store_true", - help="Print gh/git commands without executing them (default).", - ) - p_merge.add_argument( - "--no-dry-run", - dest="dry_run", - action="store_false", - help="Execute gh/git commands.", - ) - p_merge.set_defaults(func=cmd_merge_propagate, dry_run=True, update_pr_bases=True) - - p_propagate = sub.add_parser( - "propagate", help="Propagate downstream changesets after a merge." - ) - p_propagate.add_argument("--plan", default=str(DEFAULT_PLAN_PATH), help="Plan path") - p_propagate.add_argument( - "--merged-index", - type=int, - required=True, - help="1-based index of the merged changeset, or 0 to propagate onto base only", - ) - p_propagate.add_argument( - "--update-pr-bases", - dest="update_pr_bases", - action="store_true", - help="Update downstream PR bases with gh (default).", - ) - p_propagate.add_argument( - "--no-update-pr-bases", - dest="update_pr_bases", - action="store_false", - help="Skip gh PR base updates.", - ) - p_propagate.add_argument( - "--skip-local-merge", - action="store_true", - help="Skip local base-branch merge simulation.", - ) - p_propagate.add_argument( - "--strategy", - choices=("rebase", "cherry-pick"), - default="rebase", - help="Propagate via rebase (default) or cherry-picking the merged changeset commits.", - ) - p_propagate.add_argument( - "--push", - action="store_true", - help="Push updated branches to the remote with --force-with-lease.", - ) - p_propagate.add_argument( - "--remote", default="origin", help="Remote name for --push (default: origin)" - ) - p_propagate.add_argument( - "--dry-run", - dest="dry_run", - action="store_true", - help="Print gh/git commands without executing them (default).", - ) - p_propagate.add_argument( - "--no-dry-run", - dest="dry_run", - action="store_false", - help="Execute gh/git commands.", - ) - p_propagate.set_defaults(func=cmd_propagate, dry_run=True, update_pr_bases=True) - - p_push = sub.add_parser( - "push-chain", - help="Push base and changeset branches to a remote with --force-with-lease.", - ) - p_push.add_argument("--plan", default=str(DEFAULT_PLAN_PATH), help="Plan path") - p_push.add_argument( - "--remote", default="origin", help="Remote name (default: origin)" - ) - p_push.add_argument( - "--dry-run", - dest="dry_run", - action="store_true", - help="Print git commands without executing them (default).", - ) - p_push.add_argument( - "--no-dry-run", - dest="dry_run", - action="store_false", - help="Execute git commands.", - ) - p_push.set_defaults(func=cmd_push_chain, dry_run=True) - - p_db = sub.add_parser( - "db-compare", - help="Run source and chain schema commands and diff their outputs.", - ) - p_db.add_argument("--plan", default=str(DEFAULT_PLAN_PATH), help="Plan path") - p_db.add_argument( - "--source-cmd", - required=True, - help="Command to run on the source branch (stdout is captured)", - ) - p_db.add_argument( - "--chain-cmd", - required=True, - help="Command to run on the merged chain branch (stdout is captured)", - ) - p_db.add_argument( - "--out-dir", - default=str(DEFAULT_PLAN_PATH.parent / "db-compare"), - help="Directory for captured outputs (default: .prepare-changesets/db-compare)", - ) - p_db.set_defaults(func=cmd_db_compare) - - p_hunk = sub.add_parser( - "hunk-preview", - help="List candidate hunks for a file from base..source diff.", - ) - p_hunk.add_argument("--plan", default=str(DEFAULT_PLAN_PATH), help="Plan path") - p_hunk.add_argument("--base", default="", help="Base branch (optional)") - p_hunk.add_argument("--source", default="", help="Source branch (optional)") - p_hunk.add_argument("--file", required=True, help="Repo-relative file path") - p_hunk.add_argument( - "--contains", - action="append", - default=[], - help="Only show hunks containing this string (repeatable)", - ) - p_hunk.add_argument( - "--excludes", - action="append", - default=[], - help="Hide hunks containing this string (repeatable)", - ) - p_hunk.set_defaults(func=cmd_hunk_preview) - - p_run = sub.add_parser( - "run", - help="Guided runner: preflight, create a plan if missing, optionally create chain.", - ) - p_run.add_argument("--plan", default=str(DEFAULT_PLAN_PATH), help="Plan path") - p_run.add_argument("--base", required=True, help="Base branch") - p_run.add_argument("--source", required=True, help="Source branch") - p_run.add_argument("--title", required=True, help="Shared PR title base") - p_run.add_argument( - "--changesets", type=int, default=3, help="Number of placeholder changesets" - ) - p_run.add_argument( - "--test-cmd", - default="", - help="Optional test/build command to run and store in the plan", - ) - p_run.add_argument( - "--skip-tests", action="store_true", help="Skip running the test command" - ) - p_run.add_argument( - "--skip-merge-check", action="store_true", help="Skip mergeability simulation" - ) - p_run.add_argument( - "--allow-source-behind-base", - action="store_true", - help="Allow preflight to continue when source is behind base.", - ) - p_run.add_argument( - "--confirm-source-behind-base", - action="store_true", - help="Prompt for confirmation when source is behind base.", - ) - p_run.add_argument( - "--allow-recordkeeping-tracked", - action="store_true", - help="Allow preflight to continue when .prepare-changesets/ is not ignored.", - ) - p_run.add_argument( - "--create-chain", - action="store_true", - help="Create changeset branches after writing/validating the plan", - ) - p_run.add_argument( - "--force-init", - action="store_true", - help="Overwrite an existing plan file when running init", - ) - p_run.set_defaults(func=cmd_run) - - return parser - - -def main(argv: Optional[Sequence[str]] = None) -> int: - parser = build_parser() - args = parser.parse_args(argv) - - try: - args.func(args) - ensure_clean_tree() - return 0 - except CommandError as exc: - print(f"[ERROR] {exc}") - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/skills/prepare-changesets/scripts/common.py b/skills/prepare-changesets/scripts/common.py deleted file mode 100644 index 90501df..0000000 --- a/skills/prepare-changesets/scripts/common.py +++ /dev/null @@ -1,675 +0,0 @@ -"""Shared helpers for prepare-changesets scripts.""" - -from __future__ import annotations - -import datetime as _dt -import json -import re -import subprocess -from contextlib import contextmanager -from pathlib import Path -from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple - -DEFAULT_PLAN_PATH = Path(".prepare-changesets/plan.json") -STATE_PATH = Path(".prepare-changesets/state.json") - - -class CommandError(RuntimeError): - """Raised when a subprocess or git command fails.""" - - -TEST_COMMAND_HINTS: Tuple[str, ...] = ( - "just test", - "make test", - "pytest", - "python -m pytest", - "npm test", - "pnpm test", - "yarn test", - "bun test", - "go test", - "cargo test", - "tox", - "nox", -) - - -def run( - cmd: Sequence[str], *, capture: bool = True, check: bool = True -) -> subprocess.CompletedProcess: - """Run a command and return the completed process.""" - try: - result = subprocess.run( - list(cmd), - text=True, - capture_output=capture, - check=False, - ) - except FileNotFoundError as exc: - raise CommandError(f"Command not found: {cmd[0]}") from exc - - if check and result.returncode != 0: - stderr = (result.stderr or "").strip() - stdout = (result.stdout or "").strip() - detail = stderr or stdout or f"exit code {result.returncode}" - raise CommandError(f"Command failed: {' '.join(cmd)}\n{detail}") - return result - - -def git( - *args: str, capture: bool = True, check: bool = True -) -> subprocess.CompletedProcess: - """Run a git command.""" - return run(("git",) + args, capture=capture, check=check) - - -def ensure_git_repo() -> None: - git("rev-parse", "--is-inside-work-tree") - - -def repo_root() -> Path: - return Path(git("rev-parse", "--show-toplevel").stdout.strip()) - - -def ensure_clean_tree() -> None: - status = git( - "status", - "--porcelain", - "--", - ".", - ":(exclude).prepare-changesets", - ).stdout.strip() - if status: - raise CommandError( - "Working tree is not clean. Commit, stash, or discard changes first." - ) - - -def is_path_ignored(path: Path | str) -> bool: - raw = path.as_posix() if isinstance(path, Path) else str(path) - candidates = {raw, raw.rstrip("/")} - for candidate in sorted(candidates): - if not candidate: - continue - result = git( - "check-ignore", - "-q", - "--", - candidate, - capture=False, - check=False, - ) - if result.returncode == 0: - return True - return False - - -def branch_exists(name: str) -> bool: - result = git("rev-parse", "--verify", name, capture=True, check=False) - return result.returncode == 0 - - -def current_branch() -> str: - return git("rev-parse", "--abbrev-ref", "HEAD").stdout.strip() - - -def merge_base(base: str, source: str) -> str: - return git("merge-base", base, source).stdout.strip() - - -def compute_freshness(base: str, source: str) -> Dict[str, object]: - base_head = git("rev-parse", base).stdout.strip() - source_head = git("rev-parse", source).stdout.strip() - mb = merge_base(base, source) - return { - "base_head": base_head, - "source_head": source_head, - "merge_base": mb, - "source_behind_base": mb != base_head, - } - - -def diff_name_status(base: str, source: str) -> str: - return git("diff", "--name-status", f"{base}..{source}").stdout.strip() - - -def diff_stat(base: str, source: str) -> str: - return git("diff", "--stat", f"{base}..{source}").stdout.strip() - - -def unique_temp_branch(prefix: str) -> str: - ts = _dt.datetime.now().strftime("%Y%m%d-%H%M%S") - return f"{prefix}-{ts}" - - -def delete_branch(name: str) -> None: - git("branch", "-D", name, check=False) - - -def ensure_branches_exist(branches: Iterable[str]) -> None: - missing = [b for b in branches if not branch_exists(b)] - if missing: - raise CommandError("Missing branch(es):\n" + "\n".join(missing)) - - -def branch_name_for(source_branch: str, index: int) -> str: - return f"{source_branch}-{index}" - - -def base_for_changeset(base_branch: str, source_branch: str, index: int) -> str: - if index <= 1: - return base_branch - return branch_name_for(source_branch, index - 1) - - -def squashed_branch_name(source_branch: str) -> str: - return f"{source_branch}-squashed" - - -@contextmanager -def checkout_restore(target: Optional[str] = None): - """Checkout target branch (if provided) and always restore the original branch.""" - original = current_branch() - try: - if target and target != original: - git("checkout", target) - yield original - finally: - if current_branch() != original: - git("checkout", original) - - -def load_plan(path: Path) -> Dict: - try: - return json.loads(path.read_text()) - except FileNotFoundError as exc: - raise CommandError(f"Plan file not found: {path}") from exc - except json.JSONDecodeError as exc: - raise CommandError(f"Invalid JSON in plan file {path}: {exc}") from exc - - -def load_state(path: Path = STATE_PATH) -> Optional[Dict]: - try: - return json.loads(path.read_text()) - except FileNotFoundError: - return None - except json.JSONDecodeError as exc: - raise CommandError(f"Invalid JSON in state file {path}: {exc}") from exc - - -def write_state(data: Dict, path: Path = STATE_PATH) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(data, indent=2) + "\n") - - -def record_state( - plan: Dict, - chain: Sequence[str], - *, - user_confirmed_source_behind_base: Optional[bool] = None, - path: Path = STATE_PATH, -) -> None: - base = plan.get("base_branch", "") - source = plan.get("source_branch", "") - if not isinstance(source, str) or not source.strip(): - raise CommandError("Cannot record state without source_branch.") - if not isinstance(base, str) or not base.strip(): - raise CommandError("Cannot record state without base_branch.") - - freshness = compute_freshness(base, source) - existing = load_state(path) or {} - confirmed = ( - bool(user_confirmed_source_behind_base) - if user_confirmed_source_behind_base is not None - else bool(existing.get("user_confirmed_source_behind_base", False)) - ) - - changesets = plan.get("changesets", []) - entries: List[Dict[str, str]] = [] - for idx, branch in enumerate(chain, start=1): - slug = "" - if isinstance(changesets, list) and idx <= len(changesets): - slug_val = changesets[idx - 1].get("slug") - if isinstance(slug_val, str): - slug = slug_val - head = git("rev-parse", branch).stdout.strip() - entries.append( - { - "index": idx, - "slug": slug, - "branch": branch, - "head": head, - } - ) - - write_state( - { - "base_branch": base, - "base_head": freshness["base_head"], - "source_branch": source, - "source_head": freshness["source_head"], - "source_behind_base": freshness["source_behind_base"], - "user_confirmed_source_behind_base": confirmed, - "changesets": entries, - }, - path=path, - ) - - -def record_preflight_state( - base: str, - source: str, - *, - user_confirmed_source_behind_base: bool, - path: Path = STATE_PATH, -) -> None: - freshness = compute_freshness(base, source) - existing = load_state(path) or {} - state = dict(existing) - state.update( - { - "base_branch": base, - "base_head": freshness["base_head"], - "source_branch": source, - "source_head": freshness["source_head"], - "source_behind_base": freshness["source_behind_base"], - "user_confirmed_source_behind_base": bool( - user_confirmed_source_behind_base - ), - } - ) - write_state(state, path=path) - - -def validate_plan(plan: Dict) -> Tuple[bool, List[str]]: - errors: List[str] = [] - - def require_string(key: str) -> None: - if ( - key not in plan - or not isinstance(plan.get(key), str) - or not str(plan[key]).strip() - ): - errors.append(f"Missing or invalid string field: {key}") - - require_string("feature_title") - require_string("base_branch") - require_string("source_branch") - - changesets = plan.get("changesets") - if not isinstance(changesets, list) or not changesets: - errors.append("Plan must include a non-empty 'changesets' array.") - return False, errors - - for idx, cs in enumerate(changesets, start=1): - if not isinstance(cs, dict): - errors.append(f"Changeset {idx} must be an object.") - continue - - for key in ("slug", "description"): - if key not in cs: - errors.append(f"Changeset {idx} missing required field: {key}") - - if "slug" in cs and (not isinstance(cs["slug"], str) or not cs["slug"].strip()): - errors.append(f"Changeset {idx} has invalid slug.") - if "description" in cs and ( - not isinstance(cs["description"], str) or not cs["description"].strip() - ): - errors.append(f"Changeset {idx} has invalid description.") - - mode = str(cs.get("mode", "paths")).strip() or "paths" - if mode not in ("paths", "patch", "hunks"): - errors.append( - f"Changeset {idx} has invalid mode '{mode}'. Use 'paths', 'patch', or 'hunks'." - ) - - include = cs.get("include_paths", []) - if include and ( - not isinstance(include, list) - or not all(isinstance(p, str) for p in include) - ): - errors.append( - f"Changeset {idx} include_paths must be a string array when provided." - ) - if mode == "paths": - if not isinstance(include, list) or not include: - errors.append( - f"Changeset {idx} include_paths must be a non-empty string array for mode=paths." - ) - - if mode == "patch": - patch_file = cs.get("patch_file") - if not isinstance(patch_file, str) or not patch_file.strip(): - errors.append( - f"Changeset {idx} patch_file must be a non-empty string for mode=patch." - ) - - if mode == "hunks": - selectors = cs.get("hunk_selectors") - if ( - not isinstance(selectors, list) - or not selectors - or not all(isinstance(s, dict) for s in selectors) - ): - errors.append( - f"Changeset {idx} hunk_selectors must be a non-empty array for mode=hunks." - ) - - exclude = cs.get("exclude_paths", []) - if exclude and ( - not isinstance(exclude, list) - or not all(isinstance(p, str) for p in exclude) - ): - errors.append( - f"Changeset {idx} exclude_paths must be a string array when provided." - ) - - allow_partial = cs.get("allow_partial_files") - if allow_partial is not None and not isinstance(allow_partial, bool): - errors.append( - f"Changeset {idx} allow_partial_files must be a boolean when provided." - ) - - pr_notes = cs.get("pr_notes", []) - if pr_notes and ( - not isinstance(pr_notes, list) - or not all(isinstance(p, str) for p in pr_notes) - ): - errors.append( - f"Changeset {idx} pr_notes must be a string array when provided." - ) - - return (not errors), errors - - -def default_changeset(index: int) -> Dict: - return { - "mode": "paths", - "slug": f"changeset-{index}", - "description": f"Describe the intent for changeset {index}.", - "include_paths": ["src/**"], - "exclude_paths": [], - "allow_partial_files": True, - "commit_message": f"changeset: placeholder {index}", - "pr_notes": ["Replace with PR notes for this changeset."], - } - - -def init_plan( - *, - plan_path: Path, - base: str, - source: str, - title: str, - changesets: int, - test_cmd: str, - force: bool, -) -> None: - plan_path.parent.mkdir(parents=True, exist_ok=True) - - if plan_path.exists() and not force: - raise CommandError( - f"Plan already exists: {plan_path}. Use --force to overwrite." - ) - - plan = { - "feature_title": title, - "base_branch": base, - "source_branch": source, - "test_command": test_cmd or "", - "changesets": [default_changeset(i) for i in range(1, changesets + 1)], - } - - plan_path.write_text(json.dumps(plan, indent=2) + "\n") - - -def _is_test_command(cmd: str) -> bool: - lower = cmd.lower() - if any(hint in lower for hint in TEST_COMMAND_HINTS): - return True - # Catch generic patterns like "just test-foo" or "make test-all". - return bool(re.search(r"\b(test|pytest)\b", lower)) - - -def _extract_code_blocks(text: str) -> List[str]: - # Keep this permissive; we are mining for likely commands, not parsing Markdown. - pattern = re.compile(r"```[a-zA-Z0-9_-]*\n(.*?)```", re.DOTALL) - return [m.group(1) for m in pattern.finditer(text)] - - -def _commands_from_block(block: str) -> List[str]: - commands: List[str] = [] - for raw_line in block.splitlines(): - line = raw_line.strip() - if not line or line.startswith("#"): - continue - if re.match(r"^[A-Za-z_][A-Za-z0-9_-]*:", line): - continue - if re.match(r"^\"[A-Za-z0-9_-]+\":", line): - continue - if re.match(r"^'[A-Za-z0-9_-]+':", line): - continue - # Treat each non-empty line as a potential command. - commands.append(line) - return commands - - -def parse_agents_test_commands(path: Path) -> List[str]: - try: - text = path.read_text() - except FileNotFoundError: - return [] - - candidates: List[str] = [] - seen: Set[str] = set() - for block in _extract_code_blocks(text): - for cmd in _commands_from_block(block): - if not _is_test_command(cmd): - continue - if cmd not in seen: - seen.add(cmd) - candidates.append(cmd) - return candidates - - -def _add_suggestion(cmd: str, suggestions: List[str], seen: Set[str]) -> None: - clean = cmd.strip() - if not clean or clean in seen: - return - seen.add(clean) - suggestions.append(clean) - - -def _suggest_from_justfile(root: Path, suggestions: List[str], seen: Set[str]) -> None: - for name in ("justfile", "Justfile"): - path = root / name - if not path.exists(): - continue - text = path.read_text() - if re.search(r"(?m)^test\s*:", text): - _add_suggestion("just test", suggestions, seen) - - -def _suggest_from_makefile(root: Path, suggestions: List[str], seen: Set[str]) -> None: - for name in ("Makefile", "makefile"): - path = root / name - if not path.exists(): - continue - text = path.read_text() - if re.search(r"(?m)^test\s*:", text): - _add_suggestion("make test", suggestions, seen) - - -def _suggest_from_package_json( - root: Path, suggestions: List[str], seen: Set[str] -) -> None: - path = root / "package.json" - if not path.exists(): - return - try: - data = json.loads(path.read_text()) - except json.JSONDecodeError: - return - - scripts = data.get("scripts") if isinstance(data, dict) else None - test_script = scripts.get("test") if isinstance(scripts, dict) else None - if not isinstance(test_script, str) or not test_script.strip(): - return - - if (root / "pnpm-lock.yaml").exists(): - tool_cmd = "pnpm test" - elif (root / "yarn.lock").exists(): - tool_cmd = "yarn test" - elif (root / "bun.lockb").exists(): - tool_cmd = "bun test" - else: - tool_cmd = "npm test" - - _add_suggestion(tool_cmd, suggestions, seen) - # The project's declared test script is authoritative even if it does not - # contain the word "test" (for example, "vitest run"). - _add_suggestion(test_script, suggestions, seen) - - -def _suggest_from_python_project( - root: Path, suggestions: List[str], seen: Set[str] -) -> None: - pyproject = root / "pyproject.toml" - setup_cfg = root / "setup.cfg" - tests_dir = root / "tests" - - pyproject_text = pyproject.read_text() if pyproject.exists() else "" - setup_cfg_text = setup_cfg.read_text() if setup_cfg.exists() else "" - - if "[tool.pytest" in pyproject_text or "pytest" in pyproject_text: - _add_suggestion("python -m pytest", suggestions, seen) - return - if "[tool:pytest]" in setup_cfg_text or "pytest" in setup_cfg_text: - _add_suggestion("python -m pytest", suggestions, seen) - return - - if tests_dir.exists() and any(p.suffix == ".py" for p in tests_dir.rglob("*.py")): - _add_suggestion("python -m pytest", suggestions, seen) - - -def _suggest_from_other_roots( - root: Path, suggestions: List[str], seen: Set[str] -) -> None: - if (root / "tox.ini").exists(): - _add_suggestion("tox", suggestions, seen) - if (root / "noxfile.py").exists(): - _add_suggestion("nox", suggestions, seen) - if (root / "go.mod").exists(): - _add_suggestion("go test ./...", suggestions, seen) - if (root / "Cargo.toml").exists(): - _add_suggestion("cargo test", suggestions, seen) - - -def _suggest_from_workflows(root: Path, suggestions: List[str], seen: Set[str]) -> None: - workflows_dir = root / ".github" / "workflows" - if not workflows_dir.exists(): - return - - workflow_paths = sorted( - [ - *workflows_dir.glob("*.yml"), - *workflows_dir.glob("*.yaml"), - ] - ) - run_line = re.compile(r"^\s*run:\s*(.*)$") - - for path in workflow_paths: - lines = path.read_text().splitlines() - i = 0 - while i < len(lines): - line = lines[i] - m = run_line.match(line) - if not m: - i += 1 - continue - - rest = m.group(1).strip() - if rest and rest not in ("|", ">"): - if _is_test_command(rest): - _add_suggestion(rest, suggestions, seen) - i += 1 - continue - - # Handle multi-line run blocks. - base_indent = len(line) - len(line.lstrip(" ")) - i += 1 - while i < len(lines): - block_line = lines[i] - indent = len(block_line) - len(block_line.lstrip(" ")) - if indent <= base_indent: - break - cmd = block_line.strip() - if cmd and _is_test_command(cmd): - _add_suggestion(cmd, suggestions, seen) - i += 1 - - -def suggest_test_commands(root: Path, *, prior: Sequence[str] = ()) -> List[str]: - suggestions: List[str] = [] - seen: Set[str] = set() - - for cmd in prior: - _add_suggestion(cmd, suggestions, seen) - - _suggest_from_justfile(root, suggestions, seen) - _suggest_from_makefile(root, suggestions, seen) - _suggest_from_package_json(root, suggestions, seen) - _suggest_from_python_project(root, suggestions, seen) - _suggest_from_other_roots(root, suggestions, seen) - _suggest_from_workflows(root, suggestions, seen) - - return suggestions - - -def discover_test_command(preferred: str = "") -> Dict[str, object]: - """Discover a repo-specific test command and return diagnostics. - - The return object always includes: - - command: Optional[str] - - source: str - - reason: str - - candidates: List[str] - - suggestions: List[str] - """ - if preferred.strip(): - return { - "command": preferred.strip(), - "source": "cli", - "reason": "provided", - "candidates": [], - "suggestions": [], - } - - root = repo_root() - agents_path = root / "AGENTS.md" - agents_candidates = parse_agents_test_commands(agents_path) - - if agents_candidates and len(agents_candidates) == 1: - return { - "command": agents_candidates[0], - "source": "agents", - "reason": "agents-single", - "candidates": agents_candidates, - "suggestions": agents_candidates, - } - - if not agents_path.exists(): - reason = "agents-missing" - elif not agents_candidates: - reason = "agents-no-test-command" - else: - reason = "agents-ambiguous" - - suggestions = suggest_test_commands(root, prior=agents_candidates) - return { - "command": None, - "source": "none", - "reason": reason, - "candidates": agents_candidates, - "suggestions": suggestions, - } diff --git a/skills/prepare-changesets/scripts/compare.py b/skills/prepare-changesets/scripts/compare.py deleted file mode 100755 index a717abc..0000000 --- a/skills/prepare-changesets/scripts/compare.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env python3 -"""Run the compare command.""" - -from __future__ import annotations - -import sys -from pathlib import Path - -SCRIPTS_DIR = Path(__file__).resolve().parent -if str(SCRIPTS_DIR) not in sys.path: - sys.path.insert(0, str(SCRIPTS_DIR)) - -from cli import main # noqa: E402 - -if __name__ == "__main__": - raise SystemExit(main(["compare", *sys.argv[1:]])) diff --git a/skills/prepare-changesets/scripts/create_chain.py b/skills/prepare-changesets/scripts/create_chain.py deleted file mode 100755 index 90b798e..0000000 --- a/skills/prepare-changesets/scripts/create_chain.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env python3 -"""Run the create-chain command.""" - -from __future__ import annotations - -import sys -from pathlib import Path - -SCRIPTS_DIR = Path(__file__).resolve().parent -if str(SCRIPTS_DIR) not in sys.path: - sys.path.insert(0, str(SCRIPTS_DIR)) - -from cli import main # noqa: E402 - -if __name__ == "__main__": - raise SystemExit(main(["create-chain", *sys.argv[1:]])) diff --git a/skills/prepare-changesets/scripts/db_compare.py b/skills/prepare-changesets/scripts/db_compare.py deleted file mode 100755 index 8932c2f..0000000 --- a/skills/prepare-changesets/scripts/db_compare.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env python3 -"""Run the db-compare command.""" - -from __future__ import annotations - -import sys -from pathlib import Path - -SCRIPTS_DIR = Path(__file__).resolve().parent -if str(SCRIPTS_DIR) not in sys.path: - sys.path.insert(0, str(SCRIPTS_DIR)) - -from cli import main # noqa: E402 - -if __name__ == "__main__": - raise SystemExit(main(["db-compare", *sys.argv[1:]])) diff --git a/skills/prepare-changesets/scripts/dbcompare.py b/skills/prepare-changesets/scripts/dbcompare.py deleted file mode 100755 index be1aaf6..0000000 --- a/skills/prepare-changesets/scripts/dbcompare.py +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env python3 -"""Database/schema equivalence comparison hooks.""" - -from __future__ import annotations - -import argparse -import subprocess -from pathlib import Path -from typing import Dict - -from common import ( - CommandError, - branch_name_for, - checkout_restore, - delete_branch, - ensure_branches_exist, - ensure_clean_tree, - ensure_git_repo, - git, - unique_temp_branch, -) - - -def run_capture(command: str, outfile: Path) -> None: - result = subprocess.run(command, shell=True, text=True, capture_output=True) - if result.returncode != 0: - detail = (result.stderr or result.stdout or "").strip() - raise CommandError(f"Command failed: {command}\n{detail}") - outfile.write_text(result.stdout) - - -def db_compare(plan: Dict, *, source_cmd: str, chain_cmd: str, out_dir: Path) -> None: - if not source_cmd.strip() or not chain_cmd.strip(): - raise CommandError("db-compare requires both --source-cmd and --chain-cmd.") - - ensure_git_repo() - ensure_clean_tree() - - base = plan["base_branch"] - source = plan["source_branch"] - total = len(plan["changesets"]) - chain = [branch_name_for(source, i) for i in range(1, total + 1)] - ensure_branches_exist([base, source, *chain]) - - out_dir.mkdir(parents=True, exist_ok=True) - source_out = out_dir / "source.txt" - chain_out = out_dir / "chain.txt" - - temp_branch = unique_temp_branch("pcs-temp-dbcompare") - print(f"[INFO] Using output directory: {out_dir}") - print(f"[INFO] Creating temporary branch: {temp_branch}") - - with checkout_restore() as original: - try: - git("checkout", source) - print(f"[STEP] Running source command on {source}") - run_capture(source_cmd, source_out) - - git("checkout", "-B", temp_branch, base) - for name in chain: - print(f"[STEP] Merging {name} into {temp_branch}") - git("merge", "--no-ff", "--no-edit", name) - - print(f"[STEP] Running chain command on {temp_branch}") - run_capture(chain_cmd, chain_out) - - print("[STEP] Diffing outputs (git diff --no-index)") - diff = git( - "diff", "--no-index", "--", str(source_out), str(chain_out), check=False - ) - if diff.returncode == 0: - print("[OK] No differences detected.") - else: - print(diff.stdout.strip() or "[WARN] Differences detected.") - finally: - git("checkout", original) - delete_branch(temp_branch) - - ensure_clean_tree() - print("[OK] db-compare completed.") - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="DB/schema compare helper.") - parser.add_argument("--plan", default=str(Path(".prepare-changesets/plan.json"))) - parser.add_argument("--source-cmd", required=True) - parser.add_argument("--chain-cmd", required=True) - parser.add_argument( - "--out-dir", default=str(Path(".prepare-changesets/db-compare")) - ) - return parser diff --git a/skills/prepare-changesets/scripts/evals/__init__.py b/skills/prepare-changesets/scripts/evals/__init__.py deleted file mode 100644 index 3fc6d83..0000000 --- a/skills/prepare-changesets/scripts/evals/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Local eval helpers for prepare-changesets.""" diff --git a/skills/prepare-changesets/scripts/evals/grader.py b/skills/prepare-changesets/scripts/evals/grader.py deleted file mode 100644 index 9c97241..0000000 --- a/skills/prepare-changesets/scripts/evals/grader.py +++ /dev/null @@ -1,189 +0,0 @@ -#!/usr/bin/env python3 -"""Deterministic grader for prepare-changesets eval runs.""" - -from __future__ import annotations - -import argparse -import json -import sys -from dataclasses import asdict, dataclass -from pathlib import Path -from typing import Dict, List - -SCRIPTS_DIR = Path(__file__).resolve().parents[1] -if str(SCRIPTS_DIR) not in sys.path: - sys.path.insert(0, str(SCRIPTS_DIR)) - -from chain import compare_chain, create_chain, validate_chain # noqa: E402 -from common import ( # noqa: E402 - DEFAULT_PLAN_PATH, - CommandError, - ensure_clean_tree, - git, - load_plan, - validate_plan, -) - - -def branch_name_for(source_branch: str, index: int) -> str: - return f"{source_branch}-{index}" - - -@dataclass -class GradeResult: - ok: bool - checks: List[str] - failures: List[str] - - -def _require_clean_tree(checks: List[str], failures: List[str]) -> None: - try: - ensure_clean_tree() - checks.append("clean_tree") - except CommandError as exc: - failures.append(f"clean_tree: {exc}") - - -def _validate_plan(plan: Dict, checks: List[str], failures: List[str]) -> None: - valid, errors = validate_plan(plan) - if valid: - checks.append("plan_valid") - else: - failures.append("plan_valid: " + "; ".join(errors)) - - -def _check_source_hash( - source_branch: str, expected: str, checks: List[str], failures: List[str] -) -> None: - current = git("rev-parse", source_branch).stdout.strip() - if current == expected: - checks.append("source_hash_unchanged") - else: - failures.append(f"source_hash_unchanged: expected {expected}, got {current}") - - -def _check_chain_exists(plan: Dict, checks: List[str], failures: List[str]) -> None: - source = plan["source_branch"] - total = len(plan["changesets"]) - missing: List[str] = [] - for i in range(1, total + 1): - name = branch_name_for(source, i) - if git("rev-parse", "--verify", name, check=False).returncode != 0: - missing.append(name) - if missing: - failures.append("chain_exists: missing " + ", ".join(missing)) - else: - checks.append("chain_exists") - - -def _check_equivalence(plan: Dict, checks: List[str], failures: List[str]) -> None: - try: - diffstat, namestatus = compare_chain(plan) - except CommandError as exc: - failures.append(f"compare_chain: {exc}") - return - - if diffstat.strip() or namestatus.strip(): - failures.append("equivalence: differences detected") - else: - checks.append("equivalence") - - -def _check_validate_chain( - plan: Dict, test_cmd: str, checks: List[str], failures: List[str] -) -> None: - try: - validate_chain(plan, test_cmd=test_cmd) - checks.append("validate_chain") - except CommandError as exc: - failures.append(f"validate_chain: {exc}") - - -def grade_repo( - *, - plan_path: Path, - expected_source_hash: str, - test_cmd: str, - auto_create_chain: bool, -) -> GradeResult: - checks: List[str] = [] - failures: List[str] = [] - - _require_clean_tree(checks, failures) - - try: - plan = load_plan(plan_path) - except CommandError as exc: - failures.append(f"load_plan: {exc}") - return GradeResult(ok=False, checks=checks, failures=failures) - - _validate_plan(plan, checks, failures) - _check_source_hash(plan["source_branch"], expected_source_hash, checks, failures) - - if auto_create_chain: - try: - create_chain(plan) - checks.append("create_chain") - except CommandError as exc: - failures.append(f"create_chain: {exc}") - - _check_chain_exists(plan, checks, failures) - _check_equivalence(plan, checks, failures) - _check_validate_chain(plan, test_cmd=test_cmd, checks=checks, failures=failures) - - return GradeResult(ok=not failures, checks=checks, failures=failures) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="Grade a repo against prepare-changesets invariants." - ) - parser.add_argument("--plan", default=str(DEFAULT_PLAN_PATH), help="Plan path") - parser.add_argument( - "--expected-source-hash", - required=True, - help="Expected source branch hash before the run", - ) - parser.add_argument( - "--test-cmd", - default="python3 -c \"print('ok')\"", - help="Test command for validate-chain", - ) - parser.add_argument( - "--auto-create-chain", - action="store_true", - help="Create the chain before grading (useful for deterministic baselines).", - ) - parser.add_argument( - "--json", - action="store_true", - help="Emit JSON result to stdout.", - ) - return parser - - -def main(argv: List[str] | None = None) -> int: - parser = build_parser() - args = parser.parse_args(argv) - - result = grade_repo( - plan_path=Path(args.plan), - expected_source_hash=args.expected_source_hash, - test_cmd=args.test_cmd, - auto_create_chain=args.auto_create_chain, - ) - - if args.json: - print(json.dumps(asdict(result), indent=2)) - else: - print("OK" if result.ok else "FAIL") - if result.checks: - print("checks: " + ", ".join(result.checks)) - if result.failures: - print("failures: " + "; ".join(result.failures)) - - return 0 if result.ok else 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/skills/prepare-changesets/scripts/evals/helpers.py b/skills/prepare-changesets/scripts/evals/helpers.py deleted file mode 100644 index 33137df..0000000 --- a/skills/prepare-changesets/scripts/evals/helpers.py +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env python3 -"""Helpers for local eval setup and grading.""" - -from __future__ import annotations - -import json -import shutil -import subprocess -import tempfile -from pathlib import Path -from typing import Dict, Tuple - -from common import DEFAULT_PLAN_PATH - - -def run(cmd: list[str], *, cwd: Path) -> subprocess.CompletedProcess: - return subprocess.run(cmd, cwd=cwd, text=True, capture_output=True, check=True) - - -def write_plan(plan_path: Path, plan: Dict) -> None: - plan_path.parent.mkdir(parents=True, exist_ok=True) - plan_path.write_text(json.dumps(plan, indent=2) + "\n") - - -def init_eval_repo() -> Tuple[Path, Dict, str]: - """Create a temporary repo with a known base/source and plan.""" - repo_dir = Path(tempfile.mkdtemp(prefix="pcs-eval-repo-")) - - run(["git", "init", "-b", "main"], cwd=repo_dir) - run(["git", "config", "user.name", "PCS Eval"], cwd=repo_dir) - run(["git", "config", "user.email", "pcs-eval@example.com"], cwd=repo_dir) - - # Keep eval artifacts out of git status checks. - (repo_dir / ".gitignore").write_text(".prepare-changesets/\n") - - (repo_dir / "a.txt").write_text("base-a\n") - (repo_dir / "b.txt").write_text("base-b\n") - run(["git", "add", "-A"], cwd=repo_dir) - run(["git", "commit", "-m", "base"], cwd=repo_dir) - - run(["git", "checkout", "-b", "feature/test"], cwd=repo_dir) - (repo_dir / "a.txt").write_text("feature-a\n") - (repo_dir / "b.txt").write_text("feature-b\n") - (repo_dir / "c.txt").write_text("feature-c\n") - run(["git", "add", "-A"], cwd=repo_dir) - run(["git", "commit", "-m", "feature"], cwd=repo_dir) - - plan: Dict = { - "feature_title": "Eval feature", - "base_branch": "main", - "source_branch": "feature/test", - "test_command": "python3 -c \"print('ok')\"", - "changesets": [ - { - "slug": "a-only", - "description": "Apply a.txt changes.", - "include_paths": ["a.txt"], - "exclude_paths": [], - "commit_message": "cs1", - "pr_notes": ["No behavior changes."], - }, - { - "slug": "rest", - "description": "Apply remaining changes.", - "include_paths": ["b.txt", "c.txt"], - "exclude_paths": [], - "commit_message": "cs2", - "pr_notes": ["Completes the feature."], - }, - ], - } - - plan_path = repo_dir / DEFAULT_PLAN_PATH - write_plan(plan_path, plan) - - source_hash = run( - ["git", "rev-parse", plan["source_branch"]], cwd=repo_dir - ).stdout.strip() - return repo_dir, plan, source_hash - - -def cleanup_repo(repo_dir: Path) -> None: - shutil.rmtree(repo_dir) diff --git a/skills/prepare-changesets/scripts/evals/runner.py b/skills/prepare-changesets/scripts/evals/runner.py deleted file mode 100644 index 53bc5de..0000000 --- a/skills/prepare-changesets/scripts/evals/runner.py +++ /dev/null @@ -1,173 +0,0 @@ -#!/usr/bin/env python3 -"""Local eval runner that optionally invokes codex exec, then grades deterministically.""" - -from __future__ import annotations - -import argparse -import csv -import json -import os -import shutil -import subprocess -import sys -from pathlib import Path -from typing import Dict, List - -THIS_DIR = Path(__file__).resolve().parent -SCRIPTS_DIR = THIS_DIR.parents[0] -SKILL_DIR = THIS_DIR.parents[2] -DEFAULT_PROMPTS_PATH = SKILL_DIR / "evals" / "prompts.csv" -DEFAULT_OUT_DIR = SKILL_DIR / "evals" / "out" -if str(SCRIPTS_DIR) not in sys.path: - sys.path.insert(0, str(SCRIPTS_DIR)) - -from common import DEFAULT_PLAN_PATH # noqa: E402 - -from evals.grader import GradeResult, grade_repo # noqa: E402 -from evals.helpers import cleanup_repo, init_eval_repo # noqa: E402 - - -def codex_available(codex_bin: str) -> bool: - return shutil.which(codex_bin) is not None - - -def run_codex(prompt: str, *, codex_bin: str, cwd: Path) -> subprocess.CompletedProcess: - cmd = [codex_bin, "exec", "--full-auto", "--json", prompt] - return subprocess.run(cmd, cwd=cwd, text=True, capture_output=True, check=False) - - -def load_prompts(path: Path) -> List[Dict[str, str]]: - with path.open(newline="") as f: - reader = csv.DictReader(f) - return [row for row in reader] - - -def run_eval_case( - *, - case_id: str, - prompt: str, - codex_bin: str, - skip_codex: bool, - test_cmd: str, - out_dir: Path, -) -> Dict: - repo_dir, plan, source_hash = init_eval_repo() - plan_path = repo_dir / DEFAULT_PLAN_PATH - - codex_result: Dict[str, str] = {} - original_cwd = Path.cwd() - try: - os.chdir(repo_dir) - if not skip_codex: - if not codex_available(codex_bin): - raise RuntimeError(f"codex binary not found on PATH: {codex_bin}") - result = run_codex(prompt, codex_bin=codex_bin, cwd=repo_dir) - codex_result = { - "returncode": str(result.returncode), - "stdout": result.stdout, - "stderr": result.stderr, - } - else: - codex_result = {"skipped": "true"} - - grade: GradeResult = grade_repo( - plan_path=plan_path, - expected_source_hash=source_hash, - test_cmd=test_cmd, - auto_create_chain=skip_codex, - ) - - case_out = { - "id": case_id, - "ok": grade.ok, - "checks": grade.checks, - "failures": grade.failures, - "codex": codex_result, - } - return case_out - finally: - os.chdir(original_cwd) - cleanup_repo(repo_dir) - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="Run local evals for prepare-changesets." - ) - parser.add_argument( - "--prompts", - default=str(DEFAULT_PROMPTS_PATH), - help="Prompts CSV path", - ) - parser.add_argument( - "--out-dir", - default=str(DEFAULT_OUT_DIR), - help="Directory to write eval results", - ) - parser.add_argument("--codex-bin", default="codex", help="codex executable name") - parser.add_argument( - "--skip-codex", - action="store_true", - help="Skip invoking codex and grade a deterministic baseline instead.", - ) - parser.add_argument( - "--test-cmd", - default="python3 -c \"print('ok')\"", - help="Test command used by the grader's validate-chain step.", - ) - return parser - - -def main(argv: List[str] | None = None) -> int: - parser = build_parser() - args = parser.parse_args(argv) - - prompts_path = Path(args.prompts) - out_dir = Path(args.out_dir) - out_dir.mkdir(parents=True, exist_ok=True) - - cases = load_prompts(prompts_path) - results: List[Dict] = [] - - for row in cases: - case_id = row.get("id", "case") - prompt = row.get("prompt", "").strip() - if not prompt: - results.append( - {"id": case_id, "ok": False, "failures": ["empty prompt"], "checks": []} - ) - continue - try: - result = run_eval_case( - case_id=case_id, - prompt=prompt, - codex_bin=args.codex_bin, - skip_codex=args.skip_codex, - test_cmd=args.test_cmd, - out_dir=out_dir, - ) - except Exception as exc: # defensive guard for eval runs - result = { - "id": case_id, - "ok": False, - "checks": [], - "failures": [f"exception: {exc}"], - } - results.append(result) - - (out_dir / f"{case_id}.json").write_text(json.dumps(result, indent=2) + "\n") - - summary = { - "total": len(results), - "passed": sum(1 for r in results if r.get("ok")), - "failed": sum(1 for r in results if not r.get("ok")), - "results": results, - } - (out_dir / "summary.json").write_text(json.dumps(summary, indent=2) + "\n") - - print(json.dumps(summary, indent=2)) - return 0 if summary["failed"] == 0 else 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/skills/prepare-changesets/scripts/github.py b/skills/prepare-changesets/scripts/github.py deleted file mode 100755 index 5b0bdce..0000000 --- a/skills/prepare-changesets/scripts/github.py +++ /dev/null @@ -1,259 +0,0 @@ -#!/usr/bin/env python3 -"""GitHub CLI helpers for PR creation and merging.""" - -from __future__ import annotations - -import argparse -import json -import shutil -import subprocess -from typing import Dict, Iterable, List - -from common import ( - CommandError, - base_for_changeset, - branch_name_for, - ensure_clean_tree, - ensure_git_repo, - run, -) - - -def gh_available() -> bool: - return shutil.which("gh") is not None - - -def ensure_gh_ready() -> None: - if not gh_available(): - raise CommandError("GitHub CLI ('gh') not found on PATH.") - - result = run(("gh", "auth", "status"), capture=True, check=False) - if result.returncode != 0: - detail = (result.stderr or result.stdout or "").strip() - raise CommandError( - f"GitHub CLI is not authenticated. Run 'gh auth login' and retry.\n{detail}" - ) - - -def get_default_merge_method() -> str: - ensure_gh_ready() - repo_result = run( - ( - "gh", - "repo", - "view", - "--json", - "nameWithOwner", - "--jq", - ".nameWithOwner", - ), - capture=True, - check=True, - ) - name_with_owner = repo_result.stdout.strip() - if "/" not in name_with_owner: - raise CommandError( - "Unable to determine repo name from gh. Pass --method explicitly." - ) - owner, name = name_with_owner.split("/", 1) - - query = ( - "query($owner:String!, $name:String!) {" - " repository(owner:$owner, name:$name) {" - " defaultMergeMethod" - " }" - "}" - ) - result = run( - ( - "gh", - "api", - "graphql", - "-f", - f"query={query}", - "-f", - f"owner={owner}", - "-f", - f"name={name}", - ), - capture=True, - check=True, - ) - try: - payload = json.loads(result.stdout) - except json.JSONDecodeError as exc: - raise CommandError( - "Unable to parse gh api response for default merge method." - ) from exc - - method = payload.get("data", {}).get("repository", {}).get("defaultMergeMethod", "") - if not isinstance(method, str) or not method.strip(): - raise CommandError( - "Unable to determine repo default merge method. Pass --method explicitly." - ) - return method.strip().lower() - - -def pr_title_for(feature_title: str, index: int, total: int) -> str: - return f"{feature_title} ({index} of {total})" - - -def pr_body_for(plan: Dict, index: int, total: int, changeset: Dict) -> str: - feature_title = plan["feature_title"].strip() - description = str(changeset.get("description", "")).strip() - notes = changeset.get("pr_notes", []) or [] - - lines: List[str] = [] - lines.append("## Overall Feature") - lines.append(feature_title) - lines.append("") - lines.append(f"## This Changeset ({index} of {total})") - lines.append(description or "Describe the intent of this changeset.") - lines.append("") - lines.append("## Scaffolding, Flags, And Intentional Incompleteness") - if notes and all(isinstance(n, str) and n.strip() for n in notes): - for note in notes: - lines.append(f"- {note.strip()}") - else: - lines.append("- None documented.") - - return "\n".join(lines).strip() + "\n" - - -def _print_cmd(cmd: Iterable[str]) -> None: - printable = " ".join(subprocess.list2cmdline([part]) for part in cmd) - print(printable) - - -def pr_create(plan: Dict, *, indices: List[int], dry_run: bool) -> None: - ensure_git_repo() - ensure_clean_tree() - - if not dry_run: - ensure_gh_ready() - - base = plan["base_branch"] - source = plan["source_branch"] - changesets = plan["changesets"] - total = len(changesets) - - for index in indices: - if index < 1 or index > total: - raise CommandError(f"--index must be between 1 and {total}.") - - head_branch = branch_name_for(source, index) - base_branch = base_for_changeset(base, source, index) - title = pr_title_for(plan["feature_title"], index, total) - body = pr_body_for(plan, index, total, changesets[index - 1]) - - cmd = ( - "gh", - "pr", - "create", - "--base", - base_branch, - "--head", - head_branch, - "--title", - title, - "--body", - body, - ) - - print(f"[STEP] PR for changeset {index}: {head_branch} -> {base_branch}") - if dry_run: - print("[DRY-RUN] Would run:") - _print_cmd(cmd) - continue - - result = run(cmd, capture=True, check=True) - output = (result.stdout or "") + (result.stderr or "") - url = "" - for token in output.split(): - if token.startswith("http://") or token.startswith("https://"): - url = token.strip() - break - if not url: - view = run( - ( - "gh", - "pr", - "view", - "--head", - head_branch, - "--json", - "url", - "--jq", - ".url", - ), - capture=True, - check=False, - ) - url = (view.stdout or "").strip() - if url: - print(f"[OK] PR created: {url}") - else: - print("[OK] PR created.") - - if dry_run: - print("[OK] Dry-run complete. Re-run with --no-dry-run to execute.") - - -def pr_edit_base(new_base: str, *, dry_run: bool) -> None: - cmd = ("gh", "pr", "edit", "--base", new_base) - print(f"[STEP] Updating PR base -> {new_base}") - if dry_run: - print("[DRY-RUN] Would run:") - _print_cmd(cmd) - return - ensure_gh_ready() - run(cmd, capture=True, check=True) - - -def pr_merge(head_branch: str, *, method: str, dry_run: bool) -> None: - method = (method or "default").strip().lower() - method_flags = {"merge": "--merge", "squash": "--squash", "rebase": "--rebase"} - if method not in (*method_flags.keys(), "default"): - raise CommandError( - "Invalid merge method. Use 'default', 'merge', 'squash', or 'rebase'." - ) - cmd: tuple[str, ...] - if method == "default": - resolved = get_default_merge_method() - if resolved not in method_flags: - raise CommandError( - "Unable to resolve repo default merge method. Pass --method explicitly." - ) - cmd = ("gh", "pr", "merge", head_branch, method_flags[resolved]) - print(f"[STEP] Merging PR for {head_branch} with method=default ({resolved})") - else: - cmd = ("gh", "pr", "merge", head_branch, method_flags[method]) - print(f"[STEP] Merging PR for {head_branch} with method={method}") - if dry_run: - print("[DRY-RUN] Would run:") - _print_cmd(cmd) - return - ensure_gh_ready() - run(cmd, capture=True, check=True) - print("[OK] PR merged via gh.") - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="GitHub PR helpers for prepare-changesets." - ) - parser.add_argument("--help-gh", action="store_true", help="Show gh help and exit") - return parser - - -def main(argv: list[str] | None = None) -> int: - parser = build_parser() - args = parser.parse_args(argv) - if args.help_gh: - ensure_gh_ready() - run(("gh", "help"), capture=False, check=True) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/skills/prepare-changesets/scripts/hunk_preview.py b/skills/prepare-changesets/scripts/hunk_preview.py deleted file mode 100755 index 6e14f29..0000000 --- a/skills/prepare-changesets/scripts/hunk_preview.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env python3 -"""Run the hunk-preview command.""" - -from __future__ import annotations - -import sys -from pathlib import Path - -SCRIPTS_DIR = Path(__file__).resolve().parent -if str(SCRIPTS_DIR) not in sys.path: - sys.path.insert(0, str(SCRIPTS_DIR)) - -from cli import main # noqa: E402 - -if __name__ == "__main__": - raise SystemExit(main(["hunk-preview", *sys.argv[1:]])) diff --git a/skills/prepare-changesets/scripts/init_plan.py b/skills/prepare-changesets/scripts/init_plan.py deleted file mode 100755 index e46ab54..0000000 --- a/skills/prepare-changesets/scripts/init_plan.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env python3 -"""Run the init-plan command.""" - -from __future__ import annotations - -import sys -from pathlib import Path - -SCRIPTS_DIR = Path(__file__).resolve().parent -if str(SCRIPTS_DIR) not in sys.path: - sys.path.insert(0, str(SCRIPTS_DIR)) - -from cli import main # noqa: E402 - -if __name__ == "__main__": - raise SystemExit(main(["init-plan", *sys.argv[1:]])) diff --git a/skills/prepare-changesets/scripts/merge_propagate.py b/skills/prepare-changesets/scripts/merge_propagate.py deleted file mode 100755 index 387184f..0000000 --- a/skills/prepare-changesets/scripts/merge_propagate.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env python3 -"""Run the merge-propagate command.""" - -from __future__ import annotations - -import sys -from pathlib import Path - -SCRIPTS_DIR = Path(__file__).resolve().parent -if str(SCRIPTS_DIR) not in sys.path: - sys.path.insert(0, str(SCRIPTS_DIR)) - -from cli import main # noqa: E402 - -if __name__ == "__main__": - raise SystemExit(main(["merge-propagate", *sys.argv[1:]])) diff --git a/skills/prepare-changesets/scripts/patch_apply.py b/skills/prepare-changesets/scripts/patch_apply.py deleted file mode 100755 index 21d4c78..0000000 --- a/skills/prepare-changesets/scripts/patch_apply.py +++ /dev/null @@ -1,435 +0,0 @@ -#!/usr/bin/env python3 -"""Patch and hunk-based changeset application helpers.""" - -from __future__ import annotations - -import fnmatch -import tempfile -from dataclasses import dataclass, field -from pathlib import Path -from typing import Dict, Iterable, List, Optional, Sequence, Tuple - -from common import CommandError, git, repo_root - - -@dataclass(frozen=True) -class HunkSelector: - file: str - range_header: Optional[str] - contains: Tuple[str, ...] - excludes: Tuple[str, ...] - occurrence: Optional[int] - all_hunks: bool - has_filters: bool - - -@dataclass -class Hunk: - header: str - lines: List[str] - - @property - def body_lines(self) -> List[str]: - return self.lines[1:] - - @property - def body_text(self) -> str: - return "\n".join(self.body_lines) - - -@dataclass -class DiffFile: - old_path: Optional[str] - new_path: Optional[str] - header_lines: List[str] - hunks: List[Hunk] = field(default_factory=list) - is_binary: bool = False - binary_lines: List[str] = field(default_factory=list) - - -@dataclass -class SelectedPatch: - text: str - files: int - hunks: int - file_labels: List[str] - - -def _strip_prefix(path: str) -> Optional[str]: - if path in ("/dev/null", "dev/null"): - return None - if path.startswith("a/") or path.startswith("b/"): - return path[2:] - return path - - -def _file_label(df: DiffFile) -> str: - return df.new_path or df.old_path or "" - - -def _matches_any(path: str, patterns: Iterable[str]) -> bool: - return any(fnmatch.fnmatch(path, pat) for pat in patterns) - - -def parse_hunk_selectors( - selectors: Sequence[Dict], *, changeset_label: str -) -> List[HunkSelector]: - parsed: List[HunkSelector] = [] - for idx, raw in enumerate(selectors, start=1): - if not isinstance(raw, dict): - raise CommandError( - f"{changeset_label}: hunk selector {idx} must be an object." - ) - file_path = raw.get("file") - if not isinstance(file_path, str) or not file_path.strip(): - raise CommandError( - f"{changeset_label}: hunk selector {idx} missing valid 'file'." - ) - range_header = raw.get("range") - if range_header is not None and not isinstance(range_header, str): - raise CommandError( - f"{changeset_label}: hunk selector {idx} 'range' must be a string." - ) - contains = raw.get("contains", []) - excludes = raw.get("excludes", []) - if contains and ( - not isinstance(contains, list) - or not all(isinstance(c, str) for c in contains) - ): - raise CommandError( - f"{changeset_label}: hunk selector {idx} 'contains' must be a string array." - ) - if excludes and ( - not isinstance(excludes, list) - or not all(isinstance(c, str) for c in excludes) - ): - raise CommandError( - f"{changeset_label}: hunk selector {idx} 'excludes' must be a string array." - ) - all_hunks = raw.get("all", False) - if all_hunks is not False and not isinstance(all_hunks, bool): - raise CommandError( - f"{changeset_label}: hunk selector {idx} 'all' must be a boolean." - ) - - occurrence = raw.get("occurrence") - if occurrence is not None and ( - not isinstance(occurrence, int) or occurrence < 1 - ): - raise CommandError( - f"{changeset_label}: hunk selector {idx} 'occurrence' must be a positive integer." - ) - - has_filters = bool( - all_hunks or range_header or contains or excludes or occurrence is not None - ) - - parsed.append( - HunkSelector( - file=file_path, - range_header=str(range_header).strip() if range_header else None, - contains=tuple(str(c) for c in contains), - excludes=tuple(str(c) for c in excludes), - occurrence=occurrence, - all_hunks=bool(all_hunks), - has_filters=has_filters, - ) - ) - return parsed - - -def parse_unified_diff(diff_text: str) -> List[DiffFile]: - lines = diff_text.splitlines() - files: List[DiffFile] = [] - current: Optional[DiffFile] = None - i = 0 - while i < len(lines): - line = lines[i] - if line.startswith("diff --git "): - if current: - files.append(current) - parts = line.split(" ") - old_path = _strip_prefix(parts[2]) if len(parts) > 2 else None - new_path = _strip_prefix(parts[3]) if len(parts) > 3 else None - current = DiffFile( - old_path=old_path, - new_path=new_path, - header_lines=[line], - ) - i += 1 - continue - - if current is None: - i += 1 - continue - - if line.startswith("GIT binary patch"): - current.is_binary = True - while i < len(lines): - line = lines[i] - if line.startswith("diff --git "): - break - current.binary_lines.append(line) - i += 1 - continue - - if line.startswith("@@ "): - hunk_lines = [line] - i += 1 - while i < len(lines): - line = lines[i] - if line.startswith("diff --git ") or line.startswith("@@ "): - break - hunk_lines.append(line) - i += 1 - current.hunks.append(Hunk(header=hunk_lines[0], lines=hunk_lines)) - continue - - current.header_lines.append(line) - i += 1 - - if current: - files.append(current) - - return files - - -def build_diff(base: str, source: str) -> List[DiffFile]: - diff = git( - "diff", - "--binary", - "--full-index", - "--find-renames=20%", - f"{base}..{source}", - ).stdout - return parse_unified_diff(diff) - - -def _selectors_for_file( - selectors: Sequence[HunkSelector], df: DiffFile -) -> List[HunkSelector]: - label = _file_label(df) - matched: List[HunkSelector] = [] - for selector in selectors: - if selector.file == label: - matched.append(selector) - continue - if df.old_path and selector.file == df.old_path: - matched.append(selector) - continue - if df.new_path and selector.file == df.new_path: - matched.append(selector) - return matched - - -def select_hunks_for_changeset( - diff_files: Sequence[DiffFile], - selectors: Sequence[HunkSelector], - *, - include_paths: Sequence[str], - exclude_paths: Sequence[str], - allow_partial_files: bool, - changeset_label: str, -) -> SelectedPatch: - if not selectors: - raise CommandError(f"{changeset_label}: hunk_selectors must be non-empty.") - - patch_lines: List[str] = [] - selected_files: List[str] = [] - selected_hunks = 0 - seen_selectors = {id(selector): False for selector in selectors} - - for df in diff_files: - file_selectors = _selectors_for_file(selectors, df) - if not file_selectors: - continue - - label = _file_label(df) - labels = {p for p in (df.old_path, df.new_path) if p} - if df.is_binary: - raise CommandError( - f"{changeset_label}: {label} is binary; use mode=patch for binary files." - ) - if not df.hunks: - raise CommandError( - f"{changeset_label}: {label} has no hunks available to select." - ) - - select_all = any(sel.all_hunks for sel in file_selectors) or ( - not allow_partial_files - and any(not sel.has_filters for sel in file_selectors) - ) - chosen: List[Hunk] = [] - if select_all: - for selector in file_selectors: - seen_selectors[id(selector)] = True - if include_paths and not any( - _matches_any(path, include_paths) for path in labels - ): - raise CommandError( - f"{changeset_label}: selector file {selector.file} does not match include_paths." - ) - if exclude_paths and any( - _matches_any(path, exclude_paths) for path in labels - ): - raise CommandError( - f"{changeset_label}: selector file {selector.file} is excluded by exclude_paths." - ) - chosen = list(df.hunks) - else: - for selector in file_selectors: - seen_selectors[id(selector)] = True - if include_paths and not any( - _matches_any(path, include_paths) for path in labels - ): - raise CommandError( - f"{changeset_label}: selector file {selector.file} does not match include_paths." - ) - if exclude_paths and any( - _matches_any(path, exclude_paths) for path in labels - ): - raise CommandError( - f"{changeset_label}: selector file {selector.file} is excluded by exclude_paths." - ) - candidates: List[Hunk] = [] - for hunk in df.hunks: - if ( - selector.range_header - and selector.range_header.strip() != hunk.header.strip() - ): - continue - body = hunk.body_text - if selector.contains and not all( - c in body for c in selector.contains - ): - continue - if selector.excludes and any(c in body for c in selector.excludes): - continue - candidates.append(hunk) - - if not candidates: - raise CommandError( - f"{changeset_label}: selector for {label} matched no hunks." - ) - - if selector.occurrence is not None: - if selector.occurrence > len(candidates): - raise CommandError( - f"{changeset_label}: selector for {label} occurrence {selector.occurrence}" - f" exceeds {len(candidates)} matches." - ) - chosen_hunk = candidates[selector.occurrence - 1] - if chosen_hunk not in chosen: - chosen.append(chosen_hunk) - else: - if len(candidates) > 1: - raise CommandError( - f"{changeset_label}: selector for {label} matched multiple hunks; " - "add 'occurrence' to disambiguate." - ) - if candidates[0] not in chosen: - chosen.append(candidates[0]) - - if not allow_partial_files and len(chosen) != len(df.hunks): - raise CommandError( - f"{changeset_label}: {label} requires all hunks when allow_partial_files=false." - ) - - if chosen: - patch_lines.extend(df.header_lines) - for hunk in chosen: - patch_lines.extend(hunk.lines) - selected_files.append(label) - selected_hunks += len(chosen) - - missing = [s.file for s in selectors if not seen_selectors[id(s)]] - if missing: - missing_list = ", ".join(sorted(set(missing))) - raise CommandError( - f"{changeset_label}: selector file(s) not found in diff: {missing_list}." - ) - - if not selected_files: - raise CommandError(f"{changeset_label}: no hunks selected for this changeset.") - - patch_text = "\n".join(patch_lines) + "\n" - return SelectedPatch( - text=patch_text, - files=len(selected_files), - hunks=selected_hunks, - file_labels=selected_files, - ) - - -def apply_patch_text(patch_text: str, *, label: str) -> None: - if not patch_text.strip(): - raise CommandError(f"{label}: patch is empty.") - - with tempfile.NamedTemporaryFile("w", delete=False, prefix="pcs-patch-") as handle: - handle.write(patch_text) - patch_path = Path(handle.name) - - try: - result = git( - "apply", - "--index", - "--3way", - "--whitespace=nowarn", - str(patch_path), - check=False, - ) - if result.returncode != 0: - detail = (result.stderr or result.stdout or "").strip() - raise CommandError( - f"{label}: git apply failed.\n{detail or 'Patch did not apply cleanly.'}" - ) - finally: - patch_path.unlink(missing_ok=True) - - -def check_patch_text(patch_text: str, *, label: str) -> None: - if not patch_text.strip(): - raise CommandError(f"{label}: patch is empty.") - - with tempfile.NamedTemporaryFile("w", delete=False, prefix="pcs-patch-") as handle: - handle.write(patch_text) - patch_path = Path(handle.name) - - try: - result = git( - "apply", - "--check", - "--3way", - "--whitespace=nowarn", - str(patch_path), - check=False, - ) - if result.returncode != 0: - detail = (result.stderr or result.stdout or "").strip() - raise CommandError( - f"{label}: git apply --check failed.\n" - f"{detail or 'Patch did not apply cleanly.'}" - ) - finally: - patch_path.unlink(missing_ok=True) - - -def resolve_patch_path(patch_file: str) -> Path: - raw = Path(patch_file) - return raw if raw.is_absolute() else repo_root() / raw - - -def apply_patch_file(patch_file: str, *, label: str) -> None: - patch_path = resolve_patch_path(patch_file) - if not patch_path.exists(): - raise CommandError(f"{label}: patch file not found: {patch_path}") - patch_text = patch_path.read_text() - apply_patch_text(patch_text, label=label) - - -def check_patch_file(patch_file: str, *, label: str) -> None: - patch_path = resolve_patch_path(patch_file) - if not patch_path.exists(): - raise CommandError(f"{label}: patch file not found: {patch_path}") - patch_text = patch_path.read_text() - check_patch_text(patch_text, label=label) diff --git a/skills/prepare-changesets/scripts/plan_checks.py b/skills/prepare-changesets/scripts/plan_checks.py deleted file mode 100644 index 3c1d50e..0000000 --- a/skills/prepare-changesets/scripts/plan_checks.py +++ /dev/null @@ -1,335 +0,0 @@ -#!/usr/bin/env python3 -"""Strict plan validation and consistency checks.""" - -from __future__ import annotations - -import fnmatch -from pathlib import Path -from typing import Dict, List, Sequence, Tuple - -from common import ( - CommandError, - branch_exists, - checkout_restore, - compute_freshness, - delete_branch, - diff_name_status, - ensure_branches_exist, - ensure_clean_tree, - ensure_git_repo, - git, - load_state, - repo_root, - unique_temp_branch, -) -from patch_apply import ( - build_diff, - check_patch_file, - check_patch_text, - parse_hunk_selectors, - select_hunks_for_changeset, -) - - -def _changed_paths(base: str, source: str) -> List[str]: - raw = diff_name_status(base, source) - paths: List[str] = [] - for line in raw.splitlines(): - parts = line.split("\t") - if not parts: - continue - code = parts[0][:1] - if code == "R" and len(parts) >= 3: - paths.append(parts[1]) - paths.append(parts[2]) - elif len(parts) >= 2: - paths.append(parts[1]) - return paths - - -def _matches_any(path: str, patterns: Sequence[str]) -> bool: - for pattern in patterns: - if fnmatch.fnmatch(path, pattern): - return True - return False - - -def _warn_placeholders(changeset: Dict, index: int, warnings: List[str]) -> None: - slug = str(changeset.get("slug", "")).strip() - if slug.startswith("changeset-") or slug.startswith("cs-"): - warnings.append(f"Changeset {index}: slug looks like a placeholder.") - - pr_notes = changeset.get("pr_notes", []) - if isinstance(pr_notes, list): - for note in pr_notes: - if isinstance(note, str) and "replace with" in note.lower(): - warnings.append( - f"Changeset {index}: pr_notes contains placeholder text." - ) - break - - commit_message = str(changeset.get("commit_message", "")).strip().lower() - if "placeholder" in commit_message: - warnings.append(f"Changeset {index}: commit_message looks like a placeholder.") - - -def _validate_paths_mode( - *, - base: str, - source: str, - changeset: Dict, - index: int, - errors: List[str], -) -> None: - include = changeset.get("include_paths", []) - exclude = changeset.get("exclude_paths", []) - if not include: - errors.append( - f"Changeset {index}: include_paths must be non-empty for mode=paths." - ) - return - changed = _changed_paths(base, source) - matched = [] - for path in changed: - if _matches_any(path, include) and not _matches_any(path, exclude): - matched.append(path) - if not matched: - errors.append( - f"Changeset {index}: include_paths did not match any changed files." - ) - - -def _validate_patch_mode(*, changeset: Dict, index: int, errors: List[str]) -> None: - patch_file = changeset.get("patch_file") - if not isinstance(patch_file, str) or not patch_file.strip(): - errors.append(f"Changeset {index}: patch_file must be a non-empty string.") - return - path = Path(patch_file) - if not path.is_absolute(): - path = repo_root() / path - if not path.exists(): - errors.append(f"Changeset {index}: patch_file not found: {path}") - return - if path.stat().st_size == 0: - errors.append(f"Changeset {index}: patch_file is empty: {path}") - - -def _warn_old_path_selectors( - *, - diff_files: Sequence, - selectors: Sequence, - index: int, - warnings: List[str], -) -> None: - renames: Dict[str, str] = {} - for df in diff_files: - if df.old_path and df.new_path and df.old_path != df.new_path: - renames[df.old_path] = df.new_path - - for selector in selectors: - new_path = renames.get(selector.file) - if new_path: - warnings.append( - f"Changeset {index}: selector references old path {selector.file}; " - f"prefer new path {new_path} for stability." - ) - - -def _validate_hunks_mode( - *, - diff_files: Sequence, - changeset: Dict, - index: int, - errors: List[str], - warnings: List[str], -) -> None: - selectors = changeset.get("hunk_selectors", []) - try: - parsed = parse_hunk_selectors(selectors, changeset_label=f"Changeset {index}") - except CommandError as exc: - errors.append(str(exc)) - return - - include = changeset.get("include_paths", []) - exclude = changeset.get("exclude_paths", []) - allow_partial = bool(changeset.get("allow_partial_files", True)) - try: - select_hunks_for_changeset( - diff_files, - parsed, - include_paths=include, - exclude_paths=exclude, - allow_partial_files=allow_partial, - changeset_label=f"Changeset {index}", - ) - except CommandError as exc: - errors.append(str(exc)) - return - - _warn_old_path_selectors( - diff_files=diff_files, - selectors=parsed, - index=index, - warnings=warnings, - ) - - -def _warn_state_drift(plan: Dict, warnings: List[str]) -> None: - state = load_state() - if not state: - return - - source = plan.get("source_branch", "") - if isinstance(source, str) and source: - try: - current = git("rev-parse", source).stdout.strip() - except CommandError: - current = "" - recorded = str(state.get("source_head", "")).strip() - if current and recorded and current != recorded: - warnings.append( - "Source branch HEAD differs from recorded state.json. " - "Re-validate changeset boundaries before continuing." - ) - - recorded_changesets = state.get("changesets", []) - if not isinstance(recorded_changesets, list): - return - - for entry in recorded_changesets: - if not isinstance(entry, dict): - continue - branch = str(entry.get("branch", "")).strip() - head = str(entry.get("head", "")).strip() - if not branch or not head: - continue - if not branch_exists(branch): - warnings.append(f"Recorded branch missing: {branch}") - continue - current = git("rev-parse", branch).stdout.strip() - if current != head: - warnings.append( - f"Branch head drift detected for {branch}. " - "Avoid rewriting earlier changeset branches." - ) - - -def validate_plan_strict(plan: Dict) -> Tuple[bool, List[str], List[str]]: - errors: List[str] = [] - warnings: List[str] = [] - - base = str(plan.get("base_branch", "")).strip() - source = str(plan.get("source_branch", "")).strip() - if not base or not source: - errors.append("Plan missing base_branch or source_branch.") - return False, errors, warnings - - changesets = plan.get("changesets") - if not isinstance(changesets, list): - errors.append("Plan missing changesets array.") - return False, errors, warnings - - diff_files = build_diff(base, source) - - for idx, cs in enumerate(changesets, start=1): - if not isinstance(cs, dict): - errors.append(f"Changeset {idx} must be an object.") - continue - mode = str(cs.get("mode", "paths")).strip() or "paths" - if mode == "paths": - _validate_paths_mode( - base=base, source=source, changeset=cs, index=idx, errors=errors - ) - elif mode == "patch": - _validate_patch_mode(changeset=cs, index=idx, errors=errors) - elif mode == "hunks": - _validate_hunks_mode( - diff_files=diff_files, - changeset=cs, - index=idx, - errors=errors, - warnings=warnings, - ) - else: - errors.append( - f"Changeset {idx}: unsupported mode '{mode}'. Use 'paths', 'patch', or 'hunks'." - ) - - _warn_placeholders(cs, idx, warnings) - - test_cmd = str(plan.get("test_command", "")).strip() - if not test_cmd: - warnings.append("Plan test_command is empty; set it or pass --test-cmd.") - - _warn_state_drift(plan, warnings) - - try: - freshness = compute_freshness(base, source) - if freshness.get("source_behind_base"): - warnings.append( - "Source branch does not include the current base HEAD. " - "Consider updating source before proceeding." - ) - except CommandError: - warnings.append("Unable to verify whether source is behind base.") - - return (not errors), errors, warnings - - -def strict_apply_check(plan: Dict) -> None: - """Apply changesets on a temporary branch to ensure patch viability.""" - ensure_git_repo() - ensure_clean_tree() - - base = str(plan.get("base_branch", "")).strip() - source = str(plan.get("source_branch", "")).strip() - changesets = plan.get("changesets") - if not base or not source or not isinstance(changesets, list): - raise CommandError( - "strict apply check requires valid base/source and changesets." - ) - - ensure_branches_exist([base, source]) - - from chain import apply_changeset # local import to avoid cycles - - temp_branch = unique_temp_branch("pcs-temp-strict-check") - print(f"[INFO] Creating temporary strict-check branch: {temp_branch}") - - diff_files = build_diff(base, source) - - with checkout_restore() as original: - try: - git("checkout", "-B", temp_branch, base) - total = len(changesets) - for idx, cs in enumerate(changesets, start=1): - print(f"[STEP] Strict-apply changeset {idx}") - mode = str(cs.get("mode", "paths")).strip() or "paths" - if mode == "patch": - patch_file = cs.get("patch_file", "") - check_patch_file(str(patch_file), label=f"Changeset {idx}") - elif mode == "hunks": - selectors = cs.get("hunk_selectors", []) - parsed = parse_hunk_selectors( - selectors, changeset_label=f"Changeset {idx}" - ) - selected = select_hunks_for_changeset( - diff_files, - parsed, - include_paths=cs.get("include_paths", []), - exclude_paths=cs.get("exclude_paths", []), - allow_partial_files=bool(cs.get("allow_partial_files", True)), - changeset_label=f"Changeset {idx}", - ) - check_patch_text(selected.text, label=f"Changeset {idx}") - apply_changeset( - base_branch=base, - source_branch=source, - index=idx, - total=total, - changeset=cs, - ) - finally: - git("checkout", original) - delete_branch(temp_branch) - print(f"\n[INFO] Restored original branch: {original}") diff --git a/skills/prepare-changesets/scripts/pr_create.py b/skills/prepare-changesets/scripts/pr_create.py deleted file mode 100755 index 9b55d46..0000000 --- a/skills/prepare-changesets/scripts/pr_create.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env python3 -"""Run the pr-create command.""" - -from __future__ import annotations - -import sys -from pathlib import Path - -SCRIPTS_DIR = Path(__file__).resolve().parent -if str(SCRIPTS_DIR) not in sys.path: - sys.path.insert(0, str(SCRIPTS_DIR)) - -from cli import main # noqa: E402 - -if __name__ == "__main__": - raise SystemExit(main(["pr-create", *sys.argv[1:]])) diff --git a/skills/prepare-changesets/scripts/preflight.py b/skills/prepare-changesets/scripts/preflight.py deleted file mode 100755 index b38df5a..0000000 --- a/skills/prepare-changesets/scripts/preflight.py +++ /dev/null @@ -1,234 +0,0 @@ -#!/usr/bin/env python3 -"""Preflight checks for repo cleanliness, mergeability, and tests.""" - -from __future__ import annotations - -import argparse -import subprocess - -from common import ( - CommandError, - branch_exists, - checkout_restore, - compute_freshness, - delete_branch, - discover_test_command, - ensure_clean_tree, - ensure_git_repo, - git, - is_path_ignored, - record_preflight_state, - unique_temp_branch, -) - - -def check_mergeability(base: str, source: str) -> None: - temp_branch = unique_temp_branch("pcs-temp-preflight") - print(f"[INFO] Checking mergeability via temporary branch: {temp_branch}") - - with checkout_restore() as original: - try: - git("checkout", "-B", temp_branch, base) - merge_result = git("merge", "--no-commit", "--no-ff", source, check=False) - if merge_result.returncode != 0: - raise CommandError( - "Mergeability check failed. Resolve conflicts or rebase the source branch." - ) - git("reset", "--hard", base) - finally: - git("merge", "--abort", check=False) - git("checkout", original) - delete_branch(temp_branch) - - -def run_tests_on_branch(branch: str, test_cmd: str) -> None: - print(f"[INFO] Running test command on {branch}: {test_cmd}") - with checkout_restore(branch): - result = subprocess.run(test_cmd, shell=True) - if result.returncode != 0: - raise CommandError("Test command failed.") - ensure_clean_tree() - - -def _print_test_command_help(discovery: dict) -> None: - reason = str(discovery.get("reason", "unknown")) - candidates = list(discovery.get("candidates", [])) - suggestions = list(discovery.get("suggestions", [])) - - if reason == "agents-missing": - print("[WARN] No AGENTS.md found at repo root.") - elif reason == "agents-no-test-command": - print("[WARN] AGENTS.md found but no clear test command was detected.") - elif reason == "agents-ambiguous": - print("[WARN] Multiple test commands were detected in AGENTS.md:") - for cmd in candidates: - print(f" - {cmd}") - - if suggestions: - print("[HINT] Likely test commands to consider:") - for cmd in suggestions: - print(f" - {cmd}") - - print("[NEXT] Ask once for the desired test command, then re-run with --test-cmd.") - print( - "[NEXT] If still unknown, re-run with --skip-tests and record this in the plan." - ) - - -def preflight( - *, - base: str, - source: str, - test_cmd: str, - skip_tests: bool, - skip_merge_check: bool, - allow_source_behind_base: bool = False, - confirm_source_behind_base: bool = False, - allow_recordkeeping_tracked: bool = False, -) -> None: - ensure_git_repo() - ensure_clean_tree() - - if not branch_exists(base): - raise CommandError(f"Base branch does not exist: {base}") - if not branch_exists(source): - raise CommandError(f"Source branch does not exist: {source}") - - recordkeeping_path = ".prepare-changesets/" - if not is_path_ignored(recordkeeping_path): - message = ( - "[ERROR] .prepare-changesets/ is not ignored. Add it to .gitignore or " - ".git/info/exclude to keep plan/state files out of PRs.\n" - "Override (not recommended): re-run with --allow-recordkeeping-tracked." - ) - if allow_recordkeeping_tracked: - print( - "[WARN] .prepare-changesets/ is not ignored; proceeding by explicit override." - ) - else: - raise CommandError(message) - - freshness = compute_freshness(base, source) - mb = str(freshness["merge_base"]) - base_head = str(freshness["base_head"]) - print(f"[OK] merge-base({base}, {source}) = {mb}") - - user_confirmed = False - if freshness["source_behind_base"]: - message = ( - "[ERROR] Source branch is behind base branch.\n" - f"Base: {base} @ {base_head}\n" - f"Source: {source} (merge-base={mb})\n\n" - "This workflow assumes the source includes the current base HEAD to avoid churn\n" - "while carving changesets.\n\n" - f"Fix: merge or rebase {source} onto {base}, then re-run preflight.\n" - "Override (not recommended): re-run with --allow-source-behind-base and record why in the plan." - ) - if allow_source_behind_base: - print( - "[WARN] Source branch is behind base branch; proceeding by explicit override." - ) - user_confirmed = True - elif confirm_source_behind_base: - response = input("Source is behind base. Proceed anyway? [y/N] ").strip() - if response.lower() not in ("y", "yes"): - raise CommandError(message) - print( - "[WARN] Source branch is behind base branch; proceeding by explicit confirmation." - ) - user_confirmed = True - else: - raise CommandError(message) - record_preflight_state( - base, - source, - user_confirmed_source_behind_base=user_confirmed, - ) - - if not skip_merge_check: - check_mergeability(base, source) - print("[OK] Mergeability check passed.") - else: - print("[WARN] Skipping mergeability check by request.") - - effective_test_cmd = test_cmd.strip() - if not effective_test_cmd and not skip_tests: - discovery = discover_test_command("") - discovered = str(discovery.get("command") or "").strip() - if discovered: - effective_test_cmd = discovered - print(f"[INFO] Using test command from AGENTS.md: {effective_test_cmd}") - else: - _print_test_command_help(discovery) - raise CommandError("Test command is required unless --skip-tests is set.") - - if effective_test_cmd and not skip_tests: - run_tests_on_branch(source, effective_test_cmd) - print("[OK] Test command succeeded on source branch.") - elif effective_test_cmd and skip_tests: - print("[WARN] Test command provided but skipped by request.") - else: - print("[WARN] No test command provided.") - - ensure_clean_tree() - print("[OK] Preflight checks passed.") - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="Preflight checks for prepare-changesets." - ) - parser.add_argument("--base", required=True, help="Base branch (e.g., main)") - parser.add_argument("--source", required=True, help="Source branch to decompose") - parser.add_argument( - "--test-cmd", - default="", - help="Optional test/build command to run on the source branch", - ) - parser.add_argument( - "--skip-tests", action="store_true", help="Skip running the test command" - ) - parser.add_argument( - "--skip-merge-check", action="store_true", help="Skip mergeability simulation" - ) - parser.add_argument( - "--allow-source-behind-base", - action="store_true", - help="Allow preflight to continue when source is behind base.", - ) - parser.add_argument( - "--confirm-source-behind-base", - action="store_true", - help="Prompt for confirmation when source is behind base.", - ) - parser.add_argument( - "--allow-recordkeeping-tracked", - action="store_true", - help="Allow preflight to continue when .prepare-changesets/ is not ignored.", - ) - return parser - - -def main(argv: list[str] | None = None) -> int: - parser = build_parser() - args = parser.parse_args(argv) - - try: - preflight( - base=args.base, - source=args.source, - test_cmd=args.test_cmd, - skip_tests=args.skip_tests, - skip_merge_check=args.skip_merge_check, - allow_source_behind_base=args.allow_source_behind_base, - confirm_source_behind_base=args.confirm_source_behind_base, - allow_recordkeeping_tracked=args.allow_recordkeeping_tracked, - ) - return 0 - except CommandError as exc: - print(f"[ERROR] {exc}") - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/skills/prepare-changesets/scripts/propagate.py b/skills/prepare-changesets/scripts/propagate.py deleted file mode 100755 index b55811f..0000000 --- a/skills/prepare-changesets/scripts/propagate.py +++ /dev/null @@ -1,252 +0,0 @@ -#!/usr/bin/env python3 -"""Downstream propagation and remote push helpers.""" - -from __future__ import annotations - -import argparse -from typing import Dict, List - -from common import ( - CommandError, - base_for_changeset, - branch_exists, - branch_name_for, - checkout_restore, - ensure_clean_tree, - ensure_git_repo, - git, -) -from github import ensure_gh_ready, pr_edit_base - - -def downstream_base_after_merge( - base_branch: str, - source_branch: str, - merged_index: int, - index: int, -) -> str: - """Return the correct base for a downstream changeset after a merge.""" - if merged_index == 0: - return base_branch if index == 1 else branch_name_for(source_branch, index - 1) - - if index == merged_index + 1: - return ( - base_branch - if merged_index == 1 - else branch_name_for(source_branch, merged_index - 1) - ) - - return branch_name_for(source_branch, index - 1) - - -def _ensure_chain_exists(source: str, total: int) -> List[str]: - chain = [branch_name_for(source, i) for i in range(1, total + 1)] - missing = [b for b in chain if not branch_exists(b)] - if missing: - raise CommandError("Missing changeset branches:\n" + "\n".join(missing)) - return chain - - -def remote_exists(remote: str) -> bool: - return git("remote", "get-url", remote, check=False).returncode == 0 - - -def push_branch(branch: str, *, remote: str, dry_run: bool) -> None: - cmd = ("git", "push", remote, branch, "--force-with-lease") - print(f"[STEP] Pushing {branch} to {remote} with --force-with-lease") - if dry_run: - print("[DRY-RUN] Would run:") - print(" ".join(cmd)) - return - git("push", remote, branch, "--force-with-lease") - - -def push_chain(plan: Dict, *, remote: str, dry_run: bool) -> None: - ensure_git_repo() - ensure_clean_tree() - - base = plan["base_branch"] - source = plan["source_branch"] - total = len(plan["changesets"]) - - if not remote_exists(remote): - raise CommandError(f"Remote does not exist: {remote}") - - chain = _ensure_chain_exists(source, total) - branches = [base, *chain] - - with checkout_restore() as original: - for branch in branches: - git("checkout", branch) - push_branch(branch, remote=remote, dry_run=dry_run) - print(f"\n[INFO] Restored original branch: {original}") - - if dry_run: - print("[OK] Dry-run push-chain complete. Re-run with --no-dry-run to execute.") - else: - print("[OK] push-chain completed.") - - -def propagate_downstream( - *, - plan: Dict, - merged_index: int, - dry_run: bool, - update_pr_bases: bool, - skip_local_merge: bool, - push: bool, - remote: str, - strategy: str = "rebase", -) -> None: - ensure_git_repo() - ensure_clean_tree() - - base = plan["base_branch"] - source = plan["source_branch"] - total = len(plan["changesets"]) - - if merged_index < 0 or merged_index > total: - raise CommandError(f"--merged-index must be between 0 and {total}.") - - if strategy not in ("rebase", "cherry-pick"): - raise CommandError("--strategy must be 'rebase' or 'cherry-pick'.") - - if strategy == "cherry-pick" and merged_index == 0: - raise CommandError("--strategy=cherry-pick requires --merged-index to be >= 1.") - - if push and not remote_exists(remote): - raise CommandError(f"Remote does not exist: {remote}") - - chain = _ensure_chain_exists(source, total) - - if update_pr_bases and not dry_run: - ensure_gh_ready() - - merged_head = branch_name_for(source, merged_index) if merged_index >= 1 else "" - merged_base = ( - base_for_changeset(base, source, merged_index) if merged_index >= 1 else base - ) - - print(f"[INFO] Propagating forward from merged index: {merged_index}") - - with checkout_restore() as original: - if merged_index >= 1 and not skip_local_merge: - print( - f"\n[STEP] Updating local base branch {merged_base} with {merged_head}" - ) - if dry_run: - print("[DRY-RUN] Would run:") - print(f"git checkout {merged_base}") - print( - f"git merge --ff-only {merged_head} # falls back to --no-ff --no-edit on failure" - ) - else: - git("checkout", merged_base) - ff_only = git("merge", "--ff-only", merged_head, check=False) - if ff_only.returncode != 0: - print( - "[WARN] --ff-only merge failed; falling back to --no-ff --no-edit." - ) - git("merge", "--no-ff", "--no-edit", merged_head) - if push: - push_branch(merged_base, remote=remote, dry_run=dry_run) - - start_index = 1 if merged_index == 0 else merged_index + 1 - for idx in range(start_index, total + 1): - if idx <= merged_index: - continue - name = chain[idx - 1] - new_base = downstream_base_after_merge(base, source, merged_index, idx) - - if strategy == "rebase": - print(f"\n[STEP] Rebasing {name} onto {new_base}") - if dry_run: - print("[DRY-RUN] Would run:") - print(f"git checkout {name}") - print(f"git rebase {new_base}") - else: - git("checkout", name) - git("rebase", new_base) - else: - merge_base = git("merge-base", merged_head, name).stdout.strip() - commits = ( - git("rev-list", "--reverse", f"{merge_base}..{merged_head}") - .stdout.strip() - .splitlines() - ) - commits = [c for c in commits if c] - if not commits: - print( - f"\n[WARN] No new commits to cherry-pick from {merged_head} onto {name}." - ) - else: - print( - f"\n[STEP] Cherry-picking {len(commits)} commit(s) from {merged_head} onto {name}" - ) - if dry_run: - print("[DRY-RUN] Would run:") - print(f"git checkout {name}") - for commit in commits: - print(f"git cherry-pick {commit}") - else: - git("checkout", name) - for commit in commits: - git("cherry-pick", commit) - - if push: - push_branch(name, remote=remote, dry_run=dry_run) - - if update_pr_bases: - pr_edit_base(new_base, dry_run=dry_run) - - print(f"\n[INFO] Restored original branch: {original}") - - if dry_run: - print("[OK] Dry-run propagation complete. Re-run with --no-dry-run to execute.") - else: - print("[OK] Downstream propagation completed.") - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Propagate downstream changesets.") - parser.add_argument( - "--plan", default=".prepare-changesets/plan.json", help="Plan path" - ) - parser.add_argument( - "--merged-index", - type=int, - required=True, - help="1-based index of the merged changeset, or 0 to propagate onto base only", - ) - parser.add_argument( - "--update-pr-bases", dest="update_pr_bases", action="store_true" - ) - parser.add_argument( - "--no-update-pr-bases", dest="update_pr_bases", action="store_false" - ) - parser.add_argument("--skip-local-merge", action="store_true") - parser.add_argument( - "--strategy", - choices=("rebase", "cherry-pick"), - default="rebase", - help="Propagate via rebase (default) or cherry-picking the merged changeset commits.", - ) - parser.add_argument("--push", action="store_true") - parser.add_argument("--remote", default="origin") - parser.add_argument("--dry-run", dest="dry_run", action="store_true") - parser.add_argument("--no-dry-run", dest="dry_run", action="store_false") - parser.set_defaults(dry_run=True, update_pr_bases=True) - return parser - - -if __name__ == "__main__": - import sys - from pathlib import Path - - scripts_dir = Path(__file__).resolve().parent - if str(scripts_dir) not in sys.path: - sys.path.insert(0, str(scripts_dir)) - - from cli import main as cli_main - - raise SystemExit(cli_main(["propagate", *sys.argv[1:]])) diff --git a/skills/prepare-changesets/scripts/push_chain.py b/skills/prepare-changesets/scripts/push_chain.py deleted file mode 100755 index 57543c0..0000000 --- a/skills/prepare-changesets/scripts/push_chain.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env python3 -"""Run the push-chain command.""" - -from __future__ import annotations - -import sys -from pathlib import Path - -SCRIPTS_DIR = Path(__file__).resolve().parent -if str(SCRIPTS_DIR) not in sys.path: - sys.path.insert(0, str(SCRIPTS_DIR)) - -from cli import main # noqa: E402 - -if __name__ == "__main__": - raise SystemExit(main(["push-chain", *sys.argv[1:]])) diff --git a/skills/prepare-changesets/scripts/run.py b/skills/prepare-changesets/scripts/run.py deleted file mode 100755 index bbe3dcc..0000000 --- a/skills/prepare-changesets/scripts/run.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env python3 -"""Run the guided run command.""" - -from __future__ import annotations - -import sys -from pathlib import Path - -SCRIPTS_DIR = Path(__file__).resolve().parent -if str(SCRIPTS_DIR) not in sys.path: - sys.path.insert(0, str(SCRIPTS_DIR)) - -from cli import main # noqa: E402 - -if __name__ == "__main__": - raise SystemExit(main(["run", *sys.argv[1:]])) diff --git a/skills/prepare-changesets/scripts/squash_check.py b/skills/prepare-changesets/scripts/squash_check.py deleted file mode 100755 index a1ec41c..0000000 --- a/skills/prepare-changesets/scripts/squash_check.py +++ /dev/null @@ -1,139 +0,0 @@ -#!/usr/bin/env python3 -"""Compare the changeset chain against a local squashed reference via rebase.""" - -from __future__ import annotations - -import argparse -import sys -from pathlib import Path -from typing import Dict, List, Tuple - -SCRIPTS_DIR = Path(__file__).resolve().parent -if str(SCRIPTS_DIR) not in sys.path: - sys.path.insert(0, str(SCRIPTS_DIR)) - -from common import ( # noqa: E402 - CommandError, - branch_exists, - branch_name_for, - checkout_restore, - delete_branch, - diff_name_status, - diff_stat, - ensure_branches_exist, - ensure_clean_tree, - ensure_git_repo, - git, - load_plan, - squashed_branch_name, - unique_temp_branch, - validate_plan, -) - - -def _load_valid_plan(plan_path: Path) -> Dict: - plan = load_plan(plan_path) - valid, errors = validate_plan(plan) - if not valid: - detail = "; ".join(errors) or "unknown validation error" - raise CommandError(f"Plan validation failed: {detail}") - return plan - - -def _chain_for_plan(plan: Dict) -> List[str]: - source = plan["source_branch"] - total = len(plan["changesets"]) - if total < 1: - raise CommandError("Plan must include at least one changeset.") - return [branch_name_for(source, i) for i in range(1, total + 1)] - - -def squash_check(plan: Dict) -> Tuple[str, str]: - ensure_git_repo() - ensure_clean_tree() - - base = plan["base_branch"] - source = plan["source_branch"] - chain = _chain_for_plan(plan) - tip = chain[-1] - squashed = squashed_branch_name(source) - - if not branch_exists(squashed): - raise CommandError( - "\n".join( - [ - f"Squashed reference branch not found: {squashed}", - "Create it with squash_ref.py before running squash_check.", - ] - ) - ) - - ensure_branches_exist([base, source, squashed, *chain]) - - temp_branch = unique_temp_branch("pcs-temp-squash-check") - print(f"[INFO] Using squashed reference: {squashed}") - print(f"[INFO] Chain tip: {tip}") - print(f"[INFO] Creating temporary squash-check branch: {temp_branch}") - - with checkout_restore() as original: - try: - git("checkout", "-B", temp_branch, squashed) - rebase_result = git("rebase", "--empty=drop", tip, check=False) - if rebase_result.returncode != 0: - git("rebase", "--abort", check=False) - raise CommandError( - "Squash-check rebase encountered conflicts. The chain may not capture the source branch cleanly." - ) - - diffstat = diff_stat(tip, temp_branch) - namestatus = diff_name_status(tip, temp_branch) - finally: - git("checkout", original) - delete_branch(temp_branch) - print(f"\n[INFO] Restored original branch: {original}") - - ensure_clean_tree() - return diffstat, namestatus - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="Rebase a local squashed reference onto the chain tip and diff the result." - ) - parser.add_argument( - "--plan", - default=str(Path(".prepare-changesets/plan.json")), - help="Plan path", - ) - return parser - - -def main(argv: list[str] | None = None) -> int: - parser = build_parser() - args = parser.parse_args(argv) - - try: - plan = _load_valid_plan(Path(args.plan)) - diffstat, namestatus = squash_check(plan) - - print("\n[INFO] Diffstat vs chain tip after squash-check rebase:") - if diffstat: - print(diffstat) - else: - print("[OK] No diffstat differences detected.") - - print("\n[INFO] Name-status vs chain tip after squash-check rebase:") - if namestatus: - print(namestatus) - else: - print("[OK] No name-status differences detected.") - - print("[OK] squash-check completed.") - return 0 - except CommandError as exc: - print(f"[ERROR] {exc}") - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/skills/prepare-changesets/scripts/squash_ref.py b/skills/prepare-changesets/scripts/squash_ref.py deleted file mode 100755 index 2be7bf2..0000000 --- a/skills/prepare-changesets/scripts/squash_ref.py +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env python3 -"""Create a local squashed reference branch for comparison workflows.""" - -from __future__ import annotations - -import argparse -import sys -from pathlib import Path -from typing import Optional, Tuple - -SCRIPTS_DIR = Path(__file__).resolve().parent -if str(SCRIPTS_DIR) not in sys.path: - sys.path.insert(0, str(SCRIPTS_DIR)) - -from common import ( # noqa: E402 - CommandError, - branch_exists, - checkout_restore, - delete_branch, - ensure_clean_tree, - ensure_git_repo, - git, - load_plan, - merge_base, - squashed_branch_name, -) - - -def _resolve_base_source(*, plan_path: Path, base: str, source: str) -> Tuple[str, str]: - plan: Optional[dict] = None - if plan_path.exists(): - plan = load_plan(plan_path) - - resolved_base = base.strip() or ( - str(plan.get("base_branch", "")).strip() if plan else "" - ) - resolved_source = source.strip() or ( - str(plan.get("source_branch", "")).strip() if plan else "" - ) - - if not resolved_base or not resolved_source: - raise CommandError( - "Provide --base and --source, or ensure the plan exists with base_branch and source_branch." - ) - return resolved_base, resolved_source - - -def create_squashed_ref( - *, - base: str, - source: str, - reuse_existing: bool, - recreate: bool, -) -> str: - ensure_git_repo() - ensure_clean_tree() - - if not branch_exists(base): - raise CommandError(f"Base branch does not exist: {base}") - if not branch_exists(source): - raise CommandError(f"Source branch does not exist: {source}") - - squashed = squashed_branch_name(source) - exists = branch_exists(squashed) - - if exists and not (reuse_existing or recreate): - raise CommandError( - "\n".join( - [ - f"Squashed reference branch already exists: {squashed}", - "Ask whether to reuse it. If approved, re-run with --reuse-existing.", - "If it must be rebuilt, re-run with --recreate.", - ] - ) - ) - - if exists and recreate: - print(f"[STEP] Deleting existing squashed reference: {squashed}") - delete_branch(squashed) - - if branch_exists(squashed) and reuse_existing: - print(f"[OK] Reusing existing squashed reference: {squashed}") - print("[NOTE] Keep this branch local-only. Do not push it.") - return squashed - - mb = merge_base(base, source) - print(f"[INFO] merge-base({base}, {source}) = {mb}") - print(f"[STEP] Creating squashed reference branch: {squashed}") - - with checkout_restore() as original: - try: - git("checkout", "-B", squashed, mb) - merge_result = git("merge", "--squash", source, check=False) - if merge_result.returncode != 0: - git("merge", "--abort", check=False) - raise CommandError( - "Squash merge failed. Resolve source/base divergence before creating a squashed reference." - ) - - diff_cached = git("diff", "--cached", "--quiet", check=False) - if diff_cached.returncode == 0: - print("[WARN] No staged changes after squash merge.") - else: - git("commit", "-m", f"pcs: squash reference for {source}") - print("[OK] Squashed reference commit created.") - finally: - git("checkout", original) - - ensure_clean_tree() - print("[NOTE] Keep this branch local-only. Do not push it.") - return squashed - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="Create a local squashed reference branch." - ) - parser.add_argument( - "--plan", - default=str(Path(".prepare-changesets/plan.json")), - help="Optional plan path used to resolve base/source when not provided explicitly.", - ) - parser.add_argument("--base", default="", help="Base branch (e.g., main)") - parser.add_argument("--source", default="", help="Source branch to squash") - parser.add_argument( - "--reuse-existing", - action="store_true", - help="Reuse an existing -squashed branch if it exists.", - ) - parser.add_argument( - "--recreate", - action="store_true", - help="Delete and recreate the squashed reference branch.", - ) - return parser - - -def main(argv: list[str] | None = None) -> int: - parser = build_parser() - args = parser.parse_args(argv) - - try: - base, source = _resolve_base_source( - plan_path=Path(args.plan), - base=args.base, - source=args.source, - ) - create_squashed_ref( - base=base, - source=source, - reuse_existing=bool(args.reuse_existing), - recreate=bool(args.recreate), - ) - return 0 - except CommandError as exc: - print(f"[ERROR] {exc}") - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/skills/prepare-changesets/scripts/status.py b/skills/prepare-changesets/scripts/status.py deleted file mode 100755 index 45b16e4..0000000 --- a/skills/prepare-changesets/scripts/status.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env python3 -"""Run the status command.""" - -from __future__ import annotations - -import sys -from pathlib import Path - -SCRIPTS_DIR = Path(__file__).resolve().parent -if str(SCRIPTS_DIR) not in sys.path: - sys.path.insert(0, str(SCRIPTS_DIR)) - -from cli import main # noqa: E402 - -if __name__ == "__main__": - raise SystemExit(main(["status", *sys.argv[1:]])) diff --git a/skills/prepare-changesets/scripts/tests/helpers.py b/skills/prepare-changesets/scripts/tests/helpers.py deleted file mode 100644 index c0e6ff5..0000000 --- a/skills/prepare-changesets/scripts/tests/helpers.py +++ /dev/null @@ -1,133 +0,0 @@ -from __future__ import annotations - -import json -import os -import subprocess -import tempfile -from contextlib import contextmanager -from pathlib import Path - -SCRIPTS_DIR = Path(__file__).resolve().parents[1] -if str(SCRIPTS_DIR) not in os.sys.path: - os.sys.path.insert(0, str(SCRIPTS_DIR)) - - -def run( - cmd: list[str], *, cwd: Path, check: bool = True -) -> subprocess.CompletedProcess: - return subprocess.run(cmd, cwd=cwd, text=True, capture_output=True, check=check) - - -@contextmanager -def chdir(path: Path): - original = Path.cwd() - os.chdir(path) - try: - yield - finally: - os.chdir(original) - - -def init_repo() -> tuple[Path, dict]: - repo_dir = Path(tempfile.mkdtemp(prefix="pcs-test-repo-")) - - run(["git", "init", "-b", "main"], cwd=repo_dir) - run(["git", "config", "user.name", "PCS Test"], cwd=repo_dir) - run(["git", "config", "user.email", "pcs-test@example.com"], cwd=repo_dir) - - # Keep recordkeeping artifacts out of git status checks. - (repo_dir / ".gitignore").write_text(".prepare-changesets/\n") - - (repo_dir / "a.txt").write_text("base-a\n") - (repo_dir / "b.txt").write_text("base-b\n") - run(["git", "add", "-A"], cwd=repo_dir) - run(["git", "commit", "-m", "base"], cwd=repo_dir) - - run(["git", "checkout", "-b", "feature/test"], cwd=repo_dir) - (repo_dir / "a.txt").write_text("feature-a\n") - (repo_dir / "b.txt").write_text("feature-b\n") - (repo_dir / "c.txt").write_text("feature-c\n") - run(["git", "add", "-A"], cwd=repo_dir) - run(["git", "commit", "-m", "feature"], cwd=repo_dir) - - plan = { - "feature_title": "Test feature", - "base_branch": "main", - "source_branch": "feature/test", - "test_command": "", - "changesets": [ - { - "slug": "a-only", - "description": "Apply a.txt changes.", - "include_paths": ["a.txt"], - "exclude_paths": [], - "commit_message": "cs1", - "pr_notes": ["note"], - }, - { - "slug": "rest", - "description": "Apply remaining changes.", - "include_paths": ["b.txt", "c.txt"], - "exclude_paths": [], - "commit_message": "cs2", - "pr_notes": ["note"], - }, - ], - } - - return repo_dir, plan - - -def init_conflict_repo() -> tuple[Path, dict]: - repo_dir = Path(tempfile.mkdtemp(prefix="pcs-test-conflict-")) - - run(["git", "init", "-b", "main"], cwd=repo_dir) - run(["git", "config", "user.name", "PCS Test"], cwd=repo_dir) - run(["git", "config", "user.email", "pcs-test@example.com"], cwd=repo_dir) - - (repo_dir / ".gitignore").write_text(".prepare-changesets/\n") - - (repo_dir / "conflict.txt").write_text("line\n") - run(["git", "add", "-A"], cwd=repo_dir) - run(["git", "commit", "-m", "base"], cwd=repo_dir) - - run(["git", "checkout", "-b", "feature/conflict"], cwd=repo_dir) - (repo_dir / "conflict.txt").write_text("feature-change\n") - run(["git", "add", "conflict.txt"], cwd=repo_dir) - run(["git", "commit", "-m", "feature change"], cwd=repo_dir) - - run(["git", "checkout", "main"], cwd=repo_dir) - (repo_dir / "conflict.txt").write_text("main-change\n") - run(["git", "add", "conflict.txt"], cwd=repo_dir) - run(["git", "commit", "-m", "main change"], cwd=repo_dir) - - plan = { - "feature_title": "Conflict feature", - "base_branch": "main", - "source_branch": "feature/conflict", - "test_command": "", - "changesets": [ - { - "slug": "conflict", - "description": "Conflicting change.", - "include_paths": ["conflict.txt"], - "exclude_paths": [], - "commit_message": "cs1", - "pr_notes": ["note"], - } - ], - } - - return repo_dir, plan - - -def init_remote(repo_dir: Path) -> Path: - remote_dir = Path(tempfile.mkdtemp(prefix="pcs-test-remote-")) / "remote.git" - run(["git", "init", "--bare", str(remote_dir)], cwd=repo_dir) - run(["git", "remote", "add", "origin", str(remote_dir)], cwd=repo_dir) - return remote_dir - - -def write_plan(plan_path: Path, plan: dict) -> None: - plan_path.parent.mkdir(parents=True, exist_ok=True) - plan_path.write_text(json.dumps(plan, indent=2) + "\n") diff --git a/skills/prepare-changesets/scripts/tests/test_chain.py b/skills/prepare-changesets/scripts/tests/test_chain.py deleted file mode 100644 index da4cb8f..0000000 --- a/skills/prepare-changesets/scripts/tests/test_chain.py +++ /dev/null @@ -1,112 +0,0 @@ -from __future__ import annotations - -import shutil -import unittest - -import helpers # noqa: F401 # ensures sys.path is set -from chain import compare_chain, create_chain, validate_chain -from common import CommandError -from helpers import chdir, init_repo - - -class ChainTests(unittest.TestCase): - def test_create_chain_and_compare_equivalence(self) -> None: - repo_dir, plan = init_repo() - try: - from helpers import run - - source_hash_before = run( - ["git", "rev-parse", plan["source_branch"]], cwd=repo_dir - ).stdout.strip() - with chdir(repo_dir): - create_chain(plan) - diffstat, namestatus = compare_chain(plan) - source_hash_after = run( - ["git", "rev-parse", plan["source_branch"]], cwd=repo_dir - ).stdout.strip() - - self.assertEqual( - source_hash_before, source_hash_after, "Source branch hash changed" - ) - self.assertEqual(diffstat.strip(), "") - self.assertEqual(namestatus.strip(), "") - finally: - shutil.rmtree(repo_dir) - - def test_validate_chain_runs_tests(self) -> None: - repo_dir, plan = init_repo() - try: - with chdir(repo_dir): - create_chain(plan) - validate_chain(plan, test_cmd="python3 -c \"print('ok')\"") - finally: - shutil.rmtree(repo_dir) - - def test_validate_chain_fails_on_bad_command(self) -> None: - repo_dir, plan = init_repo() - try: - with chdir(repo_dir): - create_chain(plan) - with self.assertRaises(CommandError): - validate_chain( - plan, test_cmd='python3 -c "import sys; sys.exit(7)"' - ) - finally: - shutil.rmtree(repo_dir) - - def test_validate_chain_discovers_command_from_agents(self) -> None: - repo_dir, plan = init_repo() - try: - (repo_dir / "AGENTS.md").write_text( - "```bash\npython3 -c \"print('test ok')\"\n```\n" - ) - from helpers import run - - run(["git", "add", "AGENTS.md"], cwd=repo_dir) - run(["git", "commit", "-m", "add agents"], cwd=repo_dir) - with chdir(repo_dir): - create_chain(plan) - validate_chain(plan, test_cmd="") - finally: - shutil.rmtree(repo_dir) - - def test_create_chain_is_append_only_for_existing_prefix(self) -> None: - repo_dir, plan = init_repo() - try: - from helpers import run - - with chdir(repo_dir): - create_chain(plan) - cs1 = f"{plan['source_branch']}-1" - cs2 = f"{plan['source_branch']}-2" - cs1_before = run(["git", "rev-parse", cs1], cwd=repo_dir).stdout.strip() - cs2_before = run(["git", "rev-parse", cs2], cwd=repo_dir).stdout.strip() - - plan["changesets"].append( - { - "slug": "noop-3", - "description": "Placeholder changeset to test append-only behavior.", - "include_paths": ["does-not-exist.txt"], - "exclude_paths": [], - "commit_message": "cs3", - "pr_notes": [], - } - ) - - create_chain(plan) - cs1_after = run(["git", "rev-parse", cs1], cwd=repo_dir).stdout.strip() - cs2_after = run(["git", "rev-parse", cs2], cwd=repo_dir).stdout.strip() - cs3 = f"{plan['source_branch']}-3" - cs3_rc = run( - ["git", "rev-parse", "--verify", cs3], cwd=repo_dir, check=False - ).returncode - - self.assertEqual(cs1_before, cs1_after) - self.assertEqual(cs2_before, cs2_after) - self.assertEqual(cs3_rc, 0) - finally: - shutil.rmtree(repo_dir) - - -if __name__ == "__main__": - unittest.main() diff --git a/skills/prepare-changesets/scripts/tests/test_common.py b/skills/prepare-changesets/scripts/tests/test_common.py deleted file mode 100644 index f8d61aa..0000000 --- a/skills/prepare-changesets/scripts/tests/test_common.py +++ /dev/null @@ -1,39 +0,0 @@ -from __future__ import annotations - -import shutil -import tempfile -import unittest -from pathlib import Path - -import helpers # noqa: F401 # ensures sys.path is set -from common import DEFAULT_PLAN_PATH, init_plan, load_plan, validate_plan - - -class CommonTests(unittest.TestCase): - def test_plan_validation_catches_missing_fields(self) -> None: - valid, errors = validate_plan({"name": "bad"}) - self.assertFalse(valid) - self.assertTrue(errors) - - def test_init_plan_writes_valid_plan(self) -> None: - temp_dir = Path(tempfile.mkdtemp(prefix="pcs-test-plan-")) - try: - plan_path = temp_dir / DEFAULT_PLAN_PATH - init_plan( - plan_path=plan_path, - base="main", - source="feature/x", - title="Title", - changesets=2, - test_cmd="", - force=True, - ) - plan = load_plan(plan_path) - valid, errors = validate_plan(plan) - self.assertTrue(valid, f"plan should validate: {errors}") - finally: - shutil.rmtree(temp_dir) - - -if __name__ == "__main__": - unittest.main() diff --git a/skills/prepare-changesets/scripts/tests/test_dbcompare.py b/skills/prepare-changesets/scripts/tests/test_dbcompare.py deleted file mode 100644 index 18240d3..0000000 --- a/skills/prepare-changesets/scripts/tests/test_dbcompare.py +++ /dev/null @@ -1,35 +0,0 @@ -from __future__ import annotations - -import shutil -import unittest - -import dbcompare as dbcompare_mod -from chain import create_chain -from helpers import chdir, init_repo - - -class DbCompareTests(unittest.TestCase): - def test_db_compare_creates_outputs(self) -> None: - repo_dir, plan = init_repo() - try: - out_dir = repo_dir / ".prepare-changesets" / "db-compare-test" - with chdir(repo_dir): - create_chain(plan) - dbcompare_mod.db_compare( - plan, - source_cmd="cat a.txt", - chain_cmd="cat a.txt", - out_dir=out_dir, - ) - - source_out = out_dir / "source.txt" - chain_out = out_dir / "chain.txt" - self.assertTrue(source_out.exists()) - self.assertTrue(chain_out.exists()) - self.assertEqual(source_out.read_text(), chain_out.read_text()) - finally: - shutil.rmtree(repo_dir) - - -if __name__ == "__main__": - unittest.main() diff --git a/skills/prepare-changesets/scripts/tests/test_evals.py b/skills/prepare-changesets/scripts/tests/test_evals.py deleted file mode 100644 index 998539d..0000000 --- a/skills/prepare-changesets/scripts/tests/test_evals.py +++ /dev/null @@ -1,81 +0,0 @@ -from __future__ import annotations - -import shutil -import tempfile -import unittest -from pathlib import Path - -from evals.grader import grade_repo -from evals.helpers import cleanup_repo, init_eval_repo -from evals.runner import main as runner_main -from helpers import chdir - - -class EvalGraderTests(unittest.TestCase): - def test_grader_passes_on_deterministic_baseline(self) -> None: - repo_dir, plan, source_hash = init_eval_repo() - try: - plan_path = repo_dir / ".prepare-changesets/plan.json" - with chdir(repo_dir): - result = grade_repo( - plan_path=plan_path, - expected_source_hash=source_hash, - test_cmd="python3 -c \"print('ok')\"", - auto_create_chain=True, - ) - self.assertTrue(result.ok, f"grader should pass: {result.failures}") - finally: - cleanup_repo(repo_dir) - - def test_grader_detects_source_branch_mutation(self) -> None: - repo_dir, plan, source_hash = init_eval_repo() - try: - plan_path = repo_dir / ".prepare-changesets/plan.json" - with chdir(repo_dir): - # Mutate the source branch after recording its hash. - (repo_dir / "a.txt").write_text("mutated-source\n") - from helpers import run - - run(["git", "add", "a.txt"], cwd=repo_dir) - run(["git", "commit", "-m", "mutate source"], cwd=repo_dir) - - result = grade_repo( - plan_path=plan_path, - expected_source_hash=source_hash, - test_cmd="python3 -c \"print('ok')\"", - auto_create_chain=True, - ) - - self.assertFalse(result.ok) - self.assertTrue( - any("source_hash_unchanged" in failure for failure in result.failures), - f"expected source hash failure, got: {result.failures}", - ) - finally: - cleanup_repo(repo_dir) - - -class EvalRunnerTests(unittest.TestCase): - def test_runner_skip_codex_produces_passing_summary(self) -> None: - skill_dir = Path(__file__).resolve().parents[2] - prompts_path = skill_dir / "evals" / "prompts.csv" - out_dir = Path(tempfile.mkdtemp(prefix="pcs-eval-out-")) - try: - rc = runner_main( - [ - "--prompts", - str(prompts_path), - "--out-dir", - str(out_dir), - "--skip-codex", - ] - ) - self.assertEqual(rc, 0) - summary_path = out_dir / "summary.json" - self.assertTrue(summary_path.exists()) - finally: - shutil.rmtree(out_dir) - - -if __name__ == "__main__": - unittest.main() diff --git a/skills/prepare-changesets/scripts/tests/test_github.py b/skills/prepare-changesets/scripts/tests/test_github.py deleted file mode 100644 index 5ac0711..0000000 --- a/skills/prepare-changesets/scripts/tests/test_github.py +++ /dev/null @@ -1,70 +0,0 @@ -from __future__ import annotations - -import shutil -import unittest -from unittest import mock - -import github as github_mod -from chain import create_chain -from helpers import chdir, init_repo - - -class GithubTests(unittest.TestCase): - def test_pr_create_dry_run_builds_expected_commands(self) -> None: - repo_dir, plan = init_repo() - try: - with chdir(repo_dir): - create_chain(plan) - captured: list[tuple[str, ...]] = [] - - def capture(cmd: tuple[str, ...]) -> None: - captured.append(cmd) - - with mock.patch.object(github_mod, "_print_cmd", side_effect=capture): - github_mod.pr_create(plan, indices=[1, 2], dry_run=True) - - self.assertEqual(len(captured), 2) - self.assertIn("gh", captured[0][0]) - self.assertEqual(captured[0][3:5], ("--base", "main")) - self.assertEqual(captured[0][5:7], ("--head", "feature/test-1")) - self.assertEqual(captured[1][3:5], ("--base", "feature/test-1")) - self.assertEqual(captured[1][5:7], ("--head", "feature/test-2")) - finally: - shutil.rmtree(repo_dir) - - def test_pr_merge_dry_run_default(self) -> None: - captured: list[tuple[str, ...]] = [] - - def capture(cmd: tuple[str, ...]) -> None: - captured.append(cmd) - - with ( - mock.patch.object(github_mod, "_print_cmd", side_effect=capture), - mock.patch.object( - github_mod, "get_default_merge_method", return_value="merge" - ), - ): - github_mod.pr_merge("feature/test-1", method="default", dry_run=True) - - self.assertEqual(len(captured), 1) - self.assertEqual(captured[0][:3], ("gh", "pr", "merge")) - self.assertEqual( - captured[0], ("gh", "pr", "merge", "feature/test-1", "--merge") - ) - - def test_pr_merge_dry_run_merge_flag(self) -> None: - captured: list[tuple[str, ...]] = [] - - def capture(cmd: tuple[str, ...]) -> None: - captured.append(cmd) - - with mock.patch.object(github_mod, "_print_cmd", side_effect=capture): - github_mod.pr_merge("feature/test-1", method="merge", dry_run=True) - - self.assertEqual(len(captured), 1) - self.assertEqual(captured[0][:3], ("gh", "pr", "merge")) - self.assertIn("--merge", captured[0]) - - -if __name__ == "__main__": - unittest.main() diff --git a/skills/prepare-changesets/scripts/tests/test_hunks_apply.py b/skills/prepare-changesets/scripts/tests/test_hunks_apply.py deleted file mode 100644 index 45f28a1..0000000 --- a/skills/prepare-changesets/scripts/tests/test_hunks_apply.py +++ /dev/null @@ -1,395 +0,0 @@ -from __future__ import annotations - -import shutil -import tempfile -import unittest -from pathlib import Path - -import helpers -from chain import compare_chain, create_chain -from common import CommandError -from plan_checks import strict_apply_check, validate_plan_strict - - -def _write_lines(path: Path, lines: list[str]) -> None: - path.write_text("".join(line + "\n" for line in lines)) - - -def _init_hunk_repo() -> tuple[Path, dict]: - repo_dir = Path(tempfile.mkdtemp(prefix="pcs-test-hunks-")) - helpers.run(["git", "init", "-b", "main"], cwd=repo_dir) - helpers.run(["git", "config", "user.name", "PCS Test"], cwd=repo_dir) - helpers.run(["git", "config", "user.email", "pcs-test@example.com"], cwd=repo_dir) - (repo_dir / ".gitignore").write_text(".prepare-changesets/\n") - - base_lines = [f"line-{i}" for i in range(1, 41)] - _write_lines(repo_dir / "notes.txt", base_lines) - helpers.run(["git", "add", "-A"], cwd=repo_dir) - helpers.run(["git", "commit", "-m", "base"], cwd=repo_dir) - - helpers.run(["git", "checkout", "-b", "feature/hunks"], cwd=repo_dir) - source_lines = base_lines[:] - source_lines[1] = "line-2 changed" - source_lines[29] = "line-30 changed" - _write_lines(repo_dir / "notes.txt", source_lines) - helpers.run(["git", "add", "-A"], cwd=repo_dir) - helpers.run(["git", "commit", "-m", "feature"], cwd=repo_dir) - - plan = { - "feature_title": "Hunk feature", - "base_branch": "main", - "source_branch": "feature/hunks", - "test_command": "", - "changesets": [ - { - "slug": "hunk-1", - "description": "Apply first hunk.", - "mode": "hunks", - "include_paths": ["notes.txt"], - "exclude_paths": [], - "allow_partial_files": True, - "hunk_selectors": [ - {"file": "notes.txt", "contains": ["line-2 changed"]} - ], - "commit_message": "cs1", - "pr_notes": [], - }, - { - "slug": "hunk-2", - "description": "Apply second hunk.", - "mode": "hunks", - "include_paths": ["notes.txt"], - "exclude_paths": [], - "allow_partial_files": True, - "hunk_selectors": [ - {"file": "notes.txt", "contains": ["line-30 changed"]} - ], - "commit_message": "cs2", - "pr_notes": [], - }, - ], - } - - return repo_dir, plan - - -def _init_context_shift_repo() -> tuple[Path, dict]: - repo_dir = Path(tempfile.mkdtemp(prefix="pcs-test-shift-")) - helpers.run(["git", "init", "-b", "main"], cwd=repo_dir) - helpers.run(["git", "config", "user.name", "PCS Test"], cwd=repo_dir) - helpers.run(["git", "config", "user.email", "pcs-test@example.com"], cwd=repo_dir) - (repo_dir / ".gitignore").write_text(".prepare-changesets/\n") - - base_lines = [f"row-{i}" for i in range(1, 41)] - _write_lines(repo_dir / "shift.txt", base_lines) - helpers.run(["git", "add", "-A"], cwd=repo_dir) - helpers.run(["git", "commit", "-m", "base"], cwd=repo_dir) - - helpers.run(["git", "checkout", "-b", "feature/shift"], cwd=repo_dir) - source_lines = base_lines[:] - source_lines.insert(5, "row-5.5 inserted") - source_lines[30] = "row-31 changed" - _write_lines(repo_dir / "shift.txt", source_lines) - helpers.run(["git", "add", "-A"], cwd=repo_dir) - helpers.run(["git", "commit", "-m", "feature"], cwd=repo_dir) - - plan = { - "feature_title": "Shift feature", - "base_branch": "main", - "source_branch": "feature/shift", - "test_command": "", - "changesets": [ - { - "slug": "insert", - "description": "Insert line.", - "mode": "hunks", - "include_paths": ["shift.txt"], - "exclude_paths": [], - "allow_partial_files": True, - "hunk_selectors": [ - {"file": "shift.txt", "contains": ["row-5.5 inserted"]} - ], - "commit_message": "cs1", - "pr_notes": [], - }, - { - "slug": "change", - "description": "Modify later line.", - "mode": "hunks", - "include_paths": ["shift.txt"], - "exclude_paths": [], - "allow_partial_files": True, - "hunk_selectors": [ - {"file": "shift.txt", "contains": ["row-31 changed"]} - ], - "commit_message": "cs2", - "pr_notes": [], - }, - ], - } - - return repo_dir, plan - - -def _init_patch_repo() -> tuple[Path, dict]: - repo_dir = Path(tempfile.mkdtemp(prefix="pcs-test-patch-")) - helpers.run(["git", "init", "-b", "main"], cwd=repo_dir) - helpers.run(["git", "config", "user.name", "PCS Test"], cwd=repo_dir) - helpers.run(["git", "config", "user.email", "pcs-test@example.com"], cwd=repo_dir) - (repo_dir / ".gitignore").write_text(".prepare-changesets/\n") - - (repo_dir / "patch.txt").write_text("base\n") - helpers.run(["git", "add", "-A"], cwd=repo_dir) - helpers.run(["git", "commit", "-m", "base"], cwd=repo_dir) - - helpers.run(["git", "checkout", "-b", "feature/patch"], cwd=repo_dir) - (repo_dir / "patch.txt").write_text("base\npatch\n") - helpers.run(["git", "add", "-A"], cwd=repo_dir) - helpers.run(["git", "commit", "-m", "feature"], cwd=repo_dir) - - patch_dir = repo_dir / ".prepare-changesets" / "patches" - patch_dir.mkdir(parents=True, exist_ok=True) - diff = helpers.run( - ["git", "diff", "main..feature/patch", "--", "patch.txt"], - cwd=repo_dir, - ).stdout - (patch_dir / "patch.txt.patch").write_text(diff) - - plan = { - "feature_title": "Patch feature", - "base_branch": "main", - "source_branch": "feature/patch", - "test_command": "", - "changesets": [ - { - "slug": "patch", - "description": "Apply patch file.", - "mode": "patch", - "patch_file": ".prepare-changesets/patches/patch.txt.patch", - "commit_message": "cs1", - "pr_notes": [], - } - ], - } - - return repo_dir, plan - - -def _init_rename_repo() -> tuple[Path, dict]: - repo_dir = Path(tempfile.mkdtemp(prefix="pcs-test-rename-")) - helpers.run(["git", "init", "-b", "main"], cwd=repo_dir) - helpers.run(["git", "config", "user.name", "PCS Test"], cwd=repo_dir) - helpers.run(["git", "config", "user.email", "pcs-test@example.com"], cwd=repo_dir) - (repo_dir / ".gitignore").write_text(".prepare-changesets/\n") - - (repo_dir / "old.txt").write_text("alpha\nbeta\ngamma\n") - helpers.run(["git", "add", "-A"], cwd=repo_dir) - helpers.run(["git", "commit", "-m", "base"], cwd=repo_dir) - - helpers.run(["git", "checkout", "-b", "feature/rename"], cwd=repo_dir) - helpers.run(["git", "mv", "old.txt", "new.txt"], cwd=repo_dir) - (repo_dir / "new.txt").write_text("alpha\nbeta changed\ngamma\n") - helpers.run(["git", "add", "-A"], cwd=repo_dir) - helpers.run(["git", "commit", "-m", "rename"], cwd=repo_dir) - - plan = { - "feature_title": "Rename feature", - "base_branch": "main", - "source_branch": "feature/rename", - "test_command": "", - "changesets": [ - { - "slug": "rename-hunk", - "description": "Rename plus hunk.", - "mode": "hunks", - "include_paths": ["new.txt"], - "exclude_paths": [], - "allow_partial_files": True, - "hunk_selectors": [{"file": "old.txt", "contains": ["beta changed"]}], - "commit_message": "cs1", - "pr_notes": [], - } - ], - } - - return repo_dir, plan - - -def _init_bad_patch_repo() -> tuple[Path, dict]: - repo_dir = Path(tempfile.mkdtemp(prefix="pcs-test-bad-patch-")) - helpers.run(["git", "init", "-b", "main"], cwd=repo_dir) - helpers.run(["git", "config", "user.name", "PCS Test"], cwd=repo_dir) - helpers.run(["git", "config", "user.email", "pcs-test@example.com"], cwd=repo_dir) - (repo_dir / ".gitignore").write_text(".prepare-changesets/\n") - - (repo_dir / "base.txt").write_text("base\n") - helpers.run(["git", "add", "-A"], cwd=repo_dir) - helpers.run(["git", "commit", "-m", "base"], cwd=repo_dir) - - helpers.run(["git", "checkout", "-b", "feature/bad-patch"], cwd=repo_dir) - helpers.run(["git", "commit", "--allow-empty", "-m", "feature"], cwd=repo_dir) - - patch_dir = repo_dir / ".prepare-changesets" / "patches" - patch_dir.mkdir(parents=True, exist_ok=True) - (patch_dir / "bad.patch").write_text( - "diff --git a/base.txt b/base.txt\n" - "index 0000000..1111111 100644\n" - "--- a/base.txt\n" - "+++ b/base.txt\n" - "@@ -1 +1 @@\n" - "-missing\n" - "+bad\n" - ) - - plan = { - "feature_title": "Bad patch", - "base_branch": "main", - "source_branch": "feature/bad-patch", - "test_command": "", - "changesets": [ - { - "slug": "bad-patch", - "description": "Patch that will not apply.", - "mode": "patch", - "patch_file": ".prepare-changesets/patches/bad.patch", - "commit_message": "cs1", - "pr_notes": [], - } - ], - } - - return repo_dir, plan - - -class HunkApplyTests(unittest.TestCase): - def test_hunks_apply_simple(self) -> None: - repo_dir, plan = _init_hunk_repo() - try: - with helpers.chdir(repo_dir): - create_chain(plan) - diffstat, namestatus = compare_chain(plan) - self.assertEqual(diffstat, "") - self.assertEqual(namestatus, "") - finally: - shutil.rmtree(repo_dir) - - def test_hunks_apply_with_context_shift(self) -> None: - repo_dir, plan = _init_context_shift_repo() - try: - with helpers.chdir(repo_dir): - create_chain(plan) - diffstat, namestatus = compare_chain(plan) - self.assertEqual(diffstat, "") - self.assertEqual(namestatus, "") - finally: - shutil.rmtree(repo_dir) - - def test_hunks_selector_ambiguous(self) -> None: - repo_dir, plan = _init_hunk_repo() - try: - plan["changesets"][0]["hunk_selectors"] = [ - {"file": "notes.txt", "contains": ["line-"]} - ] - with helpers.chdir(repo_dir): - ok, errors, _warnings = validate_plan_strict(plan) - self.assertFalse(ok) - self.assertTrue(errors) - finally: - shutil.rmtree(repo_dir) - - def test_patch_mode(self) -> None: - repo_dir, plan = _init_patch_repo() - try: - with helpers.chdir(repo_dir): - create_chain(plan) - diffstat, namestatus = compare_chain(plan) - self.assertEqual(diffstat, "") - self.assertEqual(namestatus, "") - finally: - shutil.rmtree(repo_dir) - - def test_hunks_all_selector(self) -> None: - repo_dir, plan = _init_hunk_repo() - try: - plan["changesets"] = [ - { - "slug": "all-hunks", - "description": "Select all hunks explicitly.", - "mode": "hunks", - "include_paths": ["notes.txt"], - "exclude_paths": [], - "allow_partial_files": True, - "hunk_selectors": [{"file": "notes.txt", "all": True}], - "commit_message": "cs1", - "pr_notes": [], - } - ] - with helpers.chdir(repo_dir): - create_chain(plan) - diffstat, namestatus = compare_chain(plan) - self.assertEqual(diffstat, "") - self.assertEqual(namestatus, "") - finally: - shutil.rmtree(repo_dir) - - def test_hunks_all_with_allow_partial_false(self) -> None: - repo_dir, plan = _init_hunk_repo() - try: - plan["changesets"] = [ - { - "slug": "all-hunks", - "description": "Select all hunks implicitly.", - "mode": "hunks", - "include_paths": ["notes.txt"], - "exclude_paths": [], - "allow_partial_files": False, - "hunk_selectors": [{"file": "notes.txt"}], - "commit_message": "cs1", - "pr_notes": [], - } - ] - with helpers.chdir(repo_dir): - create_chain(plan) - diffstat, namestatus = compare_chain(plan) - self.assertEqual(diffstat, "") - self.assertEqual(namestatus, "") - finally: - shutil.rmtree(repo_dir) - - def test_hunks_with_rename_scope_include_new_path(self) -> None: - repo_dir, plan = _init_rename_repo() - try: - with helpers.chdir(repo_dir): - ok, errors, warnings = validate_plan_strict(plan) - self.assertTrue(ok, f"expected strict validation to pass: {errors}") - self.assertTrue(warnings, "expected a warning for old path usage") - create_chain(plan) - diffstat, namestatus = compare_chain(plan) - self.assertEqual(diffstat, "") - self.assertEqual(namestatus, "") - finally: - shutil.rmtree(repo_dir) - - def test_hunks_with_rename_scope_exclude_either_path(self) -> None: - repo_dir, plan = _init_rename_repo() - try: - plan["changesets"][0]["exclude_paths"] = ["old.txt"] - with helpers.chdir(repo_dir): - ok, errors, _warnings = validate_plan_strict(plan) - self.assertFalse(ok) - self.assertTrue(errors) - finally: - shutil.rmtree(repo_dir) - - def test_strict_apply_check_fails(self) -> None: - repo_dir, plan = _init_bad_patch_repo() - try: - with helpers.chdir(repo_dir): - with self.assertRaises(CommandError): - strict_apply_check(plan) - finally: - shutil.rmtree(repo_dir) - - -if __name__ == "__main__": - unittest.main() diff --git a/skills/prepare-changesets/scripts/tests/test_preflight.py b/skills/prepare-changesets/scripts/tests/test_preflight.py deleted file mode 100644 index e0f79e5..0000000 --- a/skills/prepare-changesets/scripts/tests/test_preflight.py +++ /dev/null @@ -1,124 +0,0 @@ -from __future__ import annotations - -import shutil -import unittest - -import preflight as preflight_mod -from common import CommandError -from helpers import chdir, init_conflict_repo, init_repo, run - - -class PreflightTests(unittest.TestCase): - def test_preflight_success_does_not_modify_source(self) -> None: - repo_dir, plan = init_repo() - try: - source_hash_before = run( - ["git", "rev-parse", plan["source_branch"]], cwd=repo_dir - ).stdout.strip() - with chdir(repo_dir): - preflight_mod.preflight( - base=plan["base_branch"], - source=plan["source_branch"], - test_cmd="python3 -c \"print('ok')\"", - skip_tests=False, - skip_merge_check=False, - ) - source_hash_after = run( - ["git", "rev-parse", plan["source_branch"]], cwd=repo_dir - ).stdout.strip() - self.assertEqual(source_hash_before, source_hash_after) - finally: - shutil.rmtree(repo_dir) - - def test_preflight_detects_conflicts(self) -> None: - repo_dir, plan = init_conflict_repo() - try: - with chdir(repo_dir): - with self.assertRaises(CommandError): - preflight_mod.preflight( - base=plan["base_branch"], - source=plan["source_branch"], - test_cmd="", - skip_tests=True, - skip_merge_check=False, - ) - finally: - shutil.rmtree(repo_dir) - - def test_preflight_fails_when_source_behind_base(self) -> None: - repo_dir, plan = init_repo() - try: - run(["git", "checkout", plan["base_branch"]], cwd=repo_dir) - (repo_dir / "base.txt").write_text("base-update\n") - run(["git", "add", "base.txt"], cwd=repo_dir) - run(["git", "commit", "-m", "base update"], cwd=repo_dir) - with chdir(repo_dir): - with self.assertRaises(CommandError): - preflight_mod.preflight( - base=plan["base_branch"], - source=plan["source_branch"], - test_cmd="", - skip_tests=True, - skip_merge_check=True, - ) - finally: - shutil.rmtree(repo_dir) - - def test_preflight_allows_source_behind_with_override(self) -> None: - repo_dir, plan = init_repo() - try: - run(["git", "checkout", plan["base_branch"]], cwd=repo_dir) - (repo_dir / "base.txt").write_text("base-update\n") - run(["git", "add", "base.txt"], cwd=repo_dir) - run(["git", "commit", "-m", "base update"], cwd=repo_dir) - with chdir(repo_dir): - preflight_mod.preflight( - base=plan["base_branch"], - source=plan["source_branch"], - test_cmd="", - skip_tests=True, - skip_merge_check=True, - allow_source_behind_base=True, - ) - finally: - shutil.rmtree(repo_dir) - - def test_preflight_requires_recordkeeping_ignored(self) -> None: - repo_dir, plan = init_repo() - try: - (repo_dir / ".gitignore").write_text("") - run(["git", "add", ".gitignore"], cwd=repo_dir) - run(["git", "commit", "-m", "update ignore"], cwd=repo_dir) - with chdir(repo_dir): - with self.assertRaises(CommandError): - preflight_mod.preflight( - base=plan["base_branch"], - source=plan["source_branch"], - test_cmd="", - skip_tests=True, - skip_merge_check=True, - ) - finally: - shutil.rmtree(repo_dir) - - def test_preflight_allows_recordkeeping_tracked_with_override(self) -> None: - repo_dir, plan = init_repo() - try: - (repo_dir / ".gitignore").write_text("") - run(["git", "add", ".gitignore"], cwd=repo_dir) - run(["git", "commit", "-m", "update ignore"], cwd=repo_dir) - with chdir(repo_dir): - preflight_mod.preflight( - base=plan["base_branch"], - source=plan["source_branch"], - test_cmd="", - skip_tests=True, - skip_merge_check=True, - allow_recordkeeping_tracked=True, - ) - finally: - shutil.rmtree(repo_dir) - - -if __name__ == "__main__": - unittest.main() diff --git a/skills/prepare-changesets/scripts/tests/test_propagate.py b/skills/prepare-changesets/scripts/tests/test_propagate.py deleted file mode 100644 index c699d78..0000000 --- a/skills/prepare-changesets/scripts/tests/test_propagate.py +++ /dev/null @@ -1,133 +0,0 @@ -from __future__ import annotations - -import shutil -import unittest -from unittest import mock - -from helpers import chdir, init_remote, init_repo, run -from propagate import downstream_base_after_merge, propagate_downstream, push_chain - - -class PropagateTests(unittest.TestCase): - def test_downstream_base_after_merge_matrix(self) -> None: - base = "main" - source = "feature/test" - - self.assertEqual(downstream_base_after_merge(base, source, 0, 1), "main") - self.assertEqual( - downstream_base_after_merge(base, source, 0, 2), "feature/test-1" - ) - self.assertEqual(downstream_base_after_merge(base, source, 1, 2), "main") - self.assertEqual( - downstream_base_after_merge(base, source, 2, 3), "feature/test-1" - ) - - def test_propagate_dry_run_updates_expected_pr_bases(self) -> None: - repo_dir, plan = init_repo() - try: - from chain import create_chain - - with chdir(repo_dir): - create_chain(plan) - bases: list[str] = [] - - def record_base(new_base: str, *, dry_run: bool) -> None: - del dry_run - bases.append(new_base) - - with mock.patch("propagate.pr_edit_base", side_effect=record_base): - propagate_downstream( - plan=plan, - merged_index=1, - dry_run=True, - update_pr_bases=True, - skip_local_merge=True, - push=False, - remote="origin", - strategy="rebase", - ) - - self.assertEqual(bases, ["main"]) - finally: - shutil.rmtree(repo_dir) - - def test_propagate_can_push_to_bare_remote(self) -> None: - repo_dir, plan = init_repo() - remote_dir = None - try: - remote_dir = init_remote(repo_dir) - - run(["git", "checkout", "main"], cwd=repo_dir) - run(["git", "push", "-u", "origin", "main"], cwd=repo_dir) - run(["git", "checkout", plan["source_branch"]], cwd=repo_dir) - run(["git", "push", "-u", "origin", plan["source_branch"]], cwd=repo_dir) - - from chain import create_chain - - with chdir(repo_dir): - create_chain(plan) - push_chain(plan, remote="origin", dry_run=False) - - cs1 = f"{plan['source_branch']}-1" - run(["git", "checkout", cs1], cwd=repo_dir) - (repo_dir / "a.txt").write_text("feature-a-updated\n") - run(["git", "add", "a.txt"], cwd=repo_dir) - run(["git", "commit", "-m", "cs1 update"], cwd=repo_dir) - - propagate_downstream( - plan=plan, - merged_index=1, - dry_run=False, - update_pr_bases=False, - skip_local_merge=False, - push=True, - remote="origin", - strategy="rebase", - ) - - cs2 = f"{plan['source_branch']}-2" - local_cs2 = run(["git", "rev-parse", cs2], cwd=repo_dir).stdout.strip() - remote_cs2 = run( - ["git", "ls-remote", "origin", cs2], cwd=repo_dir - ).stdout.split()[0] - self.assertEqual(local_cs2, remote_cs2) - finally: - shutil.rmtree(repo_dir) - if remote_dir is not None: - shutil.rmtree(remote_dir.parent) - - def test_propagate_cherry_picks_changes_from_merged_branch(self) -> None: - repo_dir, plan = init_repo() - try: - from chain import create_chain - - with chdir(repo_dir): - create_chain(plan) - cs1 = f"{plan['source_branch']}-1" - cs2 = f"{plan['source_branch']}-2" - - run(["git", "checkout", cs1], cwd=repo_dir) - (repo_dir / "a.txt").write_text("feature-a-reviewed\n") - run(["git", "add", "a.txt"], cwd=repo_dir) - run(["git", "commit", "-m", "review fix"], cwd=repo_dir) - - propagate_downstream( - plan=plan, - merged_index=1, - dry_run=False, - update_pr_bases=False, - skip_local_merge=True, - push=False, - remote="origin", - strategy="cherry-pick", - ) - - run(["git", "checkout", cs2], cwd=repo_dir) - content = (repo_dir / "a.txt").read_text() - self.assertEqual(content, "feature-a-reviewed\n") - finally: - shutil.rmtree(repo_dir) - - -if __name__ == "__main__": - unittest.main() diff --git a/skills/prepare-changesets/scripts/tests/test_scripts_integration.py b/skills/prepare-changesets/scripts/tests/test_scripts_integration.py deleted file mode 100644 index 3a474ef..0000000 --- a/skills/prepare-changesets/scripts/tests/test_scripts_integration.py +++ /dev/null @@ -1,114 +0,0 @@ -from __future__ import annotations - -import shutil -import unittest - -from common import DEFAULT_PLAN_PATH -from helpers import SCRIPTS_DIR, init_remote, init_repo, run, write_plan - - -class ScriptIntegrationTests(unittest.TestCase): - def test_scripts_are_runnable(self) -> None: - repo_dir, plan = init_repo() - remote_dir = None - try: - scripts = SCRIPTS_DIR - plan_path = repo_dir / DEFAULT_PLAN_PATH - - remote_dir = init_remote(repo_dir) - - run(["git", "checkout", "main"], cwd=repo_dir) - run(["git", "push", "-u", "origin", "main"], cwd=repo_dir) - run(["git", "checkout", plan["source_branch"]], cwd=repo_dir) - run(["git", "push", "-u", "origin", plan["source_branch"]], cwd=repo_dir) - - # Exercise init-plan script, then replace it with the test-specific plan. - run( - [ - str(scripts / "init_plan.py"), - "--base", - plan["base_branch"], - "--source", - plan["source_branch"], - "--title", - plan["feature_title"], - "--changesets", - "2", - "--force", - ], - cwd=repo_dir, - ) - write_plan(plan_path, plan) - - run( - [ - str(scripts / "preflight.py"), - "--base", - plan["base_branch"], - "--source", - plan["source_branch"], - "--skip-tests", - ], - cwd=repo_dir, - ) - run( - [ - str(scripts / "squash_ref.py"), - "--base", - plan["base_branch"], - "--source", - plan["source_branch"], - ], - cwd=repo_dir, - ) - run([str(scripts / "validate.py")], cwd=repo_dir) - run([str(scripts / "status.py")], cwd=repo_dir) - run([str(scripts / "create_chain.py")], cwd=repo_dir) - run([str(scripts / "squash_check.py")], cwd=repo_dir) - run( - [ - str(scripts / "validate_chain.py"), - "--test-cmd", - "python3 -c \"print('ok')\"", - ], - cwd=repo_dir, - ) - run([str(scripts / "compare.py")], cwd=repo_dir) - run( - [ - str(scripts / "propagate.py"), - "--merged-index", - "1", - "--skip-local-merge", - ], - cwd=repo_dir, - ) - run([str(scripts / "pr_create.py")], cwd=repo_dir) - run( - [ - str(scripts / "db_compare.py"), - "--source-cmd", - "cat a.txt", - "--chain-cmd", - "cat a.txt", - "--out-dir", - str(repo_dir / ".prepare-changesets" / "db-compare-script"), - ], - cwd=repo_dir, - ) - run( - [ - str(scripts / "push_chain.py"), - "--remote", - "origin", - ], - cwd=repo_dir, - ) - finally: - shutil.rmtree(repo_dir) - if remote_dir is not None: - shutil.rmtree(remote_dir.parent) - - -if __name__ == "__main__": - unittest.main() diff --git a/skills/prepare-changesets/scripts/tests/test_squash.py b/skills/prepare-changesets/scripts/tests/test_squash.py deleted file mode 100644 index b1d1187..0000000 --- a/skills/prepare-changesets/scripts/tests/test_squash.py +++ /dev/null @@ -1,55 +0,0 @@ -from __future__ import annotations - -import shutil -import unittest - -import helpers # noqa: F401 # ensures sys.path is set -from chain import create_chain -from helpers import chdir, init_repo, run -from squash_check import squash_check -from squash_ref import create_squashed_ref - - -class SquashReferenceTests(unittest.TestCase): - def test_squash_ref_creates_tree_equivalent_branch(self) -> None: - repo_dir, plan = init_repo() - try: - with chdir(repo_dir): - squashed = create_squashed_ref( - base=plan["base_branch"], - source=plan["source_branch"], - reuse_existing=False, - recreate=False, - ) - - source_tree = run( - ["git", "rev-parse", f"{plan['source_branch']}^{{tree}}"], cwd=repo_dir - ).stdout.strip() - squashed_tree = run( - ["git", "rev-parse", f"{squashed}^{{tree}}"], cwd=repo_dir - ).stdout.strip() - self.assertEqual(source_tree, squashed_tree) - finally: - shutil.rmtree(repo_dir) - - def test_squash_check_reports_no_diff_when_chain_matches_source(self) -> None: - repo_dir, plan = init_repo() - try: - with chdir(repo_dir): - create_squashed_ref( - base=plan["base_branch"], - source=plan["source_branch"], - reuse_existing=False, - recreate=False, - ) - create_chain(plan) - diffstat, namestatus = squash_check(plan) - - self.assertEqual(diffstat.strip(), "") - self.assertEqual(namestatus.strip(), "") - finally: - shutil.rmtree(repo_dir) - - -if __name__ == "__main__": - unittest.main() diff --git a/skills/prepare-changesets/scripts/tests/test_test_command_discovery.py b/skills/prepare-changesets/scripts/tests/test_test_command_discovery.py deleted file mode 100644 index ca6cce8..0000000 --- a/skills/prepare-changesets/scripts/tests/test_test_command_discovery.py +++ /dev/null @@ -1,79 +0,0 @@ -from __future__ import annotations - -import json -import shutil -import unittest - -import helpers # noqa: F401 # ensures sys.path is set -from common import discover_test_command -from helpers import chdir, init_repo - - -class TestCommandDiscoveryTests(unittest.TestCase): - def test_agents_single_command_is_selected(self) -> None: - repo_dir, _plan = init_repo() - try: - (repo_dir / "AGENTS.md").write_text("```bash\njust test\n```\n") - (repo_dir / "justfile").write_text("test:\n echo ok\n") - with chdir(repo_dir): - discovery = discover_test_command("") - self.assertEqual(discovery["command"], "just test") - self.assertEqual(discovery["source"], "agents") - finally: - shutil.rmtree(repo_dir) - - def test_agents_ambiguous_returns_suggestions(self) -> None: - repo_dir, _plan = init_repo() - try: - (repo_dir / "AGENTS.md").write_text("```bash\njust test\nmake test\n```\n") - (repo_dir / "justfile").write_text("test:\n echo ok\n") - with chdir(repo_dir): - discovery = discover_test_command("") - self.assertIsNone(discovery["command"]) - self.assertEqual(discovery["reason"], "agents-ambiguous") - self.assertIn("just test", discovery["suggestions"]) - finally: - shutil.rmtree(repo_dir) - - def test_agents_ignores_key_value_metadata_lines(self) -> None: - repo_dir, _plan = init_repo() - try: - (repo_dir / "AGENTS.md").write_text( - "```yaml\ncontextKey: 'test-context'\nmoon :test\n```\n" - ) - with chdir(repo_dir): - discovery = discover_test_command("") - self.assertEqual(discovery["command"], "moon :test") - self.assertEqual(discovery["source"], "agents") - finally: - shutil.rmtree(repo_dir) - - def test_missing_agents_suggests_just_test(self) -> None: - repo_dir, _plan = init_repo() - try: - (repo_dir / "justfile").write_text("test:\n echo ok\n") - with chdir(repo_dir): - discovery = discover_test_command("") - self.assertIsNone(discovery["command"]) - self.assertEqual(discovery["reason"], "agents-missing") - self.assertIn("just test", discovery["suggestions"]) - finally: - shutil.rmtree(repo_dir) - - def test_package_json_suggestions_include_tool_and_script(self) -> None: - repo_dir, _plan = init_repo() - try: - (repo_dir / "package.json").write_text( - json.dumps({"scripts": {"test": "vitest run"}}, indent=2) + "\n" - ) - with chdir(repo_dir): - discovery = discover_test_command("") - self.assertIsNone(discovery["command"]) - self.assertIn("npm test", discovery["suggestions"]) - self.assertIn("vitest run", discovery["suggestions"]) - finally: - shutil.rmtree(repo_dir) - - -if __name__ == "__main__": - unittest.main() diff --git a/skills/prepare-changesets/scripts/validate.py b/skills/prepare-changesets/scripts/validate.py deleted file mode 100755 index 01157db..0000000 --- a/skills/prepare-changesets/scripts/validate.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env python3 -"""Run the validate command.""" - -from __future__ import annotations - -import sys -from pathlib import Path - -SCRIPTS_DIR = Path(__file__).resolve().parent -if str(SCRIPTS_DIR) not in sys.path: - sys.path.insert(0, str(SCRIPTS_DIR)) - -from cli import main # noqa: E402 - -if __name__ == "__main__": - raise SystemExit(main(["validate", *sys.argv[1:]])) diff --git a/skills/prepare-changesets/scripts/validate_chain.py b/skills/prepare-changesets/scripts/validate_chain.py deleted file mode 100755 index d94c9fc..0000000 --- a/skills/prepare-changesets/scripts/validate_chain.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env python3 -"""Run the validate-chain command.""" - -from __future__ import annotations - -import sys -from pathlib import Path - -SCRIPTS_DIR = Path(__file__).resolve().parent -if str(SCRIPTS_DIR) not in sys.path: - sys.path.insert(0, str(SCRIPTS_DIR)) - -from cli import main # noqa: E402 - -if __name__ == "__main__": - raise SystemExit(main(["validate-chain", *sys.argv[1:]]))