turnloop 0.1.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.
Files changed (132) hide show
  1. turnloop/__init__.py +3 -0
  2. turnloop/__main__.py +4 -0
  3. turnloop/agent/__init__.py +0 -0
  4. turnloop/agent/factory.py +175 -0
  5. turnloop/agent/headless.py +150 -0
  6. turnloop/agent/loop.py +396 -0
  7. turnloop/agent/subagent.py +182 -0
  8. turnloop/agent/system_prompt.py +263 -0
  9. turnloop/cli.py +202 -0
  10. turnloop/commands/__init__.py +0 -0
  11. turnloop/commands/dispatch.py +428 -0
  12. turnloop/commands/loader.py +140 -0
  13. turnloop/config.py +425 -0
  14. turnloop/context/__init__.py +0 -0
  15. turnloop/context/budget.py +74 -0
  16. turnloop/context/compaction.py +441 -0
  17. turnloop/context/memory.py +120 -0
  18. turnloop/core/__init__.py +0 -0
  19. turnloop/core/events.py +145 -0
  20. turnloop/core/ids.py +29 -0
  21. turnloop/core/messages.py +217 -0
  22. turnloop/core/tokens.py +104 -0
  23. turnloop/diagnostics.py +233 -0
  24. turnloop/errors.py +64 -0
  25. turnloop/experiments/__init__.py +0 -0
  26. turnloop/experiments/configs/bakeoff.yaml +50 -0
  27. turnloop/experiments/configs/ceiling-check.yaml +31 -0
  28. turnloop/experiments/configs/context-glm.yaml +59 -0
  29. turnloop/experiments/configs/context.yaml +65 -0
  30. turnloop/experiments/configs/glm-selfhosted.yaml +36 -0
  31. turnloop/experiments/configs/groq-free.yaml +28 -0
  32. turnloop/experiments/configs/hard-calibration.yaml +26 -0
  33. turnloop/experiments/configs/hard-glm.yaml +41 -0
  34. turnloop/experiments/configs/loop.yaml +34 -0
  35. turnloop/experiments/configs/nvidia-smoke.yaml +22 -0
  36. turnloop/experiments/configs/reliability.yaml +44 -0
  37. turnloop/experiments/configs/rewrite-calibration.yaml +24 -0
  38. turnloop/experiments/configs/smoke.yaml +22 -0
  39. turnloop/experiments/graders.py +209 -0
  40. turnloop/experiments/report.py +371 -0
  41. turnloop/experiments/runner.py +495 -0
  42. turnloop/experiments/suite/default.yaml +185 -0
  43. turnloop/experiments/suite/fixtures/bulky/module_1.py +670 -0
  44. turnloop/experiments/suite/fixtures/bulky/module_2.py +670 -0
  45. turnloop/experiments/suite/fixtures/bulky/module_3.py +670 -0
  46. turnloop/experiments/suite/fixtures/bulky/module_4.py +670 -0
  47. turnloop/experiments/suite/fixtures/bulky/module_5.py +670 -0
  48. turnloop/experiments/suite/fixtures/bulky/module_6.py +672 -0
  49. turnloop/experiments/suite/fixtures/bulky/module_7.py +670 -0
  50. turnloop/experiments/suite/fixtures/bulky/module_8.py +670 -0
  51. turnloop/experiments/suite/fixtures/circular_import/models.py +11 -0
  52. turnloop/experiments/suite/fixtures/circular_import/services.py +15 -0
  53. turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +12 -0
  54. turnloop/experiments/suite/fixtures/cli_flag/app.py +16 -0
  55. turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +7 -0
  56. turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +10 -0
  57. turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +11 -0
  58. turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +7 -0
  59. turnloop/experiments/suite/fixtures/empty/.keep +1 -0
  60. turnloop/experiments/suite/fixtures/failing_test/calc.py +14 -0
  61. turnloop/experiments/suite/fixtures/failing_test/test_calc.py +17 -0
  62. turnloop/experiments/suite/fixtures/generate_bulky.py +80 -0
  63. turnloop/experiments/suite/fixtures/grep_edit/ingest.py +5 -0
  64. turnloop/experiments/suite/fixtures/grep_edit/load.py +5 -0
  65. turnloop/experiments/suite/fixtures/grep_edit/transform.py +5 -0
  66. turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +21 -0
  67. turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +8 -0
  68. turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +11 -0
  69. turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +12 -0
  70. turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +6 -0
  71. turnloop/experiments/suite/fixtures/needle/inventory.py +18 -0
  72. turnloop/experiments/suite/fixtures/needle/pricing.py +17 -0
  73. turnloop/experiments/suite/fixtures/needle/shipping.py +13 -0
  74. turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +14 -0
  75. turnloop/experiments/suite/fixtures/regression_trap/validators.py +23 -0
  76. turnloop/experiments/suite/fixtures/rename/billing.py +7 -0
  77. turnloop/experiments/suite/fixtures/rename/report.py +5 -0
  78. turnloop/experiments/suite/fixtures/rename/test_billing.py +11 -0
  79. turnloop/experiments/suite/fixtures/spec/parser.py +19 -0
  80. turnloop/experiments/suite/fixtures/spec/test_parser.py +28 -0
  81. turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +12 -0
  82. turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +28 -0
  83. turnloop/experiments/suite/fixtures/util/util.py +9 -0
  84. turnloop/hooks/__init__.py +0 -0
  85. turnloop/hooks/runner.py +221 -0
  86. turnloop/mcp/__init__.py +0 -0
  87. turnloop/mcp/adapter.py +109 -0
  88. turnloop/mcp/client.py +299 -0
  89. turnloop/permissions/__init__.py +0 -0
  90. turnloop/permissions/engine.py +211 -0
  91. turnloop/permissions/rules.py +325 -0
  92. turnloop/providers/__init__.py +0 -0
  93. turnloop/providers/anthropic.py +318 -0
  94. turnloop/providers/base.py +329 -0
  95. turnloop/providers/gemini.py +217 -0
  96. turnloop/providers/mock.py +226 -0
  97. turnloop/providers/openai_compat.py +357 -0
  98. turnloop/providers/pricing.py +148 -0
  99. turnloop/providers/registry.py +75 -0
  100. turnloop/providers/sse.py +114 -0
  101. turnloop/sessions/__init__.py +0 -0
  102. turnloop/sessions/models.py +139 -0
  103. turnloop/sessions/store.py +271 -0
  104. turnloop/tools/__init__.py +0 -0
  105. turnloop/tools/ask.py +133 -0
  106. turnloop/tools/base.py +286 -0
  107. turnloop/tools/bash.py +423 -0
  108. turnloop/tools/builtin.py +63 -0
  109. turnloop/tools/edit.py +238 -0
  110. turnloop/tools/glob.py +235 -0
  111. turnloop/tools/grep.py +314 -0
  112. turnloop/tools/read.py +151 -0
  113. turnloop/tools/runner.py +284 -0
  114. turnloop/tools/shell.py +222 -0
  115. turnloop/tools/skill.py +89 -0
  116. turnloop/tools/task.py +161 -0
  117. turnloop/tools/textio.py +87 -0
  118. turnloop/tools/todo.py +140 -0
  119. turnloop/tools/webfetch.py +235 -0
  120. turnloop/tools/write.py +97 -0
  121. turnloop/tui/__init__.py +0 -0
  122. turnloop/tui/app.py +330 -0
  123. turnloop/tui/app.tcss +84 -0
  124. turnloop/tui/bridge.py +142 -0
  125. turnloop/tui/widgets/__init__.py +0 -0
  126. turnloop/tui/widgets/permission.py +131 -0
  127. turnloop/tui/widgets/status.py +110 -0
  128. turnloop/tui/widgets/transcript.py +146 -0
  129. turnloop-0.1.0.dist-info/METADATA +916 -0
  130. turnloop-0.1.0.dist-info/RECORD +132 -0
  131. turnloop-0.1.0.dist-info/WHEEL +4 -0
  132. turnloop-0.1.0.dist-info/entry_points.txt +3 -0
turnloop/tools/edit.py ADDED
@@ -0,0 +1,238 @@
1
+ """Edit — exact-string replacement, applied atomically.
2
+
3
+ One tool with a list of edits rather than separate Edit and MultiEdit. Two
4
+ near-identical tool descriptions is a real cost on a 65k-token window, and the
5
+ single-element list is barely more typing for the model.
6
+
7
+ The behavior that earns its keep: edits are applied to an in-memory copy and the
8
+ file is written once, so a batch either lands completely or not at all. A partial
9
+ batch leaves a file in a state neither the model nor the user predicted, which is
10
+ much worse than a clean error.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import difflib
16
+
17
+ from pydantic import BaseModel, Field
18
+
19
+ from turnloop.tools.base import Tool, ToolContext, ToolOutput
20
+ from turnloop.tools.textio import (
21
+ detect_newline,
22
+ has_bom,
23
+ preserve_trailing_newline,
24
+ read_text,
25
+ write_text,
26
+ )
27
+
28
+
29
+ class EditOp(BaseModel):
30
+ old_string: str = Field(description="Exact text to find, including indentation.")
31
+ new_string: str = Field(description="Replacement text. Empty string deletes.")
32
+ replace_all: bool = Field(
33
+ default=False, description="Replace every occurrence instead of requiring uniqueness."
34
+ )
35
+
36
+
37
+ class EditArgs(BaseModel):
38
+ file_path: str = Field(description="Absolute or project-relative path to edit.")
39
+ edits: list[EditOp] = Field(
40
+ description="Edits applied in order. Use one element for a single change."
41
+ )
42
+
43
+
44
+ class EditTool(Tool):
45
+ name = "Edit"
46
+ Args = EditArgs
47
+ read_only = False
48
+ parallel_safe = False
49
+
50
+ descriptions = {
51
+ "terse": """
52
+ Replace exact strings in a file. `edits` is a list of {old_string, new_string}.
53
+ Read the file first. `old_string` must be unique unless replace_all.
54
+ All edits apply together or none do.
55
+ """,
56
+ "normal": """
57
+ Edit a file by exact string replacement.
58
+
59
+ - Read the file first. Edits are rejected if the file was never read, or if it
60
+ changed on disk since.
61
+ - `old_string` must match the file exactly, including indentation and line
62
+ breaks. Copy it from Read output, minus the line-number prefix.
63
+ - `old_string` must be unique in the file, otherwise the edit is rejected with a
64
+ count — add surrounding context to disambiguate, or set `replace_all`.
65
+ - Pass several edits in one call to make a coherent change. They are applied in
66
+ order against the file, and written atomically: if any edit fails, none are
67
+ applied.
68
+ - To delete text, use an empty `new_string`.
69
+ """,
70
+ "verbose": """
71
+ Edit a file by exact string replacement.
72
+
73
+ Arguments:
74
+ - `file_path`: absolute, or relative to the working directory.
75
+ - `edits`: a list of `{old_string, new_string, replace_all}`. One element is the
76
+ common case; several are applied in order.
77
+
78
+ Matching rules:
79
+ - `old_string` must match the file byte-for-byte, including leading whitespace.
80
+ When copying from Read output, strip the line-number prefix (the number and the
81
+ following tab) — including it is the most common cause of a failed edit.
82
+ - `old_string` must appear exactly once. If it appears more than once the edit is
83
+ rejected and the match count is reported; extend `old_string` with surrounding
84
+ lines to make it unique, or set `replace_all: true` when every occurrence
85
+ really should change (renaming a local variable, for example).
86
+ - `old_string` and `new_string` must differ.
87
+ - An empty `new_string` deletes the matched text.
88
+
89
+ Application semantics:
90
+ - Edits apply sequentially to an in-memory copy, so a later edit can match text a
91
+ previous edit introduced.
92
+ - The write is atomic across the batch: if any edit fails to match, the file is
93
+ left untouched and an error explains which edit failed. A half-applied batch
94
+ would leave the file in a state neither of us intended.
95
+ - The file's newline style and trailing-newline convention are preserved, so a
96
+ one-line change does not produce a whole-file diff on a CRLF checkout.
97
+ """,
98
+ }
99
+
100
+ async def run(self, args: EditArgs, ctx: ToolContext) -> ToolOutput:
101
+ path = ctx.resolve(args.file_path)
102
+
103
+ if ctx.readonly:
104
+ return ToolOutput.error("plan mode is read-only; Edit is not available")
105
+ if not args.edits:
106
+ return ToolOutput.error("no edits supplied")
107
+ if not ctx.within_allowed(path):
108
+ return ToolOutput.error(f"{path} is outside the project root ({ctx.root}).")
109
+ if not path.exists():
110
+ return ToolOutput.error(f"{path} does not exist. Use Write to create it.")
111
+ if not ctx.files.has_read(path):
112
+ return ToolOutput.error(
113
+ f"{path} has not been read in this session. Read it first — editing text you "
114
+ "have not seen is how unintended changes happen."
115
+ )
116
+ if ctx.files.is_stale(path):
117
+ return ToolOutput.error(
118
+ f"{path} changed on disk since you read it. Read it again, then retry."
119
+ )
120
+
121
+ original = read_text(path)
122
+ updated = original
123
+ applied: list[tuple[EditOp, int]] = []
124
+
125
+ for index, op in enumerate(args.edits):
126
+ if op.old_string == op.new_string:
127
+ return ToolOutput.error(
128
+ f"edit {index + 1}: old_string and new_string are identical"
129
+ )
130
+ if not op.old_string:
131
+ return ToolOutput.error(
132
+ f"edit {index + 1}: old_string is empty. Use Write to create content."
133
+ )
134
+
135
+ count = updated.count(op.old_string)
136
+ if count == 0:
137
+ return ToolOutput.error(_no_match_message(index, op, updated))
138
+ if count > 1 and not op.replace_all:
139
+ return ToolOutput.error(
140
+ f"edit {index + 1}: old_string appears {count} times. Add surrounding "
141
+ f"context to make it unique, or set replace_all: true.\n"
142
+ f"{_occurrence_context(updated, op.old_string)}"
143
+ )
144
+
145
+ updated = (
146
+ updated.replace(op.old_string, op.new_string)
147
+ if op.replace_all
148
+ else updated.replace(op.old_string, op.new_string, 1)
149
+ )
150
+ applied.append((op, count))
151
+
152
+ if updated == original:
153
+ return ToolOutput.error("edits produced no change")
154
+
155
+ updated = preserve_trailing_newline(original, updated)
156
+ write_text(path, updated, newline=detect_newline(path), bom=has_bom(path))
157
+ ctx.files.record_write(path, updated)
158
+
159
+ diff = _unified_diff(original, updated, path.name)
160
+ replaced = sum(c if op.replace_all else 1 for op, c in applied)
161
+ return ToolOutput(
162
+ content=(
163
+ f"Applied {len(applied)} edit(s) to {path} "
164
+ f"({replaced} replacement(s)).\n\n{_diff_for_model(diff)}"
165
+ ),
166
+ display=diff,
167
+ metrics={"edits": len(applied), "replacements": replaced},
168
+ )
169
+
170
+ def summary(self, args: EditArgs) -> str: # type: ignore[override]
171
+ n = len(args.edits)
172
+ return f"Edit {args.file_path}" + (f" ({n} edits)" if n > 1 else "")
173
+
174
+
175
+ def _no_match_message(index: int, op: EditOp, content: str) -> str:
176
+ """Explain a failed match usefully.
177
+
178
+ Blank whitespace mismatches account for most failures, so name that
179
+ explicitly rather than making the model guess at re-reading the file.
180
+ """
181
+ stripped = op.old_string.strip()
182
+ hint = ""
183
+ if stripped and stripped in content:
184
+ hint = (
185
+ " The text exists but with different surrounding whitespace — copy the exact "
186
+ "indentation from Read output."
187
+ )
188
+ else:
189
+ first_line = op.old_string.splitlines()[0].strip() if op.old_string.strip() else ""
190
+ if first_line and first_line in content:
191
+ hint = (
192
+ f" Its first line ({first_line[:60]!r}) is present, so the later lines differ. "
193
+ "Re-read the file and copy the current text."
194
+ )
195
+ else:
196
+ candidates = difflib.get_close_matches(
197
+ op.old_string.splitlines()[0] if op.old_string.splitlines() else op.old_string,
198
+ content.splitlines(),
199
+ n=2,
200
+ cutoff=0.7,
201
+ )
202
+ if candidates:
203
+ hint = " Closest lines in the file: " + " | ".join(c.strip()[:70] for c in candidates)
204
+ return f"edit {index + 1}: old_string not found in the file.{hint}"
205
+
206
+
207
+ def _occurrence_context(content: str, needle: str, limit: int = 3) -> str:
208
+ lines = content.splitlines()
209
+ first_needle_line = needle.splitlines()[0] if needle.splitlines() else needle
210
+ hits = [i + 1 for i, line in enumerate(lines) if first_needle_line in line][:limit]
211
+ if not hits:
212
+ return ""
213
+ return "Occurrences near lines: " + ", ".join(str(h) for h in hits)
214
+
215
+
216
+ def _unified_diff(before: str, after: str, name: str) -> str:
217
+ diff = difflib.unified_diff(
218
+ before.splitlines(keepends=False),
219
+ after.splitlines(keepends=False),
220
+ fromfile=f"a/{name}",
221
+ tofile=f"b/{name}",
222
+ n=3,
223
+ lineterm="",
224
+ )
225
+ return "\n".join(diff)
226
+
227
+
228
+ def _diff_for_model(diff: str, max_lines: int = 80) -> str:
229
+ """Send the model a bounded diff.
230
+
231
+ It needs confirmation of what changed, not the whole patch — a 400-line diff
232
+ echoed back into context is pure waste when the file is one Read away.
233
+ """
234
+ lines = diff.splitlines()
235
+ if len(lines) <= max_lines:
236
+ return diff
237
+ head = lines[:max_lines]
238
+ return "\n".join(head) + f"\n... [{len(lines) - max_lines} more diff lines]"
turnloop/tools/glob.py ADDED
@@ -0,0 +1,235 @@
1
+ """Glob — find files by name pattern.
2
+
3
+ Ignore handling delegates to `git ls-files` when the project is a repository.
4
+ Reimplementing .gitignore semantics (negations, directory-only patterns, nested
5
+ ignore files, precedence) is a genuinely hard problem that git has already
6
+ solved; falling back to a hardcoded skip list only when git is unavailable is
7
+ strictly better than getting it subtly wrong everywhere.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import subprocess
13
+ import time
14
+ from pathlib import Path
15
+
16
+ from pydantic import BaseModel, Field
17
+
18
+ from turnloop.permissions.rules import _glob_to_regex
19
+ from turnloop.tools.base import Tool, ToolContext, ToolOutput
20
+
21
+ MAX_RESULTS = 200
22
+
23
+ ALWAYS_SKIP = {
24
+ ".git", "node_modules", "__pycache__", ".venv", "venv", ".mypy_cache",
25
+ ".pytest_cache", ".ruff_cache", "dist", "build", ".next", "target",
26
+ ".turnloop", ".idea", ".gradle", "vendor",
27
+ }
28
+
29
+
30
+ class GlobArgs(BaseModel):
31
+ pattern: str = Field(description="Glob pattern, e.g. '**/*.py' or 'src/**/test_*.py'.")
32
+ path: str | None = Field(default=None, description="Directory to search in. Defaults to cwd.")
33
+ limit: int | None = Field(default=None, description=f"Max results (default {MAX_RESULTS}).")
34
+
35
+
36
+ class GlobTool(Tool):
37
+ name = "Glob"
38
+ Args = GlobArgs
39
+ read_only = True
40
+ parallel_safe = True
41
+
42
+ descriptions = {
43
+ "terse": """
44
+ Find files by glob pattern (`**/*.py`). Returns paths, newest first.
45
+ Ignored files (.git, node_modules, build output) are skipped.
46
+ """,
47
+ "normal": """
48
+ Find files by name pattern.
49
+
50
+ - `pattern` supports `**` (any depth) and `*` (one path segment).
51
+ - Results are sorted by modification time, newest first — recently touched files
52
+ are usually the relevant ones.
53
+ - Files ignored by the repository's .gitignore are excluded, as are build and
54
+ dependency directories.
55
+ - Use this to locate files by name. Use Grep to search their contents.
56
+ """,
57
+ "verbose": """
58
+ Find files by name pattern.
59
+
60
+ Arguments:
61
+ - `pattern`: a glob. `**` matches any number of directories, `*` matches within
62
+ one path segment, `?` matches one character. Examples: `**/*.ts`,
63
+ `src/**/test_*.py`, `*.md`.
64
+ - `path`: directory to search under. Defaults to the working directory.
65
+ - `limit`: maximum results, default 200.
66
+
67
+ Behavior:
68
+ - Results are ordered by modification time, newest first, on the theory that the
69
+ file you want is one somebody touched recently.
70
+ - When the project is a git repository, .gitignore is honored by asking git
71
+ itself, so the ignore semantics match what the user sees. Otherwise a fixed
72
+ skip list is used (.git, node_modules, __pycache__, .venv, dist, build,
73
+ target, .next, vendor).
74
+ - Output is truncated at the limit with an explicit note. If you hit it, narrow
75
+ the pattern rather than raising the limit — a 200-path list is already more
76
+ than a turn should spend context on.
77
+ - This searches names only. To search file contents, use Grep.
78
+ """,
79
+ }
80
+
81
+ async def run(self, args: GlobArgs, ctx: ToolContext) -> ToolOutput:
82
+ base = ctx.resolve(args.path) if args.path else ctx.cwd
83
+ if not base.is_dir():
84
+ return ToolOutput.error(f"{base} is not a directory")
85
+ if not ctx.within_allowed(base):
86
+ return ToolOutput.error(f"{base} is outside the project root ({ctx.root}).")
87
+
88
+ limit = args.limit or MAX_RESULTS
89
+ started = time.monotonic()
90
+
91
+ tracked = _git_tracked(base)
92
+ if tracked is not None:
93
+ matches = _match_against(tracked, args.pattern, base)
94
+ else:
95
+ matches = _walk_match(base, args.pattern)
96
+
97
+ matches.sort(key=lambda p: _mtime(p), reverse=True)
98
+ total = len(matches)
99
+ shown = matches[:limit]
100
+
101
+ if not shown:
102
+ return ToolOutput(
103
+ content=f"No files match {args.pattern!r} under {base}.",
104
+ metrics={"matches": 0, "elapsed_s": time.monotonic() - started},
105
+ )
106
+
107
+ rel = [_rel(p, ctx.cwd) for p in shown]
108
+ body = "\n".join(rel)
109
+ if total > limit:
110
+ body += f"\n\n[{total - limit} more matches not shown; narrow the pattern]"
111
+
112
+ return ToolOutput(
113
+ content=body,
114
+ display=f"Glob {args.pattern} → {total} file(s)",
115
+ metrics={"matches": total, "shown": len(shown), "used_git": tracked is not None},
116
+ )
117
+
118
+ def summary(self, args: GlobArgs) -> str: # type: ignore[override]
119
+ return f"Glob {args.pattern}"
120
+
121
+
122
+ def _git_tracked(base: Path) -> list[Path] | None:
123
+ """Files git knows about, honoring .gitignore. None if not a repo.
124
+
125
+ Also None if `base` itself is inside a git-ignored tree: `git ls-files`
126
+ never descends into an ignored directory, so it reports zero entries there
127
+ regardless of what actually exists on disk. Treating that the same as "not
128
+ a repo" (i.e. falling back to `_walk_match`) is deliberate — the caller
129
+ can't tell "nothing here" from "git refuses to look here" any other way,
130
+ and the alternative (trusting an empty result) is exactly the bug this
131
+ exists to avoid: Glob silently reporting no matches for a directory full
132
+ of real files just because some ancestor is .gitignore'd.
133
+ """
134
+ try:
135
+ proc = subprocess.run(
136
+ ["git", "ls-files", "--cached", "--others", "--exclude-standard"],
137
+ cwd=base,
138
+ capture_output=True,
139
+ text=True,
140
+ timeout=20,
141
+ )
142
+ except (OSError, subprocess.SubprocessError):
143
+ return None
144
+ if proc.returncode != 0:
145
+ return None
146
+ out: list[Path] = []
147
+ for line in proc.stdout.splitlines():
148
+ if not line.strip():
149
+ continue
150
+ p = base / line
151
+ if p.is_file():
152
+ out.append(p)
153
+ if not out and _is_ignored(base):
154
+ return None
155
+ return out
156
+
157
+
158
+ def _is_ignored(path: Path) -> bool:
159
+ """Whether git itself would refuse to track `path`.
160
+
161
+ Checked explicitly (rather than just "ls-files came back empty") because an
162
+ empty result also happens for a genuinely empty, non-ignored directory —
163
+ conflating the two would make Glob walk the filesystem and include files
164
+ that a normal, non-ignored empty directory would never have had, but worse,
165
+ it would also change nothing for the real failure mode this guards against.
166
+ The check is cheap and only runs on the already-rare empty-result path.
167
+ """
168
+ try:
169
+ proc = subprocess.run(
170
+ ["git", "check-ignore", "-q", str(path)],
171
+ cwd=path,
172
+ capture_output=True,
173
+ timeout=20,
174
+ )
175
+ except (OSError, subprocess.SubprocessError):
176
+ return False
177
+ return proc.returncode == 0
178
+
179
+
180
+ def _match_against(paths: list[Path], pattern: str, base: Path) -> list[Path]:
181
+ import re
182
+
183
+ regex = re.compile(_glob_to_regex(_normalize_pattern(pattern)))
184
+ out = []
185
+ for p in paths:
186
+ try:
187
+ rel = p.relative_to(base).as_posix()
188
+ except ValueError:
189
+ continue
190
+ if regex.fullmatch(rel):
191
+ out.append(p)
192
+ return out
193
+
194
+
195
+ def _walk_match(base: Path, pattern: str) -> list[Path]:
196
+ import os
197
+ import re
198
+
199
+ regex = re.compile(_glob_to_regex(_normalize_pattern(pattern)))
200
+ out: list[Path] = []
201
+ for dirpath, dirnames, filenames in os.walk(base):
202
+ dirnames[:] = [d for d in dirnames if d not in ALWAYS_SKIP and not d.startswith(".git")]
203
+ for name in filenames:
204
+ full = Path(dirpath) / name
205
+ rel = full.relative_to(base).as_posix()
206
+ if regex.fullmatch(rel):
207
+ out.append(full)
208
+ return out
209
+
210
+
211
+ def _normalize_pattern(pattern: str) -> str:
212
+ """Make a bare pattern behave the way people expect.
213
+
214
+ `*.py` almost always means "python files anywhere here", not "python files in
215
+ exactly this directory". Anchoring it strictly produces empty results and a
216
+ confused model, so a leading `**/` is implied for patterns with no separator.
217
+ """
218
+ p = pattern.replace("\\", "/").lstrip("./")
219
+ if "/" not in p:
220
+ return f"**/{p}"
221
+ return p
222
+
223
+
224
+ def _mtime(path: Path) -> float:
225
+ try:
226
+ return path.stat().st_mtime
227
+ except OSError:
228
+ return 0.0
229
+
230
+
231
+ def _rel(path: Path, base: Path) -> str:
232
+ try:
233
+ return path.resolve().relative_to(base.resolve()).as_posix()
234
+ except ValueError:
235
+ return str(path)