diff --git a/CHANGELOG.md b/CHANGELOG.md index 47db5e0..4a9b493 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ ## Unreleased +## 0.7.14 - 2026-09-11 + +对使用者的影响:`dyro doctor`、首页菜单、`dyro next` / `dyro status` 以及 +开发线准入检查在多仓库、多开发线的工作区上明显变快,诊断结论不变。 + +- `doctor` observes each repository in two batched Git reads instead of + walking every (line, repository) pair one subprocess at a time. + `git worktree list --porcelain` returns the path, HEAD, and branch of + every worktree at once — appearing in that list is itself the proof + that a worktree shares the anchor's Git common directory, so the + paired `rev-parse --git-common-dir` probes are gone. A single + `git for-each-ref refs/heads refs/remotes/origin` returns each local + branch's upstream and each ref's object id, replacing the per-line + `show-ref`, `rev-parse origin/`, and `rev-parse @{upstream}` + calls. A worktree missing from the anchor's list still falls back to + the previous single probe, so `missing worktree` and `unexpected Git + common-dir` remain distinguishable. Findings, their wording, and their + order are unchanged; `ReadBudget` byte accounting and the observation + deadline still apply, now charged per repository rather than per line. + A five-repository, thirteen-line workspace drops from 399 Git + subprocesses and 5.61s to 16 subprocesses and 0.30s. + ## 0.7.13 - 2026-09-01 对使用者的影响:未 push 的开发线不再把 `dyro next` / `dyro start` diff --git a/pyproject.toml b/pyproject.toml index 6168251..9ef9258 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "dyro" -version = "0.7.13" +version = "0.7.14" description = "DyroEngineeringFlow: local-first automation and delivery control for multi-repository teams" readme = "README.md" requires-python = ">=3.11" diff --git a/src/dyro/bridge/skill/SKILL.md b/src/dyro/bridge/skill/SKILL.md index 775447d..bf576f4 100644 --- a/src/dyro/bridge/skill/SKILL.md +++ b/src/dyro/bridge/skill/SKILL.md @@ -50,7 +50,7 @@ release, publish, console, install, or any confirmation/approval field. ```json { "protocol": {"major": 1, "minor": 0}, - "client": {"name": "dyro-agent-bridge-skill", "version": "0.7.13"}, + "client": {"name": "dyro-agent-bridge-skill", "version": "0.7.14"}, "operation": "bridge.capabilities.compact", "input": {} } diff --git a/src/dyro/workspace.py b/src/dyro/workspace.py index 7428f88..562c0ee 100644 --- a/src/dyro/workspace.py +++ b/src/dyro/workspace.py @@ -1345,6 +1345,85 @@ def status_rows( return rows +def _worktree_inventory( + anchor: Path, *, read_budget: ReadBudget | None = None +) -> dict[Path, tuple[str, str]]: + """Map每个与 anchor 共用 Git 公共目录的 worktree 到 (HEAD, 分支)。 + + ``git worktree list`` 只报告同一个仓库的 worktree,所以"出现在这份清单里" + 本身就是共用公共目录的证据;这取代了过去对每个 worktree 各发一对 + ``rev-parse --git-common-dir`` 的做法。 + """ + result = git_read( + anchor, "worktree", "list", "--porcelain", read_budget=read_budget + ) + if result.code != 0: + return {} + inventory: dict[Path, tuple[str, str]] = {} + path: Path | None = None + head = "" + branch = "" + prunable = False + + def flush() -> None: + nonlocal path, head, branch, prunable + if path is not None and not prunable: + inventory[path] = (head, branch) + path, head, branch, prunable = None, "", "", False + + for raw in result.stdout.splitlines(): + entry = raw.strip() + if not entry: + flush() + continue + key, _, value = entry.partition(" ") + if key == "worktree": + flush() + path = Path(value).resolve() + elif key == "HEAD": + head = value.strip() + elif key == "branch": + branch = value.strip() + if branch.startswith("refs/heads/"): + branch = branch[len("refs/heads/") :] + elif key == "prunable": + prunable = True + flush() + return inventory + + +def _ref_inventory( + anchor: Path, *, read_budget: ReadBudget | None = None +) -> tuple[dict[str, str], dict[str, str]]: + """返回 (本地分支 → 上游, 引用全名 → 对象 ID)。 + + 引用存放在共用的公共目录里,所以从 anchor 读到的就是每个 linked worktree + 会读到的同一份;一条 ``for-each-ref`` 取代了逐开发线的 ``show-ref``、 + ``rev-parse `` 与 ``rev-parse @{upstream}``。 + """ + upstreams: dict[str, str] = {} + objects: dict[str, str] = {} + result = git_read( + anchor, + "for-each-ref", + "--format=%(refname)%09%(objectname)%09%(upstream)", + "refs/heads", + "refs/remotes/origin", + read_budget=read_budget, + ) + if result.code != 0: + return upstreams, objects + for raw in result.stdout.splitlines(): + parts = raw.split("\t") + if len(parts) != 3: + continue + refname, objectname, upstream = parts + objects[refname] = objectname + if refname.startswith("refs/heads/"): + upstreams[refname[len("refs/heads/") :]] = _normalize_upstream(upstream) + return upstreams, objects + + def doctor(config: Config, *, read_budget: ReadBudget | None = None) -> list[str]: """Return diagnostics. Callers decide whether any FAIL means non-zero.""" findings: list[str] = [] @@ -1365,23 +1444,43 @@ def doctor(config: Config, *, read_budget: ReadBudget | None = None) -> list[str findings.append(f"PASS repository {repo_id}: {anchor}") else: findings.append(f"FAIL repository {repo_id}: missing or not Git: {anchor}") - for line in list_lines(config, read_budget=read_budget): + lines = list_lines(config, read_budget=read_budget) + observed: dict[str, tuple[dict[Path, tuple[str, str]], dict[str, str], dict[str, str]]] = {} + for repo_id in sorted( + {repo for line in lines for repo in line.repositories} + & set(config.repositories) + ): + anchor = repository_path(config, repo_id) + worktrees = _worktree_inventory(anchor, read_budget=read_budget) + upstreams, objects = _ref_inventory(anchor, read_budget=read_budget) + observed[repo_id] = (worktrees, upstreams, objects) + for line in lines: for repo_id in line.repositories: anchor = repository_path(config, repo_id) worktree = line_repository_path(config, line, repo_id) storage_mode = line.storage_for(repo_id) - if not _is_git_repo(worktree, read_budget=read_budget): + worktrees, upstreams, objects = observed.get(repo_id, ({}, {}, {})) + entry = worktrees.get(worktree.resolve()) + if entry is None and not _is_git_repo(worktree, read_budget=read_budget): findings.append(f"FAIL {line.kind}:{line.id}/{repo_id}: missing worktree") continue - actual_branch = git_read( - worktree, - "branch", - "--show-current", - read_budget=read_budget, - ) - if actual_branch.code != 0 or actual_branch.stdout.strip() != line.branch: - actual = actual_branch.stdout.strip() if actual_branch.code == 0 else "UNREADABLE" - findings.append(f"FAIL {line.kind}:{line.id}/{repo_id}: expected {line.branch}, found {actual or 'DETACHED'}") + if entry is not None: + head, actual_branch = entry + else: + # A Git repository the anchor does not own: read its branch + # directly so a foreign checkout still reports as before. + probe = git_read( + worktree, + "branch", + "--show-current", + read_budget=read_budget, + ) + head = "" + actual_branch = ( + probe.stdout.strip() if probe.code == 0 else "UNREADABLE" + ) + if actual_branch != line.branch: + findings.append(f"FAIL {line.kind}:{line.id}/{repo_id}: expected {line.branch}, found {actual_branch or 'DETACHED'}") continue if storage_mode == "anchor-reference": if not worktree.is_symlink(): @@ -1394,38 +1493,17 @@ def doctor(config: Config, *, read_budget: ReadBudget | None = None) -> list[str if worktree.is_symlink(): findings.append(f"FAIL {line.kind}:{line.id}/{repo_id}: linked-worktree cannot be a symlink") continue - anchor_common = git_read( - anchor, - "rev-parse", - "--path-format=absolute", - "--git-common-dir", - read_budget=read_budget, - ) - worktree_common = git_read( - worktree, - "rev-parse", - "--path-format=absolute", - "--git-common-dir", - read_budget=read_budget, - ) - if not ( - anchor_common.code == 0 - and worktree_common.code == 0 - and anchor_common.stdout.strip() == worktree_common.stdout.strip() - ): + if entry is None: findings.append(f"FAIL {line.kind}:{line.id}/{repo_id}: unexpected Git common-dir") continue expected_remote = _expected_remote_branch(line.branch) - if not _ref_exists( - worktree, f"refs/remotes/{expected_remote}", read_budget=read_budget - ): + remote_head = objects.get(f"refs/remotes/{expected_remote}") + if remote_head is None: findings.append( f"WARN {line.kind}:{line.id}/{repo_id}: missing {expected_remote}" ) continue - upstream = _branch_upstream(worktree, read_budget=read_budget) - head = _rev_parse(worktree, "HEAD", read_budget=read_budget) - remote_head = _rev_parse(worktree, expected_remote, read_budget=read_budget) + upstream = upstreams.get(line.branch, "") if upstream == expected_remote or ( not upstream and head and head == remote_head ): diff --git a/tests/test_cli.py b/tests/test_cli.py index baf0b7f..6aca772 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2270,6 +2270,6 @@ def test_package_version_matches_pyproject(self) -> None: from dyro import __version__ metadata = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8")) - self.assertEqual(metadata["project"]["version"], "0.7.13") - self.assertEqual(__version__, "0.7.13") + self.assertEqual(metadata["project"]["version"], "0.7.14") + self.assertEqual(__version__, "0.7.14") self.assertEqual(__version__, metadata["project"]["version"]) diff --git a/tests/test_console_artifacts.py b/tests/test_console_artifacts.py index 32518cf..1d10cca 100644 --- a/tests/test_console_artifacts.py +++ b/tests/test_console_artifacts.py @@ -225,12 +225,12 @@ def test_page_and_manifest_keep_p3_fail_closed_pins(self) -> None: self.assertNotEqual(refresh_at, -1) self.assertNotIn(b"/artifacts", script.body[refresh_at:next_fn]) - def test_package_version_is_0_7_13(self) -> None: + def test_package_version_is_0_7_14(self) -> None: import tomllib from pathlib import Path metadata = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8")) - self.assertEqual(metadata["project"]["version"], "0.7.13") + self.assertEqual(metadata["project"]["version"], "0.7.14") class ConsoleArtifactServiceTests(WorkspaceCase): diff --git a/tests/test_control_plane_read_budget.py b/tests/test_control_plane_read_budget.py index fb90555..c6fb967 100644 --- a/tests/test_control_plane_read_budget.py +++ b/tests/test_control_plane_read_budget.py @@ -59,7 +59,14 @@ def __call__(self) -> float: def _raise_deadline_on_worktree(repo, *args, read_budget=None, **kwargs): - if read_budget is not None and "versions/" in str(repo): + """Stall every read that observes a line worktree. + + ``status`` reads each worktree path directly; ``doctor`` reads them all at + once through the anchor's ``git worktree list``. Both are the moment a + stalled worktree must surface as a partial observation, not a bare error. + """ + observes_worktree = "versions/" in str(repo) or (args and args[0] == "worktree") + if read_budget is not None and observes_worktree: raise ReadLimitError( ReadLimitCode.DEADLINE_EXCEEDED, "Core observation deadline exceeded", diff --git a/tests/test_release_gates.py b/tests/test_release_gates.py index eb4ac57..8599b9a 100644 --- a/tests/test_release_gates.py +++ b/tests/test_release_gates.py @@ -32,7 +32,7 @@ def test_physics_train_refuses_published_0_6_9_tag(self) -> None: def test_0_7_release_runs_gates_without_claiming_1_0(self) -> None: stdout = StringIO() with redirect_stdout(stdout): - code = main(["--root", str(ROOT), "--release-tag", "v0.7.13"]) + code = main(["--root", str(ROOT), "--release-tag", "v0.7.14"]) self.assertEqual(code, 0) self.assertIn("0.7 gates present", stdout.getvalue()) self.assertNotIn("1.0 gates present", stdout.getvalue()) diff --git a/uv.lock b/uv.lock index c1d737f..8bcbbb2 100644 --- a/uv.lock +++ b/uv.lock @@ -286,7 +286,7 @@ wheels = [ [[package]] name = "dyro" -version = "0.7.13" +version = "0.7.14" source = { editable = "." } dependencies = [ { name = "cryptography" }, @@ -336,7 +336,7 @@ name = "importlib-metadata" version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } wheels = [