compforge-repokit 0.15.0__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.
- compforge_repokit-0.15.0/.gitignore +16 -0
- compforge_repokit-0.15.0/AGENTS.md +36 -0
- compforge_repokit-0.15.0/Makefile +13 -0
- compforge_repokit-0.15.0/PKG-INFO +114 -0
- compforge_repokit-0.15.0/README.md +105 -0
- compforge_repokit-0.15.0/pyproject.toml +33 -0
- compforge_repokit-0.15.0/repokit/__init__.py +40 -0
- compforge_repokit-0.15.0/repokit/_git.py +177 -0
- compforge_repokit-0.15.0/repokit/_inspect.py +70 -0
- compforge_repokit-0.15.0/repokit/_language.py +23 -0
- compforge_repokit-0.15.0/repokit/_layout.py +187 -0
- compforge_repokit-0.15.0/repokit/_process.py +135 -0
- compforge_repokit-0.15.0/repokit/_staging.py +59 -0
- compforge_repokit-0.15.0/repokit/budget.py +40 -0
- compforge_repokit-0.15.0/repokit/dependencies.py +378 -0
- compforge_repokit-0.15.0/repokit/forge/__init__.py +13 -0
- compforge_repokit-0.15.0/repokit/forge/_http_state.py +92 -0
- compforge_repokit-0.15.0/repokit/forge/_page.py +34 -0
- compforge_repokit-0.15.0/repokit/forge/_rest.py +233 -0
- compforge_repokit-0.15.0/repokit/forge/creation.py +88 -0
- compforge_repokit-0.15.0/repokit/forge/github.py +440 -0
- compforge_repokit-0.15.0/repokit/forge/gitlab.py +407 -0
- compforge_repokit-0.15.0/repokit/forge/model.py +380 -0
- compforge_repokit-0.15.0/repokit/forge/observation/__init__.py +9 -0
- compforge_repokit-0.15.0/repokit/forge/observation/context.py +255 -0
- compforge_repokit-0.15.0/repokit/forge/observation/model.py +40 -0
- compforge_repokit-0.15.0/repokit/forge/observation/service.py +283 -0
- compforge_repokit-0.15.0/repokit/forge/observation/store.py +136 -0
- compforge_repokit-0.15.0/repokit/git.py +296 -0
- compforge_repokit-0.15.0/repokit/git_index.py +275 -0
- compforge_repokit-0.15.0/repokit/git_state.py +447 -0
- compforge_repokit-0.15.0/repokit/languages.json +1115 -0
- compforge_repokit-0.15.0/repokit/model.py +59 -0
- compforge_repokit-0.15.0/repokit/py.typed +0 -0
- compforge_repokit-0.15.0/repokit/remote.py +41 -0
- compforge_repokit-0.15.0/repokit/repository.py +84 -0
- compforge_repokit-0.15.0/repokit/snapshot.py +275 -0
- compforge_repokit-0.15.0/repokit/stash.py +66 -0
- compforge_repokit-0.15.0/repokit/tree.py +191 -0
- compforge_repokit-0.15.0/repokit/validation.py +508 -0
- compforge_repokit-0.15.0/tests/conftest.py +16 -0
- compforge_repokit-0.15.0/tests/test_budget.py +91 -0
- compforge_repokit-0.15.0/tests/test_checkout_contract.py +66 -0
- compforge_repokit-0.15.0/tests/test_dependencies.py +223 -0
- compforge_repokit-0.15.0/tests/test_forge.py +646 -0
- compforge_repokit-0.15.0/tests/test_forge_http.py +200 -0
- compforge_repokit-0.15.0/tests/test_forge_repositories.py +125 -0
- compforge_repokit-0.15.0/tests/test_fork.py +193 -0
- compforge_repokit-0.15.0/tests/test_git_facts.py +121 -0
- compforge_repokit-0.15.0/tests/test_git_helpers.py +111 -0
- compforge_repokit-0.15.0/tests/test_git_index.py +69 -0
- compforge_repokit-0.15.0/tests/test_inspect.py +361 -0
- compforge_repokit-0.15.0/tests/test_metadata_contract.py +76 -0
- compforge_repokit-0.15.0/tests/test_operations.py +93 -0
- compforge_repokit-0.15.0/tests/test_pr_creation.py +113 -0
- compforge_repokit-0.15.0/tests/test_pr_observation.py +333 -0
- compforge_repokit-0.15.0/tests/test_process.py +113 -0
- compforge_repokit-0.15.0/tests/test_rebase.py +87 -0
- compforge_repokit-0.15.0/tests/test_repository_metadata.py +48 -0
- compforge_repokit-0.15.0/tests/test_repository_queries.py +66 -0
- compforge_repokit-0.15.0/tests/test_scalar_contract.py +88 -0
- compforge_repokit-0.15.0/tests/test_snapshot_scopes.py +97 -0
- compforge_repokit-0.15.0/tests/test_staging.py +174 -0
- compforge_repokit-0.15.0/tests/test_stash.py +85 -0
- compforge_repokit-0.15.0/tests/test_tree.py +74 -0
- compforge_repokit-0.15.0/tests/test_validation.py +191 -0
- compforge_repokit-0.15.0/uv.lock +390 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/bin/
|
|
2
|
+
/coverage.out
|
|
3
|
+
/.worktrees/
|
|
4
|
+
/.devloop/
|
|
5
|
+
|
|
6
|
+
/toolkit/typescript/node_modules/
|
|
7
|
+
/toolkit/typescript/dist/
|
|
8
|
+
|
|
9
|
+
/go.work.sum
|
|
10
|
+
|
|
11
|
+
/toolkit/python/.venv/
|
|
12
|
+
/toolkit/python/.mypy_cache/
|
|
13
|
+
/toolkit/python/.ruff_cache/
|
|
14
|
+
/toolkit/python/.pytest_cache/
|
|
15
|
+
/toolkit/python/dist/
|
|
16
|
+
/toolkit/python/**/__pycache__/
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Python 仓库工具包
|
|
2
|
+
|
|
3
|
+
## 项目定位与边界
|
|
4
|
+
|
|
5
|
+
`compforge-repokit`(导入名 `repokit`)提供原生同步 tree / inspect / owner,通过 Git CLI 读取版本控制事实,
|
|
6
|
+
不包装 repocli CLI。身份复用 harness-common;布局与工具证据归本包,消费方拥有执行策略。
|
|
7
|
+
|
|
8
|
+
## 代码地图与核心模块
|
|
9
|
+
|
|
10
|
+
```text
|
|
11
|
+
repokit/
|
|
12
|
+
├── __init__.py / model.py # 公共 API、common 派生类型和 owner 查询
|
|
13
|
+
├── validation.py # 原生 Makefile 规划、执行与整轮验证报告
|
|
14
|
+
├── dependencies.py # 只读依赖观察与显式锁定安装;workspace 安装根与回执
|
|
15
|
+
├── _inspect.py # 输入版本、整次调用预算与一致性观察
|
|
16
|
+
├── _git.py / _process.py # 文件目录、批量对象读取与有界 Git 子进程
|
|
17
|
+
├── _layout.py # 纯布局投影、manifest / Makefile 识别和包工具证据
|
|
18
|
+
└── _language.py / languages.json # 固定依赖生成的文件名识别目录
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## 关键约定
|
|
22
|
+
|
|
23
|
+
- 公共入口使用 Python 命名与 frozen dataclass;未知身份不伪造,完整性保留诊断。
|
|
24
|
+
- Git 子进程与元数据读取共享 deadline;超时、取消或超出输出容量时终止并回收子进程。
|
|
25
|
+
按目录和对象批次读取,不为每个源文件启动 Git。
|
|
26
|
+
- 配置只作静态数据;inspect 不读取源码内容;snapshot 捕获内容但不执行目标项目。操作 API 不保存消费方状态。
|
|
27
|
+
- 生成目录由根 `make generate-languages` 更新,不能独立修改;三种语言共用 conformance。
|
|
28
|
+
- 使用 `uv sync --locked` 准备依赖;改动后运行 make format/lint/test/build。uv.lock 由 uv 生成。
|
|
29
|
+
Python 包版本由 pyproject.toml 管理,同时遵守根 VERSION 约定。
|
|
30
|
+
|
|
31
|
+
## References
|
|
32
|
+
|
|
33
|
+
- [共享识别契约](../../docs/repository.md)
|
|
34
|
+
- [调用方式](README.md)
|
|
35
|
+
|
|
36
|
+
仓库操作契约见 [Git](../../docs/git.md) 与 [PR](../../docs/pr.md)。分析与写操作分别显式调用,不能让分析触发写入。
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
UV ?= uv
|
|
2
|
+
.PHONY: format lint lint-ci test build
|
|
3
|
+
format:
|
|
4
|
+
$(UV) run ruff format repokit tests
|
|
5
|
+
lint:
|
|
6
|
+
$(UV) run ruff check repokit tests
|
|
7
|
+
$(UV) run ruff format --check repokit tests
|
|
8
|
+
$(UV) run mypy repokit
|
|
9
|
+
lint-ci: lint
|
|
10
|
+
test:
|
|
11
|
+
$(UV) run pytest $(TEST_FILES)
|
|
12
|
+
build:
|
|
13
|
+
$(UV) build
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: compforge-repokit
|
|
3
|
+
Version: 0.15.0
|
|
4
|
+
Summary: Native repository inspection, content identity, Git and Forge operations
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: harness-common<0.7,>=0.6
|
|
7
|
+
Requires-Dist: pyyaml<7,>=6
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# RepoKit for Python
|
|
11
|
+
|
|
12
|
+
Native organization inspection for Git repositories: discover Components, language and
|
|
13
|
+
package-tool evidence, then locate the owner of a repository-relative path.
|
|
14
|
+
Python 3.11+ on macOS/Linux and Git on PATH are required. Organization analysis runs
|
|
15
|
+
in Python; Git provides version-control facts through batched commands.
|
|
16
|
+
|
|
17
|
+
Install from the repository checkout:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
python -m pip install ./toolkit/python
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from repokit import inspect, owner
|
|
25
|
+
|
|
26
|
+
report = inspect("/path/to/repo", timeout=5)
|
|
27
|
+
if not report.complete:
|
|
28
|
+
raise RuntimeError(report.diagnostics)
|
|
29
|
+
component = owner(report, "server/main.py")
|
|
30
|
+
if component is not None:
|
|
31
|
+
print(component.name, component.root, component.language)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`inspect(..., staged=True)` selects the index; `inspect(..., head="HEAD")` selects a
|
|
35
|
+
resolved commit. The default observes the working tree, including non-ignored untracked
|
|
36
|
+
paths. `timeout` is a whole-call budget in seconds; an optional `threading.Event` passed
|
|
37
|
+
as `cancel` requests cancellation. Git processes are terminated and reaped on timeout,
|
|
38
|
+
cancellation or output overflow. Filesystem reads and Python analysis check the budget
|
|
39
|
+
between operations. Timeout raises `TimeoutError`, cancellation raises `InterruptedError`,
|
|
40
|
+
invalid selections/configuration or limits raise `ValueError`, and filesystem/Git failures
|
|
41
|
+
propagate `OSError` / `subprocess.CalledProcessError`.
|
|
42
|
+
|
|
43
|
+
Reports and their nested collections are immutable dataclasses and tuples. Shared
|
|
44
|
+
Repository, Component, Product and Forge identities come from `harness-common`;
|
|
45
|
+
ComponentBinding adds checkout layout and tool evidence. `owner` accepts a repository-relative
|
|
46
|
+
path and returns the deepest binding or None, including for deleted paths.
|
|
47
|
+
Python properties use snake_case; these objects are not a CLI JSON protocol adapter.
|
|
48
|
+
|
|
49
|
+
Inspection reads filenames and necessary metadata, not source contents or ASTs. It never
|
|
50
|
+
runs project code or installs dependencies. `complete` concerns observed organization;
|
|
51
|
+
concurrent metadata changes return an incomplete report with diagnostics. Consumers own
|
|
52
|
+
acceptance, command selection and validation policy. Snapshot, diff and graph analysis
|
|
53
|
+
remain available through the Go toolkit / CLI.
|
|
54
|
+
|
|
55
|
+
Development: `uv sync --locked`, then `make format`, `make lint`, `make test`, `make build`.
|
|
56
|
+
Tests consume the shared `conformance/inspect` cases and generated filename probes.
|
|
57
|
+
See [the inspection contract](../../docs/repository.md).
|
|
58
|
+
|
|
59
|
+
## Repository toolkit
|
|
60
|
+
|
|
61
|
+
工具包提供仓库组织、内容身份与 Git 查询。Python 还提供 worktree、提交、推送、rebase 和
|
|
62
|
+
GitHub/GitLab 的 PR/MR 操作;调用方直接导入库,组织自己的开发流程。
|
|
63
|
+
分析接口保持只读,操作接口接收明确参数,不隐式执行验证或管理任务状态。
|
|
64
|
+
|
|
65
|
+
详见 [Git 操作](../../docs/git.md)与[PR 契约](../../docs/pr.md)。
|
|
66
|
+
|
|
67
|
+
## Repository entries
|
|
68
|
+
|
|
69
|
+
`tree` lists directories, files, Git entry kinds and known roles independently of
|
|
70
|
+
Component discovery, for working-tree, index and commit inputs. Project Manifest
|
|
71
|
+
files retain their ecosystem; Component bindings retain `manifests` as path evidence.
|
|
72
|
+
Components provide the granularity for lint, test and packaging operations. Discovery prefers
|
|
73
|
+
manifests, then Makefiles with unknown language. Without either, the repository has no
|
|
74
|
+
recognized Component; no root Component or tool-specific configuration file is created.
|
|
75
|
+
See [repository contract](../../docs/repository.md) and [snapshot v2](../../docs/snapshot.md).
|
|
76
|
+
|
|
77
|
+
### Component content identities
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from repokit import component_scopes, inspect, snapshot
|
|
81
|
+
|
|
82
|
+
layout = inspect(repository)
|
|
83
|
+
contents = snapshot(repository, scopes=component_scopes(layout))
|
|
84
|
+
for component in contents.scopes:
|
|
85
|
+
print(component.name, component.digest, component.complete)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Verify layout/content consistency in the calling operation. These scopes follow file
|
|
89
|
+
ownership, not transitive build dependencies; see [Snapshot](../../docs/snapshot.md).
|
|
90
|
+
|
|
91
|
+
## Makefile validation
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from repokit import plan_validation, execute_validation
|
|
95
|
+
|
|
96
|
+
plan = plan_validation(repo, components=("backend",), checks=("lint", "test"))
|
|
97
|
+
report = execute_validation(plan, timeout=600)
|
|
98
|
+
# Use report.complete and report.passed together, not an isolated command exit code.
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Planning never executes Make. Targets and optional LINT_FILES/TEST_FILES filtering follow
|
|
102
|
+
[the shared Makefile contract](../../docs/validation.md). Prepare dependencies and run any
|
|
103
|
+
formatter before planning. Execution freezes the environment, bounds subprocesses, and
|
|
104
|
+
checks repository contents once before and once after the round. Reports contain execution
|
|
105
|
+
facts; callers own persistence, reuse and workflow gates.
|
|
106
|
+
|
|
107
|
+
PR lifecycle queries are native Python: `repokit.forge.PullRequestObserver` owns durable observations,
|
|
108
|
+
terminal-state convergence, retries and optional periodic maintenance. `PullRequestContext` relates PRs
|
|
109
|
+
to live local branches and checkouts without choosing workflow or cleanup policy. See [PR contracts](../../docs/pr.md).
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
PR 创建和复用使用 `repokit.forge.ensure_pull_request`,无需调用方解析目标仓或默认分支。
|
|
113
|
+
Git 提交与推送使用 `repokit.git.commit` / `push`;提交范围、验证门禁和动作顺序仍由调用方负责。
|
|
114
|
+
完整契约见 [PR](../../docs/pr.md#创建与复用) 与 [Git](../../docs/git.md)。
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# RepoKit for Python
|
|
2
|
+
|
|
3
|
+
Native organization inspection for Git repositories: discover Components, language and
|
|
4
|
+
package-tool evidence, then locate the owner of a repository-relative path.
|
|
5
|
+
Python 3.11+ on macOS/Linux and Git on PATH are required. Organization analysis runs
|
|
6
|
+
in Python; Git provides version-control facts through batched commands.
|
|
7
|
+
|
|
8
|
+
Install from the repository checkout:
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
python -m pip install ./toolkit/python
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from repokit import inspect, owner
|
|
16
|
+
|
|
17
|
+
report = inspect("/path/to/repo", timeout=5)
|
|
18
|
+
if not report.complete:
|
|
19
|
+
raise RuntimeError(report.diagnostics)
|
|
20
|
+
component = owner(report, "server/main.py")
|
|
21
|
+
if component is not None:
|
|
22
|
+
print(component.name, component.root, component.language)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`inspect(..., staged=True)` selects the index; `inspect(..., head="HEAD")` selects a
|
|
26
|
+
resolved commit. The default observes the working tree, including non-ignored untracked
|
|
27
|
+
paths. `timeout` is a whole-call budget in seconds; an optional `threading.Event` passed
|
|
28
|
+
as `cancel` requests cancellation. Git processes are terminated and reaped on timeout,
|
|
29
|
+
cancellation or output overflow. Filesystem reads and Python analysis check the budget
|
|
30
|
+
between operations. Timeout raises `TimeoutError`, cancellation raises `InterruptedError`,
|
|
31
|
+
invalid selections/configuration or limits raise `ValueError`, and filesystem/Git failures
|
|
32
|
+
propagate `OSError` / `subprocess.CalledProcessError`.
|
|
33
|
+
|
|
34
|
+
Reports and their nested collections are immutable dataclasses and tuples. Shared
|
|
35
|
+
Repository, Component, Product and Forge identities come from `harness-common`;
|
|
36
|
+
ComponentBinding adds checkout layout and tool evidence. `owner` accepts a repository-relative
|
|
37
|
+
path and returns the deepest binding or None, including for deleted paths.
|
|
38
|
+
Python properties use snake_case; these objects are not a CLI JSON protocol adapter.
|
|
39
|
+
|
|
40
|
+
Inspection reads filenames and necessary metadata, not source contents or ASTs. It never
|
|
41
|
+
runs project code or installs dependencies. `complete` concerns observed organization;
|
|
42
|
+
concurrent metadata changes return an incomplete report with diagnostics. Consumers own
|
|
43
|
+
acceptance, command selection and validation policy. Snapshot, diff and graph analysis
|
|
44
|
+
remain available through the Go toolkit / CLI.
|
|
45
|
+
|
|
46
|
+
Development: `uv sync --locked`, then `make format`, `make lint`, `make test`, `make build`.
|
|
47
|
+
Tests consume the shared `conformance/inspect` cases and generated filename probes.
|
|
48
|
+
See [the inspection contract](../../docs/repository.md).
|
|
49
|
+
|
|
50
|
+
## Repository toolkit
|
|
51
|
+
|
|
52
|
+
工具包提供仓库组织、内容身份与 Git 查询。Python 还提供 worktree、提交、推送、rebase 和
|
|
53
|
+
GitHub/GitLab 的 PR/MR 操作;调用方直接导入库,组织自己的开发流程。
|
|
54
|
+
分析接口保持只读,操作接口接收明确参数,不隐式执行验证或管理任务状态。
|
|
55
|
+
|
|
56
|
+
详见 [Git 操作](../../docs/git.md)与[PR 契约](../../docs/pr.md)。
|
|
57
|
+
|
|
58
|
+
## Repository entries
|
|
59
|
+
|
|
60
|
+
`tree` lists directories, files, Git entry kinds and known roles independently of
|
|
61
|
+
Component discovery, for working-tree, index and commit inputs. Project Manifest
|
|
62
|
+
files retain their ecosystem; Component bindings retain `manifests` as path evidence.
|
|
63
|
+
Components provide the granularity for lint, test and packaging operations. Discovery prefers
|
|
64
|
+
manifests, then Makefiles with unknown language. Without either, the repository has no
|
|
65
|
+
recognized Component; no root Component or tool-specific configuration file is created.
|
|
66
|
+
See [repository contract](../../docs/repository.md) and [snapshot v2](../../docs/snapshot.md).
|
|
67
|
+
|
|
68
|
+
### Component content identities
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from repokit import component_scopes, inspect, snapshot
|
|
72
|
+
|
|
73
|
+
layout = inspect(repository)
|
|
74
|
+
contents = snapshot(repository, scopes=component_scopes(layout))
|
|
75
|
+
for component in contents.scopes:
|
|
76
|
+
print(component.name, component.digest, component.complete)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Verify layout/content consistency in the calling operation. These scopes follow file
|
|
80
|
+
ownership, not transitive build dependencies; see [Snapshot](../../docs/snapshot.md).
|
|
81
|
+
|
|
82
|
+
## Makefile validation
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from repokit import plan_validation, execute_validation
|
|
86
|
+
|
|
87
|
+
plan = plan_validation(repo, components=("backend",), checks=("lint", "test"))
|
|
88
|
+
report = execute_validation(plan, timeout=600)
|
|
89
|
+
# Use report.complete and report.passed together, not an isolated command exit code.
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Planning never executes Make. Targets and optional LINT_FILES/TEST_FILES filtering follow
|
|
93
|
+
[the shared Makefile contract](../../docs/validation.md). Prepare dependencies and run any
|
|
94
|
+
formatter before planning. Execution freezes the environment, bounds subprocesses, and
|
|
95
|
+
checks repository contents once before and once after the round. Reports contain execution
|
|
96
|
+
facts; callers own persistence, reuse and workflow gates.
|
|
97
|
+
|
|
98
|
+
PR lifecycle queries are native Python: `repokit.forge.PullRequestObserver` owns durable observations,
|
|
99
|
+
terminal-state convergence, retries and optional periodic maintenance. `PullRequestContext` relates PRs
|
|
100
|
+
to live local branches and checkouts without choosing workflow or cleanup policy. See [PR contracts](../../docs/pr.md).
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
PR 创建和复用使用 `repokit.forge.ensure_pull_request`,无需调用方解析目标仓或默认分支。
|
|
104
|
+
Git 提交与推送使用 `repokit.git.commit` / `push`;提交范围、验证门禁和动作顺序仍由调用方负责。
|
|
105
|
+
完整契约见 [PR](../../docs/pr.md#创建与复用) 与 [Git](../../docs/git.md)。
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "compforge-repokit"
|
|
3
|
+
version = "0.15.0"
|
|
4
|
+
description = "Native repository inspection, content identity, Git and Forge operations"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = ["harness-common>=0.6,<0.7", "pyyaml>=6,<7"]
|
|
8
|
+
|
|
9
|
+
[build-system]
|
|
10
|
+
requires = ["hatchling"]
|
|
11
|
+
build-backend = "hatchling.build"
|
|
12
|
+
|
|
13
|
+
[tool.hatch.build.targets.wheel]
|
|
14
|
+
packages = ["repokit"]
|
|
15
|
+
|
|
16
|
+
[dependency-groups]
|
|
17
|
+
dev = ["pytest>=8,<10", "ruff>=0.11,<1", "mypy>=1.15,<2", "types-PyYAML>=6,<7"]
|
|
18
|
+
|
|
19
|
+
[tool.ruff]
|
|
20
|
+
line-length = 100
|
|
21
|
+
|
|
22
|
+
[tool.ruff.lint]
|
|
23
|
+
select = ["E", "F", "I", "UP"]
|
|
24
|
+
|
|
25
|
+
[tool.mypy]
|
|
26
|
+
strict = true
|
|
27
|
+
|
|
28
|
+
[[tool.mypy.overrides]]
|
|
29
|
+
module = "harness_common.*"
|
|
30
|
+
follow_untyped_imports = true
|
|
31
|
+
|
|
32
|
+
[tool.hatch.metadata]
|
|
33
|
+
allow-direct-references = true
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Native repository organization; using Git paths and metadata."""
|
|
2
|
+
|
|
3
|
+
from harness_common import Component as Component
|
|
4
|
+
from harness_common import Forge as Forge
|
|
5
|
+
from harness_common import Product as Product
|
|
6
|
+
from harness_common import Repository as Repository
|
|
7
|
+
|
|
8
|
+
from ._inspect import inspect as inspect
|
|
9
|
+
from .budget import operation as operation
|
|
10
|
+
from .dependencies import DependencyEnvironment as DependencyEnvironment
|
|
11
|
+
from .dependencies import DependencyPreparation as DependencyPreparation
|
|
12
|
+
from .dependencies import inspect_dependencies as inspect_dependencies
|
|
13
|
+
from .dependencies import prepare_dependencies as prepare_dependencies
|
|
14
|
+
from .model import ComponentBinding as ComponentBinding
|
|
15
|
+
from .model import Diagnostic as Diagnostic
|
|
16
|
+
from .model import InspectReport as InspectReport
|
|
17
|
+
from .model import Layout as Layout
|
|
18
|
+
from .model import PackageTool as PackageTool
|
|
19
|
+
from .model import owner as owner
|
|
20
|
+
from .snapshot import ScopedSnapshot as ScopedSnapshot
|
|
21
|
+
from .snapshot import Snapshot as Snapshot
|
|
22
|
+
from .snapshot import SnapshotScope as SnapshotScope
|
|
23
|
+
from .snapshot import component_scopes as component_scopes
|
|
24
|
+
from .snapshot import snapshot as snapshot
|
|
25
|
+
from .tree import Directory as Directory
|
|
26
|
+
from .tree import File as File
|
|
27
|
+
from .tree import Manifest as Manifest
|
|
28
|
+
from .tree import TreeReport as TreeReport
|
|
29
|
+
from .tree import tree as tree
|
|
30
|
+
from .validation import CheckResult as CheckResult
|
|
31
|
+
from .validation import CheckSpec as CheckSpec
|
|
32
|
+
from .validation import ComponentValidationPlan as ComponentValidationPlan
|
|
33
|
+
from .validation import ComponentValidationResult as ComponentValidationResult
|
|
34
|
+
from .validation import ValidationPlan as ValidationPlan
|
|
35
|
+
from .validation import ValidationReport as ValidationReport
|
|
36
|
+
from .validation import execute_validation as execute_validation
|
|
37
|
+
from .validation import lint as lint
|
|
38
|
+
from .validation import plan_validation as plan_validation
|
|
39
|
+
from .validation import test as test
|
|
40
|
+
from .validation import validate as validate
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Read Git catalogs and layout metadata without reading source contents."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import stat
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ._layout import Catalog, needs_content, valid_path
|
|
8
|
+
from ._process import Budget, run
|
|
9
|
+
|
|
10
|
+
MAX_FILE_BYTES = 2 << 20
|
|
11
|
+
MAX_TOTAL_BYTES = 128 << 20
|
|
12
|
+
MAX_FILES = 10_000
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Git:
|
|
16
|
+
def __init__(self, root: Path, budget: Budget):
|
|
17
|
+
self.root = root
|
|
18
|
+
self.budget = budget
|
|
19
|
+
|
|
20
|
+
def run(
|
|
21
|
+
self,
|
|
22
|
+
args: list[str],
|
|
23
|
+
*,
|
|
24
|
+
input: bytes = b"",
|
|
25
|
+
allow_missing: bool = False,
|
|
26
|
+
max_output: int = 16 << 20,
|
|
27
|
+
) -> bytes:
|
|
28
|
+
return run(
|
|
29
|
+
self.root,
|
|
30
|
+
args,
|
|
31
|
+
self.budget,
|
|
32
|
+
input=input,
|
|
33
|
+
allow_missing=allow_missing,
|
|
34
|
+
max_output=max_output,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def gitlink_oid(self, name: str, oid: str) -> str:
|
|
38
|
+
"""Read checkout HEAD only; uninitialized entries retain the index reference."""
|
|
39
|
+
path = self.root / name
|
|
40
|
+
try:
|
|
41
|
+
(path / ".git").lstat()
|
|
42
|
+
except FileNotFoundError:
|
|
43
|
+
return oid
|
|
44
|
+
if path.resolve() != path:
|
|
45
|
+
raise ValueError(f"symlinked gitlink checkout: {name}")
|
|
46
|
+
child = Git(path, self.budget)
|
|
47
|
+
root = os.fsdecode(child.run(["rev-parse", "--show-toplevel"])).removesuffix("\n")
|
|
48
|
+
if Path(root) != path:
|
|
49
|
+
raise ValueError(f"invalid gitlink checkout: {name}")
|
|
50
|
+
return child.run(["rev-parse", "--verify", "HEAD"]).decode().strip()
|
|
51
|
+
|
|
52
|
+
def catalog(self, head: str, staged: bool) -> Catalog:
|
|
53
|
+
committed = bool(head) or staged
|
|
54
|
+
output = self.run(
|
|
55
|
+
["ls-tree", "-r", "-z", "--full-tree", head] if head else ["ls-files", "--stage", "-z"]
|
|
56
|
+
)
|
|
57
|
+
entries: dict[str, str] = {}
|
|
58
|
+
for entry in output.split(b"\0"):
|
|
59
|
+
if not entry:
|
|
60
|
+
continue
|
|
61
|
+
fields, separator, raw_name = entry.partition(b"\t")
|
|
62
|
+
parts = fields.split()
|
|
63
|
+
name = os.fsdecode(raw_name)
|
|
64
|
+
if not separator or len(parts) != 3 or not valid_path(name):
|
|
65
|
+
raise ValueError("invalid Git catalog entry")
|
|
66
|
+
mode = parts[0]
|
|
67
|
+
if not head and parts[2] != b"0":
|
|
68
|
+
raise ValueError(f"unmerged index entry: {name}")
|
|
69
|
+
if mode == b"160000":
|
|
70
|
+
continue
|
|
71
|
+
if mode == b"120000" and committed:
|
|
72
|
+
if needs_content(name):
|
|
73
|
+
raise ValueError(f"metadata {name} is a symlink")
|
|
74
|
+
continue
|
|
75
|
+
if mode not in (b"100644", b"100755", b"120000"):
|
|
76
|
+
raise ValueError(f"unsupported Git entry mode for {name}")
|
|
77
|
+
entries[name] = parts[2 if head else 1].decode("ascii")
|
|
78
|
+
if not committed:
|
|
79
|
+
for raw_name in self.run(["ls-files", "-z", "--others", "--exclude-standard"]).split(
|
|
80
|
+
b"\0"
|
|
81
|
+
):
|
|
82
|
+
name = os.fsdecode(raw_name)
|
|
83
|
+
if not name or name.endswith("/"):
|
|
84
|
+
continue
|
|
85
|
+
if not valid_path(name):
|
|
86
|
+
raise ValueError("unsafe repository path")
|
|
87
|
+
entries[name] = ""
|
|
88
|
+
if len(entries) > MAX_FILES:
|
|
89
|
+
raise ValueError("repository exceeds 10000 files")
|
|
90
|
+
files: Catalog = {}
|
|
91
|
+
selected: list[tuple[str, str]] = []
|
|
92
|
+
total = 0
|
|
93
|
+
for name in sorted(entries):
|
|
94
|
+
self.budget.remaining()
|
|
95
|
+
if committed:
|
|
96
|
+
files[name] = None
|
|
97
|
+
if needs_content(name):
|
|
98
|
+
oid = entries[name]
|
|
99
|
+
selected.append((name, oid))
|
|
100
|
+
continue
|
|
101
|
+
path = self.root / name
|
|
102
|
+
try:
|
|
103
|
+
info = path.lstat()
|
|
104
|
+
except FileNotFoundError:
|
|
105
|
+
continue
|
|
106
|
+
if not stat.S_ISREG(info.st_mode):
|
|
107
|
+
if needs_content(name):
|
|
108
|
+
raise ValueError(f"metadata {name} is not a regular file")
|
|
109
|
+
continue
|
|
110
|
+
if path.parent.resolve() != path.parent:
|
|
111
|
+
if needs_content(name):
|
|
112
|
+
raise ValueError(f"metadata {name} has a symlinked directory")
|
|
113
|
+
continue
|
|
114
|
+
files[name] = None
|
|
115
|
+
if not needs_content(name):
|
|
116
|
+
continue
|
|
117
|
+
if info.st_size > MAX_FILE_BYTES:
|
|
118
|
+
raise ValueError(f"metadata {name} exceeds {MAX_FILE_BYTES} bytes")
|
|
119
|
+
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
120
|
+
try:
|
|
121
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
122
|
+
raise ValueError(f"metadata {name} is not a regular file")
|
|
123
|
+
content = bytearray()
|
|
124
|
+
while len(content) <= MAX_FILE_BYTES:
|
|
125
|
+
self.budget.remaining()
|
|
126
|
+
chunk = os.read(fd, min(65536, MAX_FILE_BYTES + 1 - len(content)))
|
|
127
|
+
if not chunk:
|
|
128
|
+
break
|
|
129
|
+
content.extend(chunk)
|
|
130
|
+
if len(content) > MAX_FILE_BYTES:
|
|
131
|
+
raise ValueError(f"metadata {name} exceeds {MAX_FILE_BYTES} bytes")
|
|
132
|
+
total += len(content)
|
|
133
|
+
if total > MAX_TOTAL_BYTES:
|
|
134
|
+
raise ValueError("metadata exceeds 128 MiB")
|
|
135
|
+
files[name] = bytes(content)
|
|
136
|
+
finally:
|
|
137
|
+
os.close(fd)
|
|
138
|
+
if selected:
|
|
139
|
+
self._blobs(selected, files)
|
|
140
|
+
return files
|
|
141
|
+
|
|
142
|
+
def _blobs(self, selected: list[tuple[str, str]], files: Catalog) -> None:
|
|
143
|
+
batch = ("\n".join(oid for _, oid in selected) + "\n").encode("ascii")
|
|
144
|
+
headers = self.run(["cat-file", "--batch-check"], input=batch).splitlines()
|
|
145
|
+
if len(headers) != len(selected):
|
|
146
|
+
raise ValueError("invalid Git blob batch")
|
|
147
|
+
sizes: list[int] = []
|
|
148
|
+
total = 0
|
|
149
|
+
# Check immutable object sizes before reading any metadata contents.
|
|
150
|
+
for (name, oid), header in zip(selected, headers, strict=True):
|
|
151
|
+
fields = header.split()
|
|
152
|
+
if len(fields) != 3 or fields[:2] != [oid.encode("ascii"), b"blob"]:
|
|
153
|
+
raise ValueError("invalid Git blob")
|
|
154
|
+
size = int(fields[2])
|
|
155
|
+
if size < 0:
|
|
156
|
+
raise ValueError("invalid Git blob size")
|
|
157
|
+
if size > MAX_FILE_BYTES:
|
|
158
|
+
raise ValueError(f"metadata {name} exceeds {MAX_FILE_BYTES} bytes")
|
|
159
|
+
total += size
|
|
160
|
+
if total > MAX_TOTAL_BYTES:
|
|
161
|
+
raise ValueError("metadata exceeds 128 MiB")
|
|
162
|
+
sizes.append(size)
|
|
163
|
+
data = self.run(
|
|
164
|
+
["cat-file", "--batch"], input=batch, max_output=total + len(selected) * 128
|
|
165
|
+
)
|
|
166
|
+
offset = 0
|
|
167
|
+
for (name, oid), size in zip(selected, sizes, strict=True):
|
|
168
|
+
end = data.find(b"\n", offset)
|
|
169
|
+
if end < 0 or data[offset:end] != f"{oid} blob {size}".encode("ascii"):
|
|
170
|
+
raise ValueError("invalid Git blob header")
|
|
171
|
+
offset = end + 1
|
|
172
|
+
if data[offset + size : offset + size + 1] != b"\n":
|
|
173
|
+
raise ValueError("truncated Git blob")
|
|
174
|
+
files[name] = data[offset : offset + size]
|
|
175
|
+
offset += size + 1
|
|
176
|
+
if offset != len(data):
|
|
177
|
+
raise ValueError("unexpected Git blob bytes")
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""One operation owns input selection, deadline and consistency observation."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from threading import Event
|
|
7
|
+
|
|
8
|
+
from ._git import Git
|
|
9
|
+
from ._layout import load
|
|
10
|
+
from ._process import Budget
|
|
11
|
+
from .model import Diagnostic, InspectReport
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def inspect(
|
|
15
|
+
repository: str | os.PathLike[str] = ".",
|
|
16
|
+
*,
|
|
17
|
+
head: str | None = None,
|
|
18
|
+
staged: bool = False,
|
|
19
|
+
timeout: float = 5.0,
|
|
20
|
+
cancel: Event | None = None,
|
|
21
|
+
) -> InspectReport:
|
|
22
|
+
"""Inspect organization using Git paths and layout metadata only.
|
|
23
|
+
|
|
24
|
+
timeout is a whole-call budget in seconds; cancel accepts a threading.Event.
|
|
25
|
+
Git processes are killed and reaped on timeout/cancellation. Filesystem reads and
|
|
26
|
+
Python projection check the budget between operations. Concurrent catalog changes
|
|
27
|
+
return complete=False; consumers decide whether to accept the observation.
|
|
28
|
+
"""
|
|
29
|
+
if head and staged:
|
|
30
|
+
raise ValueError("head and staged are mutually exclusive")
|
|
31
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
32
|
+
raise ValueError("timeout must be positive and finite")
|
|
33
|
+
budget = Budget(timeout, cancel)
|
|
34
|
+
budget.remaining()
|
|
35
|
+
start = Path(repository).resolve(strict=True)
|
|
36
|
+
initial = Git(start, budget)
|
|
37
|
+
root = Path(
|
|
38
|
+
os.fsdecode(initial.run(["rev-parse", "--show-toplevel"])).removesuffix("\n")
|
|
39
|
+
).resolve()
|
|
40
|
+
git = Git(root, budget)
|
|
41
|
+
commit = (
|
|
42
|
+
git.run(["rev-parse", "--verify", "--end-of-options", head + "^{commit}"]).decode().strip()
|
|
43
|
+
if head
|
|
44
|
+
else ""
|
|
45
|
+
)
|
|
46
|
+
files = git.catalog(commit, staged)
|
|
47
|
+
origin = (
|
|
48
|
+
git.run(["config", "--get", "remote.origin.url"], allow_missing=True)
|
|
49
|
+
.decode("utf-8", errors="replace")
|
|
50
|
+
.rstrip("\n")
|
|
51
|
+
)
|
|
52
|
+
layout = load(files, origin)
|
|
53
|
+
complete = bool(commit) or files == git.catalog(commit, staged)
|
|
54
|
+
budget.remaining()
|
|
55
|
+
return InspectReport(
|
|
56
|
+
repository=layout.repository,
|
|
57
|
+
components=layout.components,
|
|
58
|
+
checkout=str(root),
|
|
59
|
+
input="commit" if commit else "index" if staged else "working_tree",
|
|
60
|
+
head=commit or None,
|
|
61
|
+
complete=complete,
|
|
62
|
+
diagnostics=()
|
|
63
|
+
if complete
|
|
64
|
+
else (
|
|
65
|
+
Diagnostic(
|
|
66
|
+
"inspection_changed",
|
|
67
|
+
"repository paths or layout metadata changed during inspection",
|
|
68
|
+
),
|
|
69
|
+
),
|
|
70
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Filename metadata generated from pinned CodeGraph, without loading a parser."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import posixpath
|
|
5
|
+
from importlib.resources import files
|
|
6
|
+
|
|
7
|
+
_CATALOG: dict[str, dict[str, str]] = json.loads(
|
|
8
|
+
files("repokit").joinpath("languages.json").read_text(encoding="utf-8")
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def language(path: str) -> str:
|
|
13
|
+
base = posixpath.basename(path)
|
|
14
|
+
ext = ("." + base.rsplit(".", 1)[1]).lower() if "." in base else ""
|
|
15
|
+
if ext in _CATALOG["overrides"]:
|
|
16
|
+
return _CATALOG["overrides"][ext]
|
|
17
|
+
if base in _CATALOG["filenames"]:
|
|
18
|
+
return _CATALOG["filenames"][base]
|
|
19
|
+
suffixes = [base[i:] for i in range(len(base) - 1, 0, -1) if base[i] == "."][:4]
|
|
20
|
+
for suffix in reversed(suffixes):
|
|
21
|
+
if suffix in _CATALOG["registry"]:
|
|
22
|
+
return _CATALOG["registry"][suffix]
|
|
23
|
+
return _CATALOG["fallback"].get(ext, "")
|