tcw-cli 2.0.1__tar.gz → 2.0.2__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.
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/PKG-INFO +1 -1
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/pyproject.toml +1 -1
- tcw_cli-2.0.2/tcw/__init__.py +1 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/store/checkouts.py +39 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/store/fs.py +106 -13
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/store/project.py +40 -1
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/validate.py +59 -1
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw_cli.egg-info/PKG-INFO +1 -1
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_external_work_store.py +29 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_project_registry.py +103 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_store_provisioning.py +226 -7
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_validate.py +101 -0
- tcw_cli-2.0.1/tcw/__init__.py +0 -1
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/LICENSE +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/README.md +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/setup.cfg +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/capabilities/__init__.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/capabilities/cli.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/cli.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/refs.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/serve/__init__.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/serve/dist/client/assets/index-CXNiOIeg.css +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/serve/dist/client/assets/index-Dz5B7M8G.js +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/serve/dist/client/index.html +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/serve/dist/client/theme-init.js +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/serve/dist/server.cjs +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/serve/runtime.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/stdin.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/store/__init__.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/store/base.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/taxonomy/__init__.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/taxonomy/cli.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/work/__init__.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/work/cli.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/work/generate.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/work/hooks.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/work/projection.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/work/prompts/implement.md +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/work/prompts/inbox.md +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/work/prompts/plan.md +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/work/prompts/postmortem.md +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/work/prompts/request.md +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/work/prompts/spec.md +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/work/prompts/verify.md +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/work/recursion.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/work/resolve.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw/work/templates.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw_cli.egg-info/SOURCES.txt +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw_cli.egg-info/dependency_links.txt +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw_cli.egg-info/entry_points.txt +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw_cli.egg-info/requires.txt +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tcw_cli.egg-info/top_level.txt +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_body_prompt.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_capabilities.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_capabilities_federation.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_capabilities_reset.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_capabilities_sidecar.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_capability_ref_wording.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_cut_version.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_documentation_config.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_documentation_prompt.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_documentation_sync_wiring.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_documented_cli_surface.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_environment_hardness.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_epic_completable.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_falsification_rule.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_generate_hook.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_inbox_title.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_lifecycle_baseline.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_lifecycle_hooks.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_lifecycle_inert.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_lifecycle_policy.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_lifecycle_validation.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_multiproject.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_non_git_writes.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_plugin_manifests.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_project_overrides.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_projection.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_prompt_fallback.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_qualified_ref.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_recursion.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_refs.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_remote_session_setup.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_repo_lifecycle.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_resolve.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_retention.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_scaffold.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_serve.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_serve_descendants.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_serve_projection.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_serve_resolve.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_serve_runtime.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_serve_write.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_session_bootstrap.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_shipped_prompts.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_show_json.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_skill_flow.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_skill_lifecycle_parity.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_smoke.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_stage_verb.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_status_parity.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_stdin.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_stdin_cli.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_store_bounds.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_store_editor.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_store_nodes.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_store_publication.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_subprocess_stdin.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_taxonomy.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_tombstone.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_unpushed_version_script.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_validate_target.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_work.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_work_autocommit.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_work_review.py +0 -0
- {tcw_cli-2.0.1 → tcw_cli-2.0.2}/tests/test_work_tags.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tcw-cli"
|
|
7
|
-
version = "2.0.
|
|
7
|
+
version = "2.0.2"
|
|
8
8
|
description = "Taxonomy · Capabilities · Work — a storage-abstracted framework for describing and evolving a software project."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.0.2"
|
|
@@ -18,6 +18,45 @@ from pathlib import Path
|
|
|
18
18
|
from tcw.store.base import RepositoryDeclaration, StoreDeclarationError
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
def normalized_url(url: str) -> str:
|
|
22
|
+
"""A canonical form of `url`, for asking whether two declarations name the
|
|
23
|
+
**same repository**.
|
|
24
|
+
|
|
25
|
+
Identity, not display, and the distinction is load-bearing. `_cache_key`
|
|
26
|
+
below does similar-looking text handling, but only for the *readable* half
|
|
27
|
+
of a directory name: the half that decides which directory a store lands in
|
|
28
|
+
is a digest of the raw url. So it deliberately keeps two spellings of one
|
|
29
|
+
repository apart, which is the opposite of what this answers. The two must
|
|
30
|
+
not be folded into one helper — routing that digest through here would
|
|
31
|
+
rename every provisioned directory on every machine at once, silently, and
|
|
32
|
+
orphan every store already in the cache.
|
|
33
|
+
|
|
34
|
+
Handles the spellings one workspace actually mixes, because the two sides of
|
|
35
|
+
a connection are written by different people at different times: a trailing
|
|
36
|
+
slash, a `.git` suffix, and `git@host:owner/repo` against
|
|
37
|
+
`https://host/owner/repo`. A local filesystem path round-trips unchanged,
|
|
38
|
+
which the tests rely on — they use real repositories in a temporary
|
|
39
|
+
directory as remotes.
|
|
40
|
+
|
|
41
|
+
The host is lowercased because hostnames are case-insensitive. The path is
|
|
42
|
+
not, because whether a forge treats `Owner/Repo` and `owner/repo` as one
|
|
43
|
+
thing varies, and merging them here would be this function claiming to know.
|
|
44
|
+
A port is kept as though it were a path segment: two spellings carrying the
|
|
45
|
+
same port still match each other, which is all identity needs, and dropping
|
|
46
|
+
it could merge two genuinely different hosts.
|
|
47
|
+
"""
|
|
48
|
+
cleaned = url.strip().rstrip("/")
|
|
49
|
+
if cleaned.endswith(".git"):
|
|
50
|
+
cleaned = cleaned[:-4]
|
|
51
|
+
cleaned = re.sub(r"^[A-Za-z][A-Za-z0-9+.-]*://", "", cleaned)
|
|
52
|
+
cleaned = cleaned.rpartition("@")[2] # drop any `git@` user part
|
|
53
|
+
host, slash, rest = cleaned.partition("/")
|
|
54
|
+
if ":" in host: # scp-style `host:owner/repo`
|
|
55
|
+
host, _, prefix = host.partition(":")
|
|
56
|
+
rest = f"{prefix}/{rest}" if slash else prefix
|
|
57
|
+
return f"{host.lower()}/{rest}"
|
|
58
|
+
|
|
59
|
+
|
|
21
60
|
def _cache_root() -> Path:
|
|
22
61
|
"""Where working copies land when a declaration names no `checkout`.
|
|
23
62
|
|
|
@@ -1552,7 +1552,8 @@ class FsTreeStore:
|
|
|
1552
1552
|
|
|
1553
1553
|
def _write_staged(self, pairs: list[tuple[Path, str]], *,
|
|
1554
1554
|
owned_dir: Path | None = None,
|
|
1555
|
-
also_stage: tuple[Path, ...] = ()
|
|
1555
|
+
also_stage: tuple[Path, ...] = (),
|
|
1556
|
+
stage_root: Path | None = None) -> None:
|
|
1556
1557
|
"""Write every `(path, content)` and stage the lot, undoing what *this
|
|
1557
1558
|
call* created if either half fails, then re-raising.
|
|
1558
1559
|
|
|
@@ -1575,6 +1576,12 @@ class FsTreeStore:
|
|
|
1575
1576
|
|
|
1576
1577
|
Best-effort and silent: the undo must not mask the original error, and
|
|
1577
1578
|
must not add a second line to a refusal whose one-line shape is pinned.
|
|
1579
|
+
|
|
1580
|
+
`stage_root` names the repository to stage in, for the one write that is
|
|
1581
|
+
not a store file: the node's own `tcw-config.yaml`. The store root and
|
|
1582
|
+
the node root vary independently — in the orchestrator layout they are
|
|
1583
|
+
different repositories — and `git add` refuses a path outside the
|
|
1584
|
+
repository it is given. Everything else defaults to the store's.
|
|
1578
1585
|
"""
|
|
1579
1586
|
# `exists() or is_symlink()`: `exists()` follows the link, so a
|
|
1580
1587
|
# pre-existing *dangling* symlink read as absent, was replaced by a real
|
|
@@ -1583,7 +1590,12 @@ class FsTreeStore:
|
|
|
1583
1590
|
new = [p for p, _ in pairs if not (p.exists() or p.is_symlink())]
|
|
1584
1591
|
try:
|
|
1585
1592
|
_atomic_write_all(pairs)
|
|
1586
|
-
|
|
1593
|
+
paths = (*(p for p, _ in pairs), *also_stage)
|
|
1594
|
+
if stage_root is None:
|
|
1595
|
+
self._stage(*paths)
|
|
1596
|
+
else:
|
|
1597
|
+
self._require_repository()
|
|
1598
|
+
git_stage(stage_root, *paths)
|
|
1587
1599
|
except BaseException:
|
|
1588
1600
|
if owned_dir is not None:
|
|
1589
1601
|
shutil.rmtree(owned_dir, ignore_errors=True)
|
|
@@ -2936,11 +2948,41 @@ def _is_store_layout(root: Path, component: str) -> bool:
|
|
|
2936
2948
|
return all((root / name).is_dir() for name in STORE_LAYOUT)
|
|
2937
2949
|
|
|
2938
2950
|
|
|
2951
|
+
def _registry_checkout_root(
|
|
2952
|
+
node_root: Path, declaration: "RepositoryDeclaration"
|
|
2953
|
+
) -> Path | None:
|
|
2954
|
+
"""Where the declared store sits inside a project this machine already has.
|
|
2955
|
+
|
|
2956
|
+
The join the ladder's rung 1.5 needs: ask the project registry which project
|
|
2957
|
+
is a checkout of the declared repository, then descend to the store's path
|
|
2958
|
+
within it. None when the registry cannot answer, which sends the ladder on
|
|
2959
|
+
to the provisioned copy.
|
|
2960
|
+
|
|
2961
|
+
**Every failure here is "no answer", never an error.** This rung is a
|
|
2962
|
+
fallback reached only after a configured path has already failed, and a
|
|
2963
|
+
graph problem must not turn a working provisioned store into a refusal. So
|
|
2964
|
+
the registry is opened without `require_valid()` — the shape `run_provision`
|
|
2965
|
+
already uses for the same reason — and anything raised is swallowed.
|
|
2966
|
+
"""
|
|
2967
|
+
try:
|
|
2968
|
+
found = FsProjectRegistry.open(node_root).checkout_of(declaration.url)
|
|
2969
|
+
except Exception:
|
|
2970
|
+
return None
|
|
2971
|
+
if found is None:
|
|
2972
|
+
return None
|
|
2973
|
+
return (found / declaration.path) if declaration.path else found
|
|
2974
|
+
|
|
2975
|
+
|
|
2939
2976
|
def resolve_store(store_cls, node_root: Path, _walk=None):
|
|
2940
2977
|
"""This node's store for `store_cls`'s component, in one ordered ladder.
|
|
2941
2978
|
|
|
2942
2979
|
1. the local store (`<component>.path`, else `docs/<component>`) when it is
|
|
2943
2980
|
usable;
|
|
2981
|
+
1.5. else inside a project the registry has already located here, when one
|
|
2982
|
+
is a checkout of the declared repository — local before remote, the same
|
|
2983
|
+
rule `ConnectedProject` states for a project's own location. Numbered as
|
|
2984
|
+
a half because it was added after the others and renumbering them would
|
|
2985
|
+
silently rewrite what every message and comment elsewhere calls "rule 2";
|
|
2944
2986
|
2. else the declared home repository's provisioned location, if usable;
|
|
2945
2987
|
3. else `StoreNotProvisioned`, when a home repository is declared;
|
|
2946
2988
|
4. else exactly what the component did before a declaration existed — the
|
|
@@ -3018,17 +3060,46 @@ def resolve_store(store_cls, node_root: Path, _walk=None):
|
|
|
3018
3060
|
raw_root, node_root, config_path,
|
|
3019
3061
|
external=configured is not None, must_exist=must_exist,
|
|
3020
3062
|
_walk=_walk)
|
|
3021
|
-
except StoreLocationUnusable:
|
|
3063
|
+
except StoreLocationUnusable as unusable:
|
|
3022
3064
|
# Only this. A store that is *there* and fails to open — a federation
|
|
3023
3065
|
# error, a malformed `extends` — is a real error, and swallowing it here
|
|
3024
3066
|
# reported "not provisioned; run `tcw provision`", which then succeeded
|
|
3025
3067
|
# and left the store exactly as unopenable. `_extended_component_stores`
|
|
3026
3068
|
# raises for many more reasons than it used to, so the hole widened.
|
|
3027
|
-
|
|
3069
|
+
#
|
|
3070
|
+
# The reason is carried rather than discarded, but **only when the
|
|
3071
|
+
# configured path is actually there**. A path that does not exist,
|
|
3072
|
+
# beside a declaration, is the ordinary case the declaration exists for
|
|
3073
|
+
# — reporting it would make every provisioned node noisy. A path that
|
|
3074
|
+
# exists and holds no store is a second configuration problem, and it
|
|
3075
|
+
# used to vanish behind rule 3's message entirely.
|
|
3076
|
+
#
|
|
3077
|
+
# Presence is tested here rather than read out of the message: `_open_at`
|
|
3078
|
+
# raises the same "is not a directory" for an absent path and for one
|
|
3079
|
+
# that is a file, so the message cannot tell them apart.
|
|
3080
|
+
local_failure = str(unusable) if raw_root.exists() else None
|
|
3081
|
+
prefix = f"{config_path}: "
|
|
3082
|
+
if local_failure and local_failure.startswith(prefix):
|
|
3083
|
+
local_failure = local_failure[len(prefix):]
|
|
3084
|
+
local = _registry_checkout_root(node_root, declaration)
|
|
3085
|
+
if local is not None:
|
|
3086
|
+
try: # rule 1.5
|
|
3087
|
+
# No `declaration=`, and that omission is the whole of "must not
|
|
3088
|
+
# publish". This store is on the user's own disk and they push it
|
|
3089
|
+
# themselves — the same reasoning rule 1 relies on. `publishes`
|
|
3090
|
+
# consults nothing else.
|
|
3091
|
+
return store_cls._open_at(
|
|
3092
|
+
local, node_root, config_path,
|
|
3093
|
+
external=True, must_exist=True, _walk=_walk)
|
|
3094
|
+
except StoreLocationUnusable:
|
|
3095
|
+
# The project is here but holds no store at the declared path.
|
|
3096
|
+
# A location that did not work out, so the ladder carries on —
|
|
3097
|
+
# the same rule rules 1 and 2 follow.
|
|
3098
|
+
pass
|
|
3028
3099
|
try: # rule 2
|
|
3029
|
-
# The declaration travels with the store *only* here.
|
|
3030
|
-
# resolved without it, so a store built there carries None and
|
|
3031
|
-
# publish — see `FsWorkStore.publishes`.
|
|
3100
|
+
# The declaration travels with the store *only* here. Rules 1 and 1.5
|
|
3101
|
+
# above resolved without it, so a store built there carries None and
|
|
3102
|
+
# does not publish — see `FsWorkStore.publishes`.
|
|
3032
3103
|
return store_cls._open_at(
|
|
3033
3104
|
provisioned_store_root(node_root, declaration), node_root, config_path,
|
|
3034
3105
|
external=True, must_exist=True, declaration=declaration,
|
|
@@ -3038,7 +3109,8 @@ def resolve_store(store_cls, node_root: Path, _walk=None):
|
|
|
3038
3109
|
raise StoreNotProvisioned( # rule 3
|
|
3039
3110
|
f"{config_path}: the {component} store is declared in "
|
|
3040
3111
|
f"{declaration.url} but has not been provisioned here; "
|
|
3041
|
-
f"run `tcw provision` to obtain it"
|
|
3112
|
+
f"run `tcw provision` to obtain it"
|
|
3113
|
+
+ (f" — and the configured {local_failure}" if local_failure else ""))
|
|
3042
3114
|
|
|
3043
3115
|
|
|
3044
3116
|
def declared_repository(
|
|
@@ -3383,8 +3455,13 @@ class FsWorkStore(FsTreeStore, WorkStore):
|
|
|
3383
3455
|
root = raw_root.resolve()
|
|
3384
3456
|
missing = [name for name in ("inbox", *WORK_STATUSES) if not (root / name).is_dir()]
|
|
3385
3457
|
if missing:
|
|
3458
|
+
# Names the directory, as the two branches above already do. Without
|
|
3459
|
+
# it the user is told `work.path` is not a work store and never told
|
|
3460
|
+
# which path that is — which matters most exactly when the value is
|
|
3461
|
+
# relative and resolved somewhere they did not expect.
|
|
3386
3462
|
raise StoreLocationUnusable(
|
|
3387
|
-
f"{config_path}: work.path is not a work store
|
|
3463
|
+
f"{config_path}: work.path is not a work store: {root}; "
|
|
3464
|
+
f"missing: {', '.join(missing)}")
|
|
3388
3465
|
repository = git_root(root) if external else node_root
|
|
3389
3466
|
if repository is None and external:
|
|
3390
3467
|
# `StoreDeclarationError`, and both halves of that choice matter.
|
|
@@ -5158,7 +5235,18 @@ class FsWorkStore(FsTreeStore, WorkStore):
|
|
|
5158
5235
|
def _write_tags(self, tags: set[str]) -> list[str]:
|
|
5159
5236
|
"""Read-modify-write `work.tags` (preserving other config keys), stage
|
|
5160
5237
|
the file. `dump_yaml` rewrites the sentinel wholesale, dropping its stub
|
|
5161
|
-
comments — accepted per plan.
|
|
5238
|
+
comments — accepted per plan.
|
|
5239
|
+
|
|
5240
|
+
**Staged in the node's repository, not the store's.** This is the only
|
|
5241
|
+
write here that touches a file outside the store, and with an external
|
|
5242
|
+
`work.path` the two are different repositories — the intended
|
|
5243
|
+
orchestrator layout, not an edge case. Staging it against the store's
|
|
5244
|
+
repository made `tcw work tags add` fail outright with *is outside
|
|
5245
|
+
repository at …*, so the whole verb was unusable there.
|
|
5246
|
+
|
|
5247
|
+
A node outside git stages nothing rather than failing: the file is
|
|
5248
|
+
written and that is the whole of what a non-git node can do.
|
|
5249
|
+
"""
|
|
5162
5250
|
self._require_repository()
|
|
5163
5251
|
config = self._config()
|
|
5164
5252
|
work = config.get("work")
|
|
@@ -5167,9 +5255,14 @@ class FsWorkStore(FsTreeStore, WorkStore):
|
|
|
5167
5255
|
result = sorted(tags)
|
|
5168
5256
|
work["tags"] = result
|
|
5169
5257
|
config["work"] = work
|
|
5170
|
-
self.
|
|
5171
|
-
|
|
5172
|
-
|
|
5258
|
+
config_path = self._config_path()
|
|
5259
|
+
node_repository = git_root(config_path.parent)
|
|
5260
|
+
payload = [(config_path, yaml.safe_dump(config, sort_keys=False,
|
|
5261
|
+
allow_unicode=True))]
|
|
5262
|
+
if node_repository is None:
|
|
5263
|
+
_atomic_write_all(payload)
|
|
5264
|
+
else:
|
|
5265
|
+
self._write_staged(payload, stage_root=node_repository)
|
|
5173
5266
|
return result
|
|
5174
5267
|
|
|
5175
5268
|
def register_tags(self, tags: list[str]) -> list[str]:
|
|
@@ -16,7 +16,7 @@ from tcw.store.base import (
|
|
|
16
16
|
RepositoryDeclaration, StoreDeclarationError, UnreachableProject,
|
|
17
17
|
WORK_STATUSES, parse_connected_entry,
|
|
18
18
|
)
|
|
19
|
-
from tcw.store.checkouts import provisioned_root
|
|
19
|
+
from tcw.store.checkouts import normalized_url, provisioned_root
|
|
20
20
|
|
|
21
21
|
SENTINEL = "tcw-config.yaml"
|
|
22
22
|
PROJECT_ID_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
@@ -231,6 +231,45 @@ class FsProjectRegistry(ProjectRegistry):
|
|
|
231
231
|
self._load_graph()
|
|
232
232
|
return [cfg.project for cfg in self._by_id.values()]
|
|
233
233
|
|
|
234
|
+
def checkout_of(self, url: str) -> Path | None:
|
|
235
|
+
"""Where a project this graph holds is a checkout of `url`, or None.
|
|
236
|
+
|
|
237
|
+
**Filesystem-adapter private, deliberately not on `ProjectRegistry`.**
|
|
238
|
+
The question itself is storage-neutral — a tracker-backed registry could
|
|
239
|
+
answer "which of your projects comes from this source" perfectly well —
|
|
240
|
+
but answering it here means comparing `RepositoryDeclaration.url` and
|
|
241
|
+
returning a `Project.locator`, and both are documented as things nothing
|
|
242
|
+
above the adapter may read. The one caller is `resolve_store` in
|
|
243
|
+
`fs.py`, which is the same adapter reading its own values.
|
|
244
|
+
|
|
245
|
+
Asks the *resolved* project rather than the declaration that named it.
|
|
246
|
+
That is the entire point: a declaration says where a project comes from,
|
|
247
|
+
and only the graph knows where it landed here, `TCW_PROJECT_<ID>`
|
|
248
|
+
included. A lookup that returned the declared locator would answer with
|
|
249
|
+
the path the config wrote, which on a machine needing this is exactly
|
|
250
|
+
the path that does not exist.
|
|
251
|
+
|
|
252
|
+
A project is reached through the edges pointing at it, since a node's
|
|
253
|
+
own repository is declared by whoever knows about that edge rather than
|
|
254
|
+
by the node itself. A declared project the graph does not hold answers
|
|
255
|
+
None: it names a repository but no location here, and sending the store
|
|
256
|
+
ladder to a path that is not there is worse than sending it nowhere.
|
|
257
|
+
"""
|
|
258
|
+
self._load_graph()
|
|
259
|
+
wanted = normalized_url(url)
|
|
260
|
+
if not wanted:
|
|
261
|
+
return None
|
|
262
|
+
for cfg in list(self._cache.values()):
|
|
263
|
+
for entry in (*cfg.parent.values(), *cfg.children.values()):
|
|
264
|
+
if entry.repository is None:
|
|
265
|
+
continue
|
|
266
|
+
if normalized_url(entry.repository.url) != wanted:
|
|
267
|
+
continue
|
|
268
|
+
target = self._by_id.get(entry.id)
|
|
269
|
+
if target is not None:
|
|
270
|
+
return Path(target.project.locator).resolve()
|
|
271
|
+
return None
|
|
272
|
+
|
|
234
273
|
def check(self) -> list[str]:
|
|
235
274
|
return list(self._problems)
|
|
236
275
|
|
|
@@ -21,7 +21,10 @@ from typing import Literal
|
|
|
21
21
|
import yaml
|
|
22
22
|
|
|
23
23
|
from tcw.refs import resolve_tcw_ref
|
|
24
|
-
from tcw.store.
|
|
24
|
+
from tcw.store.base import StoreLocationUnusable
|
|
25
|
+
from tcw.store.fs import (
|
|
26
|
+
STORE_CLASSES, FsCapabilitiesStore, FsTaxonomyStore, FsWorkStore, load_yaml,
|
|
27
|
+
)
|
|
25
28
|
|
|
26
29
|
_COMPONENTS = ("taxonomy", "capabilities", "work")
|
|
27
30
|
_LINK_RE = re.compile(r"\]\((tcw://[^)\s]+)\)")
|
|
@@ -126,6 +129,56 @@ def _components_to_check(node_root: Path, path) -> list[str]:
|
|
|
126
129
|
return []
|
|
127
130
|
|
|
128
131
|
|
|
132
|
+
def _configured_path_problem(node_root: Path, comp: str) -> str | None:
|
|
133
|
+
"""A configured `<comp>.path` that is present and holds no store, or None.
|
|
134
|
+
|
|
135
|
+
**Asked independently of resolution, and that is the whole point.** The
|
|
136
|
+
resolution ladder treats an unusable configured path as a location that did
|
|
137
|
+
not work out and moves on to the declaration, which is right for reading and
|
|
138
|
+
wrong for a command whose entire job is enumerating configuration problems.
|
|
139
|
+
Two faults were reported as one, and `_run_check` collapses any opening
|
|
140
|
+
failure into a single string, so even a compound message still counts once.
|
|
141
|
+
|
|
142
|
+
Running this only when resolution fails would miss the user it helps most:
|
|
143
|
+
the one whose declared store provisions fine, so nothing ever tells them the
|
|
144
|
+
path they wrote is wrong and they quietly read a second copy of the board.
|
|
145
|
+
|
|
146
|
+
**Silent when the path is absent.** With a declaration present that is the
|
|
147
|
+
ordinary case the declaration exists for, and reporting it would make every
|
|
148
|
+
provisioned node noisy. Presence is tested here rather than inferred from
|
|
149
|
+
the message, because `_open_at` raises the same text for a path that is
|
|
150
|
+
missing and one that is a file.
|
|
151
|
+
|
|
152
|
+
Asks the component's own `_open_at` rather than re-listing what a store
|
|
153
|
+
looks like. A third spelling of the store layout is exactly the drift the
|
|
154
|
+
shared ladder exists to prevent.
|
|
155
|
+
"""
|
|
156
|
+
config_path = node_root / "tcw-config.yaml"
|
|
157
|
+
try:
|
|
158
|
+
config = load_yaml(config_path, unique=True)
|
|
159
|
+
except Exception:
|
|
160
|
+
return None # reported by the YAML pass above
|
|
161
|
+
section = config.get(comp) if isinstance(config, dict) else None
|
|
162
|
+
configured = section.get("path") if isinstance(section, dict) else None
|
|
163
|
+
if not isinstance(configured, str) or not configured.strip():
|
|
164
|
+
return None
|
|
165
|
+
store_cls = STORE_CLASSES[comp]
|
|
166
|
+
raw_root = store_cls._local_root(node_root, configured)
|
|
167
|
+
if not raw_root.exists():
|
|
168
|
+
return None
|
|
169
|
+
try:
|
|
170
|
+
store_cls._open_at(raw_root, node_root, config_path,
|
|
171
|
+
external=True, must_exist=True)
|
|
172
|
+
except StoreLocationUnusable as unusable:
|
|
173
|
+
return str(unusable)
|
|
174
|
+
except Exception:
|
|
175
|
+
# Anything else is either not this check's business or is already
|
|
176
|
+
# reported by the component check beside it. Never swallow it into a
|
|
177
|
+
# second, differently worded copy of the same fault.
|
|
178
|
+
return None
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
129
182
|
def _run_check(node_root: Path, comp: str, identifier: str | None = None) -> list[str]:
|
|
130
183
|
"""One component's `check()`, or the reason its store could not be opened.
|
|
131
184
|
|
|
@@ -237,6 +290,11 @@ def validate(node_root: Path, path: Path | None = None, *,
|
|
|
237
290
|
else:
|
|
238
291
|
components = [target.axis] if target is not None else _components_to_check(node_root, path)
|
|
239
292
|
for comp in components:
|
|
293
|
+
# Before the component's own check, so a node reads "your path is
|
|
294
|
+
# broken" ahead of whatever the store it fell back to has to say.
|
|
295
|
+
configured = _configured_path_problem(node_root, comp)
|
|
296
|
+
if configured is not None:
|
|
297
|
+
problems.append(f"{comp} path: {configured}")
|
|
240
298
|
problems += _run_check(node_root, comp, target.ref if target else None)
|
|
241
299
|
|
|
242
300
|
return problems
|
|
@@ -1186,3 +1186,32 @@ def test_get_detail_of_a_genuinely_unknown_slug_is_none(tmp_path):
|
|
|
1186
1186
|
code = _repo(tmp_path / "code")
|
|
1187
1187
|
init(["work"], code, "corelib")
|
|
1188
1188
|
assert FsWorkStore.open(code).get_detail("2026-01-01-nope") is None
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
def test_registering_a_tag_stages_the_config_in_its_own_repository(tmp_path):
|
|
1192
|
+
"""`work.tags` lives in the node's `tcw-config.yaml`, which an external store
|
|
1193
|
+
puts in a *different* repository from the store.
|
|
1194
|
+
|
|
1195
|
+
Staging it against the store's repository fails outright — `is outside
|
|
1196
|
+
repository at …` — so registering a tag was impossible in the orchestrator
|
|
1197
|
+
layout this project documents as the intended one. The store root and the
|
|
1198
|
+
node root vary independently; a write has to go to whichever repository owns
|
|
1199
|
+
the file it touches.
|
|
1200
|
+
"""
|
|
1201
|
+
code, work_root = _external_node(tmp_path, "../orchestrator/stores/corelib")
|
|
1202
|
+
store = FsWorkStore.open(code)
|
|
1203
|
+
assert store.store_git_root != store.node_root, "the premise of this test"
|
|
1204
|
+
|
|
1205
|
+
assert store.register_tags(["chore"]) == ["chore"]
|
|
1206
|
+
|
|
1207
|
+
assert "chore" in yaml.safe_load(
|
|
1208
|
+
(code / "tcw-config.yaml").read_text())["work"]["tags"]
|
|
1209
|
+
staged = subprocess.run(
|
|
1210
|
+
["git", "-C", str(code), "diff", "--cached", "--name-only"],
|
|
1211
|
+
capture_output=True, text=True, check=True).stdout.split()
|
|
1212
|
+
assert "tcw-config.yaml" in staged, \
|
|
1213
|
+
"the config must be staged in the repository that holds it"
|
|
1214
|
+
store_staged = subprocess.run(
|
|
1215
|
+
["git", "-C", str(store.store_git_root), "diff", "--cached", "--name-only"],
|
|
1216
|
+
capture_output=True, text=True, check=True).stdout
|
|
1217
|
+
assert "tcw-config.yaml" not in store_staged
|
|
@@ -835,3 +835,106 @@ def test_the_override_is_probed_once_per_project(tmp_path, monkeypatch):
|
|
|
835
835
|
registry.require_valid()
|
|
836
836
|
assert calls.count("child-project") == 1
|
|
837
837
|
assert len(registry.overrides()) == 1
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
# ── locating a project by the repository it comes from ───────────────────────
|
|
841
|
+
#
|
|
842
|
+
# The question the store-resolution ladder asks when its configured path does
|
|
843
|
+
# not resolve: *is a checkout of this repository already on this disk?* The
|
|
844
|
+
# registry is the only thing that knows, because it is the only thing that has
|
|
845
|
+
# consulted `TCW_PROJECT_<ID>`.
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def _workspace(tmp_path: Path, url: str, *, parent_at: str = "orchestration") -> tuple[Path, Path]:
|
|
849
|
+
"""A child declaring its parent, and naming the repository the parent is a
|
|
850
|
+
checkout of. Returns `(child_root, parent_root)`.
|
|
851
|
+
|
|
852
|
+
Every axis the lookup branches on is written here rather than defaulted:
|
|
853
|
+
the declared locator, the repository url, and where the parent actually is.
|
|
854
|
+
"""
|
|
855
|
+
parent = tmp_path / parent_at
|
|
856
|
+
child = tmp_path / "core"
|
|
857
|
+
config(parent, f"id: orchestration\nconnected-projects:\n children:\n core: {child}\n")
|
|
858
|
+
config(
|
|
859
|
+
child,
|
|
860
|
+
"id: core\n"
|
|
861
|
+
"connected-projects:\n"
|
|
862
|
+
" parent:\n"
|
|
863
|
+
" orchestration:\n"
|
|
864
|
+
f" path: {parent}\n"
|
|
865
|
+
" repository:\n"
|
|
866
|
+
f" url: {url}\n"
|
|
867
|
+
" ref: main\n",
|
|
868
|
+
)
|
|
869
|
+
return child, parent
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def test_checkout_of_finds_a_project_declared_from_that_repository(tmp_path):
|
|
873
|
+
url = "https://github.invalid/acme/orchestration.git"
|
|
874
|
+
child, parent = _workspace(tmp_path, url)
|
|
875
|
+
|
|
876
|
+
registry = FsProjectRegistry.open(child)
|
|
877
|
+
|
|
878
|
+
assert registry.checkout_of(url) == parent.resolve()
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
@pytest.mark.parametrize("asked", [
|
|
882
|
+
"https://github.invalid/acme/orchestration",
|
|
883
|
+
"https://github.invalid/acme/orchestration/",
|
|
884
|
+
"git@github.invalid:acme/orchestration.git",
|
|
885
|
+
])
|
|
886
|
+
def test_checkout_of_matches_across_url_spellings(tmp_path, asked):
|
|
887
|
+
"""The two sides of a connection are written at different times by
|
|
888
|
+
different people, so one spelling is not a safe assumption."""
|
|
889
|
+
child, parent = _workspace(tmp_path, "https://github.invalid/acme/orchestration.git")
|
|
890
|
+
|
|
891
|
+
assert FsProjectRegistry.open(child).checkout_of(asked) == parent.resolve()
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def test_checkout_of_returns_none_for_an_unrelated_repository(tmp_path):
|
|
895
|
+
child, _parent = _workspace(tmp_path, "https://github.invalid/acme/orchestration.git")
|
|
896
|
+
|
|
897
|
+
registry = FsProjectRegistry.open(child)
|
|
898
|
+
|
|
899
|
+
assert registry.checkout_of("https://github.invalid/acme/something-else.git") is None
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def test_checkout_of_follows_an_override(tmp_path, monkeypatch):
|
|
903
|
+
"""The whole point of the item this exists for.
|
|
904
|
+
|
|
905
|
+
The declared locator is right for the nested layout and wrong here; the
|
|
906
|
+
environment says where the parent actually is. A lookup that read the
|
|
907
|
+
declaration instead of the resolved project would answer with a directory
|
|
908
|
+
this machine does not have.
|
|
909
|
+
"""
|
|
910
|
+
url = "https://github.invalid/acme/orchestration.git"
|
|
911
|
+
child, declared = _workspace(tmp_path, url)
|
|
912
|
+
elsewhere = tmp_path / "flat" / "orchestration"
|
|
913
|
+
config(elsewhere, f"id: orchestration\nconnected-projects:\n children:\n core: {child}\n")
|
|
914
|
+
monkeypatch.setenv("TCW_PROJECT_ORCHESTRATION", str(elsewhere))
|
|
915
|
+
|
|
916
|
+
found = FsProjectRegistry.open(child).checkout_of(url)
|
|
917
|
+
|
|
918
|
+
assert found == elsewhere.resolve()
|
|
919
|
+
assert found != declared.resolve(), "the declared locator must not win over the override"
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def test_checkout_of_ignores_a_project_the_graph_does_not_hold(tmp_path):
|
|
923
|
+
"""A declaration whose target is not on this machine names a repository but
|
|
924
|
+
no location. Answering with a path that is not there would send the store
|
|
925
|
+
ladder somewhere worse than nowhere."""
|
|
926
|
+
url = "https://github.invalid/acme/orchestration.git"
|
|
927
|
+
child = tmp_path / "core"
|
|
928
|
+
config(
|
|
929
|
+
child,
|
|
930
|
+
"id: core\n"
|
|
931
|
+
"connected-projects:\n"
|
|
932
|
+
" parent:\n"
|
|
933
|
+
" orchestration:\n"
|
|
934
|
+
f" path: {tmp_path / 'not-here'}\n"
|
|
935
|
+
" repository:\n"
|
|
936
|
+
f" url: {url}\n"
|
|
937
|
+
" ref: main\n",
|
|
938
|
+
)
|
|
939
|
+
|
|
940
|
+
assert FsProjectRegistry.open(child).checkout_of(url) is None
|