diff --git a/.claude/commands/docs.md b/.claude/commands/docs.md index f33cff6..fff3f9f 100644 --- a/.claude/commands/docs.md +++ b/.claude/commands/docs.md @@ -25,8 +25,9 @@ check: `.claude/commands/*.md`, `.claude/skills/*/SKILL.md`, `.clinerules/workflows/*.md` — these must describe the same behavior; if one changed, the other three need the matching edit (see `.github/instructions/prompts.instructions.md`). -6. **`properties.example.yml`** and any other example/config file describing setup — if `setup.sh`, - `setup.ps1`, or `modules/setup/properties.py` changed what gets generated. +6. **`properties.yml`'s built-in template fragments** (`modules/setup/templates/properties/*.yml`) and + any other example/config file describing setup — if `setup.sh`, `setup.ps1`, or + `modules/setup/properties.py` changed what gets generated. 7. Any other `*.md` file that references a file, command, module, or behavior touched by the diff. For each stale doc you find, fix it directly — this is a repo-local consistency sweep, not a diff --git a/.claude/commands/repos.md b/.claude/commands/repos.md new file mode 100644 index 0000000..3a8a9ae --- /dev/null +++ b/.claude/commands/repos.md @@ -0,0 +1,22 @@ +--- +description: Show this repo's related-repos map (org/repo list + template lineage) from properties.yml, or apply a change to all of them. +subtask: false +agent: general +slash_command: /repos +--- + +First, read `.github/instructions/repos.instructions.md` in full — it's the source of truth for +both what the `repos` map means and the Cross-Repo Change Workflow. + +Then read `properties.yml` at the repo root and resolve its `repos` key (the org/repo list) and +`lineage` sub-key (parent → child chain). If `properties.yml` doesn't exist yet, tell the user to +run `/setup` first. + +- If the user just wants to know what the related repos are, summarize the map and stop there. +- If the user wants a change applied to the related repos (e.g. "apply this to the related repos" / + "update the related repos with this"), follow the Cross-Repo Change Workflow in + `repos.instructions.md` against each repo in scope. + +This command doubles as a recognition trigger: whenever the user says "related repos", "the repos", +or "other repos" about this repo's family (not generic talk about "the repository"), read +`repos.instructions.md` and act on it — even if they didn't explicitly run `/repos`. diff --git a/.claude/skills/docs/SKILL.md b/.claude/skills/docs/SKILL.md index 5e31cfe..78b7590 100644 --- a/.claude/skills/docs/SKILL.md +++ b/.claude/skills/docs/SKILL.md @@ -17,6 +17,6 @@ uv run --no-sync invoke repo.pr_diff ``` Then sweep every doc/AI-config surface the prompt lists (root `README.md`, module `README.md`s, -`.github/instructions/*.md`, `AGENTS.md`/`CLAUDE.md`, the synced command dirs, `properties.example.yml`) -and fix anything stale directly — this is a repo-local consistency sweep, so no confirmation is -needed before editing. +`.github/instructions/*.md`, `AGENTS.md`/`CLAUDE.md`, the synced command dirs, `properties.yml`'s +built-in template fragments) and fix anything stale directly — this is a repo-local consistency +sweep, so no confirmation is needed before editing. diff --git a/.claude/skills/repos/SKILL.md b/.claude/skills/repos/SKILL.md new file mode 100644 index 0000000..3e390f0 --- /dev/null +++ b/.claude/skills/repos/SKILL.md @@ -0,0 +1,13 @@ +--- +name: repos +description: Use for showing this repo's related-repos map (org/repo list + template lineage) from properties.yml, or applying a change to all related repos. Also a TRIGGER — read .github/instructions/repos.instructions.md whenever the user says "related repos"/"the repos"/"other repos"/"all of the repos" about this repo's family, even without running /repos. Equivalent to /repos. +--- + +# Repos Workflow + +Use this file as source of truth: `.github/prompts/repos.prompt.md` + +When the user asks about this repo's related repos, wants a change applied across them, or runs +`/repos`, read `.github/instructions/repos.instructions.md` in full and follow it — it covers both +the `repos`/`lineage` map and the two-phase (apply, then checkpoint, then ship) Cross-Repo Change +Workflow. diff --git a/.clinerules/workflows/docs.md b/.clinerules/workflows/docs.md index e8c9b1a..de82c74 100644 --- a/.clinerules/workflows/docs.md +++ b/.clinerules/workflows/docs.md @@ -22,8 +22,9 @@ check: `.claude/commands/*.md`, `.claude/skills/*/SKILL.md`, `.clinerules/workflows/*.md` — these must describe the same behavior; if one changed, the other three need the matching edit (see `.github/instructions/prompts.instructions.md`). -6. **`properties.example.yml`** and any other example/config file describing setup — if `setup.sh`, - `setup.ps1`, or `modules/setup/properties.py` changed what gets generated. +6. **`properties.yml`'s built-in template fragments** (`modules/setup/templates/properties/*.yml`) and + any other example/config file describing setup — if `setup.sh`, `setup.ps1`, or + `modules/setup/properties.py` changed what gets generated. 7. Any other `*.md` file that references a file, command, module, or behavior touched by the diff. For each stale doc you find, fix it directly — this is a repo-local consistency sweep, not a diff --git a/.clinerules/workflows/repos.md b/.clinerules/workflows/repos.md new file mode 100644 index 0000000..d04a21d --- /dev/null +++ b/.clinerules/workflows/repos.md @@ -0,0 +1,15 @@ +First, read `.github/instructions/repos.instructions.md` in full — it's the source of truth for +both what the `repos` map means and the Cross-Repo Change Workflow. + +Then read `properties.yml` at the repo root and resolve its `repos` key (the org/repo list) and +`lineage` sub-key (parent → child chain). If `properties.yml` doesn't exist yet, tell the user to +run `/setup` first. + +- If the user just wants to know what the related repos are, summarize the map and stop there. +- If the user wants a change applied to the related repos (e.g. "apply this to the related repos" / + "update the related repos with this"), follow the Cross-Repo Change Workflow in + `repos.instructions.md` against each repo in scope. + +This command doubles as a recognition trigger: whenever the user says "related repos", "the repos", +or "other repos" about this repo's family (not generic talk about "the repository"), read +`repos.instructions.md` and act on it — even if they didn't explicitly run `/repos`. diff --git a/.github/instructions/git.instructions.md b/.github/instructions/git.instructions.md index 00f2cc2..5f9133a 100644 --- a/.github/instructions/git.instructions.md +++ b/.github/instructions/git.instructions.md @@ -20,3 +20,8 @@ description: "Use when creating a git branch or drafting a Pull Request for this This is the canonical PR format for this repo — `.github/prompts/pr.prompt.md` and `pr-notes.prompt.md` (and their `.claude/commands/`/`.clinerules/workflows/` mirrors) implement it; see `prompts.instructions.md` for how those commands are kept in sync. + +## Pull Request Assignee +Always assign the PR to the user who asked for it — `gh pr create --assignee @me` (the currently +authenticated `gh` user). `modules/repo/pr_create.py` does this automatically; if you ever create a +PR by hand (not through that module), pass `--assignee @me` yourself. diff --git a/.github/instructions/index.instructions.md b/.github/instructions/index.instructions.md index 5bf0224..5c5b8cf 100644 --- a/.github/instructions/index.instructions.md +++ b/.github/instructions/index.instructions.md @@ -22,6 +22,7 @@ scoped to one concern — load only the ones relevant to the task at hand: - `tests.instructions.md` — testing requirements and workflow - `style.instructions.md` — Markdown style and alphabetical-ordering rules - `docs.instructions.md` — README and inline-comment conventions +- `repos.instructions.md` — `properties.yml`'s `repos`/lineage key and the "related repos" trigger ## Project Structure ``` @@ -35,7 +36,7 @@ tests/ modules/ common/ # cli.py, properties.py, route_utils.py, utils.py — shared helpers repo/ # pull.py, push.py, squash.py, rebase.py, pr_*.py — git/PR workflow modules - setup/ # properties.py — creates/stamps properties.yml, called by setup.sh/setup.ps1 + setup/ # properties.py — creates properties.yml (no-op if it exists), called by setup.sh/setup.ps1; templates/properties/*.yml — tier fragments template/ # ignore.py, naming.py, pull.py, push.py, resolve.py, route.py, scope.py — sync shared tooling with the parent template repo for /template versioning/ # libs.py, python.py, workflows.py, upgrade.py, project.py — check pyproject.toml deps & workflow action refs vs. latest releases, bump the repo's VERSION file tasks/ diff --git a/.github/instructions/repos.instructions.md b/.github/instructions/repos.instructions.md new file mode 100644 index 0000000..06a9f23 --- /dev/null +++ b/.github/instructions/repos.instructions.md @@ -0,0 +1,82 @@ +--- +applyTo: "**" +--- +# Repos Instructions +Rules for the `repos` key in `properties.yml` — the map of GitHub repos related to this repository. + +## Purpose +`properties.yml`'s `repos` key records which other GitHub repos are part of this repo's family +(grouped by org), plus a `lineage` sub-key recording parent → child template-stamping +relationships — e.g. `LevonBecker/template_ai_vault: LevonBecker/template_python` means +`template_ai_vault` was stamped from `template_python`. + +It's built additively, tier by tier, from `modules/setup/templates/properties/*.yml` — one fragment +file per repo in the lineage, each named after itself. This repo (`template_python`) is the root: +its own fragment, `template_python.yml`, holds just its own `repos` entry (no lineage edge, since it +has no parent). Each fragment contributes only its own org/repo + the lineage edge to its parent; a +repo only ever ends up knowing its own ancestor chain, never a sibling branch it isn't descended +from. See `modules/setup/README.md` for the build mechanism. + +## "Related Repos" Trigger +When the user says **"related repos"**, **"the repos"**, **"other repos"**, **"all of the repos"**, +**"all the repos"**, or similar in the context of this repo's family — not generic talk about "the +repository" — **read this file in full before acting**, then read the `repos` key in +`properties.yml` to know which other repos are part of this repo's family and how they're related. +This applies whether or not the user ran `/repos` — the phrase itself is the trigger. + +Two distinct requests look similar but aren't: +- **"What are the related repos?"** — just resolve and show the `repos`/`lineage` map (`/repos` + does this). +- **"Apply this to the related/other repos"** vs. **"apply this to all of the repos"** — both run + the Cross-Repo Change Workflow below, but scope differs: + - "related repos" / "other repos" — the *other* repos in the family; this repo is assumed already + handled (e.g. its own PR already exists). + - "all of the repos" / "all the repos" — **includes this repo too**. If this repo's own change + isn't committed/pushed/PR'd yet, do that first (same format as every other repo — commit, push, + PR), then continue through the rest of the family in lineage order. + +## Cross-Repo Change Workflow +When the user asks to apply a change (already made in this repo, or described fresh) to the related +repos, run it as **two phases with a checkpoint in between** — don't pipeline straight through to +pushing/PRs for every repo unattended. + +### Phase 1 — Apply (no pushing yet) +1. Resolve which repos are in scope from `repos`/`lineage`. If the request is ambiguous about scope + (all of them? just this branch? a specific sub-tree?), ask. +2. For each repo in scope, **in root-to-leaf lineage order** (a child repo may depend on its parent + having the change first — e.g. `/template` pulling it down): + a. Confirm its local clone exists (sibling path convention, e.g. `$HOME/Development//` + — check `repo.local` in this repo's own `properties.yml` for the pattern actually in use). + b. `git status` — if there are uncommitted changes, stash them (`git stash push -u`) rather than + losing or clobbering in-progress work. + c. Switch to the repo's default branch (usually `main`). + d. `git fetch --prune`, then pull the default branch up to date. + e. Create a feature branch for this change. + f. Apply the change in that repo — either port the actual diff/pattern from the source repo/PR, + or run the specific command/action the user named (e.g. `/update`), whichever the request + calls for. If it's unclear what a given repo's own tier fragment or config should contain + (e.g. what's generic to that repo's product line vs. real business config), **ask rather than + guessing**. + +### Checkpoint +Once the change is applied (uncommitted) in every repo in scope, stop and ask the user, e.g.: "Made +the changes in all N repos — ready to ship them, or is there more to add first?" Don't proceed to +Phase 2 until they confirm. + +### Phase 2 — Ship +For each repo (same order), run the equivalent of `/ship-it`: fix, test, commit, push, draft PR +notes, open the PR (assigned to the user per `git.instructions.md`'s Pull Request Assignee rule). +Report each repo's PR URL back to the user. Never merge a PR yourself, and never push directly to a +shared/default branch across multiple repos without being asked. + +**Example:** "run `/update` on all the related repos" → Phase 1: for each repo in scope, cd in, +stash/switch/pull, branch, run `/update`. Checkpoint: confirm ready to ship. Phase 2: ship each +one — end state is one PR per repo, each showing that repo's own `/update` result, not one combined +PR. + +No dedicated automation exists for this yet (as of 2026-08-01) — each step above is done directly +(git commands, the repo's own test/push/PR tooling), not via a single script. + +## Module Implementation +For the build mechanism (tier fragments, additive `repos` merge, no-op-if-exists behavior), see +`modules/setup/README.md` and `modules/setup/properties.py`. diff --git a/.github/instructions/style.instructions.md b/.github/instructions/style.instructions.md index d0cc4c6..8924bbf 100644 --- a/.github/instructions/style.instructions.md +++ b/.github/instructions/style.instructions.md @@ -5,8 +5,46 @@ applyTo: "**" Rules for all markdown files created across this repository. +## Horizontal Rules + +**Never use a standalone `---` line as a section divider in the body of a `.github/instructions/*.md` +file.** The only place `---` belongs is the YAML frontmatter delimiter (the opening and closing +`---` around `applyTo: "**"`) — never between headers. Headers and blank lines alone are enough +visual separation. + +```markdown +# ✅ CORRECT +--- +applyTo: "**" +--- +# File Title +Intro sentence. + +## Section One +Content. + +## Section Two +Content. +``` + +```markdown +# ❌ WRONG +--- +applyTo: "**" +--- +# File Title + --- +## Section One +Content. + +--- + +## Section Two +Content. +``` + ## Headers **Do not add a blank line after any header (`#`, `##`, `###`, etc.).** Content begins on the very next line. diff --git a/.github/prompts/docs.prompt.md b/.github/prompts/docs.prompt.md index 013ac74..17c1fe5 100644 --- a/.github/prompts/docs.prompt.md +++ b/.github/prompts/docs.prompt.md @@ -25,8 +25,9 @@ check: `.claude/commands/*.md`, `.claude/skills/*/SKILL.md`, `.clinerules/workflows/*.md` — these must describe the same behavior; if one changed, the other three need the matching edit (see `.github/instructions/prompts.instructions.md`). -6. **`properties.example.yml`** and any other example/config file describing setup — if `setup.sh`, - `setup.ps1`, or `modules/setup/properties.py` changed what gets generated. +6. **`properties.yml`'s built-in template fragments** (`modules/setup/templates/properties/*.yml`) and + any other example/config file describing setup — if `setup.sh`, `setup.ps1`, or + `modules/setup/properties.py` changed what gets generated. 7. Any other `*.md` file that references a file, command, module, or behavior touched by the diff. For each stale doc you find, fix it directly — this is a repo-local consistency sweep, not a diff --git a/.github/prompts/repos.prompt.md b/.github/prompts/repos.prompt.md new file mode 100644 index 0000000..d42f725 --- /dev/null +++ b/.github/prompts/repos.prompt.md @@ -0,0 +1,22 @@ +--- +name: repos +description: Show this repo's related-repos map (org/repo list + template lineage) from properties.yml, or apply a change to all of them. +argument-hint: no arguments required +agent: agent +--- + +First, read `.github/instructions/repos.instructions.md` in full — it's the source of truth for +both what the `repos` map means and the Cross-Repo Change Workflow. + +Then read `properties.yml` at the repo root and resolve its `repos` key (the org/repo list) and +`lineage` sub-key (parent → child chain). If `properties.yml` doesn't exist yet, tell the user to +run `/setup` first. + +- If the user just wants to know what the related repos are, summarize the map and stop there. +- If the user wants a change applied to the related repos (e.g. "apply this to the related repos" / + "update the related repos with this"), follow the Cross-Repo Change Workflow in + `repos.instructions.md` against each repo in scope. + +This command doubles as a recognition trigger: whenever the user says "related repos", "the repos", +or "other repos" about this repo's family (not generic talk about "the repository"), read +`repos.instructions.md` and act on it — even if they didn't explicitly run `/repos`. diff --git a/.github/skills/repos/SKILL.md b/.github/skills/repos/SKILL.md new file mode 100644 index 0000000..c1961f9 --- /dev/null +++ b/.github/skills/repos/SKILL.md @@ -0,0 +1,14 @@ +--- +name: repos +description: Use for showing this repo's related-repos map (org/repo list + template lineage) from properties.yml, or applying a change to all related repos. Equivalent to /repos. Also triggered by the phrases "related repos", "the repos", "other repos", or "all of the repos". +--- + +# Repos Trigger + +Use this file as source of truth: `.github/prompts/repos.prompt.md` + +When the user says "related repos", "the repos", "other repos", "all of the repos", or otherwise +asks about this repo's family — even without running `/repos` — read +`.github/instructions/repos.instructions.md` in full and follow it. It covers both the +`repos`/`lineage` map and the two-phase (apply, then checkpoint, then ship) Cross-Repo Change +Workflow. diff --git a/AGENTS.md b/AGENTS.md index 33420a0..51c04e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ changes: - **Tests & linters**: `.github/instructions/tests.instructions.md` - **Markdown style**: `.github/instructions/style.instructions.md` - **Docs**: `.github/instructions/docs.instructions.md` +- **`properties.yml`'s `repos`/lineage key**: `.github/instructions/repos.instructions.md` **Source of truth: `.github/instructions/` and `.github/prompts/`** — all rules and slash commands live there. Never duplicate rules into this file or any tool-specific entrypoint — update diff --git a/README.md b/README.md index 6e39642..4f22ff3 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ tests/ modules/ common/ # cli.py, properties.py, route_utils.py, utils.py — shared helpers repo/ # pull.py, push.py, squash.py, rebase.py, pr_*.py — git/PR workflow modules - setup/ # properties.py — creates/stamps properties.yml, called by setup.sh/setup.ps1 + setup/ # properties.py — creates properties.yml (no-op if it exists), called by setup.sh/setup.ps1; templates/properties/*.yml — tier fragments template/ # ignore.py, naming.py, pull.py, push.py, resolve.py, route.py, scope.py — sync shared tooling with the parent template repo for /template versioning/ # libs.py, python.py, workflows.py, upgrade.py, project.py — check pyproject.toml deps & workflow action refs vs. latest releases, bump the repo's VERSION file tasks/ @@ -119,7 +119,7 @@ uv run --no-sync invoke setup.properties # Create/stamp properties.yml |--------|---------| | [`modules/common/`](modules/common/README.md) | CLI helpers, `properties.yml` config reader, output/utility helpers | | [`modules/repo/`](modules/repo/README.md) | Git workflow (pull, push, squash, rebase, PR) | -| [`modules/setup/`](modules/setup/README.md) | Creates/stamps `properties.yml`, called by `setup.sh`/`setup.ps1` | +| [`modules/setup/`](modules/setup/README.md) | Creates `properties.yml` (no-op if it exists) from tier fragments, called by `setup.sh`/`setup.ps1` | | [`modules/template/`](modules/template/README.md) | Sync shared, generic tooling with the parent template repo for `/template` | | [`modules/versioning/`](modules/versioning/README.md) | Check `pyproject.toml` deps and workflow action refs vs. latest releases, update locks; bump the repo's `VERSION` file for deploys/releases | diff --git a/modules/setup/README.md b/modules/setup/README.md index da22dd0..5d9bcb3 100644 --- a/modules/setup/README.md +++ b/modules/setup/README.md @@ -1,5 +1,5 @@ # Setup Module -One-time and idempotent repo bootstrapping helpers, called by `setup.sh`. +One-time repo bootstrapping helpers, called by `setup.sh`. ## Commands ```sh @@ -7,29 +7,29 @@ uv run --no-sync invoke setup.properties ``` ## What It Does -`properties.yml` is gitignored — it holds machine-specific local paths, so -`modules/setup/properties.py` creates it fresh on first run and only ever rewrites specific keys in -place afterward rather than treating the whole file as disposable: +`properties.yml` is gitignored. **A no-op if it already exists** — `modules/setup/properties.py` +only ever creates the file, it never rewrites an existing one. To regenerate it (e.g. after moving +the repo, renaming it, or pointing it at a new fork), delete or rename `properties.yml` first, then +run again. -1. If `properties.yml` doesn't exist, creates it from a built-in template (`_TEMPLATE` in the module). -2. Detects this repo's actual path on disk and its git `origin` remote (if any). -3. Rewrites only `repo.local` and `repo.remote` in place, targeting those specific keys by name — - every comment and the rest of the file's formatting survives untouched. Also strips any stale - sections left over from an older template version (e.g. a legacy `screenshots:` block). +On first run, assembles it from every tier fragment under `modules/setup/templates/properties/*.yml` +— one file per repo in the lineage, each named after itself. This repo (template_python) is the +root: its own fragment, `template_python.yml`, holds just `repo`, `template`, and its own `repos` +entry (no lineage edge, since it has no parent). Descendant repos add their own same-named fragment +on top when they fork from this template or a domain template descended from it. -Safe to re-run any time: `uv run --no-sync invoke setup.properties`. Re-run it after moving the repo -on disk, renaming it, or pointing it at a new fork — it just re-stamps the same fields with freshly -detected values. +`repos` (the GitHub org/repo map + template lineage) is built additively rather than concatenated: +each fragment's own `repos:` block contributes just its own org/repo + the lineage edge to its +parent, deep-merged into whatever was inherited from earlier tiers. A repo only ever ends up +knowing its own ancestor chain, never a sibling branch it isn't descended from. -`template.*` can't be fully auto-detected (only the initial guess is) and is left alone once set — -edit it by hand if it needs to point elsewhere. - -## Why Not a Placeholder Token? -An earlier version used a literal token (e.g. `PATH_TO_REPO`) that got search-and-replaced. That only -works once — after the token is replaced with a real path, a second run has nothing left to find. -Targeting the YAML key by name works every time, regardless of what value is currently there, which is -what makes re-running after a move/rename safe. +Detects this repo's actual path on disk and its git `origin` remote (if any), and stamps +`repo.local`, `repo.remote`, and `screenshots.location` (if a `screenshots` section is present — +generic to that section, not something this root repo declares) with those values. Auto-detects +`template.*` (the parent template repo for `/template`) via GitHub's generated-from link, falling +back to an interactive prompt. ## Files -- `properties.py` — creates/stamps `properties.yml` (used by `inv setup.properties`) +- `properties.py` — creates `properties.yml` (used by `inv setup.properties`) +- `templates/properties/*.yml` — per-tier fragments merged into a fresh `properties.yml` - `README.md` — this file diff --git a/modules/setup/properties.py b/modules/setup/properties.py index bce0a8d..d2fd272 100644 --- a/modules/setup/properties.py +++ b/modules/setup/properties.py @@ -1,66 +1,146 @@ """ -Stamp this machine's repo path (and git remote, if any) into properties.yml. - -Idempotent and safe to re-run any time — e.g. after moving the repo on disk, renaming it, or -forking it to a new remote. Run via `inv setup.properties` (called automatically by setup.sh). - -properties.yml is gitignored (machine-specific local paths) and created fresh from the built-in -template on first run. Every run after that only rewrites repo.local, repo.remote, and template.* -in place — everything else, including comments, is left exactly as-is, so hand edits survive. - -template.* (this repo's parent template repo, used by /template) is auto-detected — first against -a small known-family map (template_ai_vault, template_shopify → template_python), then via -GitHub's generated-from link, with an interactive prompt as final fallback — and is only ever -written while it still holds the built-in placeholder, so a hand-configured parent is never -clobbered. +Create properties.yml (this machine's repo path, git remote, and template lineage) on first run. + +Run via `inv setup.properties` (called automatically by setup.sh). A no-op if properties.yml +already exists — this only ever creates the file, never rewrites it. To regenerate (e.g. after +moving the repo, renaming it, or forking it to a new remote), delete or rename properties.yml +first, then run again. + +properties.yml is gitignored. It's assembled from every tier fragment under +`modules/setup/templates/properties/*.yml` — one file per repo in the lineage, each named after +itself. This repo (template_python) is the root: its own fragment, `template_python.yml`, holds +just `repo`, `template`, and its own `repos` entry (no lineage edge, since it has no parent). +Descendant repos add their own same-named fragment on top when they fork from this template or a +domain template descended from it — see `.github/instructions/repos.instructions.md`. `repo.local`, +`repo.remote`, and `screenshots.location` (if present) are then stamped in with values detected at +creation time. + +template.* (this repo's parent template repo, used by /template) is +auto-detected from GitHub's generated-from link when possible, with an +interactive prompt as fallback. + +repos (the GitHub org/repo map + template lineage) is built additively, one tier at a time, as you +go down the lineage: each fragment contributes just its own org/repo + the lineage edge to its +parent, deep-merged into whatever earlier tiers contributed. A repo only ever ends up knowing its +own ancestor chain — never a sibling branch it isn't descended from. """ import re import subprocess from pathlib import Path +from typing import Any + +import yaml from modules.common import cli from modules.common.utils import info, success _REPO_ROOT = Path(__file__).resolve().parent.parent.parent _PROPERTIES_FILE = _REPO_ROOT / "properties.yml" +_TEMPLATES_DIR = Path(__file__).resolve().parent / "templates" / "properties" -_TEMPLATE = """--- -# Repository Properties -# Central configuration for all scripts and automation. -# Created by /setup or ./setup.sh or ./setup.ps1 +_TEMPLATE_LOCAL_PLACEHOLDER = "$HOME/path/to/your/template/repo" +_TEMPLATE_REMOTE_PLACEHOLDER = "github.com//" -# This Repository's Local and Remote Paths -repo: - local: "$HOME/path/to/this/repo" - remote: "github.com//" -# Repository Template Parent Repository -template: - local: "$HOME/path/to/your/template/repo" - remote: "github.com//" -""" +def _extract_repos_block(text: str) -> tuple[str, dict[str, Any] | None]: + """Split a top-level `repos:` block out of raw YAML text. + Returns (text with that block removed, its parsed value) — or (text, None) if there's no + `repos:` block (a flow-style `repos: {}` placeholder doesn't count as one to extract). + """ + lines = text.splitlines(keepends=True) + for i, line in enumerate(lines): + if line.rstrip("\n") == "repos:": + end = i + 1 + while end < len(lines) and (not lines[end].strip() or lines[end][0].isspace()): + end += 1 + block = "".join(lines[i:end]) + remaining = "".join(lines[:i] + lines[end:]) + return remaining, yaml.safe_load(block)["repos"] + return text, None -_TEMPLATE_LOCAL_PLACEHOLDER = "$HOME/path/to/your/template/repo" -_TEMPLATE_REMOTE_PLACEHOLDER = "github.com//" -# Known direct children of this repo (template_python) — domain templates that fork straight from -# the root. Checked first, before GitHub's generated-from API, since it's instant and needs no -# network/gh dependency. Not exhaustive — leaf project repos (children of a domain template, e.g. -# fireball_enterprise_shopify under template_shopify) aren't and can't be listed here; those still -# rely on the API/prompt fallback below. -_KNOWN_TEMPLATE_PARENTS: dict[str, str] = { - "template_ai_vault": "github.com/LevonBecker/template_python", - "template_shopify": "github.com/LevonBecker/template_python", -} +def _merge_repos(accumulated: dict[str, Any], addition: dict[str, Any]) -> dict[str, Any]: + """Deep-merge one tier's repos contribution into the map accumulated from earlier tiers. + Org lists are extended (no duplicates); `lineage` entries are merged key by key. + """ + merged = {org: list(names) for org, names in accumulated.items() if org != "lineage"} + lineage = dict(accumulated.get("lineage", {})) + for org, names in addition.items(): + if org == "lineage": + lineage.update(names) + continue + merged.setdefault(org, []) + for name in names: + if name not in merged[org]: + merged[org].append(name) + if lineage: + merged["lineage"] = lineage + return merged + + +def _lineage_depth(repo_key: str, lineage: dict[str, str]) -> int: + """Return how many lineage hops `org/repo` is from its root (0 for the root itself).""" + depth = 0 + current = repo_key + seen: set[str] = set() + while current in lineage and current not in seen: + seen.add(current) + current = lineage[current] + depth += 1 + return depth + + +def _render_repos_block(repos: dict[str, Any]) -> str: + """Render a merged repos dict back into this file's hand-formatted YAML block style. + + Each org's list is ordered root-to-leaf by lineage depth, regardless of merge order. + """ + lineage = repos.get("lineage", {}) + lines = ["repos:"] + for org, names in repos.items(): + if org == "lineage": + continue + ordered = sorted(names, key=lambda name: _lineage_depth(f"{org}/{name}", lineage)) + lines.append(f" {org}:") + lines.extend(f" - {name}" for name in ordered) + if lineage: + lines.append(" lineage:") + lines.extend(f" {child}: {parent}" for child, parent in lineage.items()) + return "\n".join(lines) + "\n" -def _repo_name(repo_remote: str | None) -> str: - """Return this repo's own short name, preferring the git remote over the local folder name.""" - if repo_remote: - return repo_remote.rstrip("/").rsplit("/", 1)[-1] - return _REPO_ROOT.name + +def _build_initial_content() -> str: + """Assemble a fresh properties.yml from every tier fragment under templates/properties/*.yml. + + Each fragment's own `repos` contribution is deep-merged into the accumulated map (see module + docstring) rather than concatenated; every other section is appended as raw text, ordered + root-to-leaf by lineage depth regardless of filename/glob order. + """ + repos: dict[str, Any] = {} + fragments: list[tuple[str, str]] = [] + for template_file in sorted(_TEMPLATES_DIR.glob("*.yml")): + remaining, template_repos = _extract_repos_block(template_file.read_text()) + if template_repos: + repos = _merge_repos(repos, template_repos) + if remaining.strip(): + fragments.append((template_file.stem, remaining.rstrip("\n"))) + + lineage = repos.get("lineage", {}) + + def _fragment_depth(stem: str) -> int: + for org, names in repos.items(): + if org != "lineage" and stem in names: + return _lineage_depth(f"{org}/{stem}", lineage) + return 0 + + fragments.sort(key=lambda item: _fragment_depth(item[0])) + + parts = [_render_repos_block(repos).rstrip("\n")] + parts.extend(text for _, text in fragments) + return "---\n\n" + "\n\n".join(parts) + "\n" def _detect_repo_local() -> str: @@ -92,15 +172,7 @@ def _detect_repo_remote() -> str | None: def _detect_template_remote(repo_remote: str | None) -> str | None: - """Return this repo's parent template as 'github.com/owner/name'. - - Checks the known template-family map first (instant, no `gh` dependency), then falls back to - GitHub's generated-from link. - """ - known = _KNOWN_TEMPLATE_PARENTS.get(_repo_name(repo_remote)) - if known: - return known - + """Return this repo's parent template as 'github.com/owner/name' via GitHub's generated-from link.""" if not repo_remote or not repo_remote.startswith("github.com/"): return None try: @@ -160,71 +232,75 @@ def _replace_scalar(lines: list[str], section: str, key: str, value: str, *, quo return False -def _strip_legacy_section(lines: list[str], section: str) -> list[str]: - """Remove a stale top-level `section:` block (and everything indented under it), if present. +def _has_section(lines: list[str], section: str) -> bool: + """Return whether a top-level `section:` block exists (e.g. a tier fragment that wasn't present).""" + return any(line.rstrip("\n") == f"{section}:" for line in lines) - Self-heals properties.yml files stamped by an older version of this template that included a - section since removed from the schema (e.g. `screenshots:`). - """ - out: list[str] = [] - skipping = False - for line in lines: - if line.rstrip("\n") == f"{section}:": - skipping = True - continue - if skipping: - if line.strip() and not line[0].isspace(): - skipping = False - else: - continue - out.append(line) - return out + +def _prompt_icloud_enabled(lines: list[str]) -> None: + """Ask (interactively) whether to turn on iCloud sync for a freshly created properties.yml.""" + enabled = cli.confirm( + "Enable iCloud sync for /push and /pull? (most people don't need this — off by default)", + default=False, + ) + _replace_scalar(lines, "icloud", "enabled", "true" if enabled else "false", quote=False) + if enabled: + info("iCloud sync enabled — edit icloud.path in properties.yml to your Obsidian vault path") -def _stamp_template_parent(lines: list[str], repo_local: str, repo_remote: str | None) -> None: +def _stamp_template_parent(lines: list[str], repo_local: str, repo_remote: str | None) -> str | None: """Fill in template.* (the /template parent) while it still holds the placeholder. Auto-detects via GitHub's generated-from link, falling back to an interactive prompt. A hand-configured (non-placeholder) parent is never touched. + + Returns the parent remote now in effect (existing, detected, or prompted), or None. """ current = _read_scalar(lines, "template", "remote") if current not in (None, "", _TEMPLATE_REMOTE_PLACEHOLDER): - return + return current remote = _detect_template_remote(repo_remote) if remote: - info(f"Detected parent template repo: {remote}") + info(f"Detected parent template repo (GitHub generated-from): {remote}") elif cli.confirm("Sync shared tooling with a parent template repo via /template?", default=False): remote = cli.prompt("Parent template remote (e.g. github.com//)") if not remote: info("No parent template repo configured — edit template.* in properties.yml if you add one later") - return + return None template_local = f"{repo_local.rsplit('/', 1)[0]}/{remote.rstrip('/').rsplit('/', 1)[-1]}" _replace_scalar(lines, "template", "remote", remote) _replace_scalar(lines, "template", "local", template_local) success(f"properties.yml: template.remote = {remote}") success(f"properties.yml: template.local = {template_local} (sibling-path guess — edit if it lives elsewhere)") + return remote @cli.command() def main() -> None: - """Create properties.yml from the built-in template (if missing) and stamp local paths into it.""" - just_created = not _PROPERTIES_FILE.exists() - if just_created: - _PROPERTIES_FILE.write_text(_TEMPLATE) - info("Created properties.yml") + """Create properties.yml from every tier fragment; a no-op if it already exists.""" + if _PROPERTIES_FILE.exists(): + info("properties.yml already exists — leaving it untouched (delete or rename it to regenerate)") + return + + _PROPERTIES_FILE.write_text(_build_initial_content()) + info("Created properties.yml") repo_local = _detect_repo_local() repo_remote = _detect_repo_remote() lines = _PROPERTIES_FILE.read_text().splitlines(keepends=True) - lines = _strip_legacy_section(lines, "screenshots") _replace_scalar(lines, "repo", "local", repo_local) if repo_remote: _replace_scalar(lines, "repo", "remote", repo_remote) + has_screenshots = _has_section(lines, "screenshots") + if has_screenshots: + _replace_scalar(lines, "screenshots", "location", f"{repo_local}/screenshots") _stamp_template_parent(lines, repo_local, repo_remote) + if _has_section(lines, "icloud"): + _prompt_icloud_enabled(lines) _PROPERTIES_FILE.write_text("".join(lines)) success(f"properties.yml: repo.local = {repo_local}") @@ -232,6 +308,8 @@ def main() -> None: success(f"properties.yml: repo.remote = {repo_remote}") else: info("No git remote 'origin' found — repo.remote left unchanged") + if has_screenshots: + success(f"properties.yml: screenshots.location = {repo_local}/screenshots") if __name__ == "__main__": diff --git a/modules/setup/templates/properties/template_python.yml b/modules/setup/templates/properties/template_python.yml new file mode 100644 index 0000000..61708d5 --- /dev/null +++ b/modules/setup/templates/properties/template_python.yml @@ -0,0 +1,11 @@ +repos: + LevonBecker: + - template_python + +repo: + local: "$HOME/path/to/this/repo" + remote: "github.com//" + +template: + local: "$HOME/path/to/your/template/repo" + remote: "github.com//" diff --git a/properties.example.yml b/properties.example.yml deleted file mode 100644 index 1c0135e..0000000 --- a/properties.example.yml +++ /dev/null @@ -1,14 +0,0 @@ ---- -# Repository Properties -# Central configuration for all scripts and automation. -# Created by /setup or ./setup.sh or ./setup.ps1 - -# This Repository's Local and Remote Paths -repo: - local: "$HOME/path/to/this/repo" - remote: "github.com//" - -# Repository Template Parent Repository -template: - local: "$HOME/Development/levonbecker/template_python" - remote: "github.com/levonbecker/template_python" diff --git a/tests/test_markdown_style.py b/tests/test_markdown_style.py new file mode 100644 index 0000000..b1a9b85 --- /dev/null +++ b/tests/test_markdown_style.py @@ -0,0 +1,47 @@ +"""Verify `.github/instructions/*.md` files don't use `---` as a body section divider. + +See .github/instructions/style.instructions.md — `---` is reserved for the YAML frontmatter +delimiter only; headers and blank lines alone provide enough visual separation. +""" + +from __future__ import annotations + +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +INSTRUCTIONS_DIR = REPO_ROOT / ".github" / "instructions" + + +def _stray_dividers(path: Path) -> list[int]: + """Return line numbers (1-indexed) of standalone `---` lines outside frontmatter/code fences.""" + lines = path.read_text().splitlines() + stray: list[int] = [] + in_fence = False + frontmatter_done = False + skip_next_frontmatter_close = lines[:1] == ["---"] + + for i, line in enumerate(lines, start=1): + stripped = line.strip() + if i == 1 and skip_next_frontmatter_close: + continue + if skip_next_frontmatter_close and not frontmatter_done: + if stripped == "---": + frontmatter_done = True + continue + if stripped.startswith("```"): + in_fence = not in_fence + continue + if in_fence: + continue + if stripped == "---": + stray.append(i) + return stray + + +def test_no_stray_horizontal_rules_in_instructions() -> None: + offenders = {} + for path in sorted(INSTRUCTIONS_DIR.glob("*.md")): + stray = _stray_dividers(path) + if stray: + offenders[path.relative_to(REPO_ROOT).as_posix()] = stray + assert not offenders, f"Stray `---` body dividers found (see style.instructions.md): {offenders}" diff --git a/tests/test_setup_properties.py b/tests/test_setup_properties.py new file mode 100644 index 0000000..ca8496e --- /dev/null +++ b/tests/test_setup_properties.py @@ -0,0 +1,65 @@ +"""Tests for modules.setup.properties template-parent detection and stamping.""" + +import pytest + +from modules.setup import properties as setup_props + + +def _template_lines() -> list[str]: + return setup_props._build_initial_content().splitlines(keepends=True) # pylint: disable=protected-access + + +def _stamp(lines, repo_local, monkeypatch, *, detected, confirm=False): + monkeypatch.setattr(setup_props, "_detect_template_remote", lambda _remote: detected) + monkeypatch.setattr(setup_props.cli, "confirm", lambda *_a, **_k: confirm) + setup_props._stamp_template_parent( # pylint: disable=protected-access + lines, repo_local, "github.com/user/my_vault" + ) + + +class TestReadScalar: + """Tests for _read_scalar().""" + + def test_reads_value_inside_section(self): + lines = _template_lines() + value = setup_props._read_scalar(lines, "template", "remote") # pylint: disable=protected-access + assert value == setup_props._TEMPLATE_REMOTE_PLACEHOLDER # pylint: disable=protected-access + + def test_missing_key_returns_none(self): + lines = _template_lines() + assert setup_props._read_scalar(lines, "template", "nope") is None # pylint: disable=protected-access + + def test_key_outside_section_not_matched(self): + lines = _template_lines() + assert setup_props._read_scalar(lines, "repos", "remote") is None # pylint: disable=protected-access + + +class TestStampTemplateParent: + """Tests for _stamp_template_parent().""" + + def test_detected_parent_stamped_with_sibling_local_guess(self, monkeypatch: pytest.MonkeyPatch): + lines = _template_lines() + _stamp(lines, "$HOME/Development/user/my_vault", monkeypatch, detected="github.com/user/template_my_vault") + content = "".join(lines) + assert 'remote: "github.com/user/template_my_vault"' in content + assert 'local: "$HOME/Development/user/template_my_vault"' in content + + def test_hand_configured_parent_never_touched(self, monkeypatch: pytest.MonkeyPatch): + lines = _template_lines() + _stamp(lines, "$HOME/dev/my_vault", monkeypatch, detected="github.com/user/template_my_vault") + before = "".join(lines) + _stamp(lines, "$HOME/dev/my_vault", monkeypatch, detected="github.com/user/other_template") + assert "".join(lines) == before + + def test_no_detection_and_declined_prompt_leaves_placeholder(self, monkeypatch: pytest.MonkeyPatch): + lines = _template_lines() + _stamp(lines, "$HOME/dev/my_vault", monkeypatch, detected=None, confirm=False) + content = "".join(lines) + assert setup_props._TEMPLATE_REMOTE_PLACEHOLDER in content # pylint: disable=protected-access + assert setup_props._TEMPLATE_LOCAL_PLACEHOLDER in content # pylint: disable=protected-access + + def test_prompted_parent_stamped_when_confirmed(self, monkeypatch: pytest.MonkeyPatch): + lines = _template_lines() + monkeypatch.setattr(setup_props.cli, "prompt", lambda *_a, **_k: "github.com/user/template_my_vault") + _stamp(lines, "$HOME/dev/my_vault", monkeypatch, detected=None, confirm=True) + assert 'remote: "github.com/user/template_my_vault"' in "".join(lines)