chipgraph 0.1.0__py3-none-any.whl
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.
- chipgraph/__init__.py +7 -0
- chipgraph/adapters/__init__.py +1 -0
- chipgraph/adapters/format/__init__.py +6 -0
- chipgraph/adapters/format/__main__.py +16 -0
- chipgraph/adapters/format/_yaml_lines.py +54 -0
- chipgraph/adapters/format/chip_yaml.py +323 -0
- chipgraph/adapters/format/qsoc_contract.py +246 -0
- chipgraph/adapters/llm/__init__.py +58 -0
- chipgraph/adapters/llm/_budget.py +173 -0
- chipgraph/adapters/llm/_errors.py +50 -0
- chipgraph/adapters/llm/_nda.py +30 -0
- chipgraph/adapters/llm/_retry.py +85 -0
- chipgraph/adapters/llm/anthropic.py +247 -0
- chipgraph/adapters/llm/decide_backend.py +179 -0
- chipgraph/adapters/llm/fake.py +87 -0
- chipgraph/adapters/llm/guard.py +68 -0
- chipgraph/adapters/parser/__init__.py +22 -0
- chipgraph/adapters/parser/cocotb.py +304 -0
- chipgraph/adapters/parser/generic_regex.py +75 -0
- chipgraph/adapters/parser/verible.py +55 -0
- chipgraph/adapters/parser/verilator.py +46 -0
- chipgraph/adapters/review/__init__.py +7 -0
- chipgraph/adapters/review/file.py +123 -0
- chipgraph/adapters/review/github.py +886 -0
- chipgraph/adapters/runner/__init__.py +7 -0
- chipgraph/adapters/runner/local.py +122 -0
- chipgraph/adapters/runtime/__init__.py +5 -0
- chipgraph/adapters/runtime/claude_code/__init__.py +24 -0
- chipgraph/adapters/runtime/claude_code/agents.py +158 -0
- chipgraph/adapters/runtime/claude_code/decisions.py +280 -0
- chipgraph/adapters/runtime/claude_code/guarded.py +310 -0
- chipgraph/adapters/runtime/claude_code/isolation.py +482 -0
- chipgraph/adapters/runtime/claude_code/loop.py +342 -0
- chipgraph/adapters/runtime/claude_code/previous.py +419 -0
- chipgraph/adapters/runtime/claude_code/review.py +492 -0
- chipgraph/adapters/runtime/claude_code/runtime.py +192 -0
- chipgraph/adapters/runtime/claude_code/service.py +2285 -0
- chipgraph/adapters/tool/__init__.py +7 -0
- chipgraph/adapters/tool/cmd.py +229 -0
- chipgraph/adapters/tool/edalize.py +773 -0
- chipgraph/adapters/tool/filelist.py +259 -0
- chipgraph/adapters/tool/pyslang.py +485 -0
- chipgraph/adapters/vcs/__init__.py +1 -0
- chipgraph/adapters/vcs/git.py +521 -0
- chipgraph/app/__init__.py +10 -0
- chipgraph/app/baseline.py +339 -0
- chipgraph/app/build.py +332 -0
- chipgraph/app/checks.py +202 -0
- chipgraph/app/context.py +191 -0
- chipgraph/app/errors.py +11 -0
- chipgraph/app/executors.py +79 -0
- chipgraph/app/findings.py +203 -0
- chipgraph/app/ingest.py +523 -0
- chipgraph/checks/__init__.py +52 -0
- chipgraph/checks/_cdc_analyze.py +272 -0
- chipgraph/checks/_cdc_elaborate.py +455 -0
- chipgraph/checks/_common.py +268 -0
- chipgraph/checks/_cross.py +96 -0
- chipgraph/checks/_model.py +64 -0
- chipgraph/checks/_naming_pyslang.py +235 -0
- chipgraph/checks/_naming_rules.py +127 -0
- chipgraph/checks/_xref_syntax.py +260 -0
- chipgraph/checks/cdc_struct.py +235 -0
- chipgraph/checks/connect.py +353 -0
- chipgraph/checks/cross_chip.py +454 -0
- chipgraph/checks/duplicate.py +153 -0
- chipgraph/checks/filelist.py +323 -0
- chipgraph/checks/format.py +268 -0
- chipgraph/checks/generated.py +151 -0
- chipgraph/checks/hardcode.py +233 -0
- chipgraph/checks/layout.py +155 -0
- chipgraph/checks/naming.py +311 -0
- chipgraph/checks/plan_check.py +901 -0
- chipgraph/checks/ports_diff.py +203 -0
- chipgraph/checks/spec_schema.py +277 -0
- chipgraph/checks/style.py +910 -0
- chipgraph/checks/tb_static.py +599 -0
- chipgraph/checks/trace.py +224 -0
- chipgraph/checks/vendor_guard.py +369 -0
- chipgraph/checks/wrap_check.py +486 -0
- chipgraph/cli/__init__.py +1688 -0
- chipgraph/cli/next_steps.py +137 -0
- chipgraph/core/__init__.py +1 -0
- chipgraph/core/config/__init__.py +93 -0
- chipgraph/core/config/defaults.py +14 -0
- chipgraph/core/config/errors.py +32 -0
- chipgraph/core/config/loader.py +766 -0
- chipgraph/core/config/models.py +845 -0
- chipgraph/core/config/templates.py +340 -0
- chipgraph/core/contracts/__init__.py +75 -0
- chipgraph/core/contracts/_paths.py +26 -0
- chipgraph/core/contracts/agent.py +49 -0
- chipgraph/core/contracts/artifact.py +68 -0
- chipgraph/core/contracts/check.py +76 -0
- chipgraph/core/contracts/event.py +70 -0
- chipgraph/core/contracts/export.py +128 -0
- chipgraph/core/contracts/finding.py +171 -0
- chipgraph/core/contracts/gate.py +50 -0
- chipgraph/core/contracts/rule.py +177 -0
- chipgraph/core/contracts/types.py +45 -0
- chipgraph/core/engine/__init__.py +70 -0
- chipgraph/core/engine/agent_rule.py +1362 -0
- chipgraph/core/engine/baseline.py +283 -0
- chipgraph/core/engine/decide.py +462 -0
- chipgraph/core/engine/fanout.py +1358 -0
- chipgraph/core/engine/gate.py +170 -0
- chipgraph/core/engine/graph.py +647 -0
- chipgraph/core/engine/records.py +60 -0
- chipgraph/core/engine/rules.py +92 -0
- chipgraph/core/engine/scheduler.py +637 -0
- chipgraph/core/engine/select.py +1239 -0
- chipgraph/core/engine/targets.py +342 -0
- chipgraph/core/model/__init__.py +98 -0
- chipgraph/core/model/entities.py +244 -0
- chipgraph/core/model/export.py +16 -0
- chipgraph/core/model/ingest.py +693 -0
- chipgraph/core/model/json_value.py +4 -0
- chipgraph/core/model/keys.py +95 -0
- chipgraph/core/model/model.py +153 -0
- chipgraph/core/model/provenance.py +27 -0
- chipgraph/core/model/query.py +583 -0
- chipgraph/core/model/registry.py +80 -0
- chipgraph/core/model/relations.py +61 -0
- chipgraph/core/model/store.py +279 -0
- chipgraph/core/plugin_api/__init__.py +81 -0
- chipgraph/core/plugin_api/local.py +167 -0
- chipgraph/core/plugin_api/pack.py +159 -0
- chipgraph/core/plugin_api/protocols.py +177 -0
- chipgraph/core/plugin_api/registry.py +111 -0
- chipgraph/core/plugin_api/types.py +94 -0
- chipgraph/core/runtime/__init__.py +41 -0
- chipgraph/core/runtime/executor.py +79 -0
- chipgraph/core/runtime/queue.py +565 -0
- chipgraph/core/runtime/roles/__init__.py +91 -0
- chipgraph/core/runtime/roles/data/author.md +41 -0
- chipgraph/core/runtime/roles/data/critic.md +56 -0
- chipgraph/core/runtime/roles/data/planner.md +38 -0
- chipgraph/core/runtime/roles/data/researcher.md +37 -0
- chipgraph/core/runtime/roles/data/rtl-author.md +64 -0
- chipgraph/core/runtime/roles/data/tb-author.md +48 -0
- chipgraph/core/runtime/roles/data/triage.md +34 -0
- chipgraph/core/runtime/roles/policy.py +299 -0
- chipgraph/core/runtime/roles/registry.py +52 -0
- chipgraph/core/runtime/roles/review.py +275 -0
- chipgraph/core/runtime/roles/schema.py +82 -0
- chipgraph/core/runtime/roles/skills.py +134 -0
- chipgraph/core/runtime/roles/spec.py +232 -0
- chipgraph/core/state/__init__.py +1 -0
- chipgraph/core/state/artifacts.py +156 -0
- chipgraph/core/state/backend.py +117 -0
- chipgraph/core/state/findings.py +151 -0
- chipgraph/core/state/handoff.py +411 -0
- chipgraph/core/state/idempotency.py +59 -0
- chipgraph/core/state/journal.py +238 -0
- chipgraph/core/state/layout.py +82 -0
- chipgraph/core/state/lock.py +195 -0
- chipgraph/core/state/trace.py +225 -0
- chipgraph/learn/__init__.py +96 -0
- chipgraph/learn/draft.py +187 -0
- chipgraph/learn/infer.py +580 -0
- chipgraph/learn/models.py +145 -0
- chipgraph/learn/try_run.py +228 -0
- chipgraph/mcp/__init__.py +12 -0
- chipgraph/mcp/model_tools.py +167 -0
- chipgraph/mcp/server.py +787 -0
- chipgraph/orgs/qnsc/naming-v1.yml +113 -0
- chipgraph/orgs/qnsc/profile.yml +5 -0
- chipgraph/packs/__init__.py +8 -0
- chipgraph/packs/assist/__init__.py +16 -0
- chipgraph/packs/assist/ask/__init__.py +58 -0
- chipgraph/packs/assist/ask/_db.py +57 -0
- chipgraph/packs/assist/ask/_project.py +230 -0
- chipgraph/packs/assist/ask/answer.py +266 -0
- chipgraph/packs/assist/ask/check.py +182 -0
- chipgraph/packs/assist/ask/contract.py +115 -0
- chipgraph/packs/assist/ask/documents.py +157 -0
- chipgraph/packs/assist/ask/fts.py +80 -0
- chipgraph/packs/assist/ask/retrieve.py +331 -0
- chipgraph/packs/assist/audit/__init__.py +383 -0
- chipgraph/packs/assist/pack.yml +13 -0
- chipgraph/packs/assist/triage/__init__.py +64 -0
- chipgraph/packs/assist/triage/contract.py +94 -0
- chipgraph/packs/assist/triage/parse.py +371 -0
- chipgraph/packs/assist/triage/question.py +384 -0
- chipgraph/packs/assist/triage/report.py +160 -0
- chipgraph/packs/assist/triage/rules.py +447 -0
- chipgraph/packs/assist/triage/run.py +224 -0
- chipgraph/packs/digital_rtl/__init__.py +19 -0
- chipgraph/packs/digital_rtl/layout.py +144 -0
- chipgraph/packs/digital_rtl/pack.yml +25 -0
- chipgraph/packs/digital_rtl/plan/__init__.py +34 -0
- chipgraph/packs/digital_rtl/plan/__main__.py +16 -0
- chipgraph/packs/digital_rtl/plan/context.py +82 -0
- chipgraph/packs/digital_rtl/plan/expand.py +94 -0
- chipgraph/packs/digital_rtl/plan/model.py +150 -0
- chipgraph/packs/digital_rtl/plan/resolver.py +400 -0
- chipgraph/packs/digital_rtl/plan/show.py +176 -0
- chipgraph/packs/digital_rtl/pr/__init__.py +11 -0
- chipgraph/packs/digital_rtl/pr/body.py +165 -0
- chipgraph/packs/digital_rtl/pr/gate.py +110 -0
- chipgraph/packs/digital_rtl/pr/open.py +397 -0
- chipgraph/packs/digital_rtl/rtl/__init__.py +9 -0
- chipgraph/packs/digital_rtl/rtl/context.py +272 -0
- chipgraph/packs/digital_rtl/rules/open_pr.yml +30 -0
- chipgraph/packs/digital_rtl/rules/plan.yml +28 -0
- chipgraph/packs/digital_rtl/rules/plan_expand.yml +29 -0
- chipgraph/packs/digital_rtl/rules/pr_gate.yml +27 -0
- chipgraph/packs/digital_rtl/rules/review.yml +31 -0
- chipgraph/packs/digital_rtl/rules/rtl_module.yml +53 -0
- chipgraph/packs/digital_rtl/skills/lang-sv-rtl.md +58 -0
- chipgraph/packs/digital_rtl/skills/org-naming.md +29 -0
- chipgraph/packs/digital_rtl/skills/plan-modules.md +63 -0
- chipgraph/packs/digital_rtl/skills/review-diff.md +41 -0
- chipgraph/packs/digital_rtl/targets/plan.yml +11 -0
- chipgraph/packs/digital_rtl/targets/rtl.yml +20 -0
- chipgraph/packs/digital_rtl/wrapper/__init__.py +24 -0
- chipgraph/packs/digital_rtl/wrapper/common.py +171 -0
- chipgraph/packs/digital_rtl/wrapper/context.py +222 -0
- chipgraph/packs/digital_rtl/wrapper/emacs.py +626 -0
- chipgraph/packs/digital_rtl/wrapper/expansion.py +304 -0
- chipgraph/packs/digital_rtl/wrapper/lisp.py +504 -0
- chipgraph/packs/digital_rtl/wrapper/resolver.py +60 -0
- chipgraph/packs/digital_rtl/wrapper/rules/wrapper.yml +41 -0
- chipgraph/packs/digital_rtl/wrapper/rules/wrapper_gen.yml +35 -0
- chipgraph/packs/digital_rtl/wrapper/skills/wrapper-auto.md +120 -0
- chipgraph/packs/dv/__init__.py +15 -0
- chipgraph/packs/dv/declaration.py +392 -0
- chipgraph/packs/dv/interface.py +596 -0
- chipgraph/packs/dv/pack.yml +14 -0
- chipgraph/packs/dv/rules/tb_module.yml +42 -0
- chipgraph/packs/dv/skills/dv/cocotb.md +72 -0
- chipgraph/packs/spec_core/__init__.py +9 -0
- chipgraph/packs/spec_core/extract/__init__.py +12 -0
- chipgraph/packs/spec_core/extract/_markdown.py +371 -0
- chipgraph/packs/spec_core/extract/_text.py +116 -0
- chipgraph/packs/spec_core/extract/mas.py +975 -0
- chipgraph/packs/spec_core/gen/__init__.py +11 -0
- chipgraph/packs/spec_core/gen/agents_md/__init__.py +148 -0
- chipgraph/packs/spec_core/gen/agents_md/_context.py +239 -0
- chipgraph/packs/spec_core/gen/agents_md/_markers.py +131 -0
- chipgraph/packs/spec_core/gen/agents_md/agents_md.md.j2 +178 -0
- chipgraph/packs/spec_core/gen/diagram/__init__.py +110 -0
- chipgraph/packs/spec_core/gen/diagram/__main__.py +44 -0
- chipgraph/packs/spec_core/gen/diagram/_diagrams.py +403 -0
- chipgraph/packs/spec_core/gen/diagram/_scene.py +163 -0
- chipgraph/packs/spec_core/pack.yml +6 -0
- chipgraph/py.typed +0 -0
- chipgraph-0.1.0.dist-info/METADATA +53 -0
- chipgraph-0.1.0.dist-info/RECORD +252 -0
- chipgraph-0.1.0.dist-info/WHEEL +4 -0
- chipgraph-0.1.0.dist-info/entry_points.txt +61 -0
- chipgraph-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
"""The `git` VCS adapter: head, changed files, and disposable worktrees.
|
|
2
|
+
|
|
3
|
+
Implements `chipgraph.core.plugin_api.protocols.VcsAdapter`, and the fan-out port
|
|
4
|
+
`chipgraph.core.engine.fanout.FanoutVcs` (worktrees at a commit, commits, patches,
|
|
5
|
+
the result branch). All git invocations go through `subprocess.run` with argument lists
|
|
6
|
+
(never `shell=True`); a failed command raises `VcsError` naming the command and its
|
|
7
|
+
stderr.
|
|
8
|
+
|
|
9
|
+
`push_branch` (M2-11a) publishes a result branch: a fast-forward push, never a force,
|
|
10
|
+
with an HTTPS token given to git only through a `GIT_ASKPASS` helper (`askpass_helper`).
|
|
11
|
+
|
|
12
|
+
The fan-out methods never touch the working tree, index or HEAD of the user's checkout:
|
|
13
|
+
they run in their own worktree (or only add a ref), with no hooks
|
|
14
|
+
(`core.hooksPath=/dev/null`, `--no-verify`), no signing, no auto gc, and an environment
|
|
15
|
+
from which variables that would redirect git (`GIT_DIR`, `GIT_INDEX_FILE`, ...) are
|
|
16
|
+
removed.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import base64
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import shutil
|
|
25
|
+
import subprocess
|
|
26
|
+
import tempfile
|
|
27
|
+
import urllib.parse
|
|
28
|
+
from collections.abc import Sequence
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from chipgraph.core.engine.fanout import FileChange, FileStatus
|
|
32
|
+
|
|
33
|
+
_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class VcsError(Exception):
|
|
37
|
+
"""Raised when a git command fails, or a workspace operation is misused."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main_repo(path: Path) -> Path:
|
|
41
|
+
"""Return the main repository directory for the (possibly worktree) checkout at `path`."""
|
|
42
|
+
result = _run(["git", "-C", str(path), "rev-parse", "--git-common-dir"], cwd=path)
|
|
43
|
+
common_dir = Path(result.stdout.strip())
|
|
44
|
+
if not common_dir.is_absolute():
|
|
45
|
+
common_dir = (path / common_dir).resolve()
|
|
46
|
+
# `--git-common-dir` points at the `.git` directory; the repo root is its parent,
|
|
47
|
+
# unless the repo's `.git` dir does not sit directly under the worktree root (rare
|
|
48
|
+
# for our purposes), in which case the common dir itself is still a safe `-C` target.
|
|
49
|
+
if common_dir.name == ".git":
|
|
50
|
+
return common_dir.parent
|
|
51
|
+
return common_dir
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _run(cmd: list[str], *, cwd: Path) -> subprocess.CompletedProcess[str]:
|
|
55
|
+
try:
|
|
56
|
+
return subprocess.run(cmd, check=True, capture_output=True, text=True, cwd=cwd)
|
|
57
|
+
except subprocess.CalledProcessError as exc:
|
|
58
|
+
raise VcsError(f"command {' '.join(cmd)!r} failed in {cwd}: {exc.stderr.strip()}") from exc
|
|
59
|
+
except FileNotFoundError as exc:
|
|
60
|
+
raise VcsError(f"command {' '.join(cmd)!r} failed in {cwd}: {exc}") from exc
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Variables that point git at another repo, index or object store: a fan-out must
|
|
64
|
+
# never inherit them (e.g. when chipgraph runs from inside a git hook).
|
|
65
|
+
_REDIRECT_VARS = (
|
|
66
|
+
"GIT_DIR",
|
|
67
|
+
"GIT_WORK_TREE",
|
|
68
|
+
"GIT_INDEX_FILE",
|
|
69
|
+
"GIT_OBJECT_DIRECTORY",
|
|
70
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
71
|
+
"GIT_COMMON_DIR",
|
|
72
|
+
"GIT_NAMESPACE",
|
|
73
|
+
"GIT_PREFIX",
|
|
74
|
+
"GIT_CEILING_DIRECTORIES",
|
|
75
|
+
)
|
|
76
|
+
_SAFE_CONFIG = (
|
|
77
|
+
"-c",
|
|
78
|
+
f"core.hooksPath={os.devnull}",
|
|
79
|
+
"-c",
|
|
80
|
+
"commit.gpgsign=false",
|
|
81
|
+
"-c",
|
|
82
|
+
"gc.auto=0",
|
|
83
|
+
"-c",
|
|
84
|
+
"maintenance.auto=false",
|
|
85
|
+
"-c",
|
|
86
|
+
"diff.noprefix=false",
|
|
87
|
+
"-c",
|
|
88
|
+
"diff.mnemonicPrefix=false",
|
|
89
|
+
"-c",
|
|
90
|
+
"core.autocrlf=false",
|
|
91
|
+
)
|
|
92
|
+
_IDENTITY = {
|
|
93
|
+
"GIT_AUTHOR_NAME": "chipgraph",
|
|
94
|
+
"GIT_AUTHOR_EMAIL": "chipgraph@localhost",
|
|
95
|
+
"GIT_COMMITTER_NAME": "chipgraph",
|
|
96
|
+
"GIT_COMMITTER_EMAIL": "chipgraph@localhost",
|
|
97
|
+
}
|
|
98
|
+
_STATUS: dict[str, FileStatus] = {
|
|
99
|
+
"A": "added",
|
|
100
|
+
"M": "modified",
|
|
101
|
+
"D": "deleted",
|
|
102
|
+
"T": "type_changed",
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
_TOKEN_VARS = (
|
|
107
|
+
"CHIPGRAPH_GITHUB_TOKEN",
|
|
108
|
+
"GITHUB_TOKEN",
|
|
109
|
+
"GH_TOKEN",
|
|
110
|
+
"GIT_ASKPASS",
|
|
111
|
+
"CHIPGRAPH_ASKPASS_VALUE",
|
|
112
|
+
)
|
|
113
|
+
"""Variables no git command inherits: tokens, and any other askpass helper. Only
|
|
114
|
+
`push_branch` gives git a token, in its own environment, for that one command."""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _fanout_env() -> dict[str, str]:
|
|
118
|
+
env = {k: v for k, v in os.environ.items() if k not in _REDIRECT_VARS and k not in _TOKEN_VARS}
|
|
119
|
+
# The identity comes from the environment when set there, else a fixed one; the
|
|
120
|
+
# user's git config identity is not needed (and may be missing on a CI runner).
|
|
121
|
+
for key, value in _IDENTITY.items():
|
|
122
|
+
env.setdefault(key, value)
|
|
123
|
+
return env
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _git(
|
|
127
|
+
args: Sequence[str], *, cwd: Path, input: bytes | None = None, check: bool = True
|
|
128
|
+
) -> subprocess.CompletedProcess[bytes]:
|
|
129
|
+
"""Run `git <safe config> <args>` in `cwd` for the fan-out; bytes in and out."""
|
|
130
|
+
cmd = ["git", *_SAFE_CONFIG, *args]
|
|
131
|
+
try:
|
|
132
|
+
return subprocess.run(
|
|
133
|
+
cmd, check=check, capture_output=True, cwd=cwd, input=input, env=_fanout_env()
|
|
134
|
+
)
|
|
135
|
+
except subprocess.CalledProcessError as exc:
|
|
136
|
+
stderr = exc.stderr.decode("utf-8", "replace").strip()
|
|
137
|
+
raise VcsError(f"command {' '.join(cmd)!r} failed in {cwd}: {stderr}") from exc
|
|
138
|
+
except FileNotFoundError as exc:
|
|
139
|
+
raise VcsError(f"command {' '.join(cmd)!r} failed in {cwd}: {exc}") from exc
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _out(result: subprocess.CompletedProcess[bytes]) -> str:
|
|
143
|
+
return result.stdout.decode("utf-8", "surrogateescape").strip()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _parse_raw_z(output: bytes) -> tuple[FileChange, ...]:
|
|
147
|
+
"""Parse `git diff --raw -z --no-renames` output into `FileChange`s, sorted by path."""
|
|
148
|
+
fields = output.decode("utf-8", "surrogateescape").split("\0")
|
|
149
|
+
changes: list[FileChange] = []
|
|
150
|
+
i = 0
|
|
151
|
+
while i + 1 < len(fields):
|
|
152
|
+
meta, path = fields[i], fields[i + 1]
|
|
153
|
+
i += 2
|
|
154
|
+
if not meta.startswith(":"):
|
|
155
|
+
continue
|
|
156
|
+
old_mode, new_mode, _old, _new, status = meta[1:].split(" ", 4)
|
|
157
|
+
changes.append(
|
|
158
|
+
FileChange(
|
|
159
|
+
path=path,
|
|
160
|
+
status=_STATUS.get(status[:1], "modified"),
|
|
161
|
+
old_mode=None if old_mode == "000000" else old_mode,
|
|
162
|
+
new_mode=None if new_mode == "000000" else new_mode,
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
return tuple(sorted(changes, key=lambda c: c.path))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _parse_porcelain_z(output: str) -> set[str]:
|
|
169
|
+
"""Parse `git status --porcelain=v1 -z` output into a set of repo-relative paths."""
|
|
170
|
+
paths: set[str] = set()
|
|
171
|
+
fields = output.split("\0")
|
|
172
|
+
i = 0
|
|
173
|
+
while i < len(fields):
|
|
174
|
+
entry = fields[i]
|
|
175
|
+
if entry == "":
|
|
176
|
+
i += 1
|
|
177
|
+
continue
|
|
178
|
+
status = entry[:2]
|
|
179
|
+
path = entry[3:]
|
|
180
|
+
paths.add(path)
|
|
181
|
+
i += 1
|
|
182
|
+
if status[0] in ("R", "C") and i < len(fields) and fields[i] != "":
|
|
183
|
+
# Renamed/copied entries carry the original path as a following NUL field.
|
|
184
|
+
paths.add(fields[i])
|
|
185
|
+
i += 1
|
|
186
|
+
return paths
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class GitVcs:
|
|
190
|
+
"""The `git` implementation of `VcsAdapter` and of the fan-out port `FanoutVcs`."""
|
|
191
|
+
|
|
192
|
+
name = "git"
|
|
193
|
+
|
|
194
|
+
def __init__(self, workspaces_dir: Path | None = None) -> None:
|
|
195
|
+
self._workspaces_dir = workspaces_dir
|
|
196
|
+
|
|
197
|
+
def head(self, root: Path) -> str:
|
|
198
|
+
"""Return the current HEAD revision (full sha) of the repo at `root`."""
|
|
199
|
+
result = _run(["git", "rev-parse", "HEAD"], cwd=root)
|
|
200
|
+
return result.stdout.strip()
|
|
201
|
+
|
|
202
|
+
def changed_files(self, root: Path, since: str | None = None) -> tuple[str, ...]:
|
|
203
|
+
"""Return repo-relative paths changed in the working tree/index, and since `since`."""
|
|
204
|
+
paths: set[str] = set()
|
|
205
|
+
|
|
206
|
+
status = _run(["git", "status", "--porcelain=v1", "-z"], cwd=root)
|
|
207
|
+
paths |= _parse_porcelain_z(status.stdout)
|
|
208
|
+
|
|
209
|
+
if since is not None:
|
|
210
|
+
diff = _run(["git", "diff", "--name-only", "-z", since, "HEAD"], cwd=root)
|
|
211
|
+
paths |= {p for p in diff.stdout.split("\0") if p}
|
|
212
|
+
|
|
213
|
+
return tuple(sorted(paths))
|
|
214
|
+
|
|
215
|
+
def status_all(self, root: Path) -> tuple[str, ...]:
|
|
216
|
+
"""Every path that differs from HEAD in the tree at `root` (each untracked file too).
|
|
217
|
+
|
|
218
|
+
Ignored files are not listed.
|
|
219
|
+
"""
|
|
220
|
+
status = _run(["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"], cwd=root)
|
|
221
|
+
return tuple(sorted(_parse_porcelain_z(status.stdout)))
|
|
222
|
+
|
|
223
|
+
def create_workspace(self, root: Path, name: str) -> Path:
|
|
224
|
+
"""Create a detached worktree named `name` under the workspaces directory."""
|
|
225
|
+
if not _NAME_RE.match(name):
|
|
226
|
+
raise VcsError(f"invalid workspace name {name!r}: must match {_NAME_RE.pattern!r}")
|
|
227
|
+
base = self._workspaces_dir or (root / ".chipgraph" / "state" / "worktrees")
|
|
228
|
+
target = base / name
|
|
229
|
+
if target.exists():
|
|
230
|
+
raise VcsError(f"workspace path already exists: {target}")
|
|
231
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
232
|
+
_run(["git", "worktree", "add", "--detach", str(target), "HEAD"], cwd=root)
|
|
233
|
+
return target
|
|
234
|
+
|
|
235
|
+
def remove_workspace(self, path: Path) -> None:
|
|
236
|
+
"""Remove a worktree previously created by `create_workspace`."""
|
|
237
|
+
repo = main_repo(path)
|
|
238
|
+
_run(["git", "worktree", "remove", "--force", str(path)], cwd=repo)
|
|
239
|
+
_run(["git", "worktree", "prune"], cwd=repo)
|
|
240
|
+
|
|
241
|
+
# --- the fan-out port (`chipgraph.core.engine.fanout.FanoutVcs`) -------------------
|
|
242
|
+
|
|
243
|
+
def create_branch_workspace(self, root: Path, base: str, name: str, dest: Path) -> Path:
|
|
244
|
+
"""A detached worktree of `root` at commit `base`, at `dest / name`."""
|
|
245
|
+
if not _NAME_RE.match(name):
|
|
246
|
+
raise VcsError(f"invalid workspace name {name!r}: must match {_NAME_RE.pattern!r}")
|
|
247
|
+
target = dest / name
|
|
248
|
+
if target.exists():
|
|
249
|
+
raise VcsError(f"workspace path already exists: {target}")
|
|
250
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
251
|
+
_git(["worktree", "add", "--detach", "--quiet", str(target), base], cwd=root)
|
|
252
|
+
return target
|
|
253
|
+
|
|
254
|
+
def remove(self, path: Path) -> None:
|
|
255
|
+
"""Remove the worktree at `path` (even with changes, even if locked); no-op if gone."""
|
|
256
|
+
if not path.exists():
|
|
257
|
+
return
|
|
258
|
+
try:
|
|
259
|
+
repo = main_repo(path)
|
|
260
|
+
_git(["worktree", "remove", "--force", "--force", str(path)], cwd=repo)
|
|
261
|
+
except VcsError:
|
|
262
|
+
pass
|
|
263
|
+
if path.exists():
|
|
264
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
265
|
+
|
|
266
|
+
def prune(self, root: Path) -> None:
|
|
267
|
+
"""Forget worktrees of `root` whose directory is gone."""
|
|
268
|
+
_git(["worktree", "prune"], cwd=root)
|
|
269
|
+
|
|
270
|
+
def list_workspaces(self, root: Path) -> tuple[Path, ...]:
|
|
271
|
+
"""Every linked worktree of the repo at `root` (not the main one)."""
|
|
272
|
+
listing = _out(_git(["worktree", "list", "--porcelain", "-z"], cwd=root))
|
|
273
|
+
paths = [
|
|
274
|
+
Path(entry[len("worktree ") :])
|
|
275
|
+
for entry in listing.split("\0")
|
|
276
|
+
if entry.startswith("worktree ")
|
|
277
|
+
]
|
|
278
|
+
return tuple(paths[1:])
|
|
279
|
+
|
|
280
|
+
def commit_all(
|
|
281
|
+
self,
|
|
282
|
+
path: Path,
|
|
283
|
+
message: str,
|
|
284
|
+
paths: Sequence[str] | None = None,
|
|
285
|
+
*,
|
|
286
|
+
include_ignored: Sequence[str] = (),
|
|
287
|
+
) -> str:
|
|
288
|
+
"""Commit the worktree at `path`; HEAD when nothing is staged. No hooks run."""
|
|
289
|
+
if paths is None:
|
|
290
|
+
_git(["add", "-A"], cwd=path)
|
|
291
|
+
staged = list(include_ignored)
|
|
292
|
+
else:
|
|
293
|
+
staged = list(paths)
|
|
294
|
+
for rel in staged:
|
|
295
|
+
if (path / rel).exists() or (path / rel).is_symlink():
|
|
296
|
+
_git(["add", "-f", "--", rel], cwd=path)
|
|
297
|
+
else:
|
|
298
|
+
_git(["rm", "--cached", "-q", "--ignore-unmatch", "--", rel], cwd=path)
|
|
299
|
+
quiet = _git(["diff", "--cached", "--quiet"], cwd=path, check=False)
|
|
300
|
+
if quiet.returncode == 0:
|
|
301
|
+
return _out(_git(["rev-parse", "HEAD"], cwd=path))
|
|
302
|
+
_git(["commit", "-q", "--no-verify", "--no-gpg-sign", "-m", message], cwd=path)
|
|
303
|
+
return _out(_git(["rev-parse", "HEAD"], cwd=path))
|
|
304
|
+
|
|
305
|
+
def diff(self, path: Path, since: str, until: str) -> tuple[FileChange, ...]:
|
|
306
|
+
"""The files changed between two commits, sorted by path."""
|
|
307
|
+
result = _git(["diff", "--raw", "-z", "--no-renames", since, until], cwd=path)
|
|
308
|
+
return _parse_raw_z(result.stdout)
|
|
309
|
+
|
|
310
|
+
def patch(self, path: Path, since: str, until: str, paths: Sequence[str]) -> bytes:
|
|
311
|
+
"""A binary-safe patch of `paths` between two commits."""
|
|
312
|
+
if not paths:
|
|
313
|
+
return b""
|
|
314
|
+
result = _git(
|
|
315
|
+
[
|
|
316
|
+
"diff",
|
|
317
|
+
"--binary",
|
|
318
|
+
"--full-index",
|
|
319
|
+
"--no-renames",
|
|
320
|
+
"--no-color",
|
|
321
|
+
"--no-ext-diff",
|
|
322
|
+
"--no-textconv",
|
|
323
|
+
since,
|
|
324
|
+
until,
|
|
325
|
+
"--",
|
|
326
|
+
*paths,
|
|
327
|
+
],
|
|
328
|
+
cwd=path,
|
|
329
|
+
)
|
|
330
|
+
return result.stdout
|
|
331
|
+
|
|
332
|
+
def apply_patch(self, path: Path, patch: bytes) -> str | None:
|
|
333
|
+
"""Apply `patch` to the worktree at `path`, all or nothing; why not, if it fails."""
|
|
334
|
+
if not patch:
|
|
335
|
+
return None
|
|
336
|
+
result = _git(
|
|
337
|
+
["apply", "--binary", "--whitespace=nowarn", "-"], cwd=path, input=patch, check=False
|
|
338
|
+
)
|
|
339
|
+
if result.returncode == 0:
|
|
340
|
+
return None
|
|
341
|
+
return result.stderr.decode("utf-8", "replace").strip() or "the patch does not apply"
|
|
342
|
+
|
|
343
|
+
def tree_hash(self, path: Path, commit: str) -> str:
|
|
344
|
+
"""The tree id of `commit`."""
|
|
345
|
+
return _out(_git(["rev-parse", f"{commit}^{{tree}}"], cwd=path))
|
|
346
|
+
|
|
347
|
+
def create_branch(self, root: Path, name: str, commit: str) -> None:
|
|
348
|
+
"""Create branch `name` at `commit`; fails if it exists. HEAD is not moved."""
|
|
349
|
+
_git(["check-ref-format", "--branch", name], cwd=root)
|
|
350
|
+
self._refuse_checked_out(root, name)
|
|
351
|
+
_git(["branch", "--no-track", "--", name, commit], cwd=root)
|
|
352
|
+
|
|
353
|
+
def checked_out_branches(self, root: Path) -> frozenset[str]:
|
|
354
|
+
"""Branches checked out in `root`'s repository or in any of its linked worktrees.
|
|
355
|
+
|
|
356
|
+
From `git worktree list --porcelain`; a detached worktree has no branch.
|
|
357
|
+
"""
|
|
358
|
+
out = _out(_git(["worktree", "list", "--porcelain"], cwd=root))
|
|
359
|
+
prefix = "branch refs/heads/"
|
|
360
|
+
return frozenset(
|
|
361
|
+
line[len(prefix) :] for line in out.splitlines() if line.startswith(prefix)
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
def _refuse_checked_out(self, root: Path, name: str) -> None:
|
|
365
|
+
if name in self.checked_out_branches(root):
|
|
366
|
+
raise VcsError(
|
|
367
|
+
f"refusing to touch branch {name!r}: it is checked out in a worktree "
|
|
368
|
+
"(moving it would change that worktree's HEAD under it)"
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
# --- publishing a result branch (M2-11a) -----------------------------------------
|
|
372
|
+
|
|
373
|
+
def branch_tip(self, root: Path, name: str) -> str | None:
|
|
374
|
+
"""The commit branch `name` points at, or `None` if there is no such branch."""
|
|
375
|
+
_git(["check-ref-format", "--branch", name], cwd=root)
|
|
376
|
+
result = _git(
|
|
377
|
+
["rev-parse", "--verify", "--quiet", f"refs/heads/{name}^{{commit}}"],
|
|
378
|
+
cwd=root,
|
|
379
|
+
check=False,
|
|
380
|
+
)
|
|
381
|
+
if result.returncode != 0:
|
|
382
|
+
return None
|
|
383
|
+
return _out(result) or None
|
|
384
|
+
|
|
385
|
+
def advance_branch(self, root: Path, name: str, commit: str, old: str) -> None:
|
|
386
|
+
"""Move branch `name` from `old` to `commit`, only if `commit` descends from `old`.
|
|
387
|
+
|
|
388
|
+
A fast-forward only, and only if the branch is still at `old` (compare and swap);
|
|
389
|
+
HEAD, the index and the working tree are not touched.
|
|
390
|
+
"""
|
|
391
|
+
self._refuse_checked_out(root, name)
|
|
392
|
+
if commit == old:
|
|
393
|
+
return
|
|
394
|
+
ancestor = _git(["merge-base", "--is-ancestor", old, commit], cwd=root, check=False)
|
|
395
|
+
if ancestor.returncode != 0:
|
|
396
|
+
raise VcsError(
|
|
397
|
+
f"refusing to move branch {name!r}: {commit} does not descend from {old}"
|
|
398
|
+
)
|
|
399
|
+
_git(["update-ref", f"refs/heads/{name}", commit, old], cwd=root)
|
|
400
|
+
|
|
401
|
+
def remote_url(self, root: Path, remote: str) -> str:
|
|
402
|
+
"""The URL git pushes to for `remote` (raises `VcsError` if there is none)."""
|
|
403
|
+
return _out(_git(["remote", "get-url", "--push", "--", remote], cwd=root))
|
|
404
|
+
|
|
405
|
+
def push_branch(
|
|
406
|
+
self,
|
|
407
|
+
root: Path,
|
|
408
|
+
remote: str,
|
|
409
|
+
commit: str,
|
|
410
|
+
branch: str,
|
|
411
|
+
*,
|
|
412
|
+
token: str | None = None,
|
|
413
|
+
username: str = "x-access-token",
|
|
414
|
+
) -> None:
|
|
415
|
+
"""Push `commit` to `refs/heads/<branch>` of `remote`: a fast-forward, never a force.
|
|
416
|
+
|
|
417
|
+
With `token` (an HTTPS remote), git gets it through a `GIT_ASKPASS` helper script
|
|
418
|
+
in a private temporary directory, removed afterwards, which prints the token from one
|
|
419
|
+
environment variable of the git process: never in the command line, the URL, git
|
|
420
|
+
config, a file or an error message. No credential
|
|
421
|
+
helper runs (so nothing is stored), no terminal prompt, no hook. A remote that
|
|
422
|
+
refuses (not a fast-forward, no access) raises `VcsError`.
|
|
423
|
+
"""
|
|
424
|
+
_git(["check-ref-format", "--branch", branch], cwd=root)
|
|
425
|
+
args = [
|
|
426
|
+
"-c",
|
|
427
|
+
"credential.helper=",
|
|
428
|
+
"-c",
|
|
429
|
+
"core.askPass=",
|
|
430
|
+
"push",
|
|
431
|
+
"--porcelain",
|
|
432
|
+
"--no-verify",
|
|
433
|
+
"--",
|
|
434
|
+
remote,
|
|
435
|
+
f"{commit}:refs/heads/{branch}",
|
|
436
|
+
]
|
|
437
|
+
env = {k: v for k, v in _fanout_env().items() if k not in _TOKEN_VARS}
|
|
438
|
+
env["GIT_TERMINAL_PROMPT"] = "0"
|
|
439
|
+
env.pop("SSH_ASKPASS", None)
|
|
440
|
+
if token is None:
|
|
441
|
+
env.pop("GIT_ASKPASS", None)
|
|
442
|
+
_push(args, root, env, secret=None)
|
|
443
|
+
return
|
|
444
|
+
with tempfile.TemporaryDirectory(prefix="chipgraph-askpass-") as tmp:
|
|
445
|
+
helper = askpass_helper(Path(tmp), username=username)
|
|
446
|
+
env["GIT_ASKPASS"] = str(helper)
|
|
447
|
+
env[ASKPASS_VALUE_VAR] = token
|
|
448
|
+
_push(args, root, env, secret=token)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def secret_forms(secret: str) -> tuple[str, ...]:
|
|
452
|
+
"""Every spelling of `secret` that may show up in a message: as given, URL-encoded
|
|
453
|
+
(`quote`, `quote_plus`), base64 (standard and URL-safe, padded and not), and the
|
|
454
|
+
base64 of the HTTP Basic `user:token` pair that a header or a URL may carry."""
|
|
455
|
+
raw = secret.encode("utf-8")
|
|
456
|
+
forms = {secret, urllib.parse.quote(secret, safe=""), urllib.parse.quote_plus(secret)}
|
|
457
|
+
for blob in (raw, b"x-access-token:" + raw, b":" + raw):
|
|
458
|
+
for encoder in (base64.b64encode, base64.urlsafe_b64encode):
|
|
459
|
+
text = encoder(blob).decode("ascii")
|
|
460
|
+
forms.update({text, text.rstrip("=")})
|
|
461
|
+
return tuple(sorted((f for f in forms if f), key=len, reverse=True))
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def redact_secret(text: str, secret: str | None) -> str:
|
|
465
|
+
"""`text` with every form of `secret` (see `secret_forms`) replaced by `***`."""
|
|
466
|
+
if not secret:
|
|
467
|
+
return text
|
|
468
|
+
for form in secret_forms(secret):
|
|
469
|
+
text = text.replace(form, "***")
|
|
470
|
+
return text
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def url_host(url: str) -> str | None:
|
|
474
|
+
"""The host of an `http(s)://` URL, lower-case, without port or user info."""
|
|
475
|
+
parts = urllib.parse.urlsplit(url)
|
|
476
|
+
return parts.hostname.lower() if parts.scheme in ("http", "https") and parts.hostname else None
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
ASKPASS_VALUE_VAR = "CHIPGRAPH_ASKPASS_VALUE"
|
|
480
|
+
"""The variable of the git process that the askpass helper prints as the password."""
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def askpass_helper(directory: Path, *, username: str = "x-access-token") -> Path:
|
|
484
|
+
"""Write a `GIT_ASKPASS` helper in `directory` (private): it prints `username` for git's
|
|
485
|
+
username prompt and the value of `ASKPASS_VALUE_VAR` for its password prompt.
|
|
486
|
+
|
|
487
|
+
The script holds no secret and nothing secret is written to disk: the token is given to
|
|
488
|
+
the git process only as that one environment variable (never in the command line, the
|
|
489
|
+
URL or git's config), and is gone when the process ends.
|
|
490
|
+
"""
|
|
491
|
+
directory.chmod(0o700)
|
|
492
|
+
script = directory / "askpass.sh"
|
|
493
|
+
script.write_text(
|
|
494
|
+
"#!/bin/sh\n"
|
|
495
|
+
'case "$1" in\n'
|
|
496
|
+
f" Username*|username*) printf '%s\\n' {_sh_quote(username)} ;;\n"
|
|
497
|
+
f" *) printf '%s\\n' \"${{{ASKPASS_VALUE_VAR}}}\" ;;\n"
|
|
498
|
+
"esac\n",
|
|
499
|
+
encoding="utf-8",
|
|
500
|
+
)
|
|
501
|
+
script.chmod(0o700)
|
|
502
|
+
return script
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _sh_quote(text: str) -> str:
|
|
506
|
+
return "'" + text.replace("'", "'\"'\"'") + "'"
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _push(args: Sequence[str], root: Path, env: dict[str, str], *, secret: str | None) -> None:
|
|
510
|
+
cmd = ["git", *_SAFE_CONFIG, *args]
|
|
511
|
+
try:
|
|
512
|
+
subprocess.run(cmd, check=True, capture_output=True, cwd=root, env=env)
|
|
513
|
+
except subprocess.CalledProcessError as exc:
|
|
514
|
+
stderr = exc.stderr.decode("utf-8", "replace").strip()
|
|
515
|
+
stdout = exc.stdout.decode("utf-8", "replace").strip()
|
|
516
|
+
message = redact_secret(
|
|
517
|
+
f"git push to {args[-2]!r} failed in {root}: {stderr or stdout}", secret
|
|
518
|
+
)
|
|
519
|
+
raise VcsError(message) from None
|
|
520
|
+
except FileNotFoundError as exc:
|
|
521
|
+
raise VcsError(f"git push failed in {root}: {exc}") from None
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""The application layer: wires `chipgraph.core` to `chipgraph.adapters` and
|
|
2
|
+
`chipgraph.checks` for a concrete project, on behalf of the CLI (and, later, the MCP
|
|
3
|
+
server). Core must not import this package (see `pyproject.toml`'s import-linter
|
|
4
|
+
contract): this layer is where the engine actually meets tools, checks and a project's
|
|
5
|
+
`.chipgraph.yml`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from chipgraph.app.errors import AppError
|
|
9
|
+
|
|
10
|
+
__all__ = ["AppError"]
|