compforge-repokit 0.15.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.
- compforge_repokit-0.15.0.dist-info/METADATA +114 -0
- compforge_repokit-0.15.0.dist-info/RECORD +37 -0
- compforge_repokit-0.15.0.dist-info/WHEEL +4 -0
- repokit/__init__.py +40 -0
- repokit/_git.py +177 -0
- repokit/_inspect.py +70 -0
- repokit/_language.py +23 -0
- repokit/_layout.py +187 -0
- repokit/_process.py +135 -0
- repokit/_staging.py +59 -0
- repokit/budget.py +40 -0
- repokit/dependencies.py +378 -0
- repokit/forge/__init__.py +13 -0
- repokit/forge/_http_state.py +92 -0
- repokit/forge/_page.py +34 -0
- repokit/forge/_rest.py +233 -0
- repokit/forge/creation.py +88 -0
- repokit/forge/github.py +440 -0
- repokit/forge/gitlab.py +407 -0
- repokit/forge/model.py +380 -0
- repokit/forge/observation/__init__.py +9 -0
- repokit/forge/observation/context.py +255 -0
- repokit/forge/observation/model.py +40 -0
- repokit/forge/observation/service.py +283 -0
- repokit/forge/observation/store.py +136 -0
- repokit/git.py +296 -0
- repokit/git_index.py +275 -0
- repokit/git_state.py +447 -0
- repokit/languages.json +1115 -0
- repokit/model.py +59 -0
- repokit/py.typed +0 -0
- repokit/remote.py +41 -0
- repokit/repository.py +84 -0
- repokit/snapshot.py +275 -0
- repokit/stash.py +66 -0
- repokit/tree.py +191 -0
- repokit/validation.py +508 -0
repokit/_process.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Bounded subprocesses sharing an operation deadline."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import os
|
|
5
|
+
import selectors
|
|
6
|
+
import signal
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from threading import Event
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Budget:
|
|
14
|
+
def __init__(self, timeout: float, cancel: Event | None):
|
|
15
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
16
|
+
raise ValueError("timeout must be positive and finite")
|
|
17
|
+
self.parent: Budget | None = None
|
|
18
|
+
self.deadline = time.monotonic() + timeout
|
|
19
|
+
self.cancel = cancel
|
|
20
|
+
|
|
21
|
+
def remaining(self) -> float:
|
|
22
|
+
if self.parent is not None:
|
|
23
|
+
self.parent.remaining()
|
|
24
|
+
if self.cancel is not None and self.cancel.is_set():
|
|
25
|
+
raise InterruptedError("operation cancelled")
|
|
26
|
+
remaining = self.deadline - time.monotonic()
|
|
27
|
+
if remaining <= 0:
|
|
28
|
+
raise TimeoutError("operation timed out")
|
|
29
|
+
return remaining
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run(
|
|
33
|
+
root: Path,
|
|
34
|
+
args: list[str],
|
|
35
|
+
budget: Budget,
|
|
36
|
+
*,
|
|
37
|
+
input: bytes = b"",
|
|
38
|
+
allow_missing: bool = False,
|
|
39
|
+
max_output: int = 16 << 20,
|
|
40
|
+
) -> bytes:
|
|
41
|
+
return run_command(
|
|
42
|
+
["git", "-C", str(root), *args],
|
|
43
|
+
budget,
|
|
44
|
+
input=input,
|
|
45
|
+
allow_missing=allow_missing,
|
|
46
|
+
max_output=max_output,
|
|
47
|
+
).stdout
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def run_command(
|
|
51
|
+
command: list[str],
|
|
52
|
+
budget: Budget,
|
|
53
|
+
*,
|
|
54
|
+
input: bytes = b"",
|
|
55
|
+
allow_missing: bool = False,
|
|
56
|
+
max_output: int = 16 << 20,
|
|
57
|
+
env: dict[str, str] | None = None,
|
|
58
|
+
cwd: Path | None = None,
|
|
59
|
+
process_group: bool = False,
|
|
60
|
+
) -> subprocess.CompletedProcess[bytes]:
|
|
61
|
+
budget.remaining()
|
|
62
|
+
process = subprocess.Popen(
|
|
63
|
+
command,
|
|
64
|
+
stdin=subprocess.PIPE,
|
|
65
|
+
stdout=subprocess.PIPE,
|
|
66
|
+
stderr=subprocess.PIPE,
|
|
67
|
+
env=env,
|
|
68
|
+
cwd=cwd,
|
|
69
|
+
start_new_session=process_group,
|
|
70
|
+
)
|
|
71
|
+
assert process.stdin is not None and process.stdout is not None and process.stderr is not None
|
|
72
|
+
stdout, stderr = bytearray(), bytearray()
|
|
73
|
+
pending = memoryview(input)
|
|
74
|
+
try:
|
|
75
|
+
# Drain both outputs while feeding a batch. communicate() buffers without a
|
|
76
|
+
# capacity bound; waiting before draining can deadlock on a full pipe.
|
|
77
|
+
with selectors.DefaultSelector() as selector:
|
|
78
|
+
for pipe, target in ((process.stdout, stdout), (process.stderr, stderr)):
|
|
79
|
+
os.set_blocking(pipe.fileno(), False)
|
|
80
|
+
selector.register(pipe, selectors.EVENT_READ, target)
|
|
81
|
+
if pending:
|
|
82
|
+
os.set_blocking(process.stdin.fileno(), False)
|
|
83
|
+
selector.register(process.stdin, selectors.EVENT_WRITE)
|
|
84
|
+
else:
|
|
85
|
+
process.stdin.close()
|
|
86
|
+
while selector.get_map():
|
|
87
|
+
remaining = budget.remaining()
|
|
88
|
+
for key, event in selector.select(min(remaining, 0.05)):
|
|
89
|
+
if event == selectors.EVENT_WRITE:
|
|
90
|
+
try:
|
|
91
|
+
used = os.write(key.fd, pending[:65536])
|
|
92
|
+
pending = pending[used:]
|
|
93
|
+
except BrokenPipeError:
|
|
94
|
+
pending = memoryview(b"")
|
|
95
|
+
if not pending:
|
|
96
|
+
selector.unregister(key.fileobj)
|
|
97
|
+
process.stdin.close()
|
|
98
|
+
else:
|
|
99
|
+
chunk = os.read(key.fd, 65536)
|
|
100
|
+
if not chunk:
|
|
101
|
+
selector.unregister(key.fileobj)
|
|
102
|
+
continue
|
|
103
|
+
target = key.data
|
|
104
|
+
limit = max_output if target is stdout else 65536
|
|
105
|
+
if len(target) + len(chunk) > limit:
|
|
106
|
+
raise ValueError(f"Process output exceeds {limit} bytes")
|
|
107
|
+
target.extend(chunk)
|
|
108
|
+
while process.poll() is None:
|
|
109
|
+
remaining = budget.remaining()
|
|
110
|
+
try:
|
|
111
|
+
process.wait(timeout=min(remaining, 0.05))
|
|
112
|
+
except subprocess.TimeoutExpired:
|
|
113
|
+
continue
|
|
114
|
+
budget.remaining()
|
|
115
|
+
if process.returncode != 0 and not (allow_missing and process.returncode == 1):
|
|
116
|
+
raise subprocess.CalledProcessError(
|
|
117
|
+
process.returncode, command, bytes(stdout), bytes(stderr)
|
|
118
|
+
)
|
|
119
|
+
return subprocess.CompletedProcess(
|
|
120
|
+
command, process.returncode, bytes(stdout), bytes(stderr)
|
|
121
|
+
)
|
|
122
|
+
finally:
|
|
123
|
+
# Timeout, cancellation and output overflow must not leave Git running.
|
|
124
|
+
if process_group:
|
|
125
|
+
# Package-manager lifecycle scripts may outlive their parent on cancellation.
|
|
126
|
+
try:
|
|
127
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
128
|
+
except ProcessLookupError:
|
|
129
|
+
pass
|
|
130
|
+
elif process.poll() is None:
|
|
131
|
+
process.kill()
|
|
132
|
+
process.wait()
|
|
133
|
+
process.stdin.close()
|
|
134
|
+
process.stdout.close()
|
|
135
|
+
process.stderr.close()
|
repokit/_staging.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Prepare and validate an index while holding Git's native writer lock."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import tempfile
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .git import GitResult, git
|
|
10
|
+
from .git_index import IndexView, index_view
|
|
11
|
+
from .git_state import git_path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def stage_validated(
|
|
15
|
+
repo: str | Path, paths: list[str], validate: Callable[[IndexView], None]
|
|
16
|
+
) -> GitResult:
|
|
17
|
+
index = git_path(repo, "index")
|
|
18
|
+
lock = index.with_name(index.name + ".lock")
|
|
19
|
+
try:
|
|
20
|
+
lock_file = lock.open("xb")
|
|
21
|
+
except OSError as exc:
|
|
22
|
+
return GitResult(-1, "", f"cannot lock Git index: {exc}")
|
|
23
|
+
installed = False
|
|
24
|
+
try:
|
|
25
|
+
# why: hold the native lock from snapshot through replacement, so another
|
|
26
|
+
# Git writer cannot have its index update silently overwritten.
|
|
27
|
+
with (
|
|
28
|
+
lock_file,
|
|
29
|
+
tempfile.TemporaryDirectory(prefix="repokit-index-", dir=index.parent) as tmp,
|
|
30
|
+
):
|
|
31
|
+
prepared = Path(tmp) / "index"
|
|
32
|
+
if index.exists():
|
|
33
|
+
shutil.copy2(index, prepared)
|
|
34
|
+
result = GitResult(0, "", "")
|
|
35
|
+
if paths:
|
|
36
|
+
result = git(
|
|
37
|
+
repo,
|
|
38
|
+
"--literal-pathspecs",
|
|
39
|
+
"add",
|
|
40
|
+
"--",
|
|
41
|
+
*paths,
|
|
42
|
+
timeout=30,
|
|
43
|
+
index_file=prepared,
|
|
44
|
+
)
|
|
45
|
+
if not result.ok:
|
|
46
|
+
return result
|
|
47
|
+
validate(index_view(repo, index_file=prepared))
|
|
48
|
+
if prepared.exists():
|
|
49
|
+
# Use the lock file as the replacement, following Git's index protocol.
|
|
50
|
+
with prepared.open("rb") as source:
|
|
51
|
+
shutil.copyfileobj(source, lock_file)
|
|
52
|
+
lock_file.flush()
|
|
53
|
+
os.fchmod(lock_file.fileno(), prepared.stat().st_mode & 0o777)
|
|
54
|
+
os.replace(lock, index)
|
|
55
|
+
installed = True
|
|
56
|
+
return result
|
|
57
|
+
finally:
|
|
58
|
+
if not installed:
|
|
59
|
+
lock.unlink(missing_ok=True)
|
repokit/budget.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Caller-owned operation scope shared by Git and Forge transport calls."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from contextvars import ContextVar
|
|
6
|
+
from threading import Event
|
|
7
|
+
|
|
8
|
+
from ._process import Budget
|
|
9
|
+
|
|
10
|
+
_current: ContextVar[Budget | None] = ContextVar("repokit_operation", default=None)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@contextmanager
|
|
14
|
+
def operation(*, timeout: float = 30, cancel: Event | None = None) -> Iterator[None]:
|
|
15
|
+
"""Bound a compound library operation; nested scopes never extend the outer deadline.
|
|
16
|
+
|
|
17
|
+
Git cancellation kills and reaps its process. HTTP cancellation is checked between
|
|
18
|
+
requests/reads; an in-flight socket remains bounded by its request timeout.
|
|
19
|
+
This is an execution scope, not a transaction or a retry policy.
|
|
20
|
+
"""
|
|
21
|
+
budget = Budget(timeout, cancel)
|
|
22
|
+
outer = _current.get()
|
|
23
|
+
if outer is not None:
|
|
24
|
+
budget.deadline = min(budget.deadline, outer.deadline)
|
|
25
|
+
budget.parent = outer
|
|
26
|
+
token = _current.set(budget)
|
|
27
|
+
try:
|
|
28
|
+
budget.remaining()
|
|
29
|
+
yield
|
|
30
|
+
finally:
|
|
31
|
+
_current.reset(token)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def current_budget(timeout: float) -> Budget:
|
|
35
|
+
budget = Budget(timeout, None)
|
|
36
|
+
outer = _current.get()
|
|
37
|
+
if outer is not None:
|
|
38
|
+
budget.deadline = min(budget.deadline, outer.deadline)
|
|
39
|
+
budget.parent = outer
|
|
40
|
+
return budget
|
repokit/dependencies.py
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""Observe checkout-local dependencies and explicitly materialize locked installations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import stat
|
|
9
|
+
import subprocess
|
|
10
|
+
import threading
|
|
11
|
+
import tomllib
|
|
12
|
+
from dataclasses import dataclass, replace
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Literal
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from . import _process
|
|
19
|
+
from ._inspect import inspect
|
|
20
|
+
from .budget import current_budget
|
|
21
|
+
from .model import InspectReport, owner
|
|
22
|
+
|
|
23
|
+
Status = Literal["ready", "present", "missing", "stale", "unsupported"]
|
|
24
|
+
_RECEIPT = ".repokit-dependencies.json"
|
|
25
|
+
_NODE_LOCKS = {
|
|
26
|
+
"npm": ("npm-shrinkwrap.json", "package-lock.json"),
|
|
27
|
+
"pnpm": ("pnpm-lock.yaml",),
|
|
28
|
+
"yarn": ("yarn.lock",),
|
|
29
|
+
"bun": ("bun.lock", "bun.lockb"),
|
|
30
|
+
}
|
|
31
|
+
_LOCKS: dict[Path, threading.Lock] = {}
|
|
32
|
+
_LOCKS_GUARD = threading.Lock()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class DependencyEnvironment:
|
|
37
|
+
"""An installation belongs to a checkout and may serve multiple workspace Components.
|
|
38
|
+
|
|
39
|
+
present means local dependencies exist without a matching preparation receipt;
|
|
40
|
+
it is not proof that a user-managed environment matches the lockfile.
|
|
41
|
+
missing describes an absent directory, independently of whether command provides
|
|
42
|
+
a supported locked installation. unsupported describes an unusable local layout.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
component: Path
|
|
46
|
+
checkout: Path
|
|
47
|
+
root: Path
|
|
48
|
+
manager: str
|
|
49
|
+
directory: Path
|
|
50
|
+
command: tuple[str, ...]
|
|
51
|
+
inputs: tuple[Path, ...]
|
|
52
|
+
fingerprint: str
|
|
53
|
+
status: Status
|
|
54
|
+
reason: str = ""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True, slots=True)
|
|
58
|
+
class DependencyPreparation:
|
|
59
|
+
environment: DependencyEnvironment
|
|
60
|
+
status: Literal["ready", "unsupported", "failed", "uncertain"]
|
|
61
|
+
executed: bool = False
|
|
62
|
+
exit_code: int | None = None
|
|
63
|
+
stdout: str = ""
|
|
64
|
+
stderr: str = ""
|
|
65
|
+
reason: str = ""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _json(path: Path) -> dict[str, object]:
|
|
69
|
+
value = json.loads(path.read_text())
|
|
70
|
+
if not isinstance(value, dict):
|
|
71
|
+
raise ValueError(f"Expected an object: {path}")
|
|
72
|
+
return value
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _patterns(root: Path, python: bool) -> tuple[str, ...]:
|
|
76
|
+
values: object
|
|
77
|
+
if python:
|
|
78
|
+
manifest = root / "pyproject.toml"
|
|
79
|
+
if not manifest.is_file():
|
|
80
|
+
return ()
|
|
81
|
+
workspace = (
|
|
82
|
+
tomllib.loads(manifest.read_text()).get("tool", {}).get("uv", {}).get("workspace", {})
|
|
83
|
+
)
|
|
84
|
+
values = [*workspace.get("members", []), *("!" + p for p in workspace.get("exclude", []))]
|
|
85
|
+
elif (root / "pnpm-workspace.yaml").is_file():
|
|
86
|
+
value = yaml.safe_load((root / "pnpm-workspace.yaml").read_text())
|
|
87
|
+
values = value.get("packages", []) if isinstance(value, dict) else []
|
|
88
|
+
elif (root / "package.json").is_file():
|
|
89
|
+
value = _json(root / "package.json").get("workspaces", [])
|
|
90
|
+
values = value.get("packages", []) if isinstance(value, dict) else value
|
|
91
|
+
else:
|
|
92
|
+
return ()
|
|
93
|
+
if not isinstance(values, list) or any(not isinstance(p, str) for p in values):
|
|
94
|
+
raise ValueError(f"Invalid workspace members: {root}")
|
|
95
|
+
return tuple(values)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _members(root: Path, python: bool) -> tuple[Path, ...]:
|
|
99
|
+
included: set[Path] = set()
|
|
100
|
+
excluded: set[Path] = set()
|
|
101
|
+
manifest = "pyproject.toml" if python else "package.json"
|
|
102
|
+
for pattern in _patterns(root, python):
|
|
103
|
+
negative = pattern.startswith("!")
|
|
104
|
+
glob = pattern[1:] if negative else pattern
|
|
105
|
+
if Path(glob).is_absolute() or ".." in Path(glob).parts:
|
|
106
|
+
raise ValueError("Workspace members must remain inside their installation root")
|
|
107
|
+
# Do not silently interpret unsupported shell/YAML glob syntax as a different workspace.
|
|
108
|
+
if any(char in glob for char in "{}()"):
|
|
109
|
+
raise ValueError(f"Unsupported workspace glob: {glob}")
|
|
110
|
+
for path in root.glob(glob):
|
|
111
|
+
if not (path / manifest).is_file():
|
|
112
|
+
continue
|
|
113
|
+
if not path.resolve().is_relative_to(root):
|
|
114
|
+
raise ValueError("Workspace member resolves outside its installation root")
|
|
115
|
+
(excluded if negative else included).add(path.resolve())
|
|
116
|
+
if len(included) + len(excluded) > 4096:
|
|
117
|
+
raise ValueError("Workspace exceeds 4096 dependency manifests")
|
|
118
|
+
return tuple(sorted(included - excluded))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _root(component: Path, checkout: Path, python: bool) -> Path:
|
|
122
|
+
selected = component
|
|
123
|
+
for parent in component.parents:
|
|
124
|
+
if not parent.is_relative_to(checkout):
|
|
125
|
+
break
|
|
126
|
+
if selected in _members(parent, python):
|
|
127
|
+
selected = parent
|
|
128
|
+
return selected
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _fingerprint(root: Path, inputs: tuple[Path, ...]) -> str:
|
|
132
|
+
digest = hashlib.sha256(str(root).encode())
|
|
133
|
+
for path in inputs:
|
|
134
|
+
digest.update(str(path.relative_to(root)).encode())
|
|
135
|
+
digest.update(b"\0")
|
|
136
|
+
digest.update(path.read_bytes())
|
|
137
|
+
digest.update(b"\0")
|
|
138
|
+
return digest.hexdigest()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _observe(environment: DependencyEnvironment) -> DependencyEnvironment:
|
|
142
|
+
directory = environment.directory
|
|
143
|
+
# Observe the entry itself: a dangling symlink is an unsupported installation,
|
|
144
|
+
# while absence says nothing about whether a locked installer is available.
|
|
145
|
+
# Permission and other observation errors propagate instead of claiming missing.
|
|
146
|
+
try:
|
|
147
|
+
mode = directory.lstat().st_mode
|
|
148
|
+
except FileNotFoundError:
|
|
149
|
+
return replace(environment, status="missing", reason="Local dependencies are missing")
|
|
150
|
+
if stat.S_ISLNK(mode):
|
|
151
|
+
return replace(
|
|
152
|
+
environment,
|
|
153
|
+
status="unsupported",
|
|
154
|
+
reason="Dependency directory is a symlink; refusing to reuse another installation",
|
|
155
|
+
)
|
|
156
|
+
if not stat.S_ISDIR(mode):
|
|
157
|
+
return replace(
|
|
158
|
+
environment,
|
|
159
|
+
status="unsupported",
|
|
160
|
+
reason="Dependency path is not a directory",
|
|
161
|
+
)
|
|
162
|
+
receipt = directory / _RECEIPT
|
|
163
|
+
if not receipt.exists():
|
|
164
|
+
return replace(
|
|
165
|
+
environment,
|
|
166
|
+
status="present",
|
|
167
|
+
reason="Local dependencies exist without a receipt; consistency is unverified",
|
|
168
|
+
)
|
|
169
|
+
try:
|
|
170
|
+
prepared = _json(receipt)
|
|
171
|
+
except (ValueError, OSError):
|
|
172
|
+
return replace(
|
|
173
|
+
environment, status="stale", reason="Dependency preparation receipt is unreadable"
|
|
174
|
+
)
|
|
175
|
+
if prepared.get("fingerprint") != environment.fingerprint:
|
|
176
|
+
return replace(
|
|
177
|
+
environment,
|
|
178
|
+
status="stale",
|
|
179
|
+
reason="Dependency manifests, lockfile or installation path changed",
|
|
180
|
+
)
|
|
181
|
+
return replace(environment, status="ready", reason="")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def inspect_dependencies(
|
|
185
|
+
path: str | Path, *, inspection: InspectReport | None = None
|
|
186
|
+
) -> tuple[DependencyEnvironment, ...]:
|
|
187
|
+
"""Read-only dependency observations; never install during inspect or worktree creation.
|
|
188
|
+
|
|
189
|
+
An optional complete working-tree inspection avoids repeating repository discovery.
|
|
190
|
+
Installation roots are resolved only within that checkout, never through its parent.
|
|
191
|
+
"""
|
|
192
|
+
component = Path(path).resolve(strict=True)
|
|
193
|
+
report = inspection or inspect(component)
|
|
194
|
+
if not report.complete or report.input != "working_tree":
|
|
195
|
+
raise ValueError("Dependency preparation requires complete working-tree inspection")
|
|
196
|
+
checkout = Path(report.checkout).resolve()
|
|
197
|
+
binding = owner(report, component.relative_to(checkout).as_posix())
|
|
198
|
+
if binding is None:
|
|
199
|
+
raise ValueError("No Component owns the requested dependency path")
|
|
200
|
+
component = checkout / binding.root
|
|
201
|
+
environments: list[DependencyEnvironment] = []
|
|
202
|
+
kinds = []
|
|
203
|
+
if (component / "package.json").is_file():
|
|
204
|
+
kinds.append(False)
|
|
205
|
+
if (component / "pyproject.toml").is_file():
|
|
206
|
+
kinds.append(True)
|
|
207
|
+
for python in kinds:
|
|
208
|
+
root = _root(component, checkout, python)
|
|
209
|
+
manifest = root / ("pyproject.toml" if python else "package.json")
|
|
210
|
+
members = _members(root, python)
|
|
211
|
+
inputs = {manifest, *(member / manifest.name for member in members)}
|
|
212
|
+
command: tuple[str, ...] = ()
|
|
213
|
+
if python:
|
|
214
|
+
manager = "uv"
|
|
215
|
+
lock = root / "uv.lock"
|
|
216
|
+
if not lock.is_file():
|
|
217
|
+
# Legacy/unmanaged Python projects do not imply a uv environment.
|
|
218
|
+
continue
|
|
219
|
+
command = (
|
|
220
|
+
("uv", "sync", "--locked", "--all-packages")
|
|
221
|
+
if members
|
|
222
|
+
else ("uv", "sync", "--locked")
|
|
223
|
+
)
|
|
224
|
+
inputs.add(lock)
|
|
225
|
+
directory = root / ".venv"
|
|
226
|
+
else:
|
|
227
|
+
declared = _json(manifest).get("packageManager", "")
|
|
228
|
+
if not isinstance(declared, str):
|
|
229
|
+
raise ValueError("packageManager must be a string")
|
|
230
|
+
manager = declared.partition("@")[0]
|
|
231
|
+
if not manager:
|
|
232
|
+
candidates = [
|
|
233
|
+
name
|
|
234
|
+
for name, locks in _NODE_LOCKS.items()
|
|
235
|
+
if any((root / lock).is_file() for lock in locks)
|
|
236
|
+
]
|
|
237
|
+
if len(candidates) > 1:
|
|
238
|
+
raise ValueError(
|
|
239
|
+
"Ambiguous package manager; declare packageManager in package.json"
|
|
240
|
+
)
|
|
241
|
+
manager = candidates[0] if candidates else "node"
|
|
242
|
+
locks = _NODE_LOCKS.get(manager, ())
|
|
243
|
+
node_lock = next((root / name for name in locks if (root / name).is_file()), None)
|
|
244
|
+
if node_lock:
|
|
245
|
+
inputs.add(node_lock)
|
|
246
|
+
if manager == "npm":
|
|
247
|
+
command = ("npm", "ci", "--prefer-offline")
|
|
248
|
+
elif manager in {"bun", "pnpm"}:
|
|
249
|
+
command = (manager, "install", "--frozen-lockfile")
|
|
250
|
+
elif manager == "yarn":
|
|
251
|
+
classic = declared.startswith("yarn@1.") or node_lock.read_text().startswith(
|
|
252
|
+
"# yarn lockfile v1"
|
|
253
|
+
)
|
|
254
|
+
command = ("yarn", "install", "--frozen-lockfile" if classic else "--immutable")
|
|
255
|
+
directory = root / "node_modules"
|
|
256
|
+
if manager == "yarn" and (root / ".pnp.cjs").exists():
|
|
257
|
+
# A PnP installation is not a missing node_modules installation.
|
|
258
|
+
# No receipt directory or repair strategy is claimed for this mode yet.
|
|
259
|
+
raise ValueError("Yarn PnP dependency preparation is not supported")
|
|
260
|
+
for config in ("pnpm-workspace.yaml", ".npmrc", ".yarnrc.yml", "bunfig.toml", "uv.toml"):
|
|
261
|
+
if (root / config).is_file():
|
|
262
|
+
inputs.add(root / config)
|
|
263
|
+
files = tuple(sorted(inputs))
|
|
264
|
+
environment = DependencyEnvironment(
|
|
265
|
+
component,
|
|
266
|
+
checkout,
|
|
267
|
+
root,
|
|
268
|
+
manager,
|
|
269
|
+
directory,
|
|
270
|
+
command,
|
|
271
|
+
files,
|
|
272
|
+
_fingerprint(root, files),
|
|
273
|
+
"missing",
|
|
274
|
+
)
|
|
275
|
+
environments.append(_observe(environment))
|
|
276
|
+
return tuple(environments)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def prepare_dependencies(
|
|
280
|
+
environment: DependencyEnvironment, *, timeout: float = 600
|
|
281
|
+
) -> DependencyPreparation:
|
|
282
|
+
"""Explicit locked installation, serialized per installation root within this process.
|
|
283
|
+
|
|
284
|
+
Does not install runtimes, upgrade dependencies, edit locks, or own validation policy.
|
|
285
|
+
Failures retain process evidence; interruption after launch is uncertain, never ready.
|
|
286
|
+
"""
|
|
287
|
+
budget = current_budget(timeout)
|
|
288
|
+
with _LOCKS_GUARD:
|
|
289
|
+
lock = _LOCKS.setdefault(environment.root, threading.Lock())
|
|
290
|
+
try:
|
|
291
|
+
while not lock.acquire(timeout=min(0.05, budget.remaining())):
|
|
292
|
+
pass
|
|
293
|
+
except (TimeoutError, InterruptedError) as error:
|
|
294
|
+
return DependencyPreparation(environment, "failed", reason=str(error))
|
|
295
|
+
try:
|
|
296
|
+
current = next(
|
|
297
|
+
(
|
|
298
|
+
item
|
|
299
|
+
for item in inspect_dependencies(environment.component)
|
|
300
|
+
if item.manager == environment.manager and item.root == environment.root
|
|
301
|
+
),
|
|
302
|
+
None,
|
|
303
|
+
)
|
|
304
|
+
if current is None or current.fingerprint != environment.fingerprint:
|
|
305
|
+
return DependencyPreparation(
|
|
306
|
+
environment,
|
|
307
|
+
"failed",
|
|
308
|
+
reason="Dependency inputs changed; inspect again before preparation",
|
|
309
|
+
)
|
|
310
|
+
if current.status == "ready":
|
|
311
|
+
return DependencyPreparation(current, "ready")
|
|
312
|
+
if not current.command or current.status == "unsupported":
|
|
313
|
+
return DependencyPreparation(
|
|
314
|
+
current, "unsupported", reason=current.reason or "No supported locked installation"
|
|
315
|
+
)
|
|
316
|
+
# uv must not inherit a path that redirects this checkout's editable installation.
|
|
317
|
+
env = dict(os.environ)
|
|
318
|
+
if current.manager == "uv":
|
|
319
|
+
env.pop("VIRTUAL_ENV", None)
|
|
320
|
+
env.pop("UV_PROJECT_ENVIRONMENT", None)
|
|
321
|
+
try:
|
|
322
|
+
budget.remaining()
|
|
323
|
+
except (TimeoutError, InterruptedError) as error:
|
|
324
|
+
return DependencyPreparation(current, "failed", reason=str(error))
|
|
325
|
+
try:
|
|
326
|
+
result = _process.run_command(
|
|
327
|
+
list(current.command), budget, cwd=current.root, env=env, process_group=True
|
|
328
|
+
)
|
|
329
|
+
except subprocess.CalledProcessError as error:
|
|
330
|
+
return DependencyPreparation(
|
|
331
|
+
current,
|
|
332
|
+
"failed",
|
|
333
|
+
True,
|
|
334
|
+
error.returncode,
|
|
335
|
+
os.fsdecode(error.output or b""),
|
|
336
|
+
os.fsdecode(error.stderr or b""),
|
|
337
|
+
"Locked dependency installation failed",
|
|
338
|
+
)
|
|
339
|
+
except (TimeoutError, InterruptedError, ValueError) as error:
|
|
340
|
+
return DependencyPreparation(current, "uncertain", True, reason=str(error))
|
|
341
|
+
except OSError as error:
|
|
342
|
+
return DependencyPreparation(current, "failed", reason=str(error))
|
|
343
|
+
refreshed = next(
|
|
344
|
+
(
|
|
345
|
+
item
|
|
346
|
+
for item in inspect_dependencies(current.component)
|
|
347
|
+
if item.manager == current.manager and item.root == current.root
|
|
348
|
+
),
|
|
349
|
+
None,
|
|
350
|
+
)
|
|
351
|
+
if refreshed is None or refreshed.fingerprint != current.fingerprint:
|
|
352
|
+
return DependencyPreparation(
|
|
353
|
+
current,
|
|
354
|
+
"failed",
|
|
355
|
+
True,
|
|
356
|
+
reason="Dependency inputs changed during installation; no ready receipt written",
|
|
357
|
+
)
|
|
358
|
+
if not current.directory.is_dir() or current.directory.is_symlink():
|
|
359
|
+
return DependencyPreparation(
|
|
360
|
+
current,
|
|
361
|
+
"failed",
|
|
362
|
+
True,
|
|
363
|
+
reason="Installer did not materialize checkout-local dependencies",
|
|
364
|
+
)
|
|
365
|
+
receipt = current.directory / _RECEIPT
|
|
366
|
+
receipt.write_text(
|
|
367
|
+
json.dumps({"fingerprint": current.fingerprint, "manager": current.manager}) + "\n"
|
|
368
|
+
)
|
|
369
|
+
return DependencyPreparation(
|
|
370
|
+
_observe(current),
|
|
371
|
+
"ready",
|
|
372
|
+
True,
|
|
373
|
+
result.returncode,
|
|
374
|
+
os.fsdecode(result.stdout),
|
|
375
|
+
os.fsdecode(result.stderr),
|
|
376
|
+
)
|
|
377
|
+
finally:
|
|
378
|
+
lock.release()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Forge clients accept explicit endpoint and credentials; no application configuration."""
|
|
2
|
+
|
|
3
|
+
from .creation import PullRequestCreation as PullRequestCreation
|
|
4
|
+
from .creation import ensure_pull_request as ensure_pull_request
|
|
5
|
+
from .github import GitHubForge as GitHubForge
|
|
6
|
+
from .gitlab import GitLabForge as GitLabForge
|
|
7
|
+
from .model import Page as Page
|
|
8
|
+
from .observation import BranchPullRequests as BranchPullRequests
|
|
9
|
+
from .observation import PullRequestContext as PullRequestContext
|
|
10
|
+
from .observation import PullRequestKey as PullRequestKey
|
|
11
|
+
from .observation import PullRequestObserver as PullRequestObserver
|
|
12
|
+
from .observation import PullRequestStatus as PullRequestStatus
|
|
13
|
+
from .observation import PullRequestStatusUnavailable as PullRequestStatusUnavailable
|