From 006e84ae5745a9cce28b7bb30ce4a6845ee87b98 Mon Sep 17 00:00:00 2001 From: Levon Becker Date: Tue, 11 Aug 2026 14:24:12 -0700 Subject: [PATCH] Only gitignore properties.yml in template repos; real repos get it committed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit properties.yml was unconditionally gitignored everywhere, which made sense for template repos (this one and its siblings) but meant every real repo forked from a template had to manually edit .gitignore to commit its own properties.yml (as fireball_orchestrator already does by hand). modules/setup/properties.py now runs _sync_gitignore_tracking() on every `inv setup.properties` call: if this repo's name (from its git remote, or the local folder as fallback) starts with `template_`, .gitignore is left untouched; otherwise the ignore line and its explanatory comment are stripped so properties.yml gets committed like any other repo config. Idempotent — a repo that's already had the line removed, or never had it, is left alone. Co-Authored-By: Claude Sonnet 5 --- modules/setup/README.md | 12 ++++--- modules/setup/properties.py | 49 +++++++++++++++++++++++++- tests/setup/test_setup_properties.py | 52 ++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 5 deletions(-) diff --git a/modules/setup/README.md b/modules/setup/README.md index 5d9bcb3..7e9beeb 100644 --- a/modules/setup/README.md +++ b/modules/setup/README.md @@ -7,10 +7,14 @@ uv run --no-sync invoke setup.properties ``` ## What It Does -`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. +`properties.yml` is gitignored **only in template repos** (this one and its siblings — anything +named `template_*`), since it would otherwise leak this machine's local paths into the template's +own history. In a real repo forked from a template, setup strips the ignore line from `.gitignore` +the first time it runs there, so `properties.yml` is committed like any other repo config — see +`_sync_gitignore_tracking()`. Creating the file itself is **a no-op if it already exists** — +`modules/setup/properties.py` only ever creates the file, it never rewrites an existing one (the +gitignore check still runs every time, though). To regenerate the file (e.g. after moving the repo, +renaming it, or pointing it at a new fork), delete or rename `properties.yml` first, then run again. 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 diff --git a/modules/setup/properties.py b/modules/setup/properties.py index d2fd272..a0c1172 100644 --- a/modules/setup/properties.py +++ b/modules/setup/properties.py @@ -6,7 +6,11 @@ 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 +properties.yml is gitignored in template repos (this one and its siblings — anything named +`template_*`) since it would otherwise leak this machine's local paths into the template's own +history. A real repo forked from a template gets the ignore line stripped from .gitignore the +first time setup runs there instead, so properties.yml is committed like any other repo config +(see `_sync_gitignore_tracking()` below). 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). @@ -237,6 +241,47 @@ def _has_section(lines: list[str], section: str) -> bool: return any(line.rstrip("\n") == f"{section}:" for line in lines) +def _is_template_repo(repo_remote: str | None) -> bool: + """Return whether this repo is itself a template (vs. a real repo forked from one). + + Every template repo in this family is named `template_*` (template_python, template_ai_python, + template_ai_vault, template_shopify, ...) — checked against the git remote when available, + falling back to the local folder name so it still works before a remote is set. + """ + name = repo_remote.rsplit("/", 1)[-1] if repo_remote else _REPO_ROOT.name + return name.startswith("template_") + + +def _sync_gitignore_tracking(repo_remote: str | None) -> None: + """Strip properties.yml's ignore line from .gitignore for a real (non-template) repo. + + Template repos always keep properties.yml gitignored — see the module docstring. A repo forked + from one should commit it instead, so this removes the ignore line (and its explanatory + comment) the first time setup runs there. Idempotent: a repo that's already had the line + removed, or never had it, is left untouched. + """ + gitignore = _REPO_ROOT / ".gitignore" + if not gitignore.exists() or _is_template_repo(repo_remote): + return + + text = gitignore.read_text() + if "/properties.yml" not in text: + return + + kept = [ + line + for line in text.splitlines() + if line.strip() != "/properties.yml" and "generated by `inv setup.properties`" not in line + ] + collapsed: list[str] = [] + for line in kept: + if line == "" and collapsed and collapsed[-1] == "": + continue # the removal above left two blank lines in a row — keep just one + collapsed.append(line) + gitignore.write_text("\n".join(collapsed) + "\n") + success("properties.yml is no longer gitignored — commit it along with the rest of setup") + + 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( @@ -283,6 +328,7 @@ def main() -> None: """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)") + _sync_gitignore_tracking(_detect_repo_remote()) return _PROPERTIES_FILE.write_text(_build_initial_content()) @@ -302,6 +348,7 @@ def main() -> None: if _has_section(lines, "icloud"): _prompt_icloud_enabled(lines) _PROPERTIES_FILE.write_text("".join(lines)) + _sync_gitignore_tracking(repo_remote) success(f"properties.yml: repo.local = {repo_local}") if repo_remote: diff --git a/tests/setup/test_setup_properties.py b/tests/setup/test_setup_properties.py index 6382890..98a1741 100644 --- a/tests/setup/test_setup_properties.py +++ b/tests/setup/test_setup_properties.py @@ -65,3 +65,55 @@ def test_prompted_parent_stamped_when_confirmed(self, monkeypatch: pytest.Monkey 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) + + +class TestIsTemplateRepo: + """Tests for _is_template_repo().""" + + def test_template_remote_is_a_template(self): + assert setup_props._is_template_repo("github.com/LevonBecker/template_python") # pylint: disable=protected-access + + def test_real_repo_remote_is_not_a_template(self): + assert not setup_props._is_template_repo("github.com/user/my_vault") # pylint: disable=protected-access + + def test_falls_back_to_local_folder_name_when_no_remote(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(setup_props, "_REPO_ROOT", setup_props._REPO_ROOT.parent / "template_shopify") # pylint: disable=protected-access + assert setup_props._is_template_repo(None) # pylint: disable=protected-access + + +class TestSyncGitignoreTracking: + """Tests for _sync_gitignore_tracking().""" + + _BLOCK = ( + "node_modules/\n" + "\n" + "# Machine-specific config — generated by `inv setup.properties` (see modules/setup/properties.py)\n" + "/properties.yml\n" + "\n" + ".DS_Store\n" + ) + + def test_template_repo_leaves_gitignore_untouched(self, tmp_path, monkeypatch: pytest.MonkeyPatch): + gitignore = tmp_path / ".gitignore" + gitignore.write_text(self._BLOCK) + monkeypatch.setattr(setup_props, "_REPO_ROOT", tmp_path) + setup_props._sync_gitignore_tracking("github.com/LevonBecker/template_python") # pylint: disable=protected-access + assert gitignore.read_text() == self._BLOCK + + def test_real_repo_strips_ignore_line_and_comment(self, tmp_path, monkeypatch: pytest.MonkeyPatch): + gitignore = tmp_path / ".gitignore" + gitignore.write_text(self._BLOCK) + monkeypatch.setattr(setup_props, "_REPO_ROOT", tmp_path) + setup_props._sync_gitignore_tracking("github.com/user/my_vault") # pylint: disable=protected-access + content = gitignore.read_text() + assert "/properties.yml" not in content + assert "generated by `inv setup.properties`" not in content + assert "\n\n\n" not in content + assert "node_modules/" in content and ".DS_Store" in content + + def test_already_stripped_gitignore_is_idempotent(self, tmp_path, monkeypatch: pytest.MonkeyPatch): + gitignore = tmp_path / ".gitignore" + gitignore.write_text("node_modules/\n\n.DS_Store\n") + monkeypatch.setattr(setup_props, "_REPO_ROOT", tmp_path) + setup_props._sync_gitignore_tracking("github.com/user/my_vault") # pylint: disable=protected-access + assert gitignore.read_text() == "node_modules/\n\n.DS_Store\n"