workstate-bootstrap 0.7.2__tar.gz → 0.7.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/CHANGELOG.md +7 -0
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/PKG-INFO +7 -5
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/README.md +5 -3
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/pyproject.toml +2 -2
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/src/workstate_bootstrap/adopt.py +9 -2
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/src/workstate_bootstrap/install.py +136 -13
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_adopt_worktree.py +51 -0
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_bootstrap_install_rehearsal.py +2 -2
- workstate_bootstrap-0.7.3/tests/test_clone_payload_resolution.py +59 -0
- workstate_bootstrap-0.7.3/tests/test_gitignore_block.py +259 -0
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_install.py +86 -19
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_package_source.py +2 -2
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_subcommands.py +30 -9
- workstate_bootstrap-0.7.2/tests/test_gitignore_block.py +0 -104
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/.gitignore +0 -0
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/src/workstate_bootstrap/__init__.py +0 -0
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/src/workstate_bootstrap/__main__.py +0 -0
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/src/workstate_bootstrap/cli.py +0 -0
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/src/workstate_bootstrap/mcp_sync.py +0 -0
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/src/workstate_bootstrap/subcommands.py +0 -0
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/src/workstate_bootstrap/worktree.py +0 -0
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/__init__.py +0 -0
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_cli_profile.py +0 -0
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_doctor_repair_sync.py +0 -0
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_ensure_hooks_path_make.py +0 -0
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_install_manifest_walker.py +0 -0
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_install_profile_all_lifecycle.py +0 -0
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_install_profiles.py +0 -0
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_manifest_build.py +0 -0
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_mcp_sync_cli.py +0 -0
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_mcp_sync_e2e.py +0 -0
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_mcp_sync_malformed.py +0 -0
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_mcp_sync_prune.py +0 -0
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_mcp_sync_unit.py +0 -0
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_package_metadata.py +0 -0
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_render_seam.py +0 -0
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_root_visible_task_plans.py +0 -0
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_worktree.py +0 -0
- {workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_worktree_doctor.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: workstate-bootstrap
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.3
|
|
4
4
|
Summary: Bootstrap CLI that hoists the shared workstate-system surface into consumer repos.
|
|
5
5
|
Project-URL: Homepage, https://github.com/darce/workstate
|
|
6
6
|
Project-URL: Source, https://github.com/darce/workstate/tree/main/packages/workstate-bootstrap
|
|
@@ -12,7 +12,7 @@ Requires-Python: >=3.11
|
|
|
12
12
|
Requires-Dist: pyyaml>=6
|
|
13
13
|
Requires-Dist: tomlkit>=0.13
|
|
14
14
|
Requires-Dist: workstate-protocol<0.2.0,>=0.1.7
|
|
15
|
-
Requires-Dist: workstate-system<0.2.0,>=0.1.
|
|
15
|
+
Requires-Dist: workstate-system<0.2.0,>=0.1.3
|
|
16
16
|
Provides-Extra: dev
|
|
17
17
|
Requires-Dist: mcp-workstate-handoff; (python_version >= '3.12') and extra == 'dev'
|
|
18
18
|
Requires-Dist: pytest>=8; extra == 'dev'
|
|
@@ -120,9 +120,11 @@ overlay starts absent — the plugin is enabled (tracked
|
|
|
120
120
|
`.claude/settings.json`) but unresolvable. Self-heal works as follows:
|
|
121
121
|
|
|
122
122
|
- **`make task-start`** (the supported flow) adopts the overlay into the
|
|
123
|
-
new worktree automatically, so it works out of the box.
|
|
124
|
-
|
|
125
|
-
workstate
|
|
123
|
+
new worktree automatically, so it works out of the box. In source
|
|
124
|
+
checkouts, the lifecycle uses the freshly provisioned worktree `.venv`
|
|
125
|
+
`workstate-bootstrap` command when available, then falls back to `uvx`.
|
|
126
|
+
Set `WORKSTATE_ADOPT_CMD=""` to disable auto-adopt, or set it to a custom
|
|
127
|
+
command to override that default.
|
|
126
128
|
- **Raw `git worktree add` / auto-worktrees** are healed on demand:
|
|
127
129
|
|
|
128
130
|
```bash
|
|
@@ -100,9 +100,11 @@ overlay starts absent — the plugin is enabled (tracked
|
|
|
100
100
|
`.claude/settings.json`) but unresolvable. Self-heal works as follows:
|
|
101
101
|
|
|
102
102
|
- **`make task-start`** (the supported flow) adopts the overlay into the
|
|
103
|
-
new worktree automatically, so it works out of the box.
|
|
104
|
-
|
|
105
|
-
workstate
|
|
103
|
+
new worktree automatically, so it works out of the box. In source
|
|
104
|
+
checkouts, the lifecycle uses the freshly provisioned worktree `.venv`
|
|
105
|
+
`workstate-bootstrap` command when available, then falls back to `uvx`.
|
|
106
|
+
Set `WORKSTATE_ADOPT_CMD=""` to disable auto-adopt, or set it to a custom
|
|
107
|
+
command to override that default.
|
|
106
108
|
- **Raw `git worktree add` / auto-worktrees** are healed on demand:
|
|
107
109
|
|
|
108
110
|
```bash
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "workstate-bootstrap"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.3"
|
|
8
8
|
description = "Bootstrap CLI that hoists the shared workstate-system surface into consumer repos."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -14,7 +14,7 @@ dependencies = [
|
|
|
14
14
|
"tomlkit>=0.13",
|
|
15
15
|
"pyyaml>=6",
|
|
16
16
|
"workstate-protocol>=0.1.7,<0.2.0",
|
|
17
|
-
"workstate-system>=0.1.
|
|
17
|
+
"workstate-system>=0.1.3,<0.2.0",
|
|
18
18
|
]
|
|
19
19
|
|
|
20
20
|
[project.scripts]
|
|
@@ -48,6 +48,7 @@ from workstate_bootstrap.install import (
|
|
|
48
48
|
_install_lifecycle_profile,
|
|
49
49
|
_is_repointable_bootstrap_symlink,
|
|
50
50
|
_materialize_surfaces,
|
|
51
|
+
_overlay_surfaces_self_managed,
|
|
51
52
|
_prepare_generated_surfaces,
|
|
52
53
|
_raw_symlink_target_path,
|
|
53
54
|
_resolve_in_clone,
|
|
@@ -186,10 +187,16 @@ def _compute_drift(target: Path, primary: Path, clone: Path) -> list[str]:
|
|
|
186
187
|
drift.append("Makefile")
|
|
187
188
|
|
|
188
189
|
# Managed overlay-ignore block (keeps git status clean). apply writes it, so
|
|
189
|
-
# --check must verify it too, else a missing block silently regresses.
|
|
190
|
+
# --check must verify it too, else a missing block silently regresses. But a
|
|
191
|
+
# self-hosting source repo manages the surfaces itself (tracked or already
|
|
192
|
+
# ignored): apply skips the block there, so --check must too — otherwise the
|
|
193
|
+
# block is reported as perpetual, unsatisfiable drift.
|
|
190
194
|
gitignore = target / ".gitignore"
|
|
191
195
|
gitignore_text = gitignore.read_text() if gitignore.exists() else ""
|
|
192
|
-
if
|
|
196
|
+
if (
|
|
197
|
+
GITIGNORE_SENTINEL_BEGIN not in gitignore_text
|
|
198
|
+
and not _overlay_surfaces_self_managed(target)
|
|
199
|
+
):
|
|
193
200
|
drift.append(".gitignore")
|
|
194
201
|
|
|
195
202
|
# core.hooksPath (resolved against this worktree's config).
|
|
@@ -148,6 +148,13 @@ def _finalize_install_manifest(
|
|
|
148
148
|
# fake_remote_with_surfaces fixture used elsewhere in the test suite).
|
|
149
149
|
WORKSTATE_SYSTEM_SUBDIR = "packages/workstate-system"
|
|
150
150
|
|
|
151
|
+
# implementation note S3: the shipped overlay payload moved under
|
|
152
|
+
# packages/workstate-system/workstate_system/payload/. Probe this co-located
|
|
153
|
+
# payload root FIRST when resolving a surface in the clone, keeping the pre-S3
|
|
154
|
+
# subdir and the clone root as fallbacks for already-installed / hoisted
|
|
155
|
+
# consumers (and the fake_remote_with_surfaces test fixture).
|
|
156
|
+
WORKSTATE_SYSTEM_PAYLOAD_SUBDIR = "packages/workstate-system/workstate_system/payload"
|
|
157
|
+
|
|
151
158
|
# Shared overlay surfaces materialized as symlinks into ``.workstate/remote``.
|
|
152
159
|
# Per-agent surfaces (.claude/skills, .claude/commands, .github/prompts,
|
|
153
160
|
# .codex/skills) are no longer canonical in the overlay clone; they are
|
|
@@ -297,7 +304,7 @@ DEFAULT_MCP_SERVERS: dict[str, dict[str, Any]] = {
|
|
|
297
304
|
"type": "stdio",
|
|
298
305
|
"command": "uvx",
|
|
299
306
|
"args": [
|
|
300
|
-
"mcp-workstate-handoff@0.12.
|
|
307
|
+
"mcp-workstate-handoff@0.12.1",
|
|
301
308
|
"--workspace-root",
|
|
302
309
|
".",
|
|
303
310
|
"serve-stdio",
|
|
@@ -307,7 +314,7 @@ DEFAULT_MCP_SERVERS: dict[str, dict[str, Any]] = {
|
|
|
307
314
|
"type": "stdio",
|
|
308
315
|
"command": "uvx",
|
|
309
316
|
"args": [
|
|
310
|
-
"mcp-workstate-orchestrator@0.5.
|
|
317
|
+
"mcp-workstate-orchestrator@0.5.2",
|
|
311
318
|
"--workspace-root",
|
|
312
319
|
".",
|
|
313
320
|
"serve-stdio",
|
|
@@ -495,6 +502,13 @@ def _presync_local_mcp_envs(
|
|
|
495
502
|
if project in seen or not (project / "pyproject.toml").is_file():
|
|
496
503
|
continue
|
|
497
504
|
seen.add(project)
|
|
505
|
+
# Base sync only — no optional extras. The orchestrator's `bridge` extra
|
|
506
|
+
# (workstate-codex-bridge for the `codex-subagent` backend) is host-specific
|
|
507
|
+
# and unpublished, so presync deliberately leaves it out to keep install
|
|
508
|
+
# cheap and bridge-free. When a server then launches from this venv, the
|
|
509
|
+
# bridge is absent and `list_available_backends(probe=true)` reports the
|
|
510
|
+
# backend as `declared_not_installed` rather than falsely available. Install
|
|
511
|
+
# the bridge on demand with `uv sync --extra bridge` against that project.
|
|
498
512
|
subprocess.run(
|
|
499
513
|
["uv", "sync", "--project", str(project)],
|
|
500
514
|
check=True,
|
|
@@ -737,18 +751,22 @@ def _resolve_ref_to_sha(clone: Path, remote_ref: str) -> str:
|
|
|
737
751
|
def _resolve_in_clone(clone: Path, relpath: str) -> Path:
|
|
738
752
|
"""Resolve a surface/asset path against the clone.
|
|
739
753
|
|
|
740
|
-
Probes ``<clone>/packages/workstate-system/<relpath>``
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
754
|
+
Probes ``<clone>/packages/workstate-system/workstate_system/payload/<relpath>``
|
|
755
|
+
first (the implementation note S3 co-located payload layout), then the pre-S3
|
|
756
|
+
``<clone>/packages/workstate-system/<relpath>``, then ``<clone>/<relpath>``
|
|
757
|
+
for legacy hoisted overlays. Returns the co-located payload path when none
|
|
758
|
+
exists so callers can use ``.exists()`` as the discriminator.
|
|
744
759
|
"""
|
|
760
|
+
payload = clone / WORKSTATE_SYSTEM_PAYLOAD_SUBDIR / relpath
|
|
761
|
+
if payload.exists():
|
|
762
|
+
return payload
|
|
745
763
|
nested = clone / WORKSTATE_SYSTEM_SUBDIR / relpath
|
|
746
764
|
if nested.exists():
|
|
747
765
|
return nested
|
|
748
766
|
root = clone / relpath
|
|
749
767
|
if root.exists():
|
|
750
768
|
return root
|
|
751
|
-
return
|
|
769
|
+
return payload
|
|
752
770
|
|
|
753
771
|
|
|
754
772
|
def _materialize_one_symlink(
|
|
@@ -1683,6 +1701,103 @@ def _consumer_gitignore_entries() -> list[str]:
|
|
|
1683
1701
|
return entries
|
|
1684
1702
|
|
|
1685
1703
|
|
|
1704
|
+
def _git_path_is_tracked(target: Path, rel: str) -> bool:
|
|
1705
|
+
"""True when ``rel`` (relative to ``target``) has tracked content in git.
|
|
1706
|
+
|
|
1707
|
+
``git ls-files --error-unmatch`` exits non-zero when the pathspec matches no
|
|
1708
|
+
tracked file; for a tracked directory it matches the tracked children and
|
|
1709
|
+
exits 0. Any git failure (non-zero exit, git absent, not a repo) is reported
|
|
1710
|
+
as "not tracked" so the conservative default keeps the managed block.
|
|
1711
|
+
"""
|
|
1712
|
+
try:
|
|
1713
|
+
subprocess.run(
|
|
1714
|
+
["git", "-C", str(target), "ls-files", "--error-unmatch", "--", rel],
|
|
1715
|
+
check=True,
|
|
1716
|
+
capture_output=True,
|
|
1717
|
+
text=True,
|
|
1718
|
+
timeout=120,
|
|
1719
|
+
)
|
|
1720
|
+
except (
|
|
1721
|
+
subprocess.CalledProcessError,
|
|
1722
|
+
subprocess.TimeoutExpired,
|
|
1723
|
+
FileNotFoundError,
|
|
1724
|
+
OSError,
|
|
1725
|
+
):
|
|
1726
|
+
return False
|
|
1727
|
+
return True
|
|
1728
|
+
|
|
1729
|
+
|
|
1730
|
+
def _git_path_is_ignored(target: Path, rel: str) -> bool:
|
|
1731
|
+
"""True when git already ignores ``rel`` (relative to ``target``).
|
|
1732
|
+
|
|
1733
|
+
``git check-ignore -q`` exits 0 when the path is ignored, 1 when not, and 128
|
|
1734
|
+
on error. Only exit 0 counts as ignored; anything else (including git absent /
|
|
1735
|
+
not a repo) is "not ignored" so the conservative default keeps the block.
|
|
1736
|
+
|
|
1737
|
+
Probes a child path as a fallback: a hand-authored ``.gitignore`` commonly uses
|
|
1738
|
+
a directory-only pattern (``.task-state/``, ``/.github/prompts/``) which
|
|
1739
|
+
``check-ignore`` will NOT match against the bare path when that directory does
|
|
1740
|
+
not yet exist on disk (the very M1 trailing-slash subtlety the managed block
|
|
1741
|
+
avoids). An ignored directory ignores its contents, so a child probe matches a
|
|
1742
|
+
``dir/`` pattern regardless of whether the directory has been materialized.
|
|
1743
|
+
"""
|
|
1744
|
+
for probe in (rel, f"{rel}/.workstate-overlay-probe"):
|
|
1745
|
+
try:
|
|
1746
|
+
proc = subprocess.run(
|
|
1747
|
+
["git", "-C", str(target), "check-ignore", "-q", "--", probe],
|
|
1748
|
+
capture_output=True,
|
|
1749
|
+
text=True,
|
|
1750
|
+
timeout=120,
|
|
1751
|
+
)
|
|
1752
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
1753
|
+
return False
|
|
1754
|
+
if proc.returncode == 0:
|
|
1755
|
+
return True
|
|
1756
|
+
if proc.returncode != 1: # 128 / fatal — be conservative, keep the block
|
|
1757
|
+
return False
|
|
1758
|
+
return False
|
|
1759
|
+
|
|
1760
|
+
|
|
1761
|
+
def _leaking_overlay_entries(target: Path) -> list[str]:
|
|
1762
|
+
"""Managed overlay entries that would surface in ``git status`` as untracked —
|
|
1763
|
+
i.e. neither tracked nor already ignored by the consumer's own config.
|
|
1764
|
+
|
|
1765
|
+
These are the ONLY entries the managed overlay-ignore block needs, and the
|
|
1766
|
+
only ones it is *safe* to ignore. An entry that is already tracked is the
|
|
1767
|
+
repo's own source — emitting a root-anchored ``/scripts/hooks`` ignore for it
|
|
1768
|
+
would silently make that tracked source un-trackable (the footgun). An entry
|
|
1769
|
+
already ignored is redundant. Filtering per-entry (rather than all-or-nothing)
|
|
1770
|
+
keeps a MIXED self-host safe: a transiently-leaking new surface still gets an
|
|
1771
|
+
ignore line while a sibling tracked-source surface never does.
|
|
1772
|
+
|
|
1773
|
+
Order is preserved from :func:`_consumer_gitignore_entries` for a stable block.
|
|
1774
|
+
The caller checks for our own sentinel block first, so this never sees a
|
|
1775
|
+
previously-written managed block masking the consumer's own config.
|
|
1776
|
+
"""
|
|
1777
|
+
leaking: list[str] = []
|
|
1778
|
+
for entry in _consumer_gitignore_entries():
|
|
1779
|
+
rel = entry.lstrip("/")
|
|
1780
|
+
if not (_git_path_is_tracked(target, rel) or _git_path_is_ignored(target, rel)):
|
|
1781
|
+
leaking.append(entry)
|
|
1782
|
+
return leaking
|
|
1783
|
+
|
|
1784
|
+
|
|
1785
|
+
def _overlay_surfaces_self_managed(target: Path) -> bool:
|
|
1786
|
+
"""True when ``target`` already tracks or ignores every managed overlay
|
|
1787
|
+
surface on its own — the self-hosting source repo case (no entry leaks).
|
|
1788
|
+
|
|
1789
|
+
The managed overlay-ignore block exists for ONE reason: keep the adopted
|
|
1790
|
+
overlay symlinks out of ``git status``. The workstate monorepo self-hosts the
|
|
1791
|
+
overlay — it ships a hand-authored ``.gitignore`` that already ignores the
|
|
1792
|
+
very same root surfaces (and a tracked-source layout may even version-control
|
|
1793
|
+
them). When nothing leaks, the block is wholly unnecessary, so adopt/install
|
|
1794
|
+
skip it (and ``adopt --check`` must not report it as drift). A normal external
|
|
1795
|
+
consumer — freshly materialized symlinks, untracked and not yet ignored — has
|
|
1796
|
+
leaking surfaces and still gets a block.
|
|
1797
|
+
"""
|
|
1798
|
+
return not _leaking_overlay_entries(target)
|
|
1799
|
+
|
|
1800
|
+
|
|
1686
1801
|
def _ensure_consumer_gitignore_block(target: Path) -> dict[str, str]:
|
|
1687
1802
|
"""Idempotently maintain a sentinel-delimited overlay-ignore block in
|
|
1688
1803
|
``<target>/.gitignore``.
|
|
@@ -1692,23 +1807,31 @@ def _ensure_consumer_gitignore_block(target: Path) -> dict[str, str]:
|
|
|
1692
1807
|
uninstall can excise it cleanly, and never clobbers a user-authored
|
|
1693
1808
|
``.gitignore`` (the block is appended, preserving prior content). Keeps an
|
|
1694
1809
|
installed/adopted overlay out of ``git status``. Returns the action taken
|
|
1695
|
-
(``created`` / ``appended`` / ``already_present``).
|
|
1810
|
+
(``created`` / ``appended`` / ``already_present`` / ``skipped_self_managed``).
|
|
1811
|
+
|
|
1812
|
+
Only the *leaking* managed entries are emitted (see
|
|
1813
|
+
:func:`_leaking_overlay_entries`): a tracked surface is never ignored (closing
|
|
1814
|
+
the self-hosting-source footgun even in a MIXED layout), and an already-ignored
|
|
1815
|
+
surface is not duplicated. When nothing leaks the whole block is skipped.
|
|
1696
1816
|
"""
|
|
1817
|
+
leaking = _leaking_overlay_entries(target)
|
|
1697
1818
|
gitignore = target / ".gitignore"
|
|
1819
|
+
existing = gitignore.read_text() if gitignore.exists() else None
|
|
1820
|
+
if existing is not None and GITIGNORE_SENTINEL_BEGIN in existing:
|
|
1821
|
+
return {"path": ".gitignore", "action": "already_present"}
|
|
1822
|
+
if not leaking:
|
|
1823
|
+
return {"path": ".gitignore", "action": "skipped_self_managed"}
|
|
1698
1824
|
block = (
|
|
1699
1825
|
GITIGNORE_SENTINEL_BEGIN
|
|
1700
1826
|
+ "\n"
|
|
1701
|
-
+ "\n".join(
|
|
1827
|
+
+ "\n".join(leaking)
|
|
1702
1828
|
+ "\n"
|
|
1703
1829
|
+ GITIGNORE_SENTINEL_END
|
|
1704
1830
|
+ "\n"
|
|
1705
1831
|
)
|
|
1706
|
-
if
|
|
1832
|
+
if existing is None:
|
|
1707
1833
|
gitignore.write_text(block)
|
|
1708
1834
|
return {"path": ".gitignore", "action": "created"}
|
|
1709
|
-
existing = gitignore.read_text()
|
|
1710
|
-
if GITIGNORE_SENTINEL_BEGIN in existing:
|
|
1711
|
-
return {"path": ".gitignore", "action": "already_present"}
|
|
1712
1835
|
sep = "" if existing.endswith("\n") else "\n"
|
|
1713
1836
|
gitignore.write_text(existing + sep + block)
|
|
1714
1837
|
return {"path": ".gitignore", "action": "appended"}
|
|
@@ -501,6 +501,57 @@ def test_check_reports_drift_when_gitignore_block_removed(
|
|
|
501
501
|
assert ".gitignore" in receipt["drift"]
|
|
502
502
|
|
|
503
503
|
|
|
504
|
+
def test_adopt_self_hosting_worktree_does_not_touch_tracked_gitignore(
|
|
505
|
+
tmp_path: Path,
|
|
506
|
+
) -> None:
|
|
507
|
+
"""Self-hosting source repo (the workstate monorepo): a feature worktree
|
|
508
|
+
inherits a TRACKED ``.gitignore`` that already ignores the overlay surfaces.
|
|
509
|
+
Adopt must NOT append the managed block — doing so dirties every feature
|
|
510
|
+
worktree's tracked ``.gitignore`` (and its root-anchored patterns would ignore
|
|
511
|
+
the repo's own tracked source). ``--check`` must agree it is clean, not report
|
|
512
|
+
perpetual, unsatisfiable ``.gitignore`` drift.
|
|
513
|
+
"""
|
|
514
|
+
primary, _clone = _make_installed_primary(tmp_path / "primary")
|
|
515
|
+
# The self-hosting primary tracks a hand-authored .gitignore covering the
|
|
516
|
+
# runtime dirs + the overlay surfaces (a mix of root-anchored and
|
|
517
|
+
# directory-only patterns, as the live monorepo ships).
|
|
518
|
+
(primary / ".gitignore").write_text(
|
|
519
|
+
".workstate/\n"
|
|
520
|
+
".task-state/\n"
|
|
521
|
+
"/scripts/hooks\n"
|
|
522
|
+
"/.github/hooks\n"
|
|
523
|
+
"/docs/workstate/contracts\n"
|
|
524
|
+
"/docs/workstate/rules\n"
|
|
525
|
+
"/Makefile.d\n"
|
|
526
|
+
"/scripts/workstate\n"
|
|
527
|
+
"/.github/prompts/\n"
|
|
528
|
+
)
|
|
529
|
+
_git("add", ".gitignore", cwd=primary)
|
|
530
|
+
_git("commit", "-m", "hand-authored overlay ignores", cwd=primary)
|
|
531
|
+
|
|
532
|
+
wt = _add_worktree(primary, tmp_path / "wt")
|
|
533
|
+
tracked_before = (wt / ".gitignore").read_text()
|
|
534
|
+
|
|
535
|
+
receipt = adopt_worktree(target=wt, primary=primary)
|
|
536
|
+
|
|
537
|
+
assert receipt["adopted"] is True
|
|
538
|
+
assert receipt["gitignore"]["action"] == "skipped_self_managed"
|
|
539
|
+
# The tracked .gitignore is byte-for-byte untouched — no managed block, no
|
|
540
|
+
# spurious working-tree modification.
|
|
541
|
+
assert (wt / ".gitignore").read_text() == tracked_before
|
|
542
|
+
assert "WORKSTATE_BOOTSTRAP OVERLAY IGNORE" not in tracked_before
|
|
543
|
+
gitignore_status = [
|
|
544
|
+
line
|
|
545
|
+
for line in _git("status", "--porcelain", cwd=wt).splitlines()
|
|
546
|
+
if ".gitignore" in line
|
|
547
|
+
]
|
|
548
|
+
assert gitignore_status == [], gitignore_status
|
|
549
|
+
|
|
550
|
+
# --check must not report .gitignore as drift (apply skipped it on purpose).
|
|
551
|
+
check = adopt_worktree(target=wt, primary=primary, check=True)
|
|
552
|
+
assert ".gitignore" not in check["drift"], check["drift"]
|
|
553
|
+
|
|
554
|
+
|
|
504
555
|
# ---------------------------------------------------------------------------
|
|
505
556
|
# implementation note S1: apply and --check share ONE surface enumeration
|
|
506
557
|
#
|
{workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_bootstrap_install_rehearsal.py
RENAMED
|
@@ -236,8 +236,8 @@ def test_install_with_default_servers_requires_uvx_in_args() -> None:
|
|
|
236
236
|
assert entry["command"] == "uvx", (name, entry)
|
|
237
237
|
assert "--workspace-root" in entry["args"], (name, entry)
|
|
238
238
|
assert entry["args"][-1] == "serve-stdio", (name, entry)
|
|
239
|
-
assert DEFAULT_MCP_SERVERS["workstate-handoff-mcp"]["args"][0] == "mcp-workstate-handoff@0.12.
|
|
240
|
-
assert DEFAULT_MCP_SERVERS["workstate-orchestrator-mcp"]["args"][0] == "mcp-workstate-orchestrator@0.5.
|
|
239
|
+
assert DEFAULT_MCP_SERVERS["workstate-handoff-mcp"]["args"][0] == "mcp-workstate-handoff@0.12.1"
|
|
240
|
+
assert DEFAULT_MCP_SERVERS["workstate-orchestrator-mcp"]["args"][0] == "mcp-workstate-orchestrator@0.5.2"
|
|
241
241
|
|
|
242
242
|
|
|
243
243
|
def test_rehearsal_hoists_plan_targets_makefile_and_shell_wrapper(
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""implementation note S3: the clone resolver must find surfaces at the co-located
|
|
2
|
+
``workstate_system/payload/`` layout that a fresh monorepo clone now has.
|
|
3
|
+
|
|
4
|
+
This is the runbook's "highest-risk gap": the monorepo's own make/hooks read the
|
|
5
|
+
package SOURCE directly, so an in-repo regression in the CLONE resolver
|
|
6
|
+
(``_resolve_in_clone``) is invisible to every other test — a downstream consumer
|
|
7
|
+
cloning the monorepo would silently materialize an EMPTY overlay. These tests
|
|
8
|
+
pin the payload-first probe + the legacy fallback directly.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from workstate_bootstrap.install import (
|
|
16
|
+
SHARED_SURFACES,
|
|
17
|
+
WORKSTATE_SYSTEM_PAYLOAD_SUBDIR,
|
|
18
|
+
WORKSTATE_SYSTEM_SUBDIR,
|
|
19
|
+
_resolve_in_clone,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _seed(base: Path, surface: str) -> Path:
|
|
24
|
+
target = base / surface
|
|
25
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
(target / "marker.txt").write_text("x", encoding="utf-8")
|
|
27
|
+
return target
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_resolve_in_clone_finds_every_shared_surface_at_payload_layout(tmp_path: Path) -> None:
|
|
31
|
+
"""A clone at the post-S3 layout resolves every SHARED_SURFACE non-empty."""
|
|
32
|
+
clone = tmp_path / "clone"
|
|
33
|
+
payload = clone / WORKSTATE_SYSTEM_PAYLOAD_SUBDIR
|
|
34
|
+
for surface in SHARED_SURFACES:
|
|
35
|
+
_seed(payload, surface)
|
|
36
|
+
|
|
37
|
+
for surface in SHARED_SURFACES:
|
|
38
|
+
resolved = _resolve_in_clone(clone, surface)
|
|
39
|
+
assert resolved == payload / surface, surface
|
|
40
|
+
assert resolved.exists() and any(resolved.iterdir()), (
|
|
41
|
+
f"surface {surface} resolved empty from a payload-layout clone"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_resolve_in_clone_keeps_legacy_subdir_fallback(tmp_path: Path) -> None:
|
|
46
|
+
"""Already-installed/hoisted consumers at the pre-S3 layout still resolve."""
|
|
47
|
+
clone = tmp_path / "clone"
|
|
48
|
+
_seed(clone / WORKSTATE_SYSTEM_SUBDIR, "scripts/hooks")
|
|
49
|
+
resolved = _resolve_in_clone(clone, "scripts/hooks")
|
|
50
|
+
assert resolved == clone / WORKSTATE_SYSTEM_SUBDIR / "scripts" / "hooks"
|
|
51
|
+
assert resolved.exists()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_resolve_in_clone_payload_wins_over_legacy(tmp_path: Path) -> None:
|
|
55
|
+
"""When both layouts exist, the co-located payload is canonical."""
|
|
56
|
+
clone = tmp_path / "clone"
|
|
57
|
+
_seed(clone / WORKSTATE_SYSTEM_PAYLOAD_SUBDIR, "skills")
|
|
58
|
+
_seed(clone / WORKSTATE_SYSTEM_SUBDIR, "skills")
|
|
59
|
+
assert _resolve_in_clone(clone, "skills") == clone / WORKSTATE_SYSTEM_PAYLOAD_SUBDIR / "skills"
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""TDD gate for implementation note Slice S4: managed consumer ``.gitignore`` block.
|
|
2
|
+
|
|
3
|
+
An installed/adopted overlay materializes the runtime dir (``.workstate``) and a
|
|
4
|
+
set of surface SYMLINKS (``Makefile.d``, ``scripts/hooks`` …). Without ignore
|
|
5
|
+
rules these show as untracked, so ``git status`` is never clean. A managed,
|
|
6
|
+
sentinel-delimited block (mirroring the Makefile-include pattern) keeps it clean
|
|
7
|
+
without clobbering a user-authored ``.gitignore``.
|
|
8
|
+
|
|
9
|
+
M1: the patterns must be ROOT-ANCHORED and NON-trailing-slash — a trailing-slash
|
|
10
|
+
pattern matches only directories, but git treats a symlink as a non-directory,
|
|
11
|
+
so ``Makefile.d/`` would miss an adopted ``Makefile.d`` symlink.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import subprocess
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from workstate_bootstrap.install import (
|
|
20
|
+
GITIGNORE_SENTINEL_BEGIN,
|
|
21
|
+
_consumer_gitignore_entries,
|
|
22
|
+
_ensure_consumer_gitignore_block,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _git(*args: str, cwd: Path) -> str:
|
|
27
|
+
result = subprocess.run(
|
|
28
|
+
["git", *args],
|
|
29
|
+
check=True,
|
|
30
|
+
capture_output=True,
|
|
31
|
+
text=True,
|
|
32
|
+
cwd=str(cwd),
|
|
33
|
+
timeout=30,
|
|
34
|
+
)
|
|
35
|
+
return result.stdout.strip()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _init_repo(root: Path) -> None:
|
|
39
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
_git("init", "--initial-branch=main", cwd=root)
|
|
41
|
+
_git("config", "user.email", "t@e.com", cwd=root)
|
|
42
|
+
_git("config", "user.name", "T", cwd=root)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _is_ignored(repo: Path, rel: str) -> bool:
|
|
46
|
+
return (
|
|
47
|
+
subprocess.run(
|
|
48
|
+
["git", "-C", str(repo), "check-ignore", "-q", "--", rel], timeout=30
|
|
49
|
+
).returncode
|
|
50
|
+
== 0
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_block_created_when_no_gitignore(tmp_path: Path) -> None:
|
|
55
|
+
target = tmp_path / "consumer"
|
|
56
|
+
target.mkdir()
|
|
57
|
+
result = _ensure_consumer_gitignore_block(target)
|
|
58
|
+
assert result == {"path": ".gitignore", "action": "created"}
|
|
59
|
+
text = (target / ".gitignore").read_text()
|
|
60
|
+
assert GITIGNORE_SENTINEL_BEGIN in text
|
|
61
|
+
assert "/.workstate" in text
|
|
62
|
+
assert "/Makefile.d" in text
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_block_appended_preserves_user_content(tmp_path: Path) -> None:
|
|
66
|
+
target = tmp_path / "consumer"
|
|
67
|
+
target.mkdir()
|
|
68
|
+
(target / ".gitignore").write_text("# user\n*.log\nbuild/\n")
|
|
69
|
+
result = _ensure_consumer_gitignore_block(target)
|
|
70
|
+
assert result == {"path": ".gitignore", "action": "appended"}
|
|
71
|
+
text = (target / ".gitignore").read_text()
|
|
72
|
+
assert "*.log" in text and "build/" in text # user content preserved
|
|
73
|
+
assert GITIGNORE_SENTINEL_BEGIN in text
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_block_is_idempotent(tmp_path: Path) -> None:
|
|
77
|
+
target = tmp_path / "consumer"
|
|
78
|
+
target.mkdir()
|
|
79
|
+
_ensure_consumer_gitignore_block(target)
|
|
80
|
+
result = _ensure_consumer_gitignore_block(target)
|
|
81
|
+
assert result == {"path": ".gitignore", "action": "already_present"}
|
|
82
|
+
# No duplicate block.
|
|
83
|
+
assert (target / ".gitignore").read_text().count(GITIGNORE_SENTINEL_BEGIN) == 1
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_block_ignores_symlink_surfaces_git_status_clean(tmp_path: Path) -> None:
|
|
87
|
+
"""M1 regression: the block must ignore adopted SYMLINK surfaces + .workstate
|
|
88
|
+
so `git status` shows nothing but the .gitignore itself."""
|
|
89
|
+
repo = tmp_path / "repo"
|
|
90
|
+
_init_repo(repo)
|
|
91
|
+
external = tmp_path / "external"
|
|
92
|
+
external.mkdir()
|
|
93
|
+
|
|
94
|
+
# Materialize the overlay shape: a real .workstate dir with a child symlink,
|
|
95
|
+
# plus whole-dir surface symlinks (as adoption produces).
|
|
96
|
+
(repo / ".workstate").mkdir()
|
|
97
|
+
(repo / ".workstate" / "remote").symlink_to(external)
|
|
98
|
+
(repo / "Makefile.d").symlink_to(external)
|
|
99
|
+
(repo / "scripts").mkdir()
|
|
100
|
+
(repo / "scripts" / "hooks").symlink_to(external)
|
|
101
|
+
(repo / ".github").mkdir()
|
|
102
|
+
(repo / ".github" / "hooks").symlink_to(external)
|
|
103
|
+
|
|
104
|
+
_ensure_consumer_gitignore_block(repo)
|
|
105
|
+
|
|
106
|
+
status_lines = [
|
|
107
|
+
line
|
|
108
|
+
for line in _git("status", "--porcelain", cwd=repo).splitlines()
|
|
109
|
+
if line.strip()
|
|
110
|
+
]
|
|
111
|
+
# Only the .gitignore itself may be untracked; no overlay path leaks.
|
|
112
|
+
assert all(".gitignore" in line for line in status_lines), status_lines
|
|
113
|
+
for leaked in (".workstate", "Makefile.d", "scripts/hooks", ".github/hooks"):
|
|
114
|
+
assert all(leaked not in line for line in status_lines), (leaked, status_lines)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# Self-hosting source repo guard: the workstate monorepo IS the overlay source,
|
|
119
|
+
# so its surfaces are tracked (or already ignored by a hand-authored .gitignore).
|
|
120
|
+
# Appending the managed block there dirties every feature worktree's TRACKED
|
|
121
|
+
# .gitignore — and would ignore the repo's own tracked source. The block must be
|
|
122
|
+
# skipped when the consumer already manages every managed surface itself.
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# Managed entries that name overlay *surfaces* (not the runtime .workstate /
|
|
127
|
+
# .task-state dirs). These are the paths a self-hosting repo tracks as real
|
|
128
|
+
# source and that the root-anchored block would dangerously ignore.
|
|
129
|
+
_TRACKED_SURFACE_DIRS = (
|
|
130
|
+
"scripts/hooks",
|
|
131
|
+
"Makefile.d",
|
|
132
|
+
"scripts/workstate",
|
|
133
|
+
"docs/workstate/contracts",
|
|
134
|
+
"docs/workstate/rules",
|
|
135
|
+
".github/hooks",
|
|
136
|
+
".github/prompts",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_block_skipped_when_surfaces_are_tracked_source(tmp_path: Path) -> None:
|
|
141
|
+
"""Footgun guard: when the overlay surfaces are TRACKED source dirs (the
|
|
142
|
+
self-hosting monorepo), the managed block must NOT be written — its
|
|
143
|
+
root-anchored ``/scripts/hooks`` … patterns would start ignoring the repo's
|
|
144
|
+
own version-controlled source."""
|
|
145
|
+
repo = tmp_path / "monorepo"
|
|
146
|
+
_init_repo(repo)
|
|
147
|
+
# Track real source at every managed surface path.
|
|
148
|
+
for rel in _TRACKED_SURFACE_DIRS:
|
|
149
|
+
d = repo / rel
|
|
150
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
(d / "source.py").write_text("# real tracked overlay source\n")
|
|
152
|
+
# The repo still ignores its runtime dirs itself (as the live monorepo does).
|
|
153
|
+
(repo / ".gitignore").write_text(".workstate/\n.task-state/\n")
|
|
154
|
+
_git("add", "-A", cwd=repo)
|
|
155
|
+
_git("commit", "-m", "tracked overlay source", cwd=repo)
|
|
156
|
+
|
|
157
|
+
result = _ensure_consumer_gitignore_block(repo)
|
|
158
|
+
|
|
159
|
+
assert result == {"path": ".gitignore", "action": "skipped_self_managed"}
|
|
160
|
+
assert GITIGNORE_SENTINEL_BEGIN not in (repo / ".gitignore").read_text()
|
|
161
|
+
# The footgun must not occur: tracked source must never become ignored.
|
|
162
|
+
for rel in _TRACKED_SURFACE_DIRS:
|
|
163
|
+
assert not _is_ignored(repo, rel), f"tracked source {rel} must not be ignored"
|
|
164
|
+
# Sanity: every tracked surface still appears in git's tracked set.
|
|
165
|
+
tracked = _git("ls-files", cwd=repo).splitlines()
|
|
166
|
+
assert any(line.startswith("scripts/hooks/") for line in tracked)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_block_skipped_when_surfaces_already_ignored(tmp_path: Path) -> None:
|
|
170
|
+
"""The self-hosting monorepo ships a hand-authored ``.gitignore`` that already
|
|
171
|
+
ignores the overlay surfaces (some with directory-only ``dir/`` patterns). The
|
|
172
|
+
managed block must not be appended as a redundant duplicate that dirties every
|
|
173
|
+
feature worktree's tracked ``.gitignore``."""
|
|
174
|
+
repo = tmp_path / "monorepo"
|
|
175
|
+
_init_repo(repo)
|
|
176
|
+
# Mirror the live monorepo: a mix of root-anchored and directory-only
|
|
177
|
+
# (trailing-slash) patterns covering every managed entry.
|
|
178
|
+
hand_authored = (
|
|
179
|
+
".workstate/\n"
|
|
180
|
+
".task-state/\n"
|
|
181
|
+
"/scripts/hooks\n"
|
|
182
|
+
"/.github/hooks\n"
|
|
183
|
+
"/docs/workstate/contracts\n"
|
|
184
|
+
"/docs/workstate/rules\n"
|
|
185
|
+
"/Makefile.d\n"
|
|
186
|
+
"/scripts/workstate\n"
|
|
187
|
+
"/.github/prompts/\n"
|
|
188
|
+
)
|
|
189
|
+
(repo / ".gitignore").write_text(hand_authored)
|
|
190
|
+
_git("add", ".gitignore", cwd=repo)
|
|
191
|
+
_git("commit", "-m", "hand-authored overlay ignores", cwd=repo)
|
|
192
|
+
before = (repo / ".gitignore").read_text()
|
|
193
|
+
|
|
194
|
+
result = _ensure_consumer_gitignore_block(repo)
|
|
195
|
+
|
|
196
|
+
assert result == {"path": ".gitignore", "action": "skipped_self_managed"}
|
|
197
|
+
assert (repo / ".gitignore").read_text() == before # untouched, no duplicate
|
|
198
|
+
assert GITIGNORE_SENTINEL_BEGIN not in before
|
|
199
|
+
# Idempotent: a second pass is still a clean skip, never an append.
|
|
200
|
+
assert _ensure_consumer_gitignore_block(repo)["action"] == "skipped_self_managed"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_block_written_when_any_surface_leaks(tmp_path: Path) -> None:
|
|
204
|
+
"""A real external consumer (surfaces freshly materialized, untracked, not yet
|
|
205
|
+
ignored) still gets the block — the self-managed guard only fires when every
|
|
206
|
+
managed surface is already tracked or ignored."""
|
|
207
|
+
repo = tmp_path / "consumer"
|
|
208
|
+
_init_repo(repo)
|
|
209
|
+
# Ignore the runtime dirs but leave the surfaces leaking (as before install).
|
|
210
|
+
(repo / ".gitignore").write_text(".workstate/\n.task-state/\n")
|
|
211
|
+
_git("add", ".gitignore", cwd=repo)
|
|
212
|
+
_git("commit", "-m", "runtime ignores only", cwd=repo)
|
|
213
|
+
|
|
214
|
+
result = _ensure_consumer_gitignore_block(repo)
|
|
215
|
+
|
|
216
|
+
assert result["action"] == "appended"
|
|
217
|
+
assert GITIGNORE_SENTINEL_BEGIN in (repo / ".gitignore").read_text()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_mixed_self_host_never_ignores_tracked_surface(tmp_path: Path) -> None:
|
|
221
|
+
"""MIXED self-host footgun gate: one surface is TRACKED source, another is a
|
|
222
|
+
still-leaking materialized symlink. The block must be written for the leaking
|
|
223
|
+
surface but must NEVER ignore the tracked surface — emitting a root-anchored
|
|
224
|
+
``/scripts/hooks`` ignore over tracked source silently makes it un-trackable.
|
|
225
|
+
Per-entry filtering (not all-or-nothing) is what closes this."""
|
|
226
|
+
repo = tmp_path / "monorepo"
|
|
227
|
+
_init_repo(repo)
|
|
228
|
+
# Tracked source at one surface; runtime dirs ignored by hand-authored config.
|
|
229
|
+
(repo / "scripts" / "hooks").mkdir(parents=True)
|
|
230
|
+
(repo / "scripts" / "hooks" / "src.py").write_text("# tracked overlay source\n")
|
|
231
|
+
(repo / ".gitignore").write_text(".workstate/\n.task-state/\n")
|
|
232
|
+
_git("add", "-A", cwd=repo)
|
|
233
|
+
_git("commit", "-m", "tracked scripts/hooks + runtime ignores", cwd=repo)
|
|
234
|
+
# A genuinely-leaking surface: a materialized symlink, untracked + unignored.
|
|
235
|
+
external = tmp_path / "external"
|
|
236
|
+
external.mkdir()
|
|
237
|
+
(repo / "Makefile.d").symlink_to(external)
|
|
238
|
+
|
|
239
|
+
result = _ensure_consumer_gitignore_block(repo)
|
|
240
|
+
|
|
241
|
+
# A block IS written (the leaking surface needs ignoring)...
|
|
242
|
+
assert result["action"] == "appended"
|
|
243
|
+
text = (repo / ".gitignore").read_text()
|
|
244
|
+
assert GITIGNORE_SENTINEL_BEGIN in text
|
|
245
|
+
# ...but the tracked surface must NOT appear in it / must stay trackable.
|
|
246
|
+
assert "/scripts/hooks" not in text
|
|
247
|
+
assert not _is_ignored(repo, "scripts/hooks")
|
|
248
|
+
# ...while the leaking surface is now ignored.
|
|
249
|
+
assert "/Makefile.d" in text
|
|
250
|
+
assert _is_ignored(repo, "Makefile.d")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_entries_helper_covers_surface_dirs() -> None:
|
|
254
|
+
"""Pin: the surfaces the self-managed guard inspects are exactly the managed
|
|
255
|
+
entries, so a future surface addition is not silently excluded from the guard.
|
|
256
|
+
"""
|
|
257
|
+
entries = {e.lstrip("/") for e in _consumer_gitignore_entries()}
|
|
258
|
+
for rel in _TRACKED_SURFACE_DIRS:
|
|
259
|
+
assert rel in entries, rel
|
|
@@ -517,6 +517,23 @@ def fake_remote_with_generator(tmp_path: Path) -> tuple[str, str]:
|
|
|
517
517
|
if system_root is None:
|
|
518
518
|
pytest.skip("packages/workstate-system not available in this environment")
|
|
519
519
|
|
|
520
|
+
# implementation note S3: shipped overlay surfaces now live under the package's
|
|
521
|
+
# ``workstate_system/payload/`` tree. Reads of the REAL package source
|
|
522
|
+
# resolve payload-first, then fall back to the legacy top-level path for
|
|
523
|
+
# surfaces that stayed (e.g. ``Makefile.d/evals.mk``, ``scripts/workstate/
|
|
524
|
+
# evals``). The fake remote still mirrors the OLD consumer layout so the
|
|
525
|
+
# bootstrap resolver's legacy fallback probe keeps resolving.
|
|
526
|
+
payload_root = system_root / "workstate_system" / "payload"
|
|
527
|
+
|
|
528
|
+
def _real_surface_dirs(rel: str) -> list[Path]:
|
|
529
|
+
"""Real source dirs for ``rel``, payload-first then legacy top-level.
|
|
530
|
+
Partial-move surfaces (Makefile.d, scripts/workstate) exist in both."""
|
|
531
|
+
return [d for d in (payload_root / rel, system_root / rel) if d.is_dir()]
|
|
532
|
+
|
|
533
|
+
def _merge_copytree(sources: list[Path], target_dir: Path) -> None:
|
|
534
|
+
for source_dir in sources:
|
|
535
|
+
shutil.copytree(source_dir, target_dir, dirs_exist_ok=True)
|
|
536
|
+
|
|
520
537
|
src = tmp_path / "gen-src"
|
|
521
538
|
src.mkdir()
|
|
522
539
|
_git("init", "--initial-branch=main", cwd=src)
|
|
@@ -524,13 +541,13 @@ def fake_remote_with_generator(tmp_path: Path) -> tuple[str, str]:
|
|
|
524
541
|
_git("config", "user.name", "Test", cwd=src)
|
|
525
542
|
system_subdir = src / "packages" / "workstate-system"
|
|
526
543
|
for surface in SHARED_SURFACES_EXPECTED:
|
|
527
|
-
|
|
544
|
+
source_dirs = _real_surface_dirs(surface)
|
|
528
545
|
target_dir = system_subdir / surface
|
|
529
|
-
if
|
|
546
|
+
if source_dirs and any(any(d.iterdir()) for d in source_dirs):
|
|
530
547
|
# Mirror the real surface so rehearsal tests see actual hoisted
|
|
531
548
|
# content (e.g. implementation note implementation note needs Makefile.d/plans.mk's
|
|
532
549
|
# launcher token to land verbatim).
|
|
533
|
-
|
|
550
|
+
_merge_copytree(source_dirs, target_dir)
|
|
534
551
|
else:
|
|
535
552
|
target_dir.mkdir(parents=True)
|
|
536
553
|
(target_dir / "MARKER.md").write_text(f"shared {surface}\n")
|
|
@@ -556,17 +573,19 @@ def fake_remote_with_generator(tmp_path: Path) -> tuple[str, str]:
|
|
|
556
573
|
continue
|
|
557
574
|
helper_path.write_text("#!/usr/bin/env python3\nimport sys; sys.exit(0)\n")
|
|
558
575
|
helper_path.chmod(0o755)
|
|
559
|
-
# Generator + manifest + neutral skill source
|
|
576
|
+
# Generator + manifest + neutral skill source. These surfaces MOVED to
|
|
577
|
+
# the payload tree (implementation note S3); read them from payload_root but mirror
|
|
578
|
+
# them into the fake remote at the OLD consumer layout.
|
|
560
579
|
(system_subdir / "scripts").mkdir(parents=True, exist_ok=True)
|
|
561
580
|
shutil.copy2(
|
|
562
|
-
|
|
581
|
+
payload_root / "scripts" / "generate_agent_workflows.py",
|
|
563
582
|
system_subdir / "scripts" / "generate_agent_workflows.py",
|
|
564
583
|
)
|
|
565
584
|
shutil.copytree(
|
|
566
|
-
|
|
585
|
+
payload_root / "config" / "agent-workflows",
|
|
567
586
|
system_subdir / "config" / "agent-workflows",
|
|
568
587
|
)
|
|
569
|
-
shutil.copytree(
|
|
588
|
+
shutil.copytree(payload_root / "skills", system_subdir / "skills")
|
|
570
589
|
|
|
571
590
|
_git("add", "-A", cwd=src)
|
|
572
591
|
_git("commit", "-m", "seed surfaces + generator", cwd=src)
|
|
@@ -680,7 +699,13 @@ def test_install_discovers_plugin_override_root_and_composes_effective_tree(
|
|
|
680
699
|
"---\nname: branch-review\ndescription: local override\n---\n\nBootstrap-composed override body.\n"
|
|
681
700
|
)
|
|
682
701
|
|
|
683
|
-
|
|
702
|
+
# implementation note S3: skills/ moved into the package payload tree.
|
|
703
|
+
system_root = (
|
|
704
|
+
Path(__file__).resolve().parents[2]
|
|
705
|
+
/ "workstate-system"
|
|
706
|
+
/ "workstate_system"
|
|
707
|
+
/ "payload"
|
|
708
|
+
)
|
|
684
709
|
structured = yaml.safe_load((system_root / "skills" / "branch-review" / "skill.yaml").read_text())
|
|
685
710
|
structured.pop("generator", None)
|
|
686
711
|
body = (system_root / "skills" / "branch-review" / "body.md").read_text()
|
|
@@ -767,7 +792,13 @@ def test_install_writes_tracked_override_lock_for_plugin_overrides(
|
|
|
767
792
|
"---\nname: branch-review\ndescription: local override\n---\n\nBootstrap-composed override body.\n"
|
|
768
793
|
)
|
|
769
794
|
|
|
770
|
-
|
|
795
|
+
# implementation note S3: skills/ moved into the package payload tree.
|
|
796
|
+
system_root = (
|
|
797
|
+
Path(__file__).resolve().parents[2]
|
|
798
|
+
/ "workstate-system"
|
|
799
|
+
/ "workstate_system"
|
|
800
|
+
/ "payload"
|
|
801
|
+
)
|
|
771
802
|
structured = yaml.safe_load((system_root / "skills" / "branch-review" / "skill.yaml").read_text())
|
|
772
803
|
structured.pop("generator", None)
|
|
773
804
|
body = (system_root / "skills" / "branch-review" / "body.md").read_text()
|
|
@@ -826,7 +857,13 @@ def test_install_accepts_explicit_plugin_override_root(
|
|
|
826
857
|
"---\nname: branch-review\ndescription: local override\n---\n\nBootstrap-composed override body.\n"
|
|
827
858
|
)
|
|
828
859
|
|
|
829
|
-
|
|
860
|
+
# implementation note S3: skills/ moved into the package payload tree.
|
|
861
|
+
system_root = (
|
|
862
|
+
Path(__file__).resolve().parents[2]
|
|
863
|
+
/ "workstate-system"
|
|
864
|
+
/ "workstate_system"
|
|
865
|
+
/ "payload"
|
|
866
|
+
)
|
|
830
867
|
structured = yaml.safe_load((system_root / "skills" / "branch-review" / "skill.yaml").read_text())
|
|
831
868
|
structured.pop("generator", None)
|
|
832
869
|
body = (system_root / "skills" / "branch-review" / "body.md").read_text()
|
|
@@ -894,7 +931,13 @@ def test_install_console_script_accepts_plugin_overrides_flag(
|
|
|
894
931
|
"---\nname: branch-review\ndescription: local override\n---\n\nBootstrap-composed override body.\n"
|
|
895
932
|
)
|
|
896
933
|
|
|
897
|
-
|
|
934
|
+
# implementation note S3: skills/ moved into the package payload tree.
|
|
935
|
+
system_root = (
|
|
936
|
+
Path(__file__).resolve().parents[2]
|
|
937
|
+
/ "workstate-system"
|
|
938
|
+
/ "workstate_system"
|
|
939
|
+
/ "payload"
|
|
940
|
+
)
|
|
898
941
|
structured = yaml.safe_load((system_root / "skills" / "branch-review" / "skill.yaml").read_text())
|
|
899
942
|
structured.pop("generator", None)
|
|
900
943
|
body = (system_root / "skills" / "branch-review" / "body.md").read_text()
|
|
@@ -969,7 +1012,13 @@ def test_install_points_plugin_pins_at_effective_tree_when_overrides_exist(
|
|
|
969
1012
|
"---\nname: branch-review\ndescription: local override\n---\n\nBootstrap-composed override body.\n"
|
|
970
1013
|
)
|
|
971
1014
|
|
|
972
|
-
|
|
1015
|
+
# implementation note S3: skills/ moved into the package payload tree.
|
|
1016
|
+
system_root = (
|
|
1017
|
+
Path(__file__).resolve().parents[2]
|
|
1018
|
+
/ "workstate-system"
|
|
1019
|
+
/ "workstate_system"
|
|
1020
|
+
/ "payload"
|
|
1021
|
+
)
|
|
973
1022
|
structured = yaml.safe_load((system_root / "skills" / "branch-review" / "skill.yaml").read_text())
|
|
974
1023
|
structured.pop("generator", None)
|
|
975
1024
|
body = (system_root / "skills" / "branch-review" / "body.md").read_text()
|
|
@@ -1042,7 +1091,13 @@ def test_install_preserves_explicit_plugin_disable_when_overrides_exist(
|
|
|
1042
1091
|
"---\nname: branch-review\ndescription: local override\n---\n\nBootstrap-composed override body.\n"
|
|
1043
1092
|
)
|
|
1044
1093
|
|
|
1045
|
-
|
|
1094
|
+
# implementation note S3: skills/ moved into the package payload tree.
|
|
1095
|
+
system_root = (
|
|
1096
|
+
Path(__file__).resolve().parents[2]
|
|
1097
|
+
/ "workstate-system"
|
|
1098
|
+
/ "workstate_system"
|
|
1099
|
+
/ "payload"
|
|
1100
|
+
)
|
|
1046
1101
|
structured = yaml.safe_load((system_root / "skills" / "branch-review" / "skill.yaml").read_text())
|
|
1047
1102
|
structured.pop("generator", None)
|
|
1048
1103
|
body = (system_root / "skills" / "branch-review" / "body.md").read_text()
|
|
@@ -1116,7 +1171,13 @@ def test_install_reverts_effective_tree_pins_to_base_tree_when_overrides_disappe
|
|
|
1116
1171
|
"---\nname: branch-review\ndescription: local override\n---\n\nBootstrap-composed override body.\n"
|
|
1117
1172
|
)
|
|
1118
1173
|
|
|
1119
|
-
|
|
1174
|
+
# implementation note S3: skills/ moved into the package payload tree.
|
|
1175
|
+
system_root = (
|
|
1176
|
+
Path(__file__).resolve().parents[2]
|
|
1177
|
+
/ "workstate-system"
|
|
1178
|
+
/ "workstate_system"
|
|
1179
|
+
/ "payload"
|
|
1180
|
+
)
|
|
1120
1181
|
structured = yaml.safe_load((system_root / "skills" / "branch-review" / "skill.yaml").read_text())
|
|
1121
1182
|
structured.pop("generator", None)
|
|
1122
1183
|
body = (system_root / "skills" / "branch-review" / "body.md").read_text()
|
|
@@ -1181,7 +1242,13 @@ def test_install_reset_overrides_removes_clean_override_root(
|
|
|
1181
1242
|
"---\nname: branch-review\ndescription: local override\n---\n\nBootstrap-composed override body.\n"
|
|
1182
1243
|
)
|
|
1183
1244
|
|
|
1184
|
-
|
|
1245
|
+
# implementation note S3: skills/ moved into the package payload tree.
|
|
1246
|
+
system_root = (
|
|
1247
|
+
Path(__file__).resolve().parents[2]
|
|
1248
|
+
/ "workstate-system"
|
|
1249
|
+
/ "workstate_system"
|
|
1250
|
+
/ "payload"
|
|
1251
|
+
)
|
|
1185
1252
|
structured = yaml.safe_load((system_root / "skills" / "branch-review" / "skill.yaml").read_text())
|
|
1186
1253
|
structured.pop("generator", None)
|
|
1187
1254
|
body = (system_root / "skills" / "branch-review" / "body.md").read_text()
|
|
@@ -2148,8 +2215,8 @@ def test_install_with_default_sentinel_uses_built_in_server_map(
|
|
|
2148
2215
|
assert entry["type"] == "stdio"
|
|
2149
2216
|
assert "--workspace-root" in entry["args"]
|
|
2150
2217
|
assert entry["args"][-1] == "serve-stdio"
|
|
2151
|
-
assert mcp_doc["mcpServers"]["workstate-handoff-mcp"]["args"][0] == "mcp-workstate-handoff@0.12.
|
|
2152
|
-
assert mcp_doc["mcpServers"]["workstate-orchestrator-mcp"]["args"][0] == "mcp-workstate-orchestrator@0.5.
|
|
2218
|
+
assert mcp_doc["mcpServers"]["workstate-handoff-mcp"]["args"][0] == "mcp-workstate-handoff@0.12.1"
|
|
2219
|
+
assert mcp_doc["mcpServers"]["workstate-orchestrator-mcp"]["args"][0] == "mcp-workstate-orchestrator@0.5.2"
|
|
2153
2220
|
for entry in vscode_doc["servers"].values():
|
|
2154
2221
|
assert entry["type"] == "stdio"
|
|
2155
2222
|
assert "--workspace-root" in entry["args"]
|
|
@@ -2355,7 +2422,7 @@ def test_run_init_state_retries_with_cloned_handoff_project_when_uvx_fails(
|
|
|
2355
2422
|
)
|
|
2356
2423
|
|
|
2357
2424
|
assert [cmd[0] for cmd, _ in calls] == ["uvx", "uv"]
|
|
2358
|
-
assert calls[0][0][1] == "mcp-workstate-handoff@0.12.
|
|
2425
|
+
assert calls[0][0][1] == "mcp-workstate-handoff@0.12.1"
|
|
2359
2426
|
assert calls[1][0][:5] == [
|
|
2360
2427
|
"uv",
|
|
2361
2428
|
"run",
|
|
@@ -2659,7 +2726,7 @@ def test_run_init_state_surfaces_uvx_error_when_no_cloned_handoff_project_exists
|
|
|
2659
2726
|
|
|
2660
2727
|
assert calls == [[
|
|
2661
2728
|
"uvx",
|
|
2662
|
-
"mcp-workstate-handoff@0.12.
|
|
2729
|
+
"mcp-workstate-handoff@0.12.1",
|
|
2663
2730
|
"--workspace-root",
|
|
2664
2731
|
".",
|
|
2665
2732
|
"--state-dir",
|
|
@@ -51,7 +51,7 @@ def _build_and_unpack_package(tmp: Path) -> Path:
|
|
|
51
51
|
unpacked = tmp / "site"
|
|
52
52
|
with zipfile.ZipFile(wheel) as zf:
|
|
53
53
|
zf.extractall(unpacked)
|
|
54
|
-
root = unpacked / "workstate_system"
|
|
54
|
+
root = unpacked / "workstate_system" / "payload"
|
|
55
55
|
assert (root / "scripts" / "hooks").is_dir(), (
|
|
56
56
|
"unpacked payload missing scripts/hooks"
|
|
57
57
|
)
|
|
@@ -156,7 +156,7 @@ def test_cli_install_from_package_resolves_installed_payload(
|
|
|
156
156
|
from workstate_bootstrap.cli import main as cli_main
|
|
157
157
|
|
|
158
158
|
package_root = _build_and_unpack_package(tmp_path)
|
|
159
|
-
monkeypatch.syspath_prepend(str(package_root.
|
|
159
|
+
monkeypatch.syspath_prepend(str(package_root.parents[1]))
|
|
160
160
|
|
|
161
161
|
target = tmp_path / "consumer"
|
|
162
162
|
target.mkdir()
|
|
@@ -383,9 +383,12 @@ def test_overlay_manifest_contract_documents_canonical_and_legacy() -> None:
|
|
|
383
383
|
"""WS-HARNESS-FAILSAFE-01 implementation note: the overlay-manifest contract doc must
|
|
384
384
|
name the canonical `.workstate-bootstrap.json` ledger as well as the legacy
|
|
385
385
|
`.workstate-overlay.json` read-compat manifest."""
|
|
386
|
+
# implementation note S3: docs/workstate/contracts moved into the package payload tree.
|
|
386
387
|
contract = (
|
|
387
388
|
Path(__file__).resolve().parents[2]
|
|
388
389
|
/ "workstate-system"
|
|
390
|
+
/ "workstate_system"
|
|
391
|
+
/ "payload"
|
|
389
392
|
/ "docs"
|
|
390
393
|
/ "workstate"
|
|
391
394
|
/ "contracts"
|
|
@@ -436,9 +439,12 @@ def _seed_branch_review_override_at(override_root: Path, system_root: Path) -> N
|
|
|
436
439
|
"---\nname: branch-review\ndescription: local override\n---\n\nBootstrap-composed override body.\n"
|
|
437
440
|
)
|
|
438
441
|
|
|
439
|
-
|
|
442
|
+
# implementation note S3: skills/ moved into the package payload tree. Read the real
|
|
443
|
+
# upstream skill source from payload; the override layout above stays.
|
|
444
|
+
payload_root = system_root / "workstate_system" / "payload"
|
|
445
|
+
structured = yaml.safe_load((payload_root / "skills" / "branch-review" / "skill.yaml").read_text())
|
|
440
446
|
structured.pop("generator", None)
|
|
441
|
-
body = (
|
|
447
|
+
body = (payload_root / "skills" / "branch-review" / "body.md").read_text()
|
|
442
448
|
fm_text = yaml.safe_dump(structured, sort_keys=False, default_flow_style=False).rstrip()
|
|
443
449
|
base_skill = f"---\n{fm_text}\n---\n\n{body if body.endswith(chr(10)) else body + chr(10)}"
|
|
444
450
|
upstream_digest = hashlib.sha256(base_skill.encode("utf-8")).hexdigest()
|
|
@@ -764,6 +770,20 @@ def _build_generator_two_ref_remote(
|
|
|
764
770
|
if system_root is None:
|
|
765
771
|
pytest.skip("packages/workstate-system not available in this environment")
|
|
766
772
|
|
|
773
|
+
# implementation note S3: shipped overlay surfaces now live under the package's
|
|
774
|
+
# ``workstate_system/payload/`` tree. Reads of the REAL package source
|
|
775
|
+
# resolve payload-first, then fall back to the legacy top-level path for
|
|
776
|
+
# surfaces that stayed (e.g. ``Makefile.d/evals.mk``, ``scripts/workstate/
|
|
777
|
+
# evals``). The fake remote still mirrors the OLD consumer layout.
|
|
778
|
+
payload_root = system_root / "workstate_system" / "payload"
|
|
779
|
+
|
|
780
|
+
def _real_surface_dirs(rel: str) -> list[Path]:
|
|
781
|
+
return [d for d in (payload_root / rel, system_root / rel) if d.is_dir()]
|
|
782
|
+
|
|
783
|
+
def _merge_copytree(sources: list[Path], target_dir: Path) -> None:
|
|
784
|
+
for source_dir in sources:
|
|
785
|
+
shutil.copytree(source_dir, target_dir, dirs_exist_ok=True)
|
|
786
|
+
|
|
767
787
|
src = tmp_path / "gen-two-refs-src"
|
|
768
788
|
src.mkdir()
|
|
769
789
|
_git("init", "--initial-branch=main", cwd=src)
|
|
@@ -772,10 +792,10 @@ def _build_generator_two_ref_remote(
|
|
|
772
792
|
|
|
773
793
|
system_subdir = src / "packages" / "workstate-system"
|
|
774
794
|
for surface in SHARED_SURFACES_EXPECTED:
|
|
775
|
-
|
|
795
|
+
source_dirs = _real_surface_dirs(surface)
|
|
776
796
|
target_dir = system_subdir / surface
|
|
777
|
-
if
|
|
778
|
-
|
|
797
|
+
if source_dirs and any(any(d.iterdir()) for d in source_dirs):
|
|
798
|
+
_merge_copytree(source_dirs, target_dir)
|
|
779
799
|
else:
|
|
780
800
|
target_dir.mkdir(parents=True)
|
|
781
801
|
(target_dir / "MARKER.md").write_text(f"shared {surface}\n")
|
|
@@ -797,14 +817,14 @@ def _build_generator_two_ref_remote(
|
|
|
797
817
|
|
|
798
818
|
(system_subdir / "scripts").mkdir(parents=True, exist_ok=True)
|
|
799
819
|
shutil.copy2(
|
|
800
|
-
|
|
820
|
+
payload_root / "scripts" / "generate_agent_workflows.py",
|
|
801
821
|
system_subdir / "scripts" / "generate_agent_workflows.py",
|
|
802
822
|
)
|
|
803
823
|
shutil.copytree(
|
|
804
|
-
|
|
824
|
+
payload_root / "config" / "agent-workflows",
|
|
805
825
|
system_subdir / "config" / "agent-workflows",
|
|
806
826
|
)
|
|
807
|
-
shutil.copytree(
|
|
827
|
+
shutil.copytree(payload_root / "skills", system_subdir / "skills")
|
|
808
828
|
|
|
809
829
|
_git("add", "-A", cwd=src)
|
|
810
830
|
_git("commit", "-m", "v1", cwd=src)
|
|
@@ -1191,9 +1211,10 @@ def test_doctor_reports_hidden_override_collision_for_undeclared_skill(
|
|
|
1191
1211
|
_init_git_repo(target)
|
|
1192
1212
|
_seed_branch_review_override(target, system_root)
|
|
1193
1213
|
|
|
1214
|
+
# implementation note S3: skills/ moved into the package payload tree.
|
|
1194
1215
|
colliding_skill = next(
|
|
1195
1216
|
path.name
|
|
1196
|
-
for path in (system_root / "skills").iterdir()
|
|
1217
|
+
for path in (system_root / "workstate_system" / "payload" / "skills").iterdir()
|
|
1197
1218
|
if path.is_dir() and path.name != "branch-review"
|
|
1198
1219
|
)
|
|
1199
1220
|
undeclared_skill = (
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
"""TDD gate for implementation note Slice S4: managed consumer ``.gitignore`` block.
|
|
2
|
-
|
|
3
|
-
An installed/adopted overlay materializes the runtime dir (``.workstate``) and a
|
|
4
|
-
set of surface SYMLINKS (``Makefile.d``, ``scripts/hooks`` …). Without ignore
|
|
5
|
-
rules these show as untracked, so ``git status`` is never clean. A managed,
|
|
6
|
-
sentinel-delimited block (mirroring the Makefile-include pattern) keeps it clean
|
|
7
|
-
without clobbering a user-authored ``.gitignore``.
|
|
8
|
-
|
|
9
|
-
M1: the patterns must be ROOT-ANCHORED and NON-trailing-slash — a trailing-slash
|
|
10
|
-
pattern matches only directories, but git treats a symlink as a non-directory,
|
|
11
|
-
so ``Makefile.d/`` would miss an adopted ``Makefile.d`` symlink.
|
|
12
|
-
"""
|
|
13
|
-
|
|
14
|
-
from __future__ import annotations
|
|
15
|
-
|
|
16
|
-
import subprocess
|
|
17
|
-
from pathlib import Path
|
|
18
|
-
|
|
19
|
-
from workstate_bootstrap.install import (
|
|
20
|
-
GITIGNORE_SENTINEL_BEGIN,
|
|
21
|
-
_ensure_consumer_gitignore_block,
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def _git(*args: str, cwd: Path) -> str:
|
|
26
|
-
result = subprocess.run(
|
|
27
|
-
["git", *args],
|
|
28
|
-
check=True,
|
|
29
|
-
capture_output=True,
|
|
30
|
-
text=True,
|
|
31
|
-
cwd=str(cwd),
|
|
32
|
-
timeout=30,
|
|
33
|
-
)
|
|
34
|
-
return result.stdout.strip()
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def _init_repo(root: Path) -> None:
|
|
38
|
-
root.mkdir(parents=True, exist_ok=True)
|
|
39
|
-
_git("init", "--initial-branch=main", cwd=root)
|
|
40
|
-
_git("config", "user.email", "t@e.com", cwd=root)
|
|
41
|
-
_git("config", "user.name", "T", cwd=root)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def test_block_created_when_no_gitignore(tmp_path: Path) -> None:
|
|
45
|
-
target = tmp_path / "consumer"
|
|
46
|
-
target.mkdir()
|
|
47
|
-
result = _ensure_consumer_gitignore_block(target)
|
|
48
|
-
assert result == {"path": ".gitignore", "action": "created"}
|
|
49
|
-
text = (target / ".gitignore").read_text()
|
|
50
|
-
assert GITIGNORE_SENTINEL_BEGIN in text
|
|
51
|
-
assert "/.workstate" in text
|
|
52
|
-
assert "/Makefile.d" in text
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def test_block_appended_preserves_user_content(tmp_path: Path) -> None:
|
|
56
|
-
target = tmp_path / "consumer"
|
|
57
|
-
target.mkdir()
|
|
58
|
-
(target / ".gitignore").write_text("# user\n*.log\nbuild/\n")
|
|
59
|
-
result = _ensure_consumer_gitignore_block(target)
|
|
60
|
-
assert result == {"path": ".gitignore", "action": "appended"}
|
|
61
|
-
text = (target / ".gitignore").read_text()
|
|
62
|
-
assert "*.log" in text and "build/" in text # user content preserved
|
|
63
|
-
assert GITIGNORE_SENTINEL_BEGIN in text
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def test_block_is_idempotent(tmp_path: Path) -> None:
|
|
67
|
-
target = tmp_path / "consumer"
|
|
68
|
-
target.mkdir()
|
|
69
|
-
_ensure_consumer_gitignore_block(target)
|
|
70
|
-
result = _ensure_consumer_gitignore_block(target)
|
|
71
|
-
assert result == {"path": ".gitignore", "action": "already_present"}
|
|
72
|
-
# No duplicate block.
|
|
73
|
-
assert (target / ".gitignore").read_text().count(GITIGNORE_SENTINEL_BEGIN) == 1
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def test_block_ignores_symlink_surfaces_git_status_clean(tmp_path: Path) -> None:
|
|
77
|
-
"""M1 regression: the block must ignore adopted SYMLINK surfaces + .workstate
|
|
78
|
-
so `git status` shows nothing but the .gitignore itself."""
|
|
79
|
-
repo = tmp_path / "repo"
|
|
80
|
-
_init_repo(repo)
|
|
81
|
-
external = tmp_path / "external"
|
|
82
|
-
external.mkdir()
|
|
83
|
-
|
|
84
|
-
# Materialize the overlay shape: a real .workstate dir with a child symlink,
|
|
85
|
-
# plus whole-dir surface symlinks (as adoption produces).
|
|
86
|
-
(repo / ".workstate").mkdir()
|
|
87
|
-
(repo / ".workstate" / "remote").symlink_to(external)
|
|
88
|
-
(repo / "Makefile.d").symlink_to(external)
|
|
89
|
-
(repo / "scripts").mkdir()
|
|
90
|
-
(repo / "scripts" / "hooks").symlink_to(external)
|
|
91
|
-
(repo / ".github").mkdir()
|
|
92
|
-
(repo / ".github" / "hooks").symlink_to(external)
|
|
93
|
-
|
|
94
|
-
_ensure_consumer_gitignore_block(repo)
|
|
95
|
-
|
|
96
|
-
status_lines = [
|
|
97
|
-
line
|
|
98
|
-
for line in _git("status", "--porcelain", cwd=repo).splitlines()
|
|
99
|
-
if line.strip()
|
|
100
|
-
]
|
|
101
|
-
# Only the .gitignore itself may be untracked; no overlay path leaks.
|
|
102
|
-
assert all(".gitignore" in line for line in status_lines), status_lines
|
|
103
|
-
for leaked in (".workstate", "Makefile.d", "scripts/hooks", ".github/hooks"):
|
|
104
|
-
assert all(leaked not in line for line in status_lines), (leaked, status_lines)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/src/workstate_bootstrap/subcommands.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_ensure_hooks_path_make.py
RENAMED
|
File without changes
|
{workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_install_manifest_walker.py
RENAMED
|
File without changes
|
{workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_install_profile_all_lifecycle.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{workstate_bootstrap-0.7.2 → workstate_bootstrap-0.7.3}/tests/test_root_visible_task_plans.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|