agent-trace-cli 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 (42) hide show
  1. agent_trace/__init__.py +3 -0
  2. agent_trace/blame.py +471 -0
  3. agent_trace/blame_git.py +133 -0
  4. agent_trace/blame_meta.py +167 -0
  5. agent_trace/cli.py +2680 -0
  6. agent_trace/commit_link.py +226 -0
  7. agent_trace/config.py +137 -0
  8. agent_trace/context.py +385 -0
  9. agent_trace/conversations.py +358 -0
  10. agent_trace/git_notes.py +491 -0
  11. agent_trace/hooks/__init__.py +163 -0
  12. agent_trace/hooks/base.py +233 -0
  13. agent_trace/hooks/claude.py +483 -0
  14. agent_trace/hooks/codex.py +232 -0
  15. agent_trace/hooks/cursor.py +278 -0
  16. agent_trace/hooks/git.py +144 -0
  17. agent_trace/ledger.py +955 -0
  18. agent_trace/models.py +618 -0
  19. agent_trace/record.py +417 -0
  20. agent_trace/registry.py +230 -0
  21. agent_trace/remote.py +673 -0
  22. agent_trace/rewrite.py +176 -0
  23. agent_trace/rules.py +209 -0
  24. agent_trace/schemas/commit-link.schema.json +34 -0
  25. agent_trace/schemas/git-note.schema.json +88 -0
  26. agent_trace/schemas/ledger.schema.json +97 -0
  27. agent_trace/schemas/remotes.schema.json +30 -0
  28. agent_trace/schemas/sync-state.schema.json +49 -0
  29. agent_trace/schemas/trace-record.schema.json +130 -0
  30. agent_trace/session.py +136 -0
  31. agent_trace/storage.py +229 -0
  32. agent_trace/summary.py +465 -0
  33. agent_trace/summary_presets.py +188 -0
  34. agent_trace/sync.py +1182 -0
  35. agent_trace/telemetry.py +142 -0
  36. agent_trace/trace.py +460 -0
  37. agent_trace_cli-0.1.0.dist-info/METADATA +535 -0
  38. agent_trace_cli-0.1.0.dist-info/RECORD +42 -0
  39. agent_trace_cli-0.1.0.dist-info/WHEEL +5 -0
  40. agent_trace_cli-0.1.0.dist-info/entry_points.txt +2 -0
  41. agent_trace_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  42. agent_trace_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,142 @@
1
+ """
2
+ Opt-in anonymous CLI telemetry (stdlib only).
3
+
4
+ No third-party services in M0: the collect URL points at an invalid endpoint until a
5
+ backend exists; failures are always swallowed so tracing never affects user commands.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import urllib.error
13
+ import urllib.request
14
+ import uuid
15
+ from typing import Any
16
+
17
+ from .config import get_global_config, save_global_config
18
+
19
+ # Placeholder until a real collector ships (see docs/concepts/telemetry.md).
20
+ TELEMETRY_COLLECT_URL = "http://127.0.0.1:0/"
21
+
22
+ _ENV = "AGENT_TRACE_TELEMETRY"
23
+
24
+
25
+ def _parse_env_override(raw: str | None) -> bool | None:
26
+ """Return True/False if env forces telemetry on/off; None if unset or unknown."""
27
+ if raw is None or not str(raw).strip():
28
+ return None
29
+ v = str(raw).strip().lower()
30
+ if v in {"0", "false", "no", "n", "off", "disable", "disabled"}:
31
+ return False
32
+ if v in {"1", "true", "yes", "y", "on", "enable", "enabled"}:
33
+ return True
34
+ return None
35
+
36
+
37
+ def telemetry_env_override() -> bool | None:
38
+ return _parse_env_override(os.environ.get(_ENV))
39
+
40
+
41
+ def telemetry_config_enabled() -> bool:
42
+ cfg = get_global_config()
43
+ tel = cfg.get("telemetry")
44
+ if not isinstance(tel, dict):
45
+ return False
46
+ return bool(tel.get("enabled"))
47
+
48
+
49
+ def telemetry_effective_enabled() -> bool:
50
+ env = telemetry_env_override()
51
+ if env is not None:
52
+ return env
53
+ return telemetry_config_enabled()
54
+
55
+
56
+ def ensure_install_id(cfg: dict | None = None) -> str:
57
+ """Return persistent anonymous install id; creates one under global config if missing.
58
+
59
+ Pass ``cfg`` when mutating the same in-memory dict as another helper (e.g.
60
+ `set_telemetry_enabled`) so a reload from disk does not drop unsaved fields.
61
+ """
62
+ cfg = get_global_config() if cfg is None else cfg
63
+ tel = cfg.get("telemetry")
64
+ if not isinstance(tel, dict):
65
+ tel = {}
66
+ cfg["telemetry"] = tel
67
+ iid = tel.get("install_id")
68
+ if isinstance(iid, str) and len(iid) >= 8:
69
+ return iid
70
+ tel["install_id"] = str(uuid.uuid4())
71
+ save_global_config(cfg)
72
+ return tel["install_id"]
73
+
74
+
75
+ def set_telemetry_enabled(enabled: bool) -> None:
76
+ cfg = get_global_config()
77
+ tel = cfg.setdefault("telemetry", {})
78
+ if not isinstance(tel, dict):
79
+ tel = {}
80
+ cfg["telemetry"] = tel
81
+ tel["enabled"] = bool(enabled)
82
+ if enabled:
83
+ ensure_install_id(cfg)
84
+ save_global_config(cfg)
85
+
86
+
87
+ def telemetry_status_lines() -> list[str]:
88
+ """Human-readable lines for `agent-trace --telemetry status`."""
89
+ cfg_on = telemetry_config_enabled()
90
+ env_raw = os.environ.get(_ENV)
91
+ env = telemetry_env_override()
92
+ eff = telemetry_effective_enabled()
93
+ lines = [
94
+ "Telemetry (anonymous CLI usage metrics)",
95
+ f" Config file preference: {'on' if cfg_on else 'off'}",
96
+ f" {_ENV}: {repr(env_raw) if env_raw is not None else '(not set)'}",
97
+ ]
98
+ if env is None and env_raw is not None and str(env_raw).strip():
99
+ lines.append(" (environment value is not a recognised boolean — ignored)")
100
+ lines.append(f" Effective (what the CLI uses): {'on' if eff else 'off'}")
101
+ lines.append("")
102
+ lines.append(
103
+ "When on, each command may POST anonymous JSON: "
104
+ "install_id, version, command, exit_code, duration_ms. "
105
+ "See docs/concepts/telemetry.md.",
106
+ )
107
+ return lines
108
+
109
+
110
+ def maybe_report_cli_run(
111
+ *,
112
+ version: str,
113
+ command: str,
114
+ exit_code: int,
115
+ duration_ms: int,
116
+ ) -> None:
117
+ """Fire-and-forget telemetry; never raises."""
118
+ try:
119
+ if not telemetry_effective_enabled():
120
+ return
121
+ install_id = ensure_install_id()
122
+ payload: dict[str, Any] = {
123
+ "install_id": install_id,
124
+ "version": version,
125
+ "command": command,
126
+ "exit_code": int(exit_code),
127
+ "duration_ms": int(duration_ms),
128
+ }
129
+ body = json.dumps(payload).encode("utf-8")
130
+ req = urllib.request.Request(
131
+ TELEMETRY_COLLECT_URL,
132
+ data=body,
133
+ headers={"Content-Type": "application/json"},
134
+ method="POST",
135
+ )
136
+ with urllib.request.urlopen(req, timeout=2.0) as resp:
137
+ resp.read()
138
+ except (urllib.error.URLError, urllib.error.HTTPError, OSError, ValueError, TypeError):
139
+ pass
140
+ except socket.timeout:
141
+ pass
142
+
agent_trace/trace.py ADDED
@@ -0,0 +1,460 @@
1
+ """
2
+ Trace record construction helpers.
3
+
4
+ Builds trace records from hook event data following the agent-trace spec.
5
+ Resolution is file-anchored: the git repo owning the edited file, not the
6
+ agent's launch directory (Phase 1b).
7
+
8
+ No external dependencies — stdlib only.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import os
15
+ import subprocess
16
+ import uuid
17
+ from dataclasses import dataclass
18
+ from datetime import datetime, timezone
19
+
20
+
21
+ # -------------------------------------------------------------------
22
+ # Project resolution (Phase 1b)
23
+ # -------------------------------------------------------------------
24
+
25
+
26
+ @dataclass
27
+ class ProjectResolution:
28
+ """Owning repo (or detached bucket) for a path."""
29
+
30
+ repo_root: str
31
+ project_id: str
32
+ rel_path: str
33
+ vcs: dict | None
34
+ is_detached: bool = False
35
+
36
+
37
+ def _git_out(args: list[str], cwd: str, timeout: float = 10.0) -> str | None:
38
+ try:
39
+ r = subprocess.run(
40
+ ["git", *args],
41
+ capture_output=True,
42
+ text=True,
43
+ cwd=cwd,
44
+ timeout=timeout,
45
+ )
46
+ if r.returncode == 0:
47
+ return r.stdout.strip()
48
+ except (OSError, subprocess.TimeoutExpired):
49
+ pass
50
+ return None
51
+
52
+
53
+ def git_repo_root_for_path(path: str) -> str | None:
54
+ """Innermost git work tree containing ``path`` (``git -C <dir> rev-parse --show-toplevel``)."""
55
+ p = os.path.abspath(path)
56
+ if os.path.isfile(p):
57
+ d = os.path.dirname(p)
58
+ else:
59
+ d = p if os.path.isdir(p) else os.path.dirname(p)
60
+ if not os.path.isdir(d):
61
+ return None
62
+ out = _git_out(["rev-parse", "--show-toplevel"], d)
63
+ if not out:
64
+ return None
65
+ return os.path.realpath(out)
66
+
67
+
68
+ def _is_pseudo_path(file_path: str) -> bool:
69
+ """Hook placeholder paths (not real files)."""
70
+ if not file_path or file_path.startswith("/"):
71
+ return False
72
+ norm = file_path.replace("\\", "/").strip("./")
73
+ return norm in (
74
+ "shell-history",
75
+ "sessions",
76
+ "unknown",
77
+ ) or file_path in (".shell-history", ".sessions", ".unknown")
78
+
79
+
80
+ def resolve_file_project(
81
+ file_path: str,
82
+ *,
83
+ anchor_path: str | None = None,
84
+ ) -> ProjectResolution | None:
85
+ """Resolve repo + project_id from the file (or anchor), not session cwd.
86
+
87
+ ``anchor_path`` is used when ``file_path`` is a pseudo path (e.g. ``.shell-history``)
88
+ or a relative path: it should be cwd or a file/dir inside the intended repo.
89
+ """
90
+ from .config import get_global_config
91
+ from .registry import lookup_or_create_project_id
92
+
93
+ fp = file_path
94
+ if not os.path.isabs(fp) and anchor_path and not _is_pseudo_path(fp):
95
+ an = os.path.abspath(anchor_path)
96
+ base = os.path.dirname(an) if os.path.isfile(an) else an
97
+ fp = os.path.normpath(os.path.join(base, fp))
98
+
99
+ if _is_pseudo_path(file_path):
100
+ if anchor_path:
101
+ an = os.path.abspath(anchor_path)
102
+ path_for_git = os.path.dirname(an) if os.path.isfile(an) else an
103
+ else:
104
+ path_for_git = os.getcwd()
105
+ rel = file_path.replace(os.sep, "/")
106
+ else:
107
+ abs_fp = os.path.abspath(fp)
108
+ path_for_git = abs_fp
109
+ if os.path.isfile(abs_fp):
110
+ pass
111
+ elif os.path.isdir(abs_fp):
112
+ path_for_git = os.path.join(abs_fp, ".")
113
+ else:
114
+ d = os.path.dirname(abs_fp)
115
+ path_for_git = d if d else abs_fp
116
+
117
+ repo_root = git_repo_root_for_path(path_for_git)
118
+ if repo_root:
119
+ if not _is_pseudo_path(file_path):
120
+ real_file = os.path.realpath(os.path.abspath(fp))
121
+ try:
122
+ rel = os.path.relpath(real_file, repo_root)
123
+ except ValueError:
124
+ rel = os.path.basename(real_file)
125
+ else:
126
+ rel = file_path.replace(os.sep, "/")
127
+ pid = lookup_or_create_project_id(repo_root)
128
+ vcs = get_vcs_info(repo_root)
129
+ return ProjectResolution(
130
+ repo_root=repo_root,
131
+ project_id=pid,
132
+ rel_path=rel.replace(os.sep, "/"),
133
+ vcs=vcs,
134
+ is_detached=False,
135
+ )
136
+
137
+ # No git repo — optional detached capture
138
+ cfg = get_global_config()
139
+ if not cfg.get("capture_detached_edits"):
140
+ return None
141
+
142
+ if anchor_path:
143
+ an = os.path.abspath(anchor_path)
144
+ parent = os.path.dirname(an) if os.path.isfile(an) else an
145
+ else:
146
+ parent = os.path.dirname(os.path.realpath(os.path.abspath(fp)))
147
+ parent = os.path.realpath(parent or os.getcwd())
148
+ h = hashlib.sha256(parent.encode("utf-8")).hexdigest()[:16]
149
+ pid = f"detached:{h}"
150
+ from .storage import get_detached_base_dir
151
+
152
+ base = os.path.realpath(str(get_detached_base_dir() / h))
153
+ os.makedirs(base, exist_ok=True)
154
+ rel_name = file_path.replace(os.sep, "/")
155
+ if not _is_pseudo_path(file_path):
156
+ rel_name = os.path.basename(os.path.realpath(os.path.abspath(fp)))
157
+ return ProjectResolution(
158
+ repo_root=base,
159
+ project_id=pid,
160
+ rel_path=rel_name,
161
+ vcs=None,
162
+ is_detached=True,
163
+ )
164
+
165
+
166
+ def get_workspace_root() -> str:
167
+ """Best-effort git root from current working directory (session-level hints).
168
+
169
+ Does not use CURSOR/CLAUDE env vars for routing — prefer :func:`resolve_file_project`
170
+ for file-backed traces.
171
+ """
172
+ g = git_repo_root_for_path(os.getcwd())
173
+ if g:
174
+ return g
175
+ return os.getcwd()
176
+
177
+
178
+ def get_tool_info() -> dict:
179
+ """Detect which AI coding tool invoked the hook.
180
+
181
+ Walks the adapter registry — each ``CodingAgentAdapter`` may
182
+ implement ``detect_tool_info`` to return ``{name, version}`` based
183
+ on env vars or marker files. The first adapter that matches wins.
184
+ """
185
+ try:
186
+ from .hooks import iter_adapters
187
+
188
+ for adapter in iter_adapters():
189
+ info = adapter.detect_tool_info()
190
+ if info:
191
+ return info
192
+ except Exception:
193
+ # Adapter detection must never crash recording.
194
+ pass
195
+ return {"name": "unknown"}
196
+
197
+
198
+ def get_vcs_info(cwd: str | None = None) -> dict | None:
199
+ """Current git revision, or None."""
200
+ root = cwd or os.getcwd()
201
+ try:
202
+ result = subprocess.run(
203
+ ["git", "rev-parse", "HEAD"],
204
+ capture_output=True,
205
+ text=True,
206
+ cwd=root,
207
+ timeout=5,
208
+ )
209
+ if result.returncode == 0:
210
+ return {"type": "git", "revision": result.stdout.strip()}
211
+ except Exception:
212
+ pass
213
+ return None
214
+
215
+
216
+ # -------------------------------------------------------------------
217
+ # Utilities
218
+ # -------------------------------------------------------------------
219
+
220
+ def to_relative_path(absolute_path: str, root: str) -> str:
221
+ try:
222
+ return os.path.relpath(absolute_path, root)
223
+ except ValueError:
224
+ return absolute_path
225
+
226
+
227
+ def normalize_model_id(model: str | None) -> str | None:
228
+ """Add provider prefix to bare model names."""
229
+ if not model:
230
+ return None
231
+ if "/" in model:
232
+ return model
233
+ prefixes = {
234
+ "claude-": "anthropic",
235
+ "gpt-": "openai",
236
+ "o1": "openai",
237
+ "o3": "openai",
238
+ "gemini-": "google",
239
+ }
240
+ for prefix, provider in prefixes.items():
241
+ if model.startswith(prefix):
242
+ return f"{provider}/{model}"
243
+ return model
244
+
245
+
246
+ def compute_line_hashes(content: str) -> list[dict]:
247
+ """Compute per-line full SHA-256 hashes.
248
+
249
+ Each line is hashed individually so the ledger can match committed lines
250
+ back to the trace that produced them, even if surrounding lines shifted.
251
+ Full 256-bit hashes guarantee cryptographic uniqueness, so no separate
252
+ line content is stored — the hash alone is the line's identity.
253
+
254
+ Returns a list of ``{"line_offset": 0, "hash": "sha256:..."}``.
255
+ """
256
+ lines = content.split("\n")
257
+ if lines and lines[-1] == "":
258
+ lines = lines[:-1]
259
+ result: list[dict] = []
260
+ for i, line in enumerate(lines):
261
+ h = hashlib.sha256(line.encode("utf-8")).hexdigest()
262
+ result.append({
263
+ "line_offset": i,
264
+ "hash": f"sha256:{h}",
265
+ })
266
+ return result
267
+
268
+
269
+ def compute_content_hash(content: str) -> str:
270
+ """Full SHA-256 hash of normalized content for dedup / verification.
271
+
272
+ Normalization: CRLF/CR → LF, and trailing newline stripped so that the
273
+ same logical content hashes identically whether stored (e.g. tool
274
+ new_string with trailing \\n) or matched (e.g. \"\\n\".join(blame lines)).
275
+ """
276
+ normalized = content.replace("\r\n", "\n").replace("\r", "\n").rstrip("\n")
277
+ h = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
278
+ return f"sha256:{h}"
279
+
280
+
281
+ def compute_range_positions(
282
+ edits: list[dict],
283
+ file_content: str | None = None,
284
+ ) -> list[dict]:
285
+ """Derive line-range positions from a list of edits."""
286
+ positions: list[dict] = []
287
+ for edit in edits:
288
+ new_string = edit.get("new_string", "")
289
+ if not new_string:
290
+ continue
291
+
292
+ old_string = edit.get("old_string", "")
293
+ if not old_string:
294
+ # File creation, full replace, or prepend — attribute from line 1
295
+ line_count = new_string.count("\n") + 1
296
+ positions.append({"start_line": 1, "end_line": line_count})
297
+ continue
298
+
299
+ rng = edit.get("range")
300
+ if rng:
301
+ positions.append({
302
+ "start_line": rng.get("start_line_number", 1),
303
+ "end_line": rng.get("end_line_number", 1),
304
+ })
305
+ elif file_content:
306
+ idx = file_content.find(new_string)
307
+ line_count = new_string.count("\n") + 1
308
+ if idx != -1:
309
+ start = file_content[:idx].count("\n") + 1
310
+ positions.append({"start_line": start, "end_line": start + line_count - 1})
311
+ else:
312
+ positions.append({"start_line": 1, "end_line": line_count})
313
+ else:
314
+ line_count = new_string.count("\n") + 1
315
+ positions.append({"start_line": 1, "end_line": line_count})
316
+ return positions
317
+
318
+
319
+ # -------------------------------------------------------------------
320
+ # Trace construction
321
+ # -------------------------------------------------------------------
322
+
323
+ def create_trace(
324
+ contributor_type: str,
325
+ file_path: str,
326
+ *,
327
+ model: str | None = None,
328
+ range_positions: list[dict] | None = None,
329
+ range_contents: list[str] | None = None,
330
+ transcript: str | None = None,
331
+ metadata: dict | None = None,
332
+ edit_sequence: int | None = None,
333
+ anchor_path: str | None = None,
334
+ resolution: ProjectResolution | None = None,
335
+ ) -> dict | None:
336
+ """Build a trace record dict, or None if the path is not traceable (no git and no detached config)."""
337
+ res = resolution or resolve_file_project(file_path, anchor_path=anchor_path)
338
+ if res is None:
339
+ return None
340
+
341
+ root = res.repo_root
342
+ model_id = normalize_model_id(model)
343
+
344
+ # Snapshot the live transcript into the per-project content-addressed
345
+ # cache and record an opaque conversation_id + content_sha256 on the
346
+ # trace. The raw filesystem path never enters the record itself.
347
+ conversation_id: str | None = None
348
+ content_sha256: str | None = None
349
+ if transcript:
350
+ from .conversations import (
351
+ compute_conversation_id,
352
+ snapshot_transcript_to_cache,
353
+ )
354
+ conversation_id = compute_conversation_id(transcript)
355
+ snap = snapshot_transcript_to_cache(res.project_id, transcript)
356
+ if snap is not None:
357
+ content_sha256, _ = snap
358
+
359
+ # Build ranges
360
+ ranges: list[dict] = []
361
+ if range_positions:
362
+ for i, pos in enumerate(range_positions):
363
+ r = {"start_line": pos["start_line"], "end_line": pos["end_line"]}
364
+ if range_contents and i < len(range_contents) and range_contents[i]:
365
+ r["content_hash"] = compute_content_hash(range_contents[i])
366
+ r["line_hashes"] = compute_line_hashes(range_contents[i])
367
+ ranges.append(r)
368
+ if not ranges:
369
+ ranges = [{"start_line": 1, "end_line": 1}]
370
+
371
+ # Conversation entry
372
+ conversation: dict = {
373
+ "contributor": {"type": contributor_type},
374
+ "ranges": ranges,
375
+ }
376
+ if model_id:
377
+ conversation["contributor"]["model_id"] = model_id
378
+ if conversation_id:
379
+ conversation["id"] = conversation_id
380
+ if content_sha256:
381
+ conversation["content_sha256"] = content_sha256
382
+
383
+ trace: dict = {
384
+ "version": "2.0",
385
+ "id": str(uuid.uuid4()),
386
+ "timestamp": datetime.now(timezone.utc).isoformat(),
387
+ "tool": get_tool_info(),
388
+ "files": [
389
+ {
390
+ "path": res.rel_path,
391
+ "conversations": [conversation],
392
+ }
393
+ ],
394
+ }
395
+
396
+ if res.vcs:
397
+ trace["vcs"] = res.vcs
398
+
399
+ meta: dict = {}
400
+ if metadata:
401
+ meta = {k: v for k, v in metadata.items() if v is not None}
402
+ meta["project_id"] = res.project_id
403
+ meta["repo_root"] = root
404
+ if edit_sequence is not None:
405
+ meta["edit_sequence"] = edit_sequence
406
+ if meta:
407
+ trace["metadata"] = meta
408
+
409
+ return trace
410
+
411
+
412
+ def cli_resolve_project_root(project_arg: str | None, cwd: str | None = None) -> str:
413
+ """Resolve ``--project`` (path or known project_id) or cwd to a git root directory."""
414
+ from .registry import get_project_record
415
+
416
+ base = os.path.abspath(cwd or os.getcwd())
417
+ if project_arg:
418
+ p = os.path.abspath(os.path.expanduser(project_arg.strip()))
419
+ if os.path.isdir(p):
420
+ gr = git_repo_root_for_path(p)
421
+ if gr:
422
+ return gr
423
+ rec = get_project_record(project_arg)
424
+ if rec:
425
+ cr = rec.get("canonical_root")
426
+ if cr and os.path.isdir(cr):
427
+ return os.path.realpath(cr)
428
+ roots = rec.get("known_roots") or []
429
+ for r in roots:
430
+ if r and os.path.isdir(r):
431
+ return os.path.realpath(r)
432
+ raise SystemExit(
433
+ f"agent-trace: cannot resolve --project {project_arg!r} to a directory",
434
+ )
435
+
436
+ gr = git_repo_root_for_path(base)
437
+ if gr:
438
+ return gr
439
+ raise SystemExit(
440
+ "agent-trace: not inside a git repository; pass --project <path|id>",
441
+ )
442
+
443
+
444
+ def discover_ambiguous_repo_roots(cwd: str | None = None) -> list[str]:
445
+ """If ``cwd`` is not in a git repo but contains multiple git checkouts, return their roots."""
446
+ base = os.path.abspath(cwd or os.getcwd())
447
+ if git_repo_root_for_path(base):
448
+ return []
449
+ found: list[str] = []
450
+ try:
451
+ for name in sorted(os.listdir(base)):
452
+ sub = os.path.join(base, name)
453
+ if not os.path.isdir(sub):
454
+ continue
455
+ g = git_repo_root_for_path(sub)
456
+ if g and g not in found:
457
+ found.append(g)
458
+ except OSError:
459
+ pass
460
+ return found