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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions modules/setup/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 48 additions & 1 deletion modules/setup/properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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())
Expand All @@ -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:
Expand Down
52 changes: 52 additions & 0 deletions tests/setup/test_setup_properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"