context-loader 1.3.3__tar.gz → 1.3.5__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: context-loader
3
- Version: 1.3.3
3
+ Version: 1.3.5
4
4
  Summary: Render deterministic local Git context for any executor
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -204,7 +204,7 @@ License: Apache License
204
204
  See the License for the specific language governing permissions and
205
205
  limitations under the License.
206
206
 
207
- Requires-Python: >=3.12, <3.13
207
+ Requires-Python: >=3.12, <3.15
208
208
  Project-URL: Repository, https://github.com/xuanheng-tech/context-loader
209
209
  Project-URL: Issues, https://github.com/xuanheng-tech/context-loader/issues
210
210
  Description-Content-Type: text/markdown
@@ -219,14 +219,14 @@ Python standard library.
219
219
 
220
220
  ## Open-source quick start
221
221
 
222
- Current stable release: **1.3.3**. Local CLI for coding-agent workflows; renders deterministic Markdown or JSON.
222
+ Current stable release: **1.3.5**. Local CLI for coding-agent workflows; renders deterministic Markdown or JSON.
223
223
 
224
224
  ```bash
225
- uv tool install 'context-loader==1.3.3'
225
+ uv tool install 'context-loader==1.3.5'
226
226
  ```
227
227
 
228
228
  ```bash
229
- pip install 'context-loader==1.3.3'
229
+ pip install 'context-loader==1.3.5'
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.3`. Install its matching published release or an exact source commit.
242
+ Source version: `1.3.5`. 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.3'
247
+ uv tool install 'context-loader==1.3.5'
248
248
  ```
249
249
 
250
250
  Install via `pip`:
251
251
 
252
252
  ```bash
253
- pip install 'context-loader==1.3.3'
253
+ pip install 'context-loader==1.3.5'
254
254
  ```
255
255
 
256
- For development or source-based installs tracking current repository (`1.3.3`):
256
+ For development or source-based installs tracking current repository (`1.3.5`):
257
257
 
258
258
  ```bash
259
259
  uv tool install git+https://github.com/xuanheng-tech/context-loader.git
@@ -279,7 +279,7 @@ part of either distribution; run them from a checkout of the matching tag.
279
279
 
280
280
  ## Platform and Runtime Requirements
281
281
 
282
- - **Python**: Python 3.12 (`>=3.12,<3.13`). Runtime code uses only the Python standard library with zero runtime dependencies.
282
+ - **Python**: Python 3.12–3.14 (`>=3.12,<3.15`). Runtime code uses only the Python standard library with zero runtime dependencies.
283
283
  - **Git**: Requires the standard `git` CLI at the fixed absolute path `/usr/bin/git`. The
284
284
  executable is never resolved through `PATH`, so an installation elsewhere is not used and
285
285
  every invocation fails with exit `1`. Git runs with a sanitized environment that ignores
@@ -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.3"
353
+ "version": "1.3.5"
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.3`. Version 1
473
+ for `--format json` and `4` for `--format json-compact`, while `tool.version` is `1.3.5`. 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.
@@ -596,6 +596,8 @@ bullet is a per-component, read or capture bound, as each one states:
596
596
  repository root the filesystem could not decode makes the machine formats exit 1 where Markdown
597
597
  still renders
598
598
  - Git subprocess output: 16 MiB (bounded while reading)
599
+ - Git collection: one shared 30-second deadline for repository discovery, configuration/submodule
600
+ preflight, refs, status and recent commits
599
601
  - Git status preflight: at most 128 repositories, including initialized nested submodules
600
602
  - Candidate file validation: 16 MiB hard read bound
601
603
 
@@ -611,6 +613,12 @@ Symlinked candidate files are skipped, and directory symlinks are not traversed.
611
613
  Git state is derived only from the working tree and local refs. The command does not fetch or query
612
614
  the remote.
613
615
 
616
+ All Git commands in one collection share the same elapsed-time budget; each command receives only
617
+ the remaining time. Once that budget expires, the loader stops without starting subsequent commands,
618
+ terminates and reaps a running Git child, and exits with empty stdout. This deadline does not impose
619
+ a hard wall-clock limit on blocked filesystem/process-creation calls or operating-system child
620
+ reaping, and does not cover the later context-file collection.
621
+
614
622
  - An unborn working tree reports its symbolic branch name, such as `Branch: main`, and
615
623
  `HEAD: unborn`.
616
624
  - A detached HEAD reports `Branch: detached` and the resolved commit object ID.
@@ -639,7 +647,7 @@ or candidate-file content.
639
647
 
640
648
  ## Not Included
641
649
 
642
- Version 1.3.3 does not provide AI summaries, project-type detection, loading or transport of
650
+ Version 1.3.5 does not provide AI summaries, project-type detection, loading or transport of
643
651
  nested `AGENTS.md` contents (the JSON `nested_context` field reports bounded existence only),
644
652
  Memory retrieval, semantic ranking, ignore-rule parsing, plugins, profiles, caches, databases,
645
653
  network services, MCP, daemons, GUIs, CI/CD, telemetry, or automatic updates.
@@ -652,16 +660,45 @@ Run the complete local check:
652
660
  just check
653
661
  ```
654
662
 
663
+ The shared check also builds a wheel and installs it into a fresh virtual environment. Package
664
+ acceptance runs the installed CLI outside the source checkout, checking deterministic agreement
665
+ between all three formats, linked worktrees, long multi-module instructions with focus/path selection,
666
+ explicit truncation and nested-instruction presence, and safe failures. Build and installation run offline
667
+ using the backend cache populated by `uv run --frozen`; the tests use only disposable repositories.
668
+ Git deadline regressions verify that silent, streaming, and stdout-closed child processes are
669
+ terminated and reaped, that successful commands cannot reset the shared budget, and that process
670
+ creation consumes it. A timeout leaves CLI stdout empty in every output format.
671
+
655
672
  The lockfile uses public PyPI artifact URLs; no organization mirror or local Renovate preset
656
673
  is needed. Renovate keeps scheduled, rate-limited updates and manual merges using a public
657
- recommended preset plus the repository's explicit settings. Python 3.12 remains the tested
658
- runtime; widening that range requires testing those interpreters first.
674
+ recommended preset plus the repository's explicit settings. The GitHub quality gate runs on
675
+ Python 3.12.13, 3.13.13 and 3.14.5; the Gitea root-runner gate and release builds use 3.12.13.
676
+ Other interpreter/platform combinations require validation before widening the supported range.
659
677
 
660
678
  Git test fixtures discard inherited `GIT_*` routing/configuration before creating repositories.
661
- Permission-sensitive tests must run as a non-root user. Gitea uses `just ci-check`: non-root
662
- runners execute directly; root containers use `runuser` with an existing account named by
663
- `CONTEXT_LOADER_CI_USER` (default `ubuntu`). That account must be able to execute the installed
664
- `uv`, Python and `just`. No `sudo` dependency is required for this CI step.
679
+ Permission-sensitive tests must run as a non-root user. Gitea uses `just ci-check`, which installs
680
+ Python 3.12.13 without user-level executable links and keeps its interpreter/cache under
681
+ `$RUNNER_TEMP/context-loader-ci` (or ignored `.ci-runtime/context-loader-ci` locally).
682
+ Non-root runners execute directly; root containers transfer ownership of that runtime and the
683
+ checkout, then use `runuser` with an existing account named by `CONTEXT_LOADER_CI_USER` (default
684
+ `ubuntu`). That account must be able to execute the installed `uv` and `just`; it does not need
685
+ access to `/root` or a root-owned Python/cache. No `sudo` dependency is required for this CI step.
686
+
687
+ ### Performance baseline
688
+
689
+ On Linux with GNU `/usr/bin/time`, measure an explicit installed CLI using an existing empty
690
+ scratch directory:
691
+
692
+ ```bash
693
+ python scripts/benchmark.py --cli /absolute/path/to/project-context --scratch /absolute/empty/scratch
694
+ ```
695
+
696
+ The opt-in benchmark creates only synthetic repositories under that directory and reports median
697
+ elapsed time, peak RSS (KiB) and JSON-compact output bytes over five runs. It checks identical output
698
+ between runs and covers 1,000 AGENTS headings, a 5,000-file directory, and 1,000 untracked files.
699
+ Keep the CLI version, scratch path, interpreter and machine comparable when comparing runs;
700
+ measurements are observations, not timing thresholds in CI. Retain or remove the scratch directory
701
+ through your own temporary-resource workflow after inspection.
665
702
 
666
703
  ## Version maintenance
667
704
 
@@ -687,6 +724,9 @@ For environments that permit direct public reads but share an exhausted proxy qu
687
724
  set the Gitea Actions repository variable `RELEASE_PUBLIC_API_DIRECT=true`. The record
688
725
  step appends only public GitHub API/PyPI hosts to the existing proxy bypass lists. Without
689
726
  this explicit option, the workflow uses its inherited proxy settings.
727
+ Both existing bypass lists and an explicit `*` wildcard are preserved. Public HTTPS GET
728
+ transport failures get at most three attempts within the existing total retry budget;
729
+ API writes stop on transport failure so their outcome can be verified before retrying.
690
730
 
691
731
  Release checks use Python 3.12, Git, and the runner's existing OpenSSL CLI. They compare downloaded
692
732
  PyPI file bytes, metadata and SHA-256, plus the publisher/tag/commit claims in PyPI's HTTPS-served
@@ -8,14 +8,14 @@ Python standard library.
8
8
 
9
9
  ## Open-source quick start
10
10
 
11
- Current stable release: **1.3.3**. Local CLI for coding-agent workflows; renders deterministic Markdown or JSON.
11
+ Current stable release: **1.3.5**. Local CLI for coding-agent workflows; renders deterministic Markdown or JSON.
12
12
 
13
13
  ```bash
14
- uv tool install 'context-loader==1.3.3'
14
+ uv tool install 'context-loader==1.3.5'
15
15
  ```
16
16
 
17
17
  ```bash
18
- pip install 'context-loader==1.3.3'
18
+ pip install 'context-loader==1.3.5'
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.3`. Install its matching published release or an exact source commit.
31
+ Source version: `1.3.5`. 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.3'
36
+ uv tool install 'context-loader==1.3.5'
37
37
  ```
38
38
 
39
39
  Install via `pip`:
40
40
 
41
41
  ```bash
42
- pip install 'context-loader==1.3.3'
42
+ pip install 'context-loader==1.3.5'
43
43
  ```
44
44
 
45
- For development or source-based installs tracking current repository (`1.3.3`):
45
+ For development or source-based installs tracking current repository (`1.3.5`):
46
46
 
47
47
  ```bash
48
48
  uv tool install git+https://github.com/xuanheng-tech/context-loader.git
@@ -68,7 +68,7 @@ part of either distribution; run them from a checkout of the matching tag.
68
68
 
69
69
  ## Platform and Runtime Requirements
70
70
 
71
- - **Python**: Python 3.12 (`>=3.12,<3.13`). Runtime code uses only the Python standard library with zero runtime dependencies.
71
+ - **Python**: Python 3.12–3.14 (`>=3.12,<3.15`). Runtime code uses only the Python standard library with zero runtime dependencies.
72
72
  - **Git**: Requires the standard `git` CLI at the fixed absolute path `/usr/bin/git`. The
73
73
  executable is never resolved through `PATH`, so an installation elsewhere is not used and
74
74
  every invocation fails with exit `1`. Git runs with a sanitized environment that ignores
@@ -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.3"
142
+ "version": "1.3.5"
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.3`. Version 1
262
+ for `--format json` and `4` for `--format json-compact`, while `tool.version` is `1.3.5`. 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.
@@ -385,6 +385,8 @@ bullet is a per-component, read or capture bound, as each one states:
385
385
  repository root the filesystem could not decode makes the machine formats exit 1 where Markdown
386
386
  still renders
387
387
  - Git subprocess output: 16 MiB (bounded while reading)
388
+ - Git collection: one shared 30-second deadline for repository discovery, configuration/submodule
389
+ preflight, refs, status and recent commits
388
390
  - Git status preflight: at most 128 repositories, including initialized nested submodules
389
391
  - Candidate file validation: 16 MiB hard read bound
390
392
 
@@ -400,6 +402,12 @@ Symlinked candidate files are skipped, and directory symlinks are not traversed.
400
402
  Git state is derived only from the working tree and local refs. The command does not fetch or query
401
403
  the remote.
402
404
 
405
+ All Git commands in one collection share the same elapsed-time budget; each command receives only
406
+ the remaining time. Once that budget expires, the loader stops without starting subsequent commands,
407
+ terminates and reaps a running Git child, and exits with empty stdout. This deadline does not impose
408
+ a hard wall-clock limit on blocked filesystem/process-creation calls or operating-system child
409
+ reaping, and does not cover the later context-file collection.
410
+
403
411
  - An unborn working tree reports its symbolic branch name, such as `Branch: main`, and
404
412
  `HEAD: unborn`.
405
413
  - A detached HEAD reports `Branch: detached` and the resolved commit object ID.
@@ -428,7 +436,7 @@ or candidate-file content.
428
436
 
429
437
  ## Not Included
430
438
 
431
- Version 1.3.3 does not provide AI summaries, project-type detection, loading or transport of
439
+ Version 1.3.5 does not provide AI summaries, project-type detection, loading or transport of
432
440
  nested `AGENTS.md` contents (the JSON `nested_context` field reports bounded existence only),
433
441
  Memory retrieval, semantic ranking, ignore-rule parsing, plugins, profiles, caches, databases,
434
442
  network services, MCP, daemons, GUIs, CI/CD, telemetry, or automatic updates.
@@ -441,16 +449,45 @@ Run the complete local check:
441
449
  just check
442
450
  ```
443
451
 
452
+ The shared check also builds a wheel and installs it into a fresh virtual environment. Package
453
+ acceptance runs the installed CLI outside the source checkout, checking deterministic agreement
454
+ between all three formats, linked worktrees, long multi-module instructions with focus/path selection,
455
+ explicit truncation and nested-instruction presence, and safe failures. Build and installation run offline
456
+ using the backend cache populated by `uv run --frozen`; the tests use only disposable repositories.
457
+ Git deadline regressions verify that silent, streaming, and stdout-closed child processes are
458
+ terminated and reaped, that successful commands cannot reset the shared budget, and that process
459
+ creation consumes it. A timeout leaves CLI stdout empty in every output format.
460
+
444
461
  The lockfile uses public PyPI artifact URLs; no organization mirror or local Renovate preset
445
462
  is needed. Renovate keeps scheduled, rate-limited updates and manual merges using a public
446
- recommended preset plus the repository's explicit settings. Python 3.12 remains the tested
447
- runtime; widening that range requires testing those interpreters first.
463
+ recommended preset plus the repository's explicit settings. The GitHub quality gate runs on
464
+ Python 3.12.13, 3.13.13 and 3.14.5; the Gitea root-runner gate and release builds use 3.12.13.
465
+ Other interpreter/platform combinations require validation before widening the supported range.
448
466
 
449
467
  Git test fixtures discard inherited `GIT_*` routing/configuration before creating repositories.
450
- Permission-sensitive tests must run as a non-root user. Gitea uses `just ci-check`: non-root
451
- runners execute directly; root containers use `runuser` with an existing account named by
452
- `CONTEXT_LOADER_CI_USER` (default `ubuntu`). That account must be able to execute the installed
453
- `uv`, Python and `just`. No `sudo` dependency is required for this CI step.
468
+ Permission-sensitive tests must run as a non-root user. Gitea uses `just ci-check`, which installs
469
+ Python 3.12.13 without user-level executable links and keeps its interpreter/cache under
470
+ `$RUNNER_TEMP/context-loader-ci` (or ignored `.ci-runtime/context-loader-ci` locally).
471
+ Non-root runners execute directly; root containers transfer ownership of that runtime and the
472
+ checkout, then use `runuser` with an existing account named by `CONTEXT_LOADER_CI_USER` (default
473
+ `ubuntu`). That account must be able to execute the installed `uv` and `just`; it does not need
474
+ access to `/root` or a root-owned Python/cache. No `sudo` dependency is required for this CI step.
475
+
476
+ ### Performance baseline
477
+
478
+ On Linux with GNU `/usr/bin/time`, measure an explicit installed CLI using an existing empty
479
+ scratch directory:
480
+
481
+ ```bash
482
+ python scripts/benchmark.py --cli /absolute/path/to/project-context --scratch /absolute/empty/scratch
483
+ ```
484
+
485
+ The opt-in benchmark creates only synthetic repositories under that directory and reports median
486
+ elapsed time, peak RSS (KiB) and JSON-compact output bytes over five runs. It checks identical output
487
+ between runs and covers 1,000 AGENTS headings, a 5,000-file directory, and 1,000 untracked files.
488
+ Keep the CLI version, scratch path, interpreter and machine comparable when comparing runs;
489
+ measurements are observations, not timing thresholds in CI. Retain or remove the scratch directory
490
+ through your own temporary-resource workflow after inspection.
454
491
 
455
492
  ## Version maintenance
456
493
 
@@ -476,6 +513,9 @@ For environments that permit direct public reads but share an exhausted proxy qu
476
513
  set the Gitea Actions repository variable `RELEASE_PUBLIC_API_DIRECT=true`. The record
477
514
  step appends only public GitHub API/PyPI hosts to the existing proxy bypass lists. Without
478
515
  this explicit option, the workflow uses its inherited proxy settings.
516
+ Both existing bypass lists and an explicit `*` wildcard are preserved. Public HTTPS GET
517
+ transport failures get at most three attempts within the existing total retry budget;
518
+ API writes stop on transport failure so their outcome can be verified before retrying.
479
519
 
480
520
  Release checks use Python 3.12, Git, and the runner's existing OpenSSL CLI. They compare downloaded
481
521
  PyPI file bytes, metadata and SHA-256, plus the publisher/tag/commit claims in PyPI's HTTPS-served
@@ -1,3 +1,3 @@
1
1
  """Project Context Loader."""
2
2
 
3
- __version__ = "1.3.3"
3
+ __version__ = "1.3.5"
@@ -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 = time.monotonic() + timeout_seconds
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 _run_git(repo: Path, arguments: tuple[str, ...], *, check: bool = True) -> GitResult:
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(proc, MAX_GIT_OUTPUT_BYTES, GIT_TIMEOUT_SECONDS)
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(canonical, ("rev-parse", "--is-bare-repository"), check=False)
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(repo, ("symbolic-ref", "--quiet", "--short", "HEAD"), check=False)
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(repo, ("rev-parse", "--verify", "HEAD^{commit}"), check=False)
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 one repository and collect only the Phase 01 Git fields."""
447
- location = discover_repository(raw_path, require_canonical_root=require_canonical_root)
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
 
@@ -4,10 +4,10 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "context-loader"
7
- version = "1.3.3"
7
+ version = "1.3.5"
8
8
  description = "Render deterministic local Git context for any executor"
9
9
  readme = "README.md"
10
- requires-python = ">=3.12,<3.13"
10
+ requires-python = ">=3.12,<3.15"
11
11
  dependencies = []
12
12
 
13
13
  [project.license]
@@ -4,11 +4,11 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "context-loader"
7
- version = "1.3.3"
7
+ version = "1.3.5"
8
8
  description = "Render deterministic local Git context for any executor"
9
9
  license = { file = "LICENSE" }
10
10
  readme = "README.md"
11
- requires-python = ">=3.12,<3.13"
11
+ requires-python = ">=3.12,<3.15"
12
12
  dependencies = []
13
13
 
14
14
  [project.scripts]
@@ -46,5 +46,5 @@
46
46
  ],
47
47
  "schema_version": 1,
48
48
  "tool_name": "context-loader",
49
- "tool_version": "1.3.3"
49
+ "tool_version": "1.3.5"
50
50
  }
File without changes