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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<branch>`, 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`
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/dyro/bridge/skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": {}
}
Expand Down
150 changes: 114 additions & 36 deletions src/dyro/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <origin/branch>`` 与 ``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] = []
Expand All @@ -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():
Expand All @@ -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
):
Expand Down
4 changes: 2 additions & 2 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
4 changes: 2 additions & 2 deletions tests/test_console_artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
9 changes: 8 additions & 1 deletion tests/test_control_plane_read_budget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion tests/test_release_gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
4 changes: 2 additions & 2 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading