diff --git a/.git_archival.txt b/.git_archival.txt new file mode 100644 index 0000000..ff4124f --- /dev/null +++ b/.git_archival.txt @@ -0,0 +1 @@ +node: $Format:%H$ diff --git a/.gitattributes b/.gitattributes index cf24b32..f603c48 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,14 @@ # Cmd.exe parses LF-only .bat files incorrectly on Windows (joined tokens / mangled commands). *.bat text eol=crlf + +# Keep development-only agent/editor metadata out of user-facing source and +# update archives. +.agents export-ignore +.codex export-ignore +.cursor export-ignore +.github export-ignore +.vscode export-ignore + +# Expand the exact archived commit so a fresh source download can identify the +# version it already contains without committing a self-referential literal SHA. +.git_archival.txt export-subst diff --git a/.gitignore b/.gitignore index b42b45d..1fb4cdb 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ __pycache__ !assets/*.png !assets/engine_icons/** !.pre-commit-config.yaml +!.git_archival.txt !data/skills/** !data/help/** !data/vocab_base.txt diff --git a/gui/main.py b/gui/main.py index e610c5e..a0628c0 100644 --- a/gui/main.py +++ b/gui/main.py @@ -53,8 +53,7 @@ def check_tool_update() -> str | None: """Return latest commit SHA when a tool update is available, else None.""" try: latest_sha = UpdateThread.fetch_latest_sha() - sha_path = Path(UpdateThread.SHA_FILE) - current_sha = sha_path.read_text().strip() if sha_path.is_file() else "" + current_sha = UpdateThread.read_installed_sha() if latest_sha != current_sha: return latest_sha except Exception: @@ -82,9 +81,21 @@ class UpdateThread(QThread): # "dazedtl"). Resolve dynamically so a rename cannot silently no-op again. ARCHIVE_ROOT = "dazedtl" SHA_FILE = str(LAST_UPDATE_SHA_PATH) + ARCHIVE_SHA_FILE = str(PROJECT_ROOT / ".git_archival.txt") # Top-level paths that should never be touched during update - PROTECTED_TOP = {".env", "venv", "log", "files", "translated"} + PROTECTED_TOP = { + ".agents", + ".codex", + ".cursor", + ".env", + ".github", + ".vscode", + "venv", + "log", + "files", + "translated", + } # User-local files under data/ that must not be overwritten. # Shipped defaults (translation_contexts.json, skills/*.md, help/*, vocab_base.txt, …) @@ -185,14 +196,45 @@ class UpdateThread(QThread): with urllib.request.urlopen(req, timeout=15) as resp: return json.loads(resp.read())["commit"]["id"] + @staticmethod + def _read_archive_sha(path: Path) -> str: + """Read an expanded git-archive commit ID, or return an empty string.""" + if not path.is_file(): + return "" + try: + for line in path.read_text(encoding="utf-8").splitlines(): + key, separator, value = line.partition(":") + candidate = value.strip() + if ( + separator + and key.strip() == "node" + and re.fullmatch(r"[0-9a-fA-F]{40,64}", candidate) + ): + return candidate + except OSError: + pass + return "" + + @classmethod + def read_installed_sha(cls) -> str: + """Return the updated SHA, falling back to source-archive metadata.""" + runtime_path = Path(cls.SHA_FILE) + if runtime_path.is_file(): + try: + runtime_sha = runtime_path.read_text(encoding="utf-8").strip() + if runtime_sha: + return runtime_sha + except OSError: + pass + return cls._read_archive_sha(Path(cls.ARCHIVE_SHA_FILE)) + # ------------------------------------------------------------------ # def _fetch_latest_sha(self): return self.fetch_latest_sha() def _read_stored_sha(self): - p = Path(self.SHA_FILE) - return p.read_text().strip() if p.exists() else "" + return self.read_installed_sha() def _download_archive(self, zip_path: Path): req = urllib.request.Request( @@ -493,10 +535,7 @@ class UpdateDialog(QDialog): @classmethod def _installed_sha_display(cls) -> str: - sha_path = Path(UpdateThread.SHA_FILE) - if sha_path.is_file(): - return cls._short_sha(sha_path.read_text().strip()) - return "Not recorded" + return cls._short_sha(UpdateThread.read_installed_sha()) def _set_stage_pills(self, active_stage: str | None = None): stage_order = list(UpdateThread.STAGES) diff --git a/tests/test_bundled_updates.py b/tests/test_bundled_updates.py index cfa8587..3d2f30d 100644 --- a/tests/test_bundled_updates.py +++ b/tests/test_bundled_updates.py @@ -37,6 +37,7 @@ class UpdateThreadInstallFilterTests(unittest.TestCase): for rel in ( *_SHIPPED_DATA_FILES, + ".git_archival.txt", "gui/main.py", "gameupdate/GameUpdate.bat", "gameupdate/gameupdate/patch.ps1", @@ -58,6 +59,11 @@ class UpdateThreadInstallFilterTests(unittest.TestCase): "log/translations.txt", "files/Map001.json", "translated/Map001.json", + ".agents/skills/shipped-data-assets/SKILL.md", + ".codex/config.toml", + ".cursor/rules/project.mdc", + ".github/workflows/tests.yml", + ".vscode/settings.json", ): with self.subTest(rel=rel): self.assertFalse(UpdateThread.should_install(Path(rel))) @@ -180,8 +186,49 @@ class ShippedDataTrackingTests(unittest.TestCase): ) +class SourceArchiveMetadataTests(unittest.TestCase): + """Source archives identify their commit and omit development metadata.""" + + def test_archival_info_is_configured_for_sha_expansion(self): + archival_info = _REPO_ROOT / ".git_archival.txt" + self.assertTrue(archival_info.is_file()) + self.assertIn("$Format:%H$", archival_info.read_text(encoding="utf-8")) + + ignored = subprocess.run( + ["git", "check-ignore", "--", ".git_archival.txt"], + cwd=_REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + self.assertNotEqual(ignored.returncode, 0) + + result = subprocess.run( + ["git", "check-attr", "export-subst", "--", ".git_archival.txt"], + cwd=_REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0) + self.assertIn("export-subst: set", result.stdout) + + def test_development_directories_are_export_ignored(self): + for rel in (".agents", ".codex", ".cursor", ".github", ".vscode"): + with self.subTest(rel=rel): + result = subprocess.run( + ["git", "check-attr", "export-ignore", "--", rel], + cwd=_REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0) + self.assertIn("export-ignore: set", result.stdout) + + class CheckToolUpdateTests(unittest.TestCase): - """Tool update check compares remote SHA to data/last_update_sha.txt only.""" + """Tool update checks runtime state, then source-archive commit metadata.""" def test_returns_sha_when_remote_differs(self): with tempfile.TemporaryDirectory() as raw: @@ -207,6 +254,43 @@ class CheckToolUpdateTests(unittest.TestCase): ): self.assertIsNone(check_tool_update()) + def test_archive_sha_prevents_false_update_on_first_launch(self): + archived_sha = "a" * 40 + with tempfile.TemporaryDirectory() as raw: + base = Path(raw) + sha_file = base / "missing-last-update-sha.txt" + archive_file = base / ".git_archival.txt" + archive_file.write_text(f"node: {archived_sha}\n", encoding="utf-8") + from gui.main import UpdateThread, check_tool_update + + with patch.multiple( + UpdateThread, + SHA_FILE=str(sha_file), + ARCHIVE_SHA_FILE=str(archive_file), + ): + with patch.object( + UpdateThread, "fetch_latest_sha", return_value=archived_sha + ): + self.assertIsNone(check_tool_update()) + + def test_unexpanded_archive_sha_is_ignored(self): + latest_sha = "b" * 40 + with tempfile.TemporaryDirectory() as raw: + base = Path(raw) + archive_file = base / ".git_archival.txt" + archive_file.write_text("node: $Format:%H$\n", encoding="utf-8") + from gui.main import UpdateThread, check_tool_update + + with patch.multiple( + UpdateThread, + SHA_FILE=str(base / "missing-last-update-sha.txt"), + ARCHIVE_SHA_FILE=str(archive_file), + ): + with patch.object( + UpdateThread, "fetch_latest_sha", return_value=latest_sha + ): + self.assertEqual(check_tool_update(), latest_sha) + def test_returns_none_on_fetch_failure(self): from gui.main import UpdateThread, check_tool_update