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.
Files changed (67) hide show
  1. compforge_repokit-0.15.0/.gitignore +16 -0
  2. compforge_repokit-0.15.0/AGENTS.md +36 -0
  3. compforge_repokit-0.15.0/Makefile +13 -0
  4. compforge_repokit-0.15.0/PKG-INFO +114 -0
  5. compforge_repokit-0.15.0/README.md +105 -0
  6. compforge_repokit-0.15.0/pyproject.toml +33 -0
  7. compforge_repokit-0.15.0/repokit/__init__.py +40 -0
  8. compforge_repokit-0.15.0/repokit/_git.py +177 -0
  9. compforge_repokit-0.15.0/repokit/_inspect.py +70 -0
  10. compforge_repokit-0.15.0/repokit/_language.py +23 -0
  11. compforge_repokit-0.15.0/repokit/_layout.py +187 -0
  12. compforge_repokit-0.15.0/repokit/_process.py +135 -0
  13. compforge_repokit-0.15.0/repokit/_staging.py +59 -0
  14. compforge_repokit-0.15.0/repokit/budget.py +40 -0
  15. compforge_repokit-0.15.0/repokit/dependencies.py +378 -0
  16. compforge_repokit-0.15.0/repokit/forge/__init__.py +13 -0
  17. compforge_repokit-0.15.0/repokit/forge/_http_state.py +92 -0
  18. compforge_repokit-0.15.0/repokit/forge/_page.py +34 -0
  19. compforge_repokit-0.15.0/repokit/forge/_rest.py +233 -0
  20. compforge_repokit-0.15.0/repokit/forge/creation.py +88 -0
  21. compforge_repokit-0.15.0/repokit/forge/github.py +440 -0
  22. compforge_repokit-0.15.0/repokit/forge/gitlab.py +407 -0
  23. compforge_repokit-0.15.0/repokit/forge/model.py +380 -0
  24. compforge_repokit-0.15.0/repokit/forge/observation/__init__.py +9 -0
  25. compforge_repokit-0.15.0/repokit/forge/observation/context.py +255 -0
  26. compforge_repokit-0.15.0/repokit/forge/observation/model.py +40 -0
  27. compforge_repokit-0.15.0/repokit/forge/observation/service.py +283 -0
  28. compforge_repokit-0.15.0/repokit/forge/observation/store.py +136 -0
  29. compforge_repokit-0.15.0/repokit/git.py +296 -0
  30. compforge_repokit-0.15.0/repokit/git_index.py +275 -0
  31. compforge_repokit-0.15.0/repokit/git_state.py +447 -0
  32. compforge_repokit-0.15.0/repokit/languages.json +1115 -0
  33. compforge_repokit-0.15.0/repokit/model.py +59 -0
  34. compforge_repokit-0.15.0/repokit/py.typed +0 -0
  35. compforge_repokit-0.15.0/repokit/remote.py +41 -0
  36. compforge_repokit-0.15.0/repokit/repository.py +84 -0
  37. compforge_repokit-0.15.0/repokit/snapshot.py +275 -0
  38. compforge_repokit-0.15.0/repokit/stash.py +66 -0
  39. compforge_repokit-0.15.0/repokit/tree.py +191 -0
  40. compforge_repokit-0.15.0/repokit/validation.py +508 -0
  41. compforge_repokit-0.15.0/tests/conftest.py +16 -0
  42. compforge_repokit-0.15.0/tests/test_budget.py +91 -0
  43. compforge_repokit-0.15.0/tests/test_checkout_contract.py +66 -0
  44. compforge_repokit-0.15.0/tests/test_dependencies.py +223 -0
  45. compforge_repokit-0.15.0/tests/test_forge.py +646 -0
  46. compforge_repokit-0.15.0/tests/test_forge_http.py +200 -0
  47. compforge_repokit-0.15.0/tests/test_forge_repositories.py +125 -0
  48. compforge_repokit-0.15.0/tests/test_fork.py +193 -0
  49. compforge_repokit-0.15.0/tests/test_git_facts.py +121 -0
  50. compforge_repokit-0.15.0/tests/test_git_helpers.py +111 -0
  51. compforge_repokit-0.15.0/tests/test_git_index.py +69 -0
  52. compforge_repokit-0.15.0/tests/test_inspect.py +361 -0
  53. compforge_repokit-0.15.0/tests/test_metadata_contract.py +76 -0
  54. compforge_repokit-0.15.0/tests/test_operations.py +93 -0
  55. compforge_repokit-0.15.0/tests/test_pr_creation.py +113 -0
  56. compforge_repokit-0.15.0/tests/test_pr_observation.py +333 -0
  57. compforge_repokit-0.15.0/tests/test_process.py +113 -0
  58. compforge_repokit-0.15.0/tests/test_rebase.py +87 -0
  59. compforge_repokit-0.15.0/tests/test_repository_metadata.py +48 -0
  60. compforge_repokit-0.15.0/tests/test_repository_queries.py +66 -0
  61. compforge_repokit-0.15.0/tests/test_scalar_contract.py +88 -0
  62. compforge_repokit-0.15.0/tests/test_snapshot_scopes.py +97 -0
  63. compforge_repokit-0.15.0/tests/test_staging.py +174 -0
  64. compforge_repokit-0.15.0/tests/test_stash.py +85 -0
  65. compforge_repokit-0.15.0/tests/test_tree.py +74 -0
  66. compforge_repokit-0.15.0/tests/test_validation.py +191 -0
  67. 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, "")