misterdev 0.2.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 (136) hide show
  1. misterdev/__init__.py +3 -0
  2. misterdev/agent.py +2166 -0
  3. misterdev/agent_helpers.py +194 -0
  4. misterdev/analyzers/__init__.py +0 -0
  5. misterdev/analyzers/project_analyzer/__init__.py +246 -0
  6. misterdev/analyzers/project_analyzer/detection.py +146 -0
  7. misterdev/analyzers/project_analyzer/merge.py +137 -0
  8. misterdev/analyzers/project_analyzer/overview.py +312 -0
  9. misterdev/analyzers/project_analyzer/prompts.py +83 -0
  10. misterdev/cli.py +370 -0
  11. misterdev/config.py +521 -0
  12. misterdev/core/__init__.py +0 -0
  13. misterdev/core/audit.py +88 -0
  14. misterdev/core/config.py +10 -0
  15. misterdev/core/context/__init__.py +0 -0
  16. misterdev/core/context/change_tracker.py +218 -0
  17. misterdev/core/context/contracts/__init__.py +63 -0
  18. misterdev/core/context/contracts/_log.py +9 -0
  19. misterdev/core/context/contracts/_text.py +12 -0
  20. misterdev/core/context/contracts/extraction.py +30 -0
  21. misterdev/core/context/contracts/python_generic.py +42 -0
  22. misterdev/core/context/contracts/registry.py +127 -0
  23. misterdev/core/context/contracts/rust_line.py +225 -0
  24. misterdev/core/context/contracts/rust_tree_sitter.py +141 -0
  25. misterdev/core/context/lsp.py +174 -0
  26. misterdev/core/context/scratchpad.py +111 -0
  27. misterdev/core/context/topography/__init__.py +43 -0
  28. misterdev/core/context/topography/_log.py +10 -0
  29. misterdev/core/context/topography/cache.py +91 -0
  30. misterdev/core/context/topography/engine.py +172 -0
  31. misterdev/core/context/topography/graph.py +675 -0
  32. misterdev/core/context/topography/nodes.py +55 -0
  33. misterdev/core/context/topography/parsers.py +95 -0
  34. misterdev/core/context/topography/syntax.py +54 -0
  35. misterdev/core/economics/__init__.py +0 -0
  36. misterdev/core/economics/context_budget.py +175 -0
  37. misterdev/core/economics/embeddings.py +232 -0
  38. misterdev/core/economics/free_models.py +108 -0
  39. misterdev/core/economics/llm_cache.py +105 -0
  40. misterdev/core/economics/model_catalog.py +79 -0
  41. misterdev/core/economics/model_ledger.py +331 -0
  42. misterdev/core/economics/model_selector.py +281 -0
  43. misterdev/core/execution/__init__.py +0 -0
  44. misterdev/core/execution/bounded.py +50 -0
  45. misterdev/core/execution/container.py +221 -0
  46. misterdev/core/execution/error_classifier.py +366 -0
  47. misterdev/core/execution/error_resolver.py +201 -0
  48. misterdev/core/execution/governance.py +283 -0
  49. misterdev/core/execution/outcomes.py +50 -0
  50. misterdev/core/execution/progress.py +120 -0
  51. misterdev/core/execution/project.py +231 -0
  52. misterdev/core/execution/registry.py +97 -0
  53. misterdev/core/execution/runtime.py +279 -0
  54. misterdev/core/gitcmd.py +39 -0
  55. misterdev/core/integration/__init__.py +0 -0
  56. misterdev/core/integration/mcp.py +368 -0
  57. misterdev/core/integration/mcp_gather.py +186 -0
  58. misterdev/core/models.py +35 -0
  59. misterdev/core/modes.py +184 -0
  60. misterdev/core/planning/__init__.py +0 -0
  61. misterdev/core/planning/advisor.py +89 -0
  62. misterdev/core/planning/assessment.py +135 -0
  63. misterdev/core/planning/decomposer.py +387 -0
  64. misterdev/core/planning/metacognition.py +103 -0
  65. misterdev/core/planning/sovereign.py +308 -0
  66. misterdev/core/planning/targets.py +201 -0
  67. misterdev/core/reporting/__init__.py +0 -0
  68. misterdev/core/reporting/report.py +377 -0
  69. misterdev/core/reporting/report_view.py +151 -0
  70. misterdev/core/task.py +163 -0
  71. misterdev/core/verification/__init__.py +0 -0
  72. misterdev/core/verification/claim_verifier.py +210 -0
  73. misterdev/core/verification/critic.py +324 -0
  74. misterdev/core/verification/gatekeeper/__init__.py +631 -0
  75. misterdev/core/verification/gatekeeper/constants.py +138 -0
  76. misterdev/core/verification/gatekeeper/helpers.py +28 -0
  77. misterdev/core/verification/goal_check.py +219 -0
  78. misterdev/core/verification/independent.py +68 -0
  79. misterdev/core/verification/mutation_gate.py +221 -0
  80. misterdev/core/verification/preflight.py +95 -0
  81. misterdev/core/verification/spec_tests.py +175 -0
  82. misterdev/core/verification/validator.py +495 -0
  83. misterdev/core/verification/vision_verify.py +185 -0
  84. misterdev/core/verification/web_verify.py +408 -0
  85. misterdev/environments/__init__.py +0 -0
  86. misterdev/environments/base_env.py +18 -0
  87. misterdev/environments/container_env.py +87 -0
  88. misterdev/environments/venv_env.py +42 -0
  89. misterdev/llm/__init__.py +0 -0
  90. misterdev/llm/client/__init__.py +152 -0
  91. misterdev/llm/client/base.py +382 -0
  92. misterdev/llm/client/edits.py +70 -0
  93. misterdev/llm/client/embeddings.py +121 -0
  94. misterdev/llm/client/errors.py +134 -0
  95. misterdev/llm/client/providers.py +535 -0
  96. misterdev/llm/client/response.py +24 -0
  97. misterdev/llm/prompt_manager.py +82 -0
  98. misterdev/llm/responses/__init__.py +34 -0
  99. misterdev/llm/responses/apply.py +131 -0
  100. misterdev/llm/responses/json_extract.py +80 -0
  101. misterdev/llm/responses/models.py +43 -0
  102. misterdev/llm/responses/parsing.py +494 -0
  103. misterdev/logging_setup.py +20 -0
  104. misterdev/mcp_server.py +208 -0
  105. misterdev/nl_cli.py +149 -0
  106. misterdev/plugins.py +115 -0
  107. misterdev/py.typed +0 -0
  108. misterdev/task_executors/__init__.py +0 -0
  109. misterdev/task_executors/base_executor.py +10 -0
  110. misterdev/task_executors/markdown_plan_executor/__init__.py +90 -0
  111. misterdev/task_executors/markdown_plan_executor/commands_mixin.py +82 -0
  112. misterdev/task_executors/markdown_plan_executor/context_mixin.py +221 -0
  113. misterdev/task_executors/markdown_plan_executor/critic_spec_mixin.py +174 -0
  114. misterdev/task_executors/markdown_plan_executor/edits_mixin.py +251 -0
  115. misterdev/task_executors/markdown_plan_executor/execute_mixin.py +727 -0
  116. misterdev/task_executors/markdown_plan_executor/gates_mixin.py +203 -0
  117. misterdev/task_executors/markdown_plan_executor/git_mixin.py +219 -0
  118. misterdev/task_executors/markdown_plan_executor/helpers.py +521 -0
  119. misterdev/task_executors/markdown_plan_executor/llm_mixin.py +238 -0
  120. misterdev/task_executors/markdown_plan_executor/results_mixin.py +23 -0
  121. misterdev/tools/__init__.py +19 -0
  122. misterdev/tools/base_tool.py +14 -0
  123. misterdev/tools/command.py +75 -0
  124. misterdev/tools/file_io.py +69 -0
  125. misterdev/tools/formatter.py +26 -0
  126. misterdev/tools/git_tool.py +90 -0
  127. misterdev/utils/__init__.py +0 -0
  128. misterdev/utils/file_utils.py +169 -0
  129. misterdev/utils/process.py +23 -0
  130. misterdev-0.2.0.dist-info/METADATA +326 -0
  131. misterdev-0.2.0.dist-info/RECORD +136 -0
  132. misterdev-0.2.0.dist-info/WHEEL +5 -0
  133. misterdev-0.2.0.dist-info/entry_points.txt +3 -0
  134. misterdev-0.2.0.dist-info/licenses/COMMERCIAL_LICENSE.md +34 -0
  135. misterdev-0.2.0.dist-info/licenses/LICENSE +661 -0
  136. misterdev-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,97 @@
1
+ import json
2
+ from pathlib import Path
3
+ from typing import Dict, Optional, Any
4
+
5
+ from misterdev.core.execution.project import Project
6
+ from misterdev.config import ConfigManager
7
+ from misterdev.logging_setup import setup_logger
8
+
9
+ logger = setup_logger(__name__)
10
+
11
+
12
+ class ProjectRegistry:
13
+ """Manages discovery, instantiation, and persistence of projects."""
14
+
15
+ def __init__(self, state_file: Optional[str | Path] = None):
16
+ self.projects: Dict[str, Project] = {}
17
+ self.config_manager = ConfigManager()
18
+
19
+ if state_file:
20
+ self.state_file = Path(state_file)
21
+ else:
22
+ self.state_file = Path.home() / ".misterdev" / "registry.json"
23
+
24
+ self.state_file.parent.mkdir(parents=True, exist_ok=True)
25
+ self._load_state()
26
+
27
+ def _load_state(self):
28
+ """Loads registered project paths from the state file."""
29
+ if self.state_file.exists():
30
+ try:
31
+ with open(self.state_file, "r", encoding="utf-8") as f:
32
+ state = json.load(f)
33
+ for project_path in state.get("registered_paths", []):
34
+ try:
35
+ self.register_project(project_path, save=False)
36
+ except Exception as e:
37
+ logger.error(
38
+ f"Failed to reload project at {project_path}: {e}"
39
+ )
40
+ except Exception as e:
41
+ logger.error(f"Failed to load registry state: {e}")
42
+
43
+ def _save_state(self):
44
+ """Saves registered project paths to the state file."""
45
+ state = {"registered_paths": list(self.projects.keys())}
46
+ try:
47
+ with open(self.state_file, "w", encoding="utf-8") as f:
48
+ json.dump(state, f, indent=4)
49
+ except Exception as e:
50
+ logger.error(f"Failed to save registry state: {e}")
51
+
52
+ def discover_projects(self, root_dir: str | Path):
53
+ """Scans for project.yaml files and registers projects."""
54
+ root_path = Path(root_dir)
55
+ logger.info(f"Scanning for projects in {root_path}")
56
+
57
+ found_any = False
58
+ for yaml_path in root_path.rglob("project.yaml"):
59
+ project_dir = yaml_path.parent
60
+ try:
61
+ self.register_project(project_dir)
62
+ found_any = True
63
+ except Exception as e:
64
+ logger.error(f"Failed to register project at {project_dir}: {e}")
65
+
66
+ if found_any:
67
+ self._save_state()
68
+
69
+ def register_project(self, project_path: str | Path, save: bool = True) -> Project:
70
+ """Loads config and initializes a Project object."""
71
+ path = Path(project_path).resolve()
72
+ project_id = str(path)
73
+
74
+ if project_id in self.projects:
75
+ logger.debug(f"Project already registered: {project_id}")
76
+ return self.projects[project_id]
77
+
78
+ config = self.config_manager.load_project_config(path)
79
+ project = Project(path, config)
80
+ self.projects[project_id] = project
81
+ logger.info(f"Registered project: {project.name} at {path}")
82
+
83
+ if save:
84
+ self._save_state()
85
+
86
+ return project
87
+
88
+ def get_project(self, project_path: str | Path) -> Optional[Project]:
89
+ path = str(Path(project_path).resolve())
90
+ return self.projects.get(path)
91
+
92
+ def list_projects(self) -> Dict[str, Any]:
93
+ """Returns a summary of all registered projects."""
94
+ return {
95
+ path: {"name": p.name, "description": p.description, "path": str(p.path)}
96
+ for path, p in self.projects.items()
97
+ }
@@ -0,0 +1,279 @@
1
+ """Optional runtime smoke gate.
2
+
3
+ A passing build/test suite proves the code compiles and units behave, but not
4
+ that the assembled artifact actually launches and responds. This gate launches
5
+ the built artifact, waits for a readiness signal, sends a probe, and asserts an
6
+ expected substring appears in the output — a cheap end-to-end liveness check.
7
+
8
+ It mirrors :mod:`misterdev.core.context.lsp`: strictly opt-in (off unless
9
+ ``runtime.smoke`` is configured), best-effort, and run in a daemon worker thread
10
+ with a hard timeout so a hung launch can NEVER block the build. Absent config or
11
+ a timeout is a SKIP (no opinion), not a failure; only a process that exits
12
+ non-zero or whose output lacks the expectation is a RED. Real stdout and exit
13
+ code are captured as evidence.
14
+ """
15
+
16
+ import os
17
+ import select
18
+ import signal
19
+ import subprocess
20
+ import time
21
+ from pathlib import Path
22
+ from typing import Optional
23
+
24
+ from misterdev.core.execution.bounded import run_bounded
25
+ from misterdev.core.execution.outcomes import (
26
+ GREEN,
27
+ RED,
28
+ SKIP,
29
+ GateOutcome,
30
+ )
31
+
32
+ from misterdev.logging_setup import setup_logger
33
+
34
+ logger = setup_logger(__name__)
35
+
36
+ # Outcome constants. SKIP means "no opinion" (no config, timed out, or launch
37
+ # unavailable) and must never be treated as a pass/fail signal by callers.
38
+ # A hard ceiling on probe-output reading so a chatty process can't grow the
39
+ # evidence buffer without bound.
40
+ _MAX_EVIDENCE_CHARS = 16384
41
+
42
+
43
+ class SmokeResult(GateOutcome):
44
+ """Outcome of a smoke run. ``status`` is SKIP/GREEN/RED; ``evidence`` is the
45
+ captured stdout/stderr (truncated); ``exit_code`` is the process exit code
46
+ when it terminated, else ``None``."""
47
+
48
+ def __init__(
49
+ self,
50
+ status: str,
51
+ evidence: str = "",
52
+ exit_code: Optional[int] = None,
53
+ reason: str = "",
54
+ ):
55
+ super().__init__(status, reason)
56
+ self.evidence = evidence
57
+ self.exit_code = exit_code
58
+
59
+ def __repr__(self) -> str:
60
+ return f"SmokeResult(status={self.status!r}, exit_code={self.exit_code!r})"
61
+
62
+
63
+ def run_smoke_gate(
64
+ project_root: Path,
65
+ smoke_config: Optional[dict],
66
+ runner=None,
67
+ ) -> SmokeResult:
68
+ """Run the smoke gate described by ``smoke_config``.
69
+
70
+ ``smoke_config`` keys:
71
+ - ``launch`` (required): command that starts the artifact.
72
+ - ``ready`` (optional): substring that, once seen in output, means ready.
73
+ - ``probe`` (optional): text piped to the process's stdin after readiness.
74
+ - ``expect`` (required for a verdict): substring asserted in the output.
75
+ - ``timeout`` (optional, default 30): hard ceiling for the whole run.
76
+
77
+ Returns a :class:`SmokeResult`. SKIP when there is no config or no
78
+ ``launch`` (feature off), or when the hard timeout fires (never blocks).
79
+ ``runner`` is accepted for symmetry with the gate seam but smoke launches
80
+ run on the host (where the artifact and its ports live); it is unused today.
81
+ """
82
+ if not smoke_config or not smoke_config.get("launch"):
83
+ return SmokeResult(SKIP, reason="no runtime.smoke config")
84
+
85
+ launch = smoke_config["launch"]
86
+ ready = smoke_config.get("ready")
87
+ probe = smoke_config.get("probe")
88
+ expect = smoke_config.get("expect")
89
+ timeout = float(smoke_config.get("timeout", 30))
90
+
91
+ def _work() -> SmokeResult:
92
+ try:
93
+ return _smoke(project_root, launch, ready, probe, expect, timeout)
94
+ except Exception as e: # any launch/IO failure is non-fatal -> skip
95
+ logger.debug(f"Runtime smoke gate unavailable: {e}")
96
+ return SmokeResult(SKIP, reason=f"error: {e}")
97
+
98
+ # A small margin over the inner timeout so a clean inner teardown is
99
+ # preferred, but the outer bound still guarantees we return.
100
+ return run_bounded(
101
+ _work, timeout + 5, SmokeResult(SKIP, reason="timed out"), "Runtime smoke gate"
102
+ )
103
+
104
+
105
+ def _smoke(
106
+ project_root: Path,
107
+ launch: str,
108
+ ready: Optional[str],
109
+ probe: Optional[str],
110
+ expect: Optional[str],
111
+ timeout: float,
112
+ ) -> SmokeResult:
113
+ """Launch, await readiness, probe, assert. Always tears the process down."""
114
+ deadline = time.monotonic() + timeout
115
+ # Binary, unbuffered pipes so reads go through select()+os.read and are
116
+ # deadline-bounded (a text-mode readline() blocks until a newline, defeating
117
+ # the inner timeout). start_new_session puts the launch in its own process
118
+ # group so teardown can kill the whole tree, not just the shell.
119
+ proc = subprocess.Popen(
120
+ launch,
121
+ shell=True,
122
+ cwd=str(project_root),
123
+ stdin=subprocess.PIPE,
124
+ stdout=subprocess.PIPE,
125
+ stderr=subprocess.STDOUT,
126
+ start_new_session=True,
127
+ )
128
+ captured: list[str] = []
129
+ try:
130
+ if ready:
131
+ if not _wait_for_substring(proc, captured, ready, deadline):
132
+ return _finish(proc, captured, RED, "readiness signal not seen")
133
+ elif probe is None:
134
+ # No readiness signal and no probe: give the process a brief moment
135
+ # to emit startup output before we evaluate it.
136
+ _drain_until(proc, captured, deadline, max_wait=min(2.0, timeout))
137
+
138
+ if probe is not None and proc.stdin is not None:
139
+ try:
140
+ data = probe if probe.endswith("\n") else probe + "\n"
141
+ proc.stdin.write(data.encode("utf-8"))
142
+ proc.stdin.flush()
143
+ except (BrokenPipeError, OSError):
144
+ pass
145
+
146
+ if expect:
147
+ seen = _wait_for_substring(proc, captured, expect, deadline)
148
+ status = GREEN if seen else RED
149
+ reason = "" if seen else f"expected substring {expect!r} not found"
150
+ return _finish(proc, captured, status, reason)
151
+
152
+ # No expectation to assert: GREEN if it launched and is still healthy
153
+ # (or exited cleanly).
154
+ _drain_until(proc, captured, deadline, max_wait=0.2)
155
+ code = proc.poll()
156
+ if code is not None and code != 0:
157
+ return _finish(proc, captured, RED, f"exited {code}")
158
+ return _finish(proc, captured, GREEN, "")
159
+ finally:
160
+ _terminate(proc)
161
+
162
+
163
+ def _read_available(proc: subprocess.Popen, captured: list, deadline: float) -> None:
164
+ """Append whatever stdout is ready, waiting at most until ``deadline``.
165
+
166
+ Uses ``select`` + ``os.read`` so a process that emits no newline (or nothing
167
+ at all) can never block past the deadline — unlike ``readline()``, which the
168
+ inner timeout was silently relying on and which made the timeout a no-op.
169
+ """
170
+ fd = proc.stdout
171
+ if fd is None:
172
+ return
173
+ remaining = deadline - time.monotonic()
174
+ if remaining <= 0:
175
+ return
176
+ try:
177
+ ready, _, _ = select.select([fd], [], [], min(remaining, 0.1))
178
+ except (OSError, ValueError):
179
+ return
180
+ if not ready:
181
+ return
182
+ try:
183
+ chunk = os.read(fd.fileno(), 65536)
184
+ except (OSError, ValueError):
185
+ return
186
+ if chunk:
187
+ captured.append(chunk.decode("utf-8", "replace"))
188
+
189
+
190
+ def _drain_remaining(proc: subprocess.Popen, captured: list) -> None:
191
+ """Read all output still buffered after the process has exited (non-blocking)."""
192
+ fd = proc.stdout
193
+ if fd is None:
194
+ return
195
+ while True:
196
+ try:
197
+ ready, _, _ = select.select([fd], [], [], 0)
198
+ if not ready:
199
+ return
200
+ chunk = os.read(fd.fileno(), 65536)
201
+ except (OSError, ValueError):
202
+ return
203
+ if not chunk: # EOF
204
+ return
205
+ captured.append(chunk.decode("utf-8", "replace"))
206
+
207
+
208
+ def _wait_for_substring(
209
+ proc: subprocess.Popen, captured: list, needle: str, deadline: float
210
+ ) -> bool:
211
+ """Read output until ``needle`` appears, the process exits, or the deadline
212
+ passes. Returns True iff ``needle`` was seen in the cumulative output."""
213
+ while time.monotonic() < deadline:
214
+ if needle in "".join(captured):
215
+ return True
216
+ if proc.poll() is not None:
217
+ # Process gone; drain any remaining buffered output then check once.
218
+ _drain_remaining(proc, captured)
219
+ return needle in "".join(captured)
220
+ _read_available(proc, captured, deadline)
221
+ return needle in "".join(captured)
222
+
223
+
224
+ def _drain_until(
225
+ proc: subprocess.Popen, captured: list, deadline: float, max_wait: float
226
+ ) -> None:
227
+ """Read output for up to ``max_wait`` seconds (bounded by ``deadline``)."""
228
+ stop = min(deadline, time.monotonic() + max_wait)
229
+ while time.monotonic() < stop and proc.poll() is None:
230
+ _read_available(proc, captured, stop)
231
+ if proc.poll() is not None:
232
+ _drain_remaining(proc, captured)
233
+
234
+
235
+ def _finish(
236
+ proc: subprocess.Popen, captured: list, status: str, reason: str
237
+ ) -> SmokeResult:
238
+ code = proc.poll()
239
+ evidence = "".join(captured)[:_MAX_EVIDENCE_CHARS]
240
+ return SmokeResult(status, evidence=evidence, exit_code=code, reason=reason)
241
+
242
+
243
+ def _signal_group(proc: subprocess.Popen, sig: int) -> None:
244
+ """Send ``sig`` to the launch's whole process group so a server that forked
245
+ its own children (e.g. ``npm start`` -> node worker) is torn down too, not
246
+ just the shell. Falls back to signalling the direct child where process
247
+ groups are unavailable (Windows) or already gone."""
248
+ if hasattr(os, "killpg"):
249
+ try:
250
+ os.killpg(os.getpgid(proc.pid), sig)
251
+ return
252
+ except (ProcessLookupError, PermissionError, OSError):
253
+ pass
254
+ try:
255
+ proc.terminate() if sig == signal.SIGTERM else proc.kill()
256
+ except OSError:
257
+ pass
258
+
259
+
260
+ def _terminate(proc: subprocess.Popen) -> None:
261
+ """Best-effort teardown: close stdin, SIGTERM the group, then SIGKILL it."""
262
+ if proc.poll() is not None:
263
+ return
264
+ try:
265
+ if proc.stdin is not None:
266
+ proc.stdin.close()
267
+ except OSError:
268
+ pass
269
+ _signal_group(proc, signal.SIGTERM)
270
+ try:
271
+ proc.wait(timeout=3)
272
+ return
273
+ except (subprocess.TimeoutExpired, OSError):
274
+ pass
275
+ _signal_group(proc, signal.SIGKILL)
276
+ try:
277
+ proc.wait(timeout=3)
278
+ except (subprocess.TimeoutExpired, OSError):
279
+ logger.debug("Smoke process group did not exit after kill.")
@@ -0,0 +1,39 @@
1
+ """Shared runner for one-off, read-only git commands.
2
+
3
+ Several modules run a single git command (``rev-parse``, ``diff``, ``log``,
4
+ ``ls-files``) the same way: ``subprocess.run`` with ``shell=True`` against a repo
5
+ directory, capturing text output, bounded by a short timeout, and degrading to a
6
+ no-op on any failure. This centralizes that boilerplate; callers keep their own
7
+ post-processing (which legitimately differs — a sha, a diff body, stat text).
8
+
9
+ Only for fixed, read-only commands. Commands that interpolate untrusted values
10
+ must NOT use this shell path; they belong in the list-form helpers that avoid the
11
+ shell entirely.
12
+ """
13
+
14
+ import subprocess
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+
19
+ def run_git(
20
+ command: str, cwd: Path, timeout: float = 30
21
+ ) -> Optional[subprocess.CompletedProcess]:
22
+ """Run a fixed git ``command`` (shell string) in ``cwd``.
23
+
24
+ Returns the ``CompletedProcess`` (with ``.returncode`` and ``.stdout``), or
25
+ ``None`` when it could not run at all — missing git, a timeout, or an OS
26
+ error. Never raises, so callers can treat ``None`` (and a non-zero
27
+ ``returncode``) as "no result" and degrade gracefully.
28
+ """
29
+ try:
30
+ return subprocess.run(
31
+ command,
32
+ shell=True,
33
+ cwd=cwd,
34
+ capture_output=True,
35
+ text=True,
36
+ timeout=timeout,
37
+ )
38
+ except (OSError, subprocess.SubprocessError):
39
+ return None
File without changes