context-loader 1.3.4__tar.gz → 1.3.6__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.
- {context_loader-1.3.4 → context_loader-1.3.6}/PKG-INFO +32 -14
- {context_loader-1.3.4 → context_loader-1.3.6}/README.md +31 -13
- {context_loader-1.3.4 → context_loader-1.3.6}/context_loader/__init__.py +1 -1
- {context_loader-1.3.4 → context_loader-1.3.6}/context_loader/collect.py +33 -10
- {context_loader-1.3.4 → context_loader-1.3.6}/context_loader/git.py +72 -25
- {context_loader-1.3.4 → context_loader-1.3.6}/pyproject.toml +1 -1
- {context_loader-1.3.4 → context_loader-1.3.6}/pyproject.toml.orig +1 -1
- {context_loader-1.3.4 → context_loader-1.3.6}/tool_cli_contract.json +1 -1
- {context_loader-1.3.4 → context_loader-1.3.6}/LICENSE +0 -0
- {context_loader-1.3.4 → context_loader-1.3.6}/context_loader/application.py +0 -0
- {context_loader-1.3.4 → context_loader-1.3.6}/context_loader/cli.py +0 -0
- {context_loader-1.3.4 → context_loader-1.3.6}/context_loader/filesystem.py +0 -0
- {context_loader-1.3.4 → context_loader-1.3.6}/context_loader/model.py +0 -0
- {context_loader-1.3.4 → context_loader-1.3.6}/context_loader/render.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: context-loader
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.6
|
|
4
4
|
Summary: Render deterministic local Git context for any executor
|
|
5
5
|
License: Apache License
|
|
6
6
|
Version 2.0, January 2004
|
|
@@ -219,14 +219,14 @@ Python standard library.
|
|
|
219
219
|
|
|
220
220
|
## Open-source quick start
|
|
221
221
|
|
|
222
|
-
Current stable release: **1.3.
|
|
222
|
+
Current stable release: **1.3.6**. Local CLI for coding-agent workflows; renders deterministic Markdown or JSON.
|
|
223
223
|
|
|
224
224
|
```bash
|
|
225
|
-
uv tool install 'context-loader==1.3.
|
|
225
|
+
uv tool install 'context-loader==1.3.6'
|
|
226
226
|
```
|
|
227
227
|
|
|
228
228
|
```bash
|
|
229
|
-
pip install 'context-loader==1.3.
|
|
229
|
+
pip install 'context-loader==1.3.6'
|
|
230
230
|
```
|
|
231
231
|
|
|
232
232
|
```bash
|
|
@@ -239,21 +239,21 @@ project-context --repo /path/to/repo --format json
|
|
|
239
239
|
|
|
240
240
|
## Install
|
|
241
241
|
|
|
242
|
-
Source version: `1.3.
|
|
242
|
+
Source version: `1.3.6`. Install its matching published release or an exact source commit.
|
|
243
243
|
|
|
244
244
|
Install via `uv`:
|
|
245
245
|
|
|
246
246
|
```bash
|
|
247
|
-
uv tool install 'context-loader==1.3.
|
|
247
|
+
uv tool install 'context-loader==1.3.6'
|
|
248
248
|
```
|
|
249
249
|
|
|
250
250
|
Install via `pip`:
|
|
251
251
|
|
|
252
252
|
```bash
|
|
253
|
-
pip install 'context-loader==1.3.
|
|
253
|
+
pip install 'context-loader==1.3.6'
|
|
254
254
|
```
|
|
255
255
|
|
|
256
|
-
For development or source-based installs tracking current repository (`1.3.
|
|
256
|
+
For development or source-based installs tracking current repository (`1.3.6`):
|
|
257
257
|
|
|
258
258
|
```bash
|
|
259
259
|
uv tool install git+https://github.com/xuanheng-tech/context-loader.git
|
|
@@ -350,7 +350,7 @@ set differs, as described under “Compact model consumption”. The declared co
|
|
|
350
350
|
"schema_version": 3,
|
|
351
351
|
"tool": {
|
|
352
352
|
"name": "context-loader",
|
|
353
|
-
"version": "1.3.
|
|
353
|
+
"version": "1.3.6"
|
|
354
354
|
},
|
|
355
355
|
"repository": {
|
|
356
356
|
"requested_path": "/canonical/requested/path",
|
|
@@ -470,7 +470,7 @@ although 1.1.0 added the top-level `statuses` key. From release 1.2.0 onward eac
|
|
|
470
470
|
names exactly one key set, and a new number is issued whenever a key set would otherwise be reused.
|
|
471
471
|
|
|
472
472
|
The JSON schema version and package version are independent: this build emits `schema_version` `3`
|
|
473
|
-
for `--format json` and `4` for `--format json-compact`, while `tool.version` is `1.3.
|
|
473
|
+
for `--format json` and `4` for `--format json-compact`, while `tool.version` is `1.3.6`. Version 1
|
|
474
474
|
and 2 are the exact shapes already published in release 1.2.0 and are never reused: because
|
|
475
475
|
`nested_context` changes the default document's key set, version 3 names the current full-document
|
|
476
476
|
key set and version 4 names the compact projection.
|
|
@@ -512,12 +512,20 @@ heading on the first line is still recognized as a heading.
|
|
|
512
512
|
|
|
513
513
|
The root `AGENTS.md` is split at Markdown headings outside fenced code blocks. The output always
|
|
514
514
|
starts with complete early sections fitting a 4-KiB head, then adds complete relevant sections in
|
|
515
|
-
source order using
|
|
515
|
+
source order using normalized focus/path tokens and their parent context. Remaining headings
|
|
516
516
|
appear in an explicit index whose body text is not loaded. With no selection signals, only the small
|
|
517
517
|
head and index are emitted. Unsafe heading parsing falls back to a bounded head and an explicit
|
|
518
518
|
manual-recovery notice. A head whose own headings would not fit the budget falls back to the same
|
|
519
519
|
bounded head without a section index; a single instruction file never fails the whole collection.
|
|
520
520
|
|
|
521
|
+
Matching uses NFKC normalization and case folding. Ordinary tokens require at least three characters;
|
|
522
|
+
two-character Han terms such as `安全` and `部署`, and the technical abbreviations `AI`, `CD`, `CI`,
|
|
523
|
+
`DB`, `IO`, `JS`, `ML`, `OS`, `PR`, `QA`, `TS`, `UI` and `UX`, are also eligible. Han terms can match
|
|
524
|
+
within entirely Han words, so `安全` matches `安全规则`; Latin terms still require complete tokens,
|
|
525
|
+
so `CI` does not match `special`. Existing stopwords, scoring and byte budgets still apply. Repeating
|
|
526
|
+
a Han term in a heading and body does not increase its score. This is literal matching without word
|
|
527
|
+
segmentation, synonyms or translation.
|
|
528
|
+
|
|
521
529
|
`Omitted source characters` is measured against the whole normalized source, so a bounded source
|
|
522
530
|
scan that ends before EOF still reports the characters it could not select.
|
|
523
531
|
|
|
@@ -596,6 +604,8 @@ bullet is a per-component, read or capture bound, as each one states:
|
|
|
596
604
|
repository root the filesystem could not decode makes the machine formats exit 1 where Markdown
|
|
597
605
|
still renders
|
|
598
606
|
- Git subprocess output: 16 MiB (bounded while reading)
|
|
607
|
+
- Git collection: one shared 30-second deadline for repository discovery, configuration/submodule
|
|
608
|
+
preflight, refs, status and recent commits
|
|
599
609
|
- Git status preflight: at most 128 repositories, including initialized nested submodules
|
|
600
610
|
- Candidate file validation: 16 MiB hard read bound
|
|
601
611
|
|
|
@@ -611,6 +621,12 @@ Symlinked candidate files are skipped, and directory symlinks are not traversed.
|
|
|
611
621
|
Git state is derived only from the working tree and local refs. The command does not fetch or query
|
|
612
622
|
the remote.
|
|
613
623
|
|
|
624
|
+
All Git commands in one collection share the same elapsed-time budget; each command receives only
|
|
625
|
+
the remaining time. Once that budget expires, the loader stops without starting subsequent commands,
|
|
626
|
+
terminates and reaps a running Git child, and exits with empty stdout. This deadline does not impose
|
|
627
|
+
a hard wall-clock limit on blocked filesystem/process-creation calls or operating-system child
|
|
628
|
+
reaping, and does not cover the later context-file collection.
|
|
629
|
+
|
|
614
630
|
- An unborn working tree reports its symbolic branch name, such as `Branch: main`, and
|
|
615
631
|
`HEAD: unborn`.
|
|
616
632
|
- A detached HEAD reports `Branch: detached` and the resolved commit object ID.
|
|
@@ -639,7 +655,7 @@ or candidate-file content.
|
|
|
639
655
|
|
|
640
656
|
## Not Included
|
|
641
657
|
|
|
642
|
-
Version 1.3.
|
|
658
|
+
Version 1.3.6 does not provide AI summaries, project-type detection, loading or transport of
|
|
643
659
|
nested `AGENTS.md` contents (the JSON `nested_context` field reports bounded existence only),
|
|
644
660
|
Memory retrieval, semantic ranking, ignore-rule parsing, plugins, profiles, caches, databases,
|
|
645
661
|
network services, MCP, daemons, GUIs, CI/CD, telemetry, or automatic updates.
|
|
@@ -654,10 +670,12 @@ just check
|
|
|
654
670
|
|
|
655
671
|
The shared check also builds a wheel and installs it into a fresh virtual environment. Package
|
|
656
672
|
acceptance runs the installed CLI outside the source checkout, checking deterministic agreement
|
|
657
|
-
between all three formats, linked worktrees,
|
|
673
|
+
between all three formats, linked worktrees, long multi-module instructions with focus/path selection,
|
|
674
|
+
explicit truncation and nested-instruction presence, and safe failures. Build and installation run offline
|
|
658
675
|
using the backend cache populated by `uv run --frozen`; the tests use only disposable repositories.
|
|
659
676
|
Git deadline regressions verify that silent, streaming, and stdout-closed child processes are
|
|
660
|
-
terminated and reaped,
|
|
677
|
+
terminated and reaped, that successful commands cannot reset the shared budget, and that process
|
|
678
|
+
creation consumes it. A timeout leaves CLI stdout empty in every output format.
|
|
661
679
|
|
|
662
680
|
The lockfile uses public PyPI artifact URLs; no organization mirror or local Renovate preset
|
|
663
681
|
is needed. Renovate keeps scheduled, rate-limited updates and manual merges using a public
|
|
@@ -8,14 +8,14 @@ Python standard library.
|
|
|
8
8
|
|
|
9
9
|
## Open-source quick start
|
|
10
10
|
|
|
11
|
-
Current stable release: **1.3.
|
|
11
|
+
Current stable release: **1.3.6**. Local CLI for coding-agent workflows; renders deterministic Markdown or JSON.
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
|
-
uv tool install 'context-loader==1.3.
|
|
14
|
+
uv tool install 'context-loader==1.3.6'
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
pip install 'context-loader==1.3.
|
|
18
|
+
pip install 'context-loader==1.3.6'
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
```bash
|
|
@@ -28,21 +28,21 @@ project-context --repo /path/to/repo --format json
|
|
|
28
28
|
|
|
29
29
|
## Install
|
|
30
30
|
|
|
31
|
-
Source version: `1.3.
|
|
31
|
+
Source version: `1.3.6`. Install its matching published release or an exact source commit.
|
|
32
32
|
|
|
33
33
|
Install via `uv`:
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
|
-
uv tool install 'context-loader==1.3.
|
|
36
|
+
uv tool install 'context-loader==1.3.6'
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
Install via `pip`:
|
|
40
40
|
|
|
41
41
|
```bash
|
|
42
|
-
pip install 'context-loader==1.3.
|
|
42
|
+
pip install 'context-loader==1.3.6'
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
For development or source-based installs tracking current repository (`1.3.
|
|
45
|
+
For development or source-based installs tracking current repository (`1.3.6`):
|
|
46
46
|
|
|
47
47
|
```bash
|
|
48
48
|
uv tool install git+https://github.com/xuanheng-tech/context-loader.git
|
|
@@ -139,7 +139,7 @@ set differs, as described under “Compact model consumption”. The declared co
|
|
|
139
139
|
"schema_version": 3,
|
|
140
140
|
"tool": {
|
|
141
141
|
"name": "context-loader",
|
|
142
|
-
"version": "1.3.
|
|
142
|
+
"version": "1.3.6"
|
|
143
143
|
},
|
|
144
144
|
"repository": {
|
|
145
145
|
"requested_path": "/canonical/requested/path",
|
|
@@ -259,7 +259,7 @@ although 1.1.0 added the top-level `statuses` key. From release 1.2.0 onward eac
|
|
|
259
259
|
names exactly one key set, and a new number is issued whenever a key set would otherwise be reused.
|
|
260
260
|
|
|
261
261
|
The JSON schema version and package version are independent: this build emits `schema_version` `3`
|
|
262
|
-
for `--format json` and `4` for `--format json-compact`, while `tool.version` is `1.3.
|
|
262
|
+
for `--format json` and `4` for `--format json-compact`, while `tool.version` is `1.3.6`. Version 1
|
|
263
263
|
and 2 are the exact shapes already published in release 1.2.0 and are never reused: because
|
|
264
264
|
`nested_context` changes the default document's key set, version 3 names the current full-document
|
|
265
265
|
key set and version 4 names the compact projection.
|
|
@@ -301,12 +301,20 @@ heading on the first line is still recognized as a heading.
|
|
|
301
301
|
|
|
302
302
|
The root `AGENTS.md` is split at Markdown headings outside fenced code blocks. The output always
|
|
303
303
|
starts with complete early sections fitting a 4-KiB head, then adds complete relevant sections in
|
|
304
|
-
source order using
|
|
304
|
+
source order using normalized focus/path tokens and their parent context. Remaining headings
|
|
305
305
|
appear in an explicit index whose body text is not loaded. With no selection signals, only the small
|
|
306
306
|
head and index are emitted. Unsafe heading parsing falls back to a bounded head and an explicit
|
|
307
307
|
manual-recovery notice. A head whose own headings would not fit the budget falls back to the same
|
|
308
308
|
bounded head without a section index; a single instruction file never fails the whole collection.
|
|
309
309
|
|
|
310
|
+
Matching uses NFKC normalization and case folding. Ordinary tokens require at least three characters;
|
|
311
|
+
two-character Han terms such as `安全` and `部署`, and the technical abbreviations `AI`, `CD`, `CI`,
|
|
312
|
+
`DB`, `IO`, `JS`, `ML`, `OS`, `PR`, `QA`, `TS`, `UI` and `UX`, are also eligible. Han terms can match
|
|
313
|
+
within entirely Han words, so `安全` matches `安全规则`; Latin terms still require complete tokens,
|
|
314
|
+
so `CI` does not match `special`. Existing stopwords, scoring and byte budgets still apply. Repeating
|
|
315
|
+
a Han term in a heading and body does not increase its score. This is literal matching without word
|
|
316
|
+
segmentation, synonyms or translation.
|
|
317
|
+
|
|
310
318
|
`Omitted source characters` is measured against the whole normalized source, so a bounded source
|
|
311
319
|
scan that ends before EOF still reports the characters it could not select.
|
|
312
320
|
|
|
@@ -385,6 +393,8 @@ bullet is a per-component, read or capture bound, as each one states:
|
|
|
385
393
|
repository root the filesystem could not decode makes the machine formats exit 1 where Markdown
|
|
386
394
|
still renders
|
|
387
395
|
- Git subprocess output: 16 MiB (bounded while reading)
|
|
396
|
+
- Git collection: one shared 30-second deadline for repository discovery, configuration/submodule
|
|
397
|
+
preflight, refs, status and recent commits
|
|
388
398
|
- Git status preflight: at most 128 repositories, including initialized nested submodules
|
|
389
399
|
- Candidate file validation: 16 MiB hard read bound
|
|
390
400
|
|
|
@@ -400,6 +410,12 @@ Symlinked candidate files are skipped, and directory symlinks are not traversed.
|
|
|
400
410
|
Git state is derived only from the working tree and local refs. The command does not fetch or query
|
|
401
411
|
the remote.
|
|
402
412
|
|
|
413
|
+
All Git commands in one collection share the same elapsed-time budget; each command receives only
|
|
414
|
+
the remaining time. Once that budget expires, the loader stops without starting subsequent commands,
|
|
415
|
+
terminates and reaps a running Git child, and exits with empty stdout. This deadline does not impose
|
|
416
|
+
a hard wall-clock limit on blocked filesystem/process-creation calls or operating-system child
|
|
417
|
+
reaping, and does not cover the later context-file collection.
|
|
418
|
+
|
|
403
419
|
- An unborn working tree reports its symbolic branch name, such as `Branch: main`, and
|
|
404
420
|
`HEAD: unborn`.
|
|
405
421
|
- A detached HEAD reports `Branch: detached` and the resolved commit object ID.
|
|
@@ -428,7 +444,7 @@ or candidate-file content.
|
|
|
428
444
|
|
|
429
445
|
## Not Included
|
|
430
446
|
|
|
431
|
-
Version 1.3.
|
|
447
|
+
Version 1.3.6 does not provide AI summaries, project-type detection, loading or transport of
|
|
432
448
|
nested `AGENTS.md` contents (the JSON `nested_context` field reports bounded existence only),
|
|
433
449
|
Memory retrieval, semantic ranking, ignore-rule parsing, plugins, profiles, caches, databases,
|
|
434
450
|
network services, MCP, daemons, GUIs, CI/CD, telemetry, or automatic updates.
|
|
@@ -443,10 +459,12 @@ just check
|
|
|
443
459
|
|
|
444
460
|
The shared check also builds a wheel and installs it into a fresh virtual environment. Package
|
|
445
461
|
acceptance runs the installed CLI outside the source checkout, checking deterministic agreement
|
|
446
|
-
between all three formats, linked worktrees,
|
|
462
|
+
between all three formats, linked worktrees, long multi-module instructions with focus/path selection,
|
|
463
|
+
explicit truncation and nested-instruction presence, and safe failures. Build and installation run offline
|
|
447
464
|
using the backend cache populated by `uv run --frozen`; the tests use only disposable repositories.
|
|
448
465
|
Git deadline regressions verify that silent, streaming, and stdout-closed child processes are
|
|
449
|
-
terminated and reaped,
|
|
466
|
+
terminated and reaped, that successful commands cannot reset the shared budget, and that process
|
|
467
|
+
creation consumes it. A timeout leaves CLI stdout empty in every output format.
|
|
450
468
|
|
|
451
469
|
The lockfile uses public PyPI artifact URLs; no organization mirror or local Renovate preset
|
|
452
470
|
is needed. Renovate keeps scheduled, rate-limited updates and manual merges using a public
|
|
@@ -68,6 +68,9 @@ _MAKE_TARGET_RE = re.compile(
|
|
|
68
68
|
)
|
|
69
69
|
_ATX_HEADING_RE = re.compile(r"^ {0,3}(#{1,6})(?:[ \t]+(.*)|[ \t]*)$")
|
|
70
70
|
_TOKEN_RE = re.compile(r"[^\W_]+", re.UNICODE)
|
|
71
|
+
_SHORT_TECHNICAL_TOKENS = frozenset(
|
|
72
|
+
{"ai", "cd", "ci", "db", "io", "js", "ml", "os", "pr", "qa", "ts", "ui", "ux"}
|
|
73
|
+
)
|
|
71
74
|
_MATCH_TOKEN_STOPWORDS = frozenset(
|
|
72
75
|
{
|
|
73
76
|
"and",
|
|
@@ -138,11 +141,35 @@ def _normalized_text(value: str) -> str:
|
|
|
138
141
|
return unicodedata.normalize("NFKC", value).casefold()
|
|
139
142
|
|
|
140
143
|
|
|
144
|
+
def _is_han_token(value: str) -> bool:
|
|
145
|
+
return bool(value) and all(
|
|
146
|
+
unicodedata.name(character, "").startswith("CJK UNIFIED IDEOGRAPH-") for character in value
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
141
150
|
def _matching_tokens(value: str, stopwords: frozenset[str]) -> frozenset[str]:
|
|
142
151
|
return frozenset(
|
|
143
152
|
token
|
|
144
153
|
for token in _TOKEN_RE.findall(_normalized_text(value))
|
|
145
|
-
if
|
|
154
|
+
if token not in stopwords
|
|
155
|
+
and (
|
|
156
|
+
len(token) >= 3
|
|
157
|
+
or token in _SHORT_TECHNICAL_TOKENS
|
|
158
|
+
or (len(token) == 2 and _is_han_token(token))
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _matched_terms(terms: frozenset[str], tokens: frozenset[str]) -> frozenset[str]:
|
|
164
|
+
matches = terms & tokens
|
|
165
|
+
han_terms = tuple(term for term in terms - matches if _is_han_token(term))
|
|
166
|
+
if not han_terms:
|
|
167
|
+
return matches
|
|
168
|
+
# Han text need not delimit words with spaces. Count each query term once,
|
|
169
|
+
# without introducing substring matching for Latin words or abbreviations.
|
|
170
|
+
han_tokens = tuple(token for token in tokens if _is_han_token(token))
|
|
171
|
+
return matches | frozenset(
|
|
172
|
+
term for term in han_terms if any(term in token for token in han_tokens)
|
|
146
173
|
)
|
|
147
174
|
|
|
148
175
|
|
|
@@ -334,24 +361,20 @@ def _small_head_end(content: str, sections: tuple[MarkdownSection, ...]) -> int:
|
|
|
334
361
|
def _section_relevance(
|
|
335
362
|
section: MarkdownSection, signals: _SelectionSignals
|
|
336
363
|
) -> tuple[int, frozenset[str]]:
|
|
337
|
-
heading_tokens =
|
|
338
|
-
token
|
|
339
|
-
for token in section.normalized_heading.split()
|
|
340
|
-
if len(token) >= 3 and token not in _MATCH_TOKEN_STOPWORDS
|
|
341
|
-
)
|
|
364
|
+
heading_tokens = _matching_tokens(section.normalized_heading, _MATCH_TOKEN_STOPWORDS)
|
|
342
365
|
section_tokens = _matching_tokens(section.text, _MATCH_TOKEN_STOPWORDS)
|
|
343
366
|
body_tokens = section_tokens - heading_tokens
|
|
344
367
|
score = 0
|
|
345
368
|
reasons: set[str] = set()
|
|
346
369
|
|
|
347
|
-
focus_heading = signals.focus_tokens
|
|
348
|
-
focus_body = signals.focus_tokens
|
|
370
|
+
focus_heading = _matched_terms(signals.focus_tokens, heading_tokens)
|
|
371
|
+
focus_body = _matched_terms(signals.focus_tokens, body_tokens) - focus_heading
|
|
349
372
|
if focus_heading or focus_body:
|
|
350
373
|
reasons.add("focus_match")
|
|
351
374
|
score += 6 * len(focus_heading) + len(focus_body)
|
|
352
375
|
|
|
353
|
-
path_heading = signals.path_tokens
|
|
354
|
-
path_body = signals.path_tokens
|
|
376
|
+
path_heading = _matched_terms(signals.path_tokens, heading_tokens)
|
|
377
|
+
path_body = _matched_terms(signals.path_tokens, body_tokens) - path_heading
|
|
355
378
|
exact_path = bool(
|
|
356
379
|
signals.normalized_path
|
|
357
380
|
and _normalized_text(signals.normalized_path) in _normalized_text(section.text)
|
|
@@ -118,8 +118,11 @@ def _read_bounded_process_stdout(
|
|
|
118
118
|
proc: subprocess.Popen[bytes],
|
|
119
119
|
max_bytes: int,
|
|
120
120
|
timeout_seconds: float,
|
|
121
|
+
*,
|
|
122
|
+
deadline: float | None = None,
|
|
121
123
|
) -> bytes:
|
|
122
|
-
deadline
|
|
124
|
+
if deadline is None:
|
|
125
|
+
deadline = time.monotonic() + timeout_seconds
|
|
123
126
|
buffer = bytearray()
|
|
124
127
|
assert proc.stdout is not None
|
|
125
128
|
os.set_blocking(proc.stdout.fileno(), False)
|
|
@@ -168,9 +171,23 @@ def _read_bounded_process_stdout(
|
|
|
168
171
|
return bytes(buffer)
|
|
169
172
|
|
|
170
173
|
|
|
171
|
-
def
|
|
174
|
+
def _check_git_deadline(deadline: float) -> None:
|
|
175
|
+
if time.monotonic() >= deadline:
|
|
176
|
+
raise ContextLoaderError("unable to read repository Git state")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _run_git(
|
|
180
|
+
repo: Path,
|
|
181
|
+
arguments: tuple[str, ...],
|
|
182
|
+
*,
|
|
183
|
+
check: bool = True,
|
|
184
|
+
deadline: float | None = None,
|
|
185
|
+
) -> GitResult:
|
|
186
|
+
command_deadline = time.monotonic() + GIT_TIMEOUT_SECONDS
|
|
187
|
+
deadline = command_deadline if deadline is None else min(deadline, command_deadline)
|
|
172
188
|
argv = [GIT_EXECUTABLE, *_GIT_PREFIX, "-C", os.fspath(repo), *arguments]
|
|
173
189
|
try:
|
|
190
|
+
_check_git_deadline(deadline)
|
|
174
191
|
with subprocess.Popen( # noqa: S603 - executable and every command are fixed here.
|
|
175
192
|
argv,
|
|
176
193
|
cwd="/",
|
|
@@ -179,8 +196,11 @@ def _run_git(repo: Path, arguments: tuple[str, ...], *, check: bool = True) -> G
|
|
|
179
196
|
stdout=subprocess.PIPE,
|
|
180
197
|
stderr=subprocess.DEVNULL,
|
|
181
198
|
) as proc:
|
|
182
|
-
stdout = _read_bounded_process_stdout(
|
|
199
|
+
stdout = _read_bounded_process_stdout(
|
|
200
|
+
proc, MAX_GIT_OUTPUT_BYTES, GIT_TIMEOUT_SECONDS, deadline=deadline
|
|
201
|
+
)
|
|
183
202
|
returncode = proc.returncode
|
|
203
|
+
_check_git_deadline(deadline)
|
|
184
204
|
except ContextLoaderError:
|
|
185
205
|
raise
|
|
186
206
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
@@ -209,6 +229,16 @@ def discover_repository(
|
|
|
209
229
|
raw_path: str | os.PathLike[str], *, require_canonical_root: bool = False
|
|
210
230
|
) -> RepositoryLocation:
|
|
211
231
|
"""Resolve an absolute directory and discover its non-bare Git worktree root."""
|
|
232
|
+
return _discover_repository(
|
|
233
|
+
raw_path,
|
|
234
|
+
require_canonical_root=require_canonical_root,
|
|
235
|
+
deadline=time.monotonic() + GIT_TIMEOUT_SECONDS,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _discover_repository(
|
|
240
|
+
raw_path: str | os.PathLike[str], *, require_canonical_root: bool, deadline: float
|
|
241
|
+
) -> RepositoryLocation:
|
|
212
242
|
candidate = Path(raw_path)
|
|
213
243
|
if not candidate.is_absolute():
|
|
214
244
|
raise ContextLoaderError("--repo must be an absolute path", exit_code=2)
|
|
@@ -219,11 +249,14 @@ def discover_repository(
|
|
|
219
249
|
if not canonical.is_dir():
|
|
220
250
|
raise ContextLoaderError("--repo must name an existing directory", exit_code=2)
|
|
221
251
|
|
|
222
|
-
bare = _run_git(
|
|
252
|
+
bare = _run_git(
|
|
253
|
+
canonical, ("rev-parse", "--is-bare-repository"), check=False, deadline=deadline
|
|
254
|
+
)
|
|
223
255
|
root = _run_git(
|
|
224
256
|
canonical,
|
|
225
257
|
("rev-parse", "--path-format=absolute", "--show-toplevel"),
|
|
226
258
|
check=False,
|
|
259
|
+
deadline=deadline,
|
|
227
260
|
)
|
|
228
261
|
if bare.returncode != 0 or root.returncode != 0:
|
|
229
262
|
raise ContextLoaderError(
|
|
@@ -243,6 +276,7 @@ def discover_repository(
|
|
|
243
276
|
raise ContextLoaderError(
|
|
244
277
|
"--repo must be the canonical root of a non-bare Git worktree", exit_code=2
|
|
245
278
|
)
|
|
279
|
+
_check_git_deadline(deadline)
|
|
246
280
|
return RepositoryLocation(requested_path=canonical, canonical_root=discovered)
|
|
247
281
|
|
|
248
282
|
|
|
@@ -251,14 +285,18 @@ def validate_repository(raw_path: str | os.PathLike[str]) -> Path:
|
|
|
251
285
|
return discover_repository(raw_path, require_canonical_root=True).canonical_root
|
|
252
286
|
|
|
253
287
|
|
|
254
|
-
def _head_and_branch(repo: Path) -> tuple[str, str, str | None]:
|
|
255
|
-
symbolic = _run_git(
|
|
288
|
+
def _head_and_branch(repo: Path, deadline: float) -> tuple[str, str, str | None]:
|
|
289
|
+
symbolic = _run_git(
|
|
290
|
+
repo, ("symbolic-ref", "--quiet", "--short", "HEAD"), check=False, deadline=deadline
|
|
291
|
+
)
|
|
256
292
|
branch_name = (
|
|
257
293
|
_decode_path(_without_one_line_ending(symbolic.stdout))
|
|
258
294
|
if symbolic.returncode == 0
|
|
259
295
|
else None
|
|
260
296
|
)
|
|
261
|
-
resolved_head = _run_git(
|
|
297
|
+
resolved_head = _run_git(
|
|
298
|
+
repo, ("rev-parse", "--verify", "HEAD^{commit}"), check=False, deadline=deadline
|
|
299
|
+
)
|
|
262
300
|
if resolved_head.returncode != 0:
|
|
263
301
|
if branch_name is None:
|
|
264
302
|
raise ContextLoaderError("unable to read repository Git state")
|
|
@@ -270,17 +308,17 @@ def _head_and_branch(repo: Path) -> tuple[str, str, str | None]:
|
|
|
270
308
|
return branch_name or "detached", head, branch_name
|
|
271
309
|
|
|
272
310
|
|
|
273
|
-
def _config_value(repo: Path, key: str) -> str | None:
|
|
274
|
-
result = _run_git(repo, ("config", "--get", key), check=False)
|
|
311
|
+
def _config_value(repo: Path, key: str, deadline: float) -> str | None:
|
|
312
|
+
result = _run_git(repo, ("config", "--get", key), check=False, deadline=deadline)
|
|
275
313
|
if result.returncode != 0:
|
|
276
314
|
return None
|
|
277
315
|
value = _decode_text(_without_one_line_ending(result.stdout))
|
|
278
316
|
return value or None
|
|
279
317
|
|
|
280
318
|
|
|
281
|
-
def _fallback_upstream(repo: Path, branch_name: str) -> str | None:
|
|
282
|
-
remote = _config_value(repo, f"branch.{branch_name}.remote")
|
|
283
|
-
merge = _config_value(repo, f"branch.{branch_name}.merge")
|
|
319
|
+
def _fallback_upstream(repo: Path, branch_name: str, deadline: float) -> str | None:
|
|
320
|
+
remote = _config_value(repo, f"branch.{branch_name}.remote", deadline)
|
|
321
|
+
merge = _config_value(repo, f"branch.{branch_name}.merge", deadline)
|
|
284
322
|
if remote is None or merge is None:
|
|
285
323
|
return None
|
|
286
324
|
merge_name = merge.removeprefix("refs/heads/")
|
|
@@ -289,7 +327,7 @@ def _fallback_upstream(repo: Path, branch_name: str) -> str | None:
|
|
|
289
327
|
return f"{remote}/{merge_name}"
|
|
290
328
|
|
|
291
329
|
|
|
292
|
-
def _upstream_state(repo: Path, branch_name: str | None) -> tuple[str, str]:
|
|
330
|
+
def _upstream_state(repo: Path, branch_name: str | None, deadline: float) -> tuple[str, str]:
|
|
293
331
|
if branch_name is None:
|
|
294
332
|
return "not configured", "not available"
|
|
295
333
|
|
|
@@ -297,9 +335,10 @@ def _upstream_state(repo: Path, branch_name: str | None) -> tuple[str, str]:
|
|
|
297
335
|
repo,
|
|
298
336
|
("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"),
|
|
299
337
|
check=False,
|
|
338
|
+
deadline=deadline,
|
|
300
339
|
)
|
|
301
340
|
if resolved.returncode != 0:
|
|
302
|
-
configured = _fallback_upstream(repo, branch_name)
|
|
341
|
+
configured = _fallback_upstream(repo, branch_name, deadline)
|
|
303
342
|
return configured or "not configured", "not available"
|
|
304
343
|
|
|
305
344
|
upstream = _decode_text(_without_one_line_ending(resolved.stdout))
|
|
@@ -309,6 +348,7 @@ def _upstream_state(repo: Path, branch_name: str | None) -> tuple[str, str]:
|
|
|
309
348
|
repo,
|
|
310
349
|
("rev-list", "--left-right", "--count", "HEAD...@{upstream}"),
|
|
311
350
|
check=False,
|
|
351
|
+
deadline=deadline,
|
|
312
352
|
)
|
|
313
353
|
if divergence.returncode != 0:
|
|
314
354
|
return upstream, "not available"
|
|
@@ -318,7 +358,7 @@ def _upstream_state(repo: Path, branch_name: str | None) -> tuple[str, str]:
|
|
|
318
358
|
return upstream, f"{counts[0].decode('ascii')} / {counts[1].decode('ascii')}"
|
|
319
359
|
|
|
320
360
|
|
|
321
|
-
def _ensure_safe_status(repo: Path) -> None:
|
|
361
|
+
def _ensure_safe_status(repo: Path, deadline: float) -> None:
|
|
322
362
|
"""Reject executable conversions before status can refresh any index entry.
|
|
323
363
|
|
|
324
364
|
Attributes can select clean/process drivers even during a read-only status.
|
|
@@ -340,6 +380,7 @@ def _ensure_safe_status(repo: Path) -> None:
|
|
|
340
380
|
current,
|
|
341
381
|
("config", "--null", "--get-regexp", r"^filter\..*\.(clean|process)$"),
|
|
342
382
|
check=False,
|
|
383
|
+
deadline=deadline,
|
|
343
384
|
)
|
|
344
385
|
if filters.returncode not in (0, 1):
|
|
345
386
|
raise ContextLoaderError("unable to safely inspect repository Git filters")
|
|
@@ -356,7 +397,7 @@ def _ensure_safe_status(repo: Path) -> None:
|
|
|
356
397
|
"repository or submodule config enables Git clean/process filters; "
|
|
357
398
|
"unable to safely inspect working-tree state"
|
|
358
399
|
)
|
|
359
|
-
index = _run_git(current, ("ls-files", "--stage", "-z"))
|
|
400
|
+
index = _run_git(current, ("ls-files", "--stage", "-z"), deadline=deadline)
|
|
360
401
|
children: set[Path] = set()
|
|
361
402
|
for record in index.stdout.split(b"\0"):
|
|
362
403
|
metadata, separator, raw_path = record.partition(b"\t")
|
|
@@ -375,8 +416,8 @@ def _ensure_safe_status(repo: Path) -> None:
|
|
|
375
416
|
raise ContextLoaderError("repository submodule inspection exceeded the safety limit")
|
|
376
417
|
|
|
377
418
|
|
|
378
|
-
def _working_tree_changes(repo: Path) -> tuple[WorkingTreeChange, ...]:
|
|
379
|
-
_ensure_safe_status(repo)
|
|
419
|
+
def _working_tree_changes(repo: Path, deadline: float) -> tuple[WorkingTreeChange, ...]:
|
|
420
|
+
_ensure_safe_status(repo, deadline)
|
|
380
421
|
result = _run_git(
|
|
381
422
|
repo,
|
|
382
423
|
(
|
|
@@ -387,6 +428,7 @@ def _working_tree_changes(repo: Path) -> tuple[WorkingTreeChange, ...]:
|
|
|
387
428
|
"--no-renames",
|
|
388
429
|
"--ignore-submodules=none",
|
|
389
430
|
),
|
|
431
|
+
deadline=deadline,
|
|
390
432
|
)
|
|
391
433
|
if not result.stdout:
|
|
392
434
|
return ()
|
|
@@ -408,7 +450,7 @@ def _working_tree_changes(repo: Path) -> tuple[WorkingTreeChange, ...]:
|
|
|
408
450
|
return tuple(sorted(changes, key=lambda change: change.sort_key))
|
|
409
451
|
|
|
410
452
|
|
|
411
|
-
def _recent_commits(repo: Path, head: str) -> tuple[RecentCommit, ...]:
|
|
453
|
+
def _recent_commits(repo: Path, head: str, deadline: float) -> tuple[RecentCommit, ...]:
|
|
412
454
|
if head == "unborn":
|
|
413
455
|
return ()
|
|
414
456
|
result = _run_git(
|
|
@@ -421,6 +463,7 @@ def _recent_commits(repo: Path, head: str) -> tuple[RecentCommit, ...]:
|
|
|
421
463
|
"HEAD",
|
|
422
464
|
"--",
|
|
423
465
|
),
|
|
466
|
+
deadline=deadline,
|
|
424
467
|
)
|
|
425
468
|
commits: list[RecentCommit] = []
|
|
426
469
|
for line in result.stdout.splitlines():
|
|
@@ -443,13 +486,16 @@ def _recent_commits(repo: Path, head: str) -> tuple[RecentCommit, ...]:
|
|
|
443
486
|
def collect_repository(
|
|
444
487
|
raw_path: str | os.PathLike[str], *, require_canonical_root: bool = True
|
|
445
488
|
) -> tuple[RepositoryLocation, RepositoryState]:
|
|
446
|
-
"""Discover
|
|
447
|
-
|
|
489
|
+
"""Discover and collect local Git state within one shared elapsed-time budget."""
|
|
490
|
+
deadline = time.monotonic() + GIT_TIMEOUT_SECONDS
|
|
491
|
+
location = _discover_repository(
|
|
492
|
+
raw_path, require_canonical_root=require_canonical_root, deadline=deadline
|
|
493
|
+
)
|
|
448
494
|
repository = location.canonical_root
|
|
449
|
-
branch, head, branch_name = _head_and_branch(repository)
|
|
450
|
-
upstream, ahead_behind = _upstream_state(repository, branch_name)
|
|
451
|
-
changes = _working_tree_changes(repository)
|
|
452
|
-
commits = _recent_commits(repository, head)
|
|
495
|
+
branch, head, branch_name = _head_and_branch(repository, deadline)
|
|
496
|
+
upstream, ahead_behind = _upstream_state(repository, branch_name, deadline)
|
|
497
|
+
changes = _working_tree_changes(repository, deadline)
|
|
498
|
+
commits = _recent_commits(repository, head, deadline)
|
|
453
499
|
state = RepositoryState(
|
|
454
500
|
repository=repository,
|
|
455
501
|
branch=branch,
|
|
@@ -459,6 +505,7 @@ def collect_repository(
|
|
|
459
505
|
changes=changes,
|
|
460
506
|
commits=commits,
|
|
461
507
|
)
|
|
508
|
+
_check_git_deadline(deadline)
|
|
462
509
|
return location, state
|
|
463
510
|
|
|
464
511
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|