ferrum-cli 0.1.0__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/PKG-INFO +1 -1
  2. {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/ferrum/__init__.py +1 -1
  3. ferrum_cli-0.1.2/ferrum/agent.py +320 -0
  4. ferrum_cli-0.1.2/ferrum/apply_patch.py +197 -0
  5. ferrum_cli-0.1.0/ferrum/tools.py → ferrum_cli-0.1.2/ferrum/builtin_tools.py +326 -296
  6. ferrum_cli-0.1.2/ferrum/cli.py +194 -0
  7. ferrum_cli-0.1.2/ferrum/commands/__init__.py +15 -0
  8. ferrum_cli-0.1.2/ferrum/commands/config.py +234 -0
  9. ferrum_cli-0.1.2/ferrum/commands/doctor.py +109 -0
  10. ferrum_cli-0.1.2/ferrum/commands/setup.py +191 -0
  11. ferrum_cli-0.1.2/ferrum/commands/task.py +186 -0
  12. ferrum_cli-0.1.2/ferrum/config.py +358 -0
  13. ferrum_cli-0.1.2/ferrum/config_store.py +149 -0
  14. {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/ferrum/context.py +295 -379
  15. ferrum_cli-0.1.2/ferrum/gitignore.py +131 -0
  16. {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/ferrum/model.py +129 -124
  17. {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/ferrum/prompts/system.md +7 -2
  18. {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/ferrum/safety.py +4 -2
  19. ferrum_cli-0.1.2/ferrum/toolcalls.py +105 -0
  20. ferrum_cli-0.1.2/ferrum/tools.py +112 -0
  21. ferrum_cli-0.1.2/ferrum/ui.py +196 -0
  22. {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/ferrum/verifier.py +21 -9
  23. {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/ferrum_cli.egg-info/PKG-INFO +1 -1
  24. {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/ferrum_cli.egg-info/SOURCES.txt +12 -0
  25. {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/pyproject.toml +11 -2
  26. {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/tests/test_agent.py +508 -455
  27. {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/tests/test_cli.py +395 -343
  28. {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/tests/test_config.py +143 -1
  29. {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/tests/test_context.py +32 -6
  30. {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/tests/test_model.py +538 -455
  31. {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/tests/test_tools.py +393 -268
  32. ferrum_cli-0.1.2/tests/test_ui.py +91 -0
  33. {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/tests/test_verifier.py +28 -7
  34. ferrum_cli-0.1.0/ferrum/agent.py +0 -387
  35. ferrum_cli-0.1.0/ferrum/cli.py +0 -479
  36. ferrum_cli-0.1.0/ferrum/config.py +0 -178
  37. {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/LICENSE +0 -0
  38. {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/README.md +0 -0
  39. {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/ferrum/patch.py +0 -0
  40. {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/ferrum_cli.egg-info/dependency_links.txt +0 -0
  41. {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/ferrum_cli.egg-info/entry_points.txt +0 -0
  42. {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/ferrum_cli.egg-info/top_level.txt +0 -0
  43. {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/setup.cfg +0 -0
  44. {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/tests/test_patch.py +0 -0
  45. {ferrum_cli-0.1.0 → ferrum_cli-0.1.2}/tests/test_safety.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ferrum-cli
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: A lightweight coding harness for low-level and systems programming: inspect, understand, patch, verify.
5
5
  Author: Ferrum contributors
6
6
  License-Expression: Apache-2.0
@@ -3,4 +3,4 @@
3
3
  Understand -> Change -> Build -> Verify.
4
4
  """
5
5
 
6
- __version__ = "0.1.0"
6
+ __version__ = "0.1.2"
@@ -0,0 +1,320 @@
1
+ """The agent loop: the model reads (and, in fix mode, patches) until done."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from ferrum.apply_patch import ApplyPatch
13
+ from ferrum.config import Config
14
+ from ferrum.context import ProjectContext
15
+ from ferrum.model import ModelProvider, ModelResponse
16
+ from ferrum.toolcalls import MALFORMED_JSON, ToolCall, extract_tool_calls
17
+ from ferrum.tools import ToolRegistry, ToolResult
18
+ from ferrum.ui import Console
19
+ from ferrum.verifier import Verifier
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+ # Inside the package, so wheel installs ship the real prompt too.
24
+ PROMPT_PATH = Path(__file__).resolve().parent / "prompts" / "system.md"
25
+ FALLBACK_SYSTEM = (
26
+ "You are Ferrum, a coding harness for systems programming. "
27
+ "Inspect before you modify, cite file:line evidence, make the smallest "
28
+ "change, and never claim a fix is verified unless a build or test "
29
+ "actually passed."
30
+ )
31
+
32
+ READ_ONLY_NOTE = (
33
+ "Read-only mode: investigate thoroughly before answering. A directory "
34
+ "listing alone is not an answer — read the relevant source files and "
35
+ "search for the relevant names; several tool calls are expected. You "
36
+ "cannot modify files, so do not call apply_patch; run_command is "
37
+ "available for builds, tests, and running the program."
38
+ )
39
+
40
+ FIX_NOTE = (
41
+ "Fix mode: inspect thoroughly before patching — read the file you are "
42
+ "about to change and its callers; several tool calls are expected. "
43
+ "Reproduce the failure with run_command first when a build or test can "
44
+ "show it. Call apply_patch only with text you have read in this session."
45
+ )
46
+
47
+ DRY_RUN_NOTE = (
48
+ "Dry run: apply_patch will display your proposed diff, but the harness "
49
+ "will not modify files, ask for confirmation, or run verification. "
50
+ "Propose the one correct patch and then summarize it for the user."
51
+ )
52
+
53
+ NUDGE = (
54
+ "You have not called any tools yet. Investigate first: call list_files, "
55
+ "read_file or search_code and base your answer on what you read."
56
+ )
57
+
58
+ READ_NUDGE = (
59
+ "A directory listing alone does not answer the question. Use read_file "
60
+ "on the most relevant source file (and search_code for names) before "
61
+ "you answer."
62
+ )
63
+
64
+ MAX_NUDGES = 2
65
+ READ_TOOLS = ("read_file", "search_code", "apply_patch")
66
+
67
+ PROGRESS = {
68
+ "list_files": "inspecting the project",
69
+ "read_file": "reading",
70
+ "search_code": "searching",
71
+ "run_command": "running",
72
+ "apply_patch": "proposing a patch to",
73
+ }
74
+
75
+
76
+ @dataclass
77
+ class AgentResult:
78
+ text: str
79
+ turns: int
80
+ capped: bool = False
81
+ patched: bool = False
82
+ verified: bool | None = None
83
+ verify_report: str | None = None
84
+
85
+
86
+ class Agent:
87
+ def __init__(
88
+ self,
89
+ config: Config,
90
+ provider: ModelProvider,
91
+ registry: ToolRegistry,
92
+ printer: Callable[[str], None] | None = None,
93
+ verifier: Verifier | None = None,
94
+ ui: Console | None = None,
95
+ ) -> None:
96
+ self.config = config
97
+ self.provider = provider
98
+ self.registry = registry
99
+ self.printer = printer
100
+ self.verifier = verifier
101
+ self.ui = ui
102
+ self.apply_tool: ApplyPatch | None = None
103
+
104
+ @staticmethod
105
+ def system_prompt() -> str:
106
+ try:
107
+ return PROMPT_PATH.read_text(encoding="utf-8")
108
+ except OSError:
109
+ return FALLBACK_SYSTEM
110
+
111
+ def run(
112
+ self,
113
+ task: str,
114
+ context: ProjectContext,
115
+ *,
116
+ edit: bool = False,
117
+ confirm: Callable[[str], bool] | None = None,
118
+ dry_run: bool = False,
119
+ ) -> AgentResult:
120
+ if edit and "apply_patch" not in self.registry:
121
+ if confirm is None:
122
+ raise ValueError("edit mode requires a confirm callback")
123
+ self._register_apply(context, confirm, dry_run)
124
+
125
+ messages = self._messages(task, context, edit=edit, dry_run=dry_run)
126
+ state = _TurnState()
127
+ turns = 0
128
+ for turn in range(1, self.config.max_iterations + 1):
129
+ turns = turn
130
+ response = self._complete(messages)
131
+ calls = _merge_tool_calls(
132
+ response.tool_calls, extract_tool_calls(response.content)
133
+ )
134
+ if calls:
135
+ response = ModelResponse(response.content, calls)
136
+ if not response.tool_calls:
137
+ nudge = _nudge_for(response, state)
138
+ if nudge:
139
+ messages.append(_assistant_message(response))
140
+ messages.append({"role": "user", "content": nudge})
141
+ continue
142
+ return self._finish(response.content, turns, capped=False, edit=edit)
143
+ messages.append(_assistant_message(response))
144
+ messages.extend(self._run_tools(response, state))
145
+ return self._finish(
146
+ f"Stopped after {self.config.max_iterations} iterations "
147
+ "without a final answer.",
148
+ turns,
149
+ capped=True,
150
+ edit=edit,
151
+ )
152
+
153
+ def _run_tools(
154
+ self, response: ModelResponse, state: _TurnState
155
+ ) -> list[dict[str, Any]]:
156
+ """Run every requested tool, one tool message per call."""
157
+ state.used_tools = True
158
+ messages: list[dict[str, Any]] = []
159
+ for call in response.tool_calls:
160
+ if call.name in READ_TOOLS:
161
+ state.used_read = True
162
+ self._progress(call)
163
+ result = self._execute(call)
164
+ messages.append(
165
+ {
166
+ "role": "tool",
167
+ "tool_call_id": call.id,
168
+ "content": result.to_model_message(),
169
+ }
170
+ )
171
+ return messages
172
+
173
+ def _register_apply(
174
+ self,
175
+ context: ProjectContext,
176
+ confirm: Callable[[str], bool],
177
+ dry_run: bool,
178
+ ) -> None:
179
+ """Give the model the apply_patch tool, wired to the user's answer."""
180
+ self.apply_tool = ApplyPatch(
181
+ context.root,
182
+ confirm,
183
+ ui=self.printer or print,
184
+ verifier=self.verifier,
185
+ dry_run=dry_run,
186
+ console=self.ui,
187
+ )
188
+ self.registry.register(self.apply_tool)
189
+
190
+ def _messages(
191
+ self, task: str, context: ProjectContext, *, edit: bool, dry_run: bool
192
+ ) -> list[dict[str, Any]]:
193
+ """The first two messages: who the model is, and what it is being asked."""
194
+ user = task
195
+ user += "\n\n" + (FIX_NOTE if edit else READ_ONLY_NOTE)
196
+ if dry_run:
197
+ user += "\n\n" + DRY_RUN_NOTE
198
+ user += "\n\nProject:\n" + context.summary()
199
+ return [
200
+ {"role": "system", "content": self.system_prompt()},
201
+ {"role": "user", "content": user},
202
+ ]
203
+
204
+ def _complete(self, messages: list[dict[str, Any]]) -> ModelResponse:
205
+ """One round trip, with a live line so the wait is never silent."""
206
+ tools = self.registry.schemas()
207
+ if self.ui is None:
208
+ return self.provider.complete(messages, tools)
209
+ model = getattr(self.provider, "model", "") or "the model"
210
+ with self.ui.live(f"asking {model}"):
211
+ return self.provider.complete(messages, tools)
212
+
213
+ def _execute(self, call: ToolCall) -> ToolResult:
214
+ if MALFORMED_JSON in call.arguments:
215
+ raw = str(call.arguments[MALFORMED_JSON])[:500]
216
+ return ToolResult.failure(
217
+ f"invalid JSON arguments for {call.name!r}: {raw!r}. "
218
+ "Send only the argument values as a JSON object "
219
+ '(e.g. {"path": "main.c"}); escape newlines as \\n.'
220
+ )
221
+ if not call.name:
222
+ return ToolResult.failure("tool call had no name")
223
+ return self.registry.execute(call.name, call.arguments)
224
+
225
+ def _progress(self, call: ToolCall) -> None:
226
+ label = PROGRESS.get(call.name, call.name)
227
+ if call.name in ("read_file", "apply_patch"):
228
+ detail = f" {call.arguments.get('path', '')}"
229
+ elif call.name == "search_code":
230
+ detail = f" {call.arguments.get('pattern', '')!r}"
231
+ elif call.name == "run_command":
232
+ detail = f" {str(call.arguments.get('command', ''))[:60]}"
233
+ else:
234
+ detail = ""
235
+ text = f"{label}{detail}"
236
+ if self.ui is not None:
237
+ self.ui.tool(text)
238
+ elif self.printer is not None:
239
+ self.printer(f"Ferrum > {text}...")
240
+
241
+ def _finish(
242
+ self, text: str, turns: int, *, capped: bool, edit: bool
243
+ ) -> AgentResult:
244
+ if not text:
245
+ text = "The model returned no answer."
246
+ patched = bool(self.apply_tool and self.apply_tool.applied)
247
+ verified: bool | None = None
248
+ report: str | None = None
249
+ if patched and self.apply_tool and self.apply_tool.outcome:
250
+ verified = self.apply_tool.outcome.verified
251
+ report = self.apply_tool.outcome.report()
252
+ return AgentResult(
253
+ text=text,
254
+ turns=turns,
255
+ capped=capped,
256
+ patched=patched,
257
+ verified=verified,
258
+ verify_report=report,
259
+ )
260
+
261
+
262
+ @dataclass
263
+ class _TurnState:
264
+ """What the loop has seen so far, for deciding whether to nudge."""
265
+
266
+ nudges: int = 0
267
+ read_nudges: int = 0
268
+ used_tools: bool = False
269
+ used_read: bool = False
270
+
271
+
272
+ def _nudge_for(response: ModelResponse, state: _TurnState) -> str | None:
273
+ """The one-line push this reply deserves, if any.
274
+
275
+ Small models love answering from memory, so the first reply gets one
276
+ look; a directory listing alone is not reading, so push once more.
277
+ """
278
+ if response.tool_calls:
279
+ return None
280
+ if not state.used_tools and state.nudges < MAX_NUDGES:
281
+ state.nudges += 1
282
+ return NUDGE
283
+ if state.used_tools and not state.used_read and state.read_nudges < MAX_NUDGES:
284
+ state.read_nudges += 1
285
+ return READ_NUDGE
286
+ return None
287
+
288
+
289
+ def _merge_tool_calls(
290
+ native: list[ToolCall], extracted: list[ToolCall]
291
+ ) -> list[ToolCall]:
292
+ """Native calls first, then JSON-from-text calls that are not duplicates."""
293
+ seen = {(c.name, json.dumps(c.arguments, sort_keys=True)) for c in native}
294
+ merged = list(native)
295
+ for call in extracted:
296
+ key = (call.name, json.dumps(call.arguments, sort_keys=True))
297
+ if key not in seen:
298
+ seen.add(key)
299
+ merged.append(call)
300
+ return merged
301
+
302
+
303
+ def _assistant_message(response: ModelResponse) -> dict[str, Any]:
304
+ message: dict[str, Any] = {
305
+ "role": "assistant",
306
+ "content": response.content or "",
307
+ }
308
+ if response.tool_calls:
309
+ message["tool_calls"] = [
310
+ {
311
+ "id": call.id,
312
+ "type": "function",
313
+ "function": {
314
+ "name": call.name,
315
+ "arguments": json.dumps(call.arguments),
316
+ },
317
+ }
318
+ for call in response.tool_calls
319
+ ]
320
+ return message
@@ -0,0 +1,197 @@
1
+ """The apply_patch tool: guard the file, patch it, then verify the build.
2
+
3
+ Everything that touches the filesystem lives here so the agent loop stays a
4
+ conversation. The tool never writes without the user's yes (or, in dry-run,
5
+ never writes at all), and it reports what the build said afterwards.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable, Mapping
11
+ from contextlib import nullcontext
12
+ from pathlib import Path, PurePosixPath
13
+ from typing import Any, ClassVar
14
+
15
+ from ferrum.patch import Patch, format_unified_diff
16
+ from ferrum.safety import PathEscapeError, is_denied, safe_join
17
+ from ferrum.tools import Tool, ToolError, ToolResult, _require_string
18
+ from ferrum.ui import Console
19
+ from ferrum.verifier import Verifier, VerifyOutcome, run_command
20
+
21
+ DRY_RUN_RESULT = (
22
+ "Dry run: the patch was NOT applied to {rel}"
23
+ " — the file on disk is unchanged and no verification ran. "
24
+ "Summarize this proposed change for the user and stop."
25
+ )
26
+
27
+ DECLINED_RESULT = (
28
+ "The user declined this patch. Do not apply it. Ask what they would prefer or stop."
29
+ )
30
+
31
+ GIT_TIMEOUT = 15
32
+
33
+
34
+ class ApplyPatch(Tool):
35
+ name = "apply_patch"
36
+ description = (
37
+ "Replace exact text in a project file after showing the diff and "
38
+ "asking the user. The old text must match the file verbatim and "
39
+ "uniquely. After a successful patch the project is built and "
40
+ "tested; in a git work tree the result also carries git's status "
41
+ "and diff for the file, and the verification output comes back "
42
+ "afterwards."
43
+ )
44
+ parameters: ClassVar[dict[str, Any]] = {
45
+ "type": "object",
46
+ "properties": {
47
+ "path": {
48
+ "type": "string",
49
+ "description": "File path relative to the project root.",
50
+ },
51
+ "old": {
52
+ "type": "string",
53
+ "description": "Exact text currently in the file.",
54
+ },
55
+ "new": {
56
+ "type": "string",
57
+ "description": "Replacement text.",
58
+ },
59
+ "description": {
60
+ "type": "string",
61
+ "description": "One line: why this change fixes the problem.",
62
+ },
63
+ },
64
+ "required": ["path", "old", "new"],
65
+ }
66
+
67
+ def __init__(
68
+ self,
69
+ root: Path,
70
+ confirm: Callable[[str], bool],
71
+ ui: Callable[[str], None] = print,
72
+ verifier: Verifier | None = None,
73
+ dry_run: bool = False,
74
+ console: Console | None = None,
75
+ ) -> None:
76
+ self.root = Path(root)
77
+ self.confirm = confirm
78
+ self.ui = ui
79
+ self.verifier = verifier
80
+ self.dry_run = dry_run
81
+ self.console = console
82
+ self.applied = False
83
+ self.outcome: VerifyOutcome | None = None
84
+
85
+ def execute(self, arguments: Mapping[str, Any]) -> ToolResult:
86
+ rel = _require_string(arguments, "path")
87
+ old = _require_string(arguments, "old")
88
+ new, description = _new_text(arguments)
89
+
90
+ target, current, crlf = _read_target(self.root, rel)
91
+ updated = _apply(rel, current, old, new, description)
92
+ self.ui(format_unified_diff(current, updated, rel))
93
+
94
+ if self.dry_run:
95
+ return ToolResult.success(DRY_RUN_RESULT.format(rel=rel))
96
+ if not self.confirm("Apply patch? [y/N] "):
97
+ return ToolResult.failure(DECLINED_RESULT)
98
+
99
+ _write(target, updated, crlf, rel)
100
+ self.applied = True
101
+ return ToolResult.success(self._report(rel, description))
102
+
103
+ def _report(self, rel: str, description: str) -> str:
104
+ """The tool's answer: what changed, then what the build said."""
105
+ report = f"Patch applied to {rel}."
106
+ if description:
107
+ report += f" ({description})"
108
+ git_section = _git_report(self.root, rel)
109
+ if git_section:
110
+ report += "\n\n--- git ---\n" + git_section
111
+ if self.verifier is None:
112
+ return report + "\n(verification unavailable)"
113
+ live = (
114
+ self.console.live("building and testing")
115
+ if self.console is not None
116
+ else nullcontext()
117
+ )
118
+ with live:
119
+ self.outcome = self.verifier.verify()
120
+ return report + "\n\n--- verification ---\n" + self.outcome.report()
121
+
122
+
123
+ def _new_text(arguments: Mapping[str, Any]) -> tuple[str, str]:
124
+ new = arguments.get("new")
125
+ if not isinstance(new, str):
126
+ raise ToolError("new is required and must be a string")
127
+ description = arguments.get("description") or ""
128
+ if not isinstance(description, str):
129
+ description = str(description)
130
+ return new, description
131
+
132
+
133
+ def _read_target(root: Path, rel: str) -> tuple[Path, str, bool]:
134
+ """Resolve, guard, and read the file; returns text with LF endings."""
135
+ if is_denied(PurePosixPath(rel.replace("\\", "/"))):
136
+ raise ToolError(f"refused: {rel} is excluded (secret, binary, or ignored)")
137
+ try:
138
+ target = safe_join(root, rel)
139
+ except PathEscapeError as exc:
140
+ raise ToolError(str(exc)) from exc
141
+ if target.is_dir():
142
+ raise ToolError(f"is a directory: {rel}")
143
+ if not target.exists():
144
+ raise ToolError(f"file not found: {rel} (read it first)")
145
+
146
+ try:
147
+ data = target.read_bytes()
148
+ except OSError as exc:
149
+ raise ToolError(f"cannot read {rel}: {exc}") from exc
150
+ if b"\x00" in data[:8192]:
151
+ raise ToolError(f"refused: {rel} looks like a binary file")
152
+ try:
153
+ text = data.decode("utf-8")
154
+ except UnicodeDecodeError as exc:
155
+ raise ToolError(f"not valid UTF-8 text: {rel}") from exc
156
+
157
+ crlf = "\r\n" in text
158
+ return target, text.replace("\r\n", "\n").replace("\r", "\n"), crlf
159
+
160
+
161
+ def _apply(rel: str, current: str, old: str, new: str, description: str) -> str:
162
+ patch = Patch(rel=rel, old=old, new=new, description=description)
163
+ try:
164
+ return patch.apply(current)
165
+ except Exception as exc:
166
+ raise ToolError(f"Patch rejected: {exc}. Re-read the file and retry.") from exc
167
+
168
+
169
+ def _write(target: Path, updated: str, crlf: bool, rel: str) -> None:
170
+ out = updated.replace("\n", "\r\n") if crlf else updated
171
+ try:
172
+ target.write_bytes(out.encode("utf-8"))
173
+ except OSError as exc:
174
+ raise ToolError(f"cannot write {rel}: {exc}") from exc
175
+
176
+
177
+ def _git_report(root: Path, rel: str) -> str | None:
178
+ """What git sees for this file after the write.
179
+
180
+ Returns None when git is missing or root is not a work tree, so projects
181
+ outside version control never pay for the extra sections.
182
+ """
183
+ probe = run_command(
184
+ ["git", "rev-parse", "--is-inside-work-tree"], root, timeout=GIT_TIMEOUT
185
+ )
186
+ if not probe.ok or probe.stdout.strip() != "true":
187
+ return None
188
+ status = run_command(
189
+ ["git", "status", "--porcelain", "--", rel], root, timeout=GIT_TIMEOUT
190
+ )
191
+ lines = [status.stdout.strip() or "(no changes against the index)"]
192
+ diff = run_command(
193
+ ["git", "diff", "--no-color", "--", rel], root, timeout=GIT_TIMEOUT
194
+ )
195
+ if diff.stdout.strip():
196
+ lines.append(diff.stdout.strip())
197
+ return "\n".join(lines)