repokg 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.
repokg/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """repokg: generate an AI-ready knowledge graph of any codebase."""
2
+
3
+ __version__ = "0.1.0"
repokg/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
repokg/cli.py ADDED
@@ -0,0 +1,144 @@
1
+ """repokg CLI: scan | prompts | render | generate."""
2
+
3
+ import argparse
4
+ import datetime
5
+ import json
6
+ import os
7
+ import sys
8
+
9
+ from . import __version__, code, deps, github, gitinfo, inject, markdown, ops, prompts
10
+
11
+
12
+ def scan(repo, out, no_github, pr_limit):
13
+ info, branches = gitinfo.collect(repo)
14
+ if no_github:
15
+ prs, note = [], "GitHub lookup disabled (--no-github)"
16
+ else:
17
+ prs, note = github.collect(repo, pr_limit)
18
+ github.classify(branches, prs, info["trunk"], info["integration"])
19
+ tree = dict(code.walk(repo)) # single filesystem walk, shared by all collectors
20
+ languages, modules = code.collect(repo, tree)
21
+ kg = {
22
+ "repokg_version": 1,
23
+ "generated_at": datetime.date.today().isoformat(),
24
+ "repo": info,
25
+ "languages": languages,
26
+ "modules": modules,
27
+ "edges": deps.collect(repo, tree),
28
+ "branches": branches,
29
+ "prs": prs,
30
+ "github_note": note,
31
+ "ops": ops.collect(repo, tree),
32
+ }
33
+ os.makedirs(out, exist_ok=True)
34
+ path = os.path.join(out, "kg.json")
35
+ with open(path, "w", encoding="utf-8") as f:
36
+ json.dump(kg, f, indent=1)
37
+ print("wrote %s (%d modules, %d edges, %d branches, %d PRs)" %
38
+ (path, len(modules), len(kg["edges"]), len(branches), len(prs)))
39
+ return kg
40
+
41
+
42
+ def write_prompts(repo, out, md):
43
+ pdir = os.path.join(out, "prompts")
44
+ os.makedirs(pdir, exist_ok=True)
45
+ path = os.path.join(pdir, "enrich.md")
46
+ with open(path, "w", encoding="utf-8") as f:
47
+ f.write(prompts.render(repo, out, md))
48
+ print("wrote %s (hand this to your AI agent)" % path)
49
+
50
+
51
+ def render(out, md):
52
+ with open(os.path.join(out, "kg.json"), encoding="utf-8") as f:
53
+ kg = json.load(f)
54
+ narratives = {}
55
+ npath = os.path.join(out, "narratives.json")
56
+ if os.path.isfile(npath):
57
+ with open(npath, encoding="utf-8") as f:
58
+ narratives = json.load(f)
59
+ doc = markdown.render(kg, narratives)
60
+ with open(md, "w", encoding="utf-8") as f:
61
+ f.write(doc)
62
+ state = "enriched" if narratives else "structure-only; run .repokg/prompts/enrich.md to enrich"
63
+ print("wrote %s (%s)" % (md, state))
64
+
65
+
66
+ def do_inject(repo, md):
67
+ for path, status in inject.run(repo, md).items():
68
+ print("%s: %s" % (path, status))
69
+
70
+
71
+ def check(repo, out, md):
72
+ """Exit 0 if the knowledge graph matches HEAD, 1 if stale/missing. CI-friendly."""
73
+ apath = os.path.join(out, "kg.json")
74
+ if not os.path.isfile(apath) or not os.path.isfile(md):
75
+ print("stale: knowledge graph not generated (run `repokg generate`)")
76
+ return 1
77
+ with open(apath, encoding="utf-8") as f:
78
+ stored = json.load(f).get("repo", {}).get("head", "")
79
+ head = gitinfo.try_run(repo, "rev-parse", "HEAD")
80
+ if stored and head and stored != head:
81
+ print("stale: knowledge graph at %s, HEAD is %s (run `repokg generate`)"
82
+ % (stored[:12], head[:12]))
83
+ return 1
84
+ print("fresh: knowledge graph matches HEAD %s" % (head[:12] or "(unknown)"))
85
+ return 0
86
+
87
+
88
+ def main(argv=None):
89
+ ap = argparse.ArgumentParser(
90
+ prog="repokg",
91
+ description="Generate an AI-ready knowledge graph of a codebase.")
92
+ ap.add_argument("command", nargs="?", default="generate",
93
+ choices=["scan", "prompts", "render", "generate", "inject",
94
+ "check", "version"],
95
+ help="scan: extract structure to .repokg/kg.json | "
96
+ "prompts: write the AI enrichment prompt | "
97
+ "render: kg.json (+narratives.json) -> KNOWLEDGE_GRAPH.md | "
98
+ "generate: scan + prompts + render (default) | "
99
+ "inject: add knowledge-graph pointer to CLAUDE.md/AGENTS.md/cursor rules | "
100
+ "check: exit 1 if knowledge graph is stale vs HEAD")
101
+ ap.add_argument("path", nargs="?", default=".", help="repository path (default: .)")
102
+ ap.add_argument("--out", default=None, help="output dir (default: <repo>/.repokg)")
103
+ ap.add_argument("--md", default=None, help="markdown output (default: <repo>/KNOWLEDGE_GRAPH.md)")
104
+ ap.add_argument("--no-github", action="store_true", help="skip gh PR lookup")
105
+ ap.add_argument("--pr-limit", type=int, default=1000, help="max PRs to fetch (default 1000)")
106
+ args = ap.parse_args(argv)
107
+
108
+ if args.command == "version":
109
+ print("repokg %s" % __version__)
110
+ return 0
111
+
112
+ repo = os.path.abspath(args.path)
113
+ if not os.path.isdir(repo):
114
+ print("error: %s is not a directory" % repo, file=sys.stderr)
115
+ return 2
116
+ out = args.out or os.path.join(repo, ".repokg")
117
+ md = args.md or os.path.join(repo, "KNOWLEDGE_GRAPH.md")
118
+
119
+ try:
120
+ if args.command == "scan":
121
+ scan(repo, out, args.no_github, args.pr_limit)
122
+ elif args.command == "prompts":
123
+ write_prompts(repo, out, md)
124
+ elif args.command == "render":
125
+ render(out, md)
126
+ elif args.command == "inject":
127
+ do_inject(repo, md)
128
+ elif args.command == "check":
129
+ return check(repo, out, md)
130
+ else: # generate
131
+ scan(repo, out, args.no_github, args.pr_limit)
132
+ write_prompts(repo, out, md)
133
+ render(out, md)
134
+ except RuntimeError as e:
135
+ print("error: %s" % e, file=sys.stderr)
136
+ return 1
137
+ except FileNotFoundError as e:
138
+ print("error: %s (run `repokg scan` first?)" % e, file=sys.stderr)
139
+ return 1
140
+ return 0
141
+
142
+
143
+ if __name__ == "__main__":
144
+ sys.exit(main())
repokg/code.py ADDED
@@ -0,0 +1,104 @@
1
+ """Code collectors: language breakdown, module discovery, LOC."""
2
+
3
+ import os
4
+ import re
5
+
6
+ SKIP_DIRS = {
7
+ ".git", "node_modules", "vendor", "dist", "build", "out", "target", ".next",
8
+ ".venv", "venv", "__pycache__", ".idea", ".vscode", ".repokg", "coverage",
9
+ ".terraform", "third_party", ".tox", ".mypy_cache", ".ruff_cache",
10
+ "site-packages", ".pytest_cache", ".cache",
11
+ }
12
+
13
+ LANG_BY_EXT = {
14
+ ".go": "Go", ".py": "Python", ".ts": "TypeScript", ".tsx": "TypeScript",
15
+ ".js": "JavaScript", ".jsx": "JavaScript", ".mjs": "JavaScript",
16
+ ".rs": "Rust", ".java": "Java", ".kt": "Kotlin", ".rb": "Ruby",
17
+ ".php": "PHP", ".cs": "C#", ".c": "C", ".cpp": "C++", ".cc": "C++",
18
+ ".h": "C/C++", ".hpp": "C++", ".sol": "Solidity", ".swift": "Swift",
19
+ ".scala": "Scala", ".ex": "Elixir", ".exs": "Elixir", ".zig": "Zig",
20
+ ".lua": "Lua", ".dart": "Dart", ".vue": "Vue", ".svelte": "Svelte",
21
+ ".sql": "SQL", ".sh": "Shell", ".proto": "Protobuf", ".tf": "Terraform",
22
+ ".yaml": "YAML", ".yml": "YAML", ".html": "HTML", ".css": "CSS",
23
+ }
24
+
25
+ # Languages whose presence makes a directory a "module" worth graphing.
26
+ MODULE_LANGS = {
27
+ "Go", "Python", "TypeScript", "JavaScript", "Rust", "Java", "Kotlin",
28
+ "Ruby", "PHP", "C#", "C", "C++", "Solidity", "Swift", "Scala", "Elixir",
29
+ "Zig", "Lua", "Dart", "Vue", "Svelte",
30
+ }
31
+
32
+ ROOT_MARKERS = {
33
+ "go.mod", "package.json", "pyproject.toml", "setup.py", "Cargo.toml",
34
+ "pom.xml", "build.gradle", "composer.json", "Gemfile",
35
+ }
36
+
37
+ GENERATED_RE = re.compile(r"(generated|sqlcgen|_pb2|\.pb\.|/pb$|/pb/|bindings)")
38
+ MAX_FILE_BYTES = 2_000_000
39
+
40
+
41
+ def walk(repo):
42
+ """os.walk with skip-dir pruning; yields (rel_dir, filenames)."""
43
+ for dirpath, dirnames, filenames in os.walk(repo):
44
+ dirnames[:] = sorted(
45
+ d for d in dirnames
46
+ if d not in SKIP_DIRS and not (d.startswith(".") and d != ".github")
47
+ )
48
+ rel = os.path.relpath(dirpath, repo)
49
+ yield ("" if rel == "." else rel.replace(os.sep, "/")), filenames
50
+
51
+
52
+ def count_loc(path):
53
+ try:
54
+ if os.path.getsize(path) > MAX_FILE_BYTES:
55
+ return 0
56
+ with open(path, "rb") as f:
57
+ data = f.read()
58
+ if b"\0" in data[:1024]: # binary
59
+ return 0
60
+ return data.count(b"\n") + (1 if data and not data.endswith(b"\n") else 0)
61
+ except OSError:
62
+ return 0
63
+
64
+
65
+ def collect(repo, tree=None):
66
+ """Return (languages, modules).
67
+
68
+ languages: [{lang, files, loc}] sorted by loc desc
69
+ modules: [{path, lang, files, loc, root, generated}] one per dir containing code
70
+ tree: optional pre-built {rel_dir: filenames} to avoid re-walking the repo.
71
+ """
72
+ lang_stats = {}
73
+ modules = []
74
+ for rel, files in (tree.items() if tree is not None else walk(repo)):
75
+ if rel.startswith(".github"):
76
+ continue
77
+ per_lang = {}
78
+ is_root = any(f in ROOT_MARKERS for f in files)
79
+ for f in files:
80
+ ext = os.path.splitext(f)[1].lower()
81
+ lang = LANG_BY_EXT.get(ext)
82
+ if not lang:
83
+ continue
84
+ loc = count_loc(os.path.join(repo, rel, f))
85
+ ls = lang_stats.setdefault(lang, {"lang": lang, "files": 0, "loc": 0})
86
+ ls["files"] += 1
87
+ ls["loc"] += loc
88
+ if lang in MODULE_LANGS:
89
+ pl = per_lang.setdefault(lang, [0, 0])
90
+ pl[0] += 1
91
+ pl[1] += loc
92
+ if per_lang:
93
+ dominant = max(per_lang.items(), key=lambda kv: kv[1][1])
94
+ modules.append({
95
+ "path": rel or "(root)",
96
+ "lang": dominant[0],
97
+ "files": sum(v[0] for v in per_lang.values()),
98
+ "loc": sum(v[1] for v in per_lang.values()),
99
+ "root": is_root,
100
+ "generated": bool(GENERATED_RE.search("/" + rel)),
101
+ })
102
+ languages = sorted(lang_stats.values(), key=lambda x: -x["loc"])
103
+ modules.sort(key=lambda m: -m["loc"])
104
+ return languages, modules
repokg/deps.py ADDED
@@ -0,0 +1,166 @@
1
+ """Dependency-edge extraction: internal import graphs for Go, Python, JS/TS.
2
+
3
+ Edges are directory -> directory, deduplicated with counts. Only imports that
4
+ resolve inside the repo are kept (external/third-party imports are ignored).
5
+ """
6
+
7
+ import ast
8
+ import os
9
+ import re
10
+ from collections import Counter
11
+
12
+ from .code import walk
13
+
14
+ GO_BLOCK_RE = re.compile(r"^import\s*\(\s*(.*?)\s*\)", re.S | re.M)
15
+ GO_SINGLE_RE = re.compile(r'^import\s+(?:\w+\s+)?"([^"]+)"', re.M)
16
+ GO_QUOTED_RE = re.compile(r'"([^"]+)"')
17
+ GO_MODULE_RE = re.compile(r"^module\s+(\S+)", re.M)
18
+ JS_IMPORT_RE = re.compile(
19
+ r"""(?:from\s+|require\(\s*|import\(\s*|^\s*import\s+)['"](\.{1,2}/[^'"]+)['"]""",
20
+ re.M)
21
+ JS_EXTS = (".ts", ".tsx", ".js", ".jsx", ".mjs")
22
+
23
+
24
+ def collect(repo, tree=None):
25
+ """Return [{"from": dir, "to": dir, "lang": lang, "count": n}] sorted by count.
26
+
27
+ tree: optional pre-built {rel_dir: filenames} to avoid re-walking the repo.
28
+ """
29
+ counter = Counter()
30
+ if tree is None:
31
+ tree = {rel: files for rel, files in walk(repo)}
32
+ dirs = set(tree)
33
+
34
+ _go_edges(repo, tree, counter)
35
+ _py_edges(repo, tree, dirs, counter)
36
+ _js_edges(repo, tree, dirs, counter)
37
+
38
+ edges = [{"from": f or "(root)", "to": t or "(root)", "lang": lang, "count": n}
39
+ for (f, t, lang), n in counter.items()]
40
+ edges.sort(key=lambda e: -e["count"])
41
+ return edges
42
+
43
+
44
+ def _read(repo, rel, name):
45
+ try:
46
+ with open(os.path.join(repo, rel, name), encoding="utf-8", errors="replace") as f:
47
+ return f.read()
48
+ except OSError:
49
+ return ""
50
+
51
+
52
+ # -- Go ----------------------------------------------------------------------
53
+
54
+ def _go_edges(repo, tree, counter):
55
+ roots = {} # rel dir of go.mod -> module path
56
+ for rel, files in tree.items():
57
+ if "go.mod" in files:
58
+ m = GO_MODULE_RE.search(_read(repo, rel, "go.mod"))
59
+ if m:
60
+ roots[rel] = m.group(1)
61
+ if not roots:
62
+ return
63
+ for rel, files in tree.items():
64
+ root = _owning_root(rel, roots)
65
+ if root is None:
66
+ continue
67
+ modpath = roots[root]
68
+ for f in files:
69
+ if not f.endswith(".go") or f.endswith("_test.go"):
70
+ continue
71
+ src = _read(repo, rel, f)
72
+ imports = GO_SINGLE_RE.findall(src)
73
+ for block in GO_BLOCK_RE.findall(src):
74
+ imports.extend(GO_QUOTED_RE.findall(block))
75
+ for imp in imports:
76
+ if imp != modpath and not imp.startswith(modpath + "/"):
77
+ continue
78
+ sub = imp[len(modpath):].lstrip("/")
79
+ target = _norm(os.path.join(root, sub) if root else sub)
80
+ if target != rel and target in tree:
81
+ counter[(rel, target, "Go")] += 1
82
+
83
+
84
+ def _owning_root(rel, roots):
85
+ best = None
86
+ for root in roots:
87
+ if rel == root or root == "" or rel.startswith(root + "/"):
88
+ if best is None or len(root) > len(best):
89
+ best = root
90
+ return best
91
+
92
+
93
+ # -- Python ------------------------------------------------------------------
94
+
95
+ def _py_edges(repo, tree, dirs, counter):
96
+ # Internal top-level packages: dirs at repo root or under src/ holding .py files.
97
+ pkg_map = {}
98
+ for rel in dirs:
99
+ parts = rel.split("/") if rel else []
100
+ if not parts:
101
+ continue
102
+ if len(parts) == 1 or (len(parts) == 2 and parts[0] == "src"):
103
+ if any(f.endswith(".py") for f in tree.get(rel, ())):
104
+ pkg_map[parts[-1]] = rel
105
+ for rel, files in tree.items():
106
+ for f in files:
107
+ if not f.endswith(".py"):
108
+ continue
109
+ try:
110
+ node = ast.parse(_read(repo, rel, f))
111
+ except (SyntaxError, ValueError): # ValueError: null bytes on py<=3.11
112
+ continue
113
+ for stmt in ast.walk(node):
114
+ if isinstance(stmt, ast.Import):
115
+ for alias in stmt.names:
116
+ _py_edge(rel, alias.name, 0, pkg_map, dirs, counter)
117
+ elif isinstance(stmt, ast.ImportFrom):
118
+ _py_edge(rel, stmt.module or "", stmt.level, pkg_map, dirs, counter)
119
+
120
+
121
+ def _py_edge(rel, module, level, pkg_map, dirs, counter):
122
+ if level: # relative import
123
+ base = rel
124
+ for _ in range(level - 1):
125
+ base = os.path.dirname(base)
126
+ target = _norm(os.path.join(base, *module.split("."))) if module else base
127
+ else:
128
+ head = module.split(".")[0]
129
+ if head not in pkg_map:
130
+ return
131
+ target = _norm(os.path.join(os.path.dirname(pkg_map[head]),
132
+ *module.split(".")))
133
+ target = _existing_dir(target, dirs)
134
+ if target is not None and target != rel:
135
+ counter[(rel, target, "Python")] += 1
136
+
137
+
138
+ # -- JS / TS -----------------------------------------------------------------
139
+
140
+ def _js_edges(repo, tree, dirs, counter):
141
+ for rel, files in tree.items():
142
+ for f in files:
143
+ if not f.endswith(JS_EXTS) or f.endswith(".d.ts"):
144
+ continue
145
+ for imp in JS_IMPORT_RE.findall(_read(repo, rel, f)):
146
+ target = _existing_dir(_norm(os.path.join(rel, imp)), dirs)
147
+ if target is not None and target != rel:
148
+ counter[(rel, target, "JS/TS")] += 1
149
+
150
+
151
+ # -- helpers -----------------------------------------------------------------
152
+
153
+ def _norm(p):
154
+ p = os.path.normpath(p).replace(os.sep, "/")
155
+ return "" if p == "." else p
156
+
157
+
158
+ def _existing_dir(path, dirs):
159
+ """Resolve an import target to a known repo dir (itself, or its parent
160
+ when the import points at a file)."""
161
+ if path in dirs:
162
+ return path
163
+ parent = _norm(os.path.dirname(path))
164
+ if parent in dirs:
165
+ return parent
166
+ return None
repokg/github.py ADDED
@@ -0,0 +1,66 @@
1
+ """GitHub collectors (via `gh` CLI) and branch x PR classification."""
2
+
3
+ import json
4
+ import shutil
5
+ import subprocess
6
+
7
+ FIELDS = "number,title,state,headRefName,author,createdAt,mergedAt,closedAt,isDraft"
8
+
9
+ # Branch statuses:
10
+ # trunk / integration - the long-lived branches
11
+ # active - has an open PR
12
+ # merged - reachable from the default base (merge-commit merge)
13
+ # squash-merged - not an ancestor, but its PR was merged (squash/rebase)
14
+ # abandoned - only closed-without-merge PRs
15
+ # stale - no PR ever opened
16
+
17
+
18
+ def collect(repo, limit=1000):
19
+ """Return (prs, note). Degrades gracefully when gh is unavailable."""
20
+ if not shutil.which("gh"):
21
+ return [], "gh CLI not found; PR data skipped"
22
+ p = subprocess.run(
23
+ ["gh", "pr", "list", "--state", "all", "--limit", str(limit), "--json", FIELDS],
24
+ cwd=repo, capture_output=True, text=True)
25
+ if p.returncode != 0:
26
+ return [], "gh failed: " + p.stderr.strip()[:200]
27
+ prs = []
28
+ for x in json.loads(p.stdout or "[]"):
29
+ prs.append({
30
+ "number": x["number"],
31
+ "title": x.get("title", "").strip(),
32
+ "state": x.get("state", ""),
33
+ "head": x.get("headRefName", ""),
34
+ "author": (x.get("author") or {}).get("login", ""),
35
+ "created": (x.get("createdAt") or "")[:10],
36
+ "merged": (x.get("mergedAt") or "")[:10],
37
+ "closed": (x.get("closedAt") or "")[:10],
38
+ "draft": bool(x.get("isDraft")),
39
+ })
40
+ prs.sort(key=lambda pr: pr["number"])
41
+ return prs, ""
42
+
43
+
44
+ def classify(branches, prs, trunk, integration):
45
+ """Set status + prs on every branch by cross-referencing PR head refs."""
46
+ by_head = {}
47
+ for pr in prs:
48
+ by_head.setdefault(pr["head"], []).append(pr)
49
+ for b in branches:
50
+ linked = by_head.get(b["name"], [])
51
+ b["prs"] = [pr["number"] for pr in linked]
52
+ states = {pr["state"] for pr in linked}
53
+ if b["name"] == trunk:
54
+ b["status"] = "trunk"
55
+ elif integration and b["name"] == integration:
56
+ b["status"] = "integration"
57
+ elif "OPEN" in states:
58
+ b["status"] = "active"
59
+ elif b.get("merged_ancestry"):
60
+ b["status"] = "merged"
61
+ elif "MERGED" in states:
62
+ b["status"] = "squash-merged"
63
+ elif "CLOSED" in states:
64
+ b["status"] = "abandoned"
65
+ else:
66
+ b["status"] = "stale"
repokg/gitinfo.py ADDED
@@ -0,0 +1,124 @@
1
+ """Git collectors: branches, trunk/integration detection, merge classification, contributors."""
2
+
3
+ import os
4
+ import subprocess
5
+
6
+ INTEGRATION_CANDIDATES = ("staging", "develop", "dev", "next", "canary")
7
+
8
+
9
+ def run(repo, *args):
10
+ p = subprocess.run(["git", *args], cwd=repo, capture_output=True, text=True)
11
+ if p.returncode != 0:
12
+ raise RuntimeError("git %s: %s" % (" ".join(args), p.stderr.strip()))
13
+ return p.stdout.strip()
14
+
15
+
16
+ def try_run(repo, *args):
17
+ try:
18
+ return run(repo, *args)
19
+ except (RuntimeError, OSError):
20
+ return ""
21
+
22
+
23
+ def collect(repo):
24
+ """Return (repo_info, branches). Each branch dict carries merged_ancestry/ahead
25
+ against the default base (integration branch if present, else trunk)."""
26
+ top = run(repo, "rev-parse", "--show-toplevel")
27
+ remote = try_run(repo, "remote", "get-url", "origin")
28
+
29
+ fmt = "%(refname:short)%09%(committerdate:short)%09%(authorname)%09%(contents:subject)"
30
+ raw = try_run(repo, "for-each-ref", "refs/remotes/origin", "--format=" + fmt)
31
+ use_remote = bool(raw)
32
+ if not use_remote:
33
+ raw = try_run(repo, "for-each-ref", "refs/heads", "--format=" + fmt)
34
+
35
+ branches = []
36
+ for line in raw.splitlines():
37
+ parts = line.split("\t", 3)
38
+ while len(parts) < 4:
39
+ parts.append("")
40
+ ref, date, author, subject = parts
41
+ name = ref[len("origin/"):] if use_remote and ref.startswith("origin/") else ref
42
+ if name in ("HEAD", "") or ref == "origin":
43
+ continue
44
+ branches.append({
45
+ "name": name, "ref": ref, "date": date,
46
+ "author": author, "subject": subject,
47
+ })
48
+ names = {b["name"] for b in branches}
49
+
50
+ trunk = _detect_trunk(repo, names, use_remote)
51
+ integration = next((c for c in INTEGRATION_CANDIDATES if c in names and c != trunk), "")
52
+ base = integration or trunk
53
+ base_ref = ("origin/" + base) if (use_remote and base) else base
54
+ prefix = "refs/remotes/origin" if use_remote else "refs/heads"
55
+
56
+ merged_refs, ahead_map = set(), {}
57
+ if base_ref:
58
+ out = try_run(repo, "for-each-ref", prefix, "--merged=" + base_ref,
59
+ "--format=%(refname:short)")
60
+ merged_refs = set(out.splitlines())
61
+ # One call for all ahead counts (git >= 2.41); fall back per-branch below.
62
+ out = try_run(repo, "for-each-ref", prefix,
63
+ "--format=%(refname:short)%09%(ahead-behind:" + base_ref + ")")
64
+ for line in out.splitlines():
65
+ parts = line.split("\t")
66
+ if len(parts) == 2:
67
+ nums = parts[1].split()
68
+ if len(nums) == 2 and nums[0].isdigit():
69
+ ahead_map[parts[0]] = int(nums[0])
70
+
71
+ for b in branches:
72
+ b["merged_ancestry"] = b["ref"] in merged_refs
73
+ if b["name"] in (trunk, integration) or b["merged_ancestry"] or not base_ref:
74
+ b["ahead"] = 0
75
+ elif b["ref"] in ahead_map:
76
+ b["ahead"] = ahead_map[b["ref"]]
77
+ else:
78
+ n = try_run(repo, "rev-list", "--count", "%s..%s" % (base_ref, b["ref"]))
79
+ b["ahead"] = int(n) if n.isdigit() else 0
80
+
81
+ trunk_ref = ("origin/" + trunk) if use_remote and trunk in names else trunk
82
+ contributors = []
83
+ for line in try_run(repo, "shortlog", "-sn", "--no-merges", trunk_ref).splitlines():
84
+ parts = line.strip().split("\t", 1)
85
+ if len(parts) == 2 and parts[0].strip().isdigit():
86
+ contributors.append({"name": parts[1].strip(), "commits": int(parts[0])})
87
+
88
+ count = try_run(repo, "rev-list", "--count", trunk_ref)
89
+ roots = try_run(repo, "log", "--max-parents=0", "--format=%cs", trunk_ref).splitlines()
90
+
91
+ info = {
92
+ "path": top,
93
+ "name": _repo_name(remote, top),
94
+ "remote": remote,
95
+ "head": try_run(repo, "rev-parse", "HEAD"),
96
+ "trunk": trunk,
97
+ "integration": integration,
98
+ "default_base": base,
99
+ "first_commit": roots[-1] if roots else "",
100
+ "commit_count": int(count) if count.isdigit() else 0,
101
+ "contributors": contributors,
102
+ }
103
+ return info, branches
104
+
105
+
106
+ def _detect_trunk(repo, names, use_remote):
107
+ if use_remote:
108
+ head = try_run(repo, "symbolic-ref", "refs/remotes/origin/HEAD")
109
+ if head:
110
+ return head.rsplit("/", 1)[-1]
111
+ for cand in ("main", "master"):
112
+ if cand in names:
113
+ return cand
114
+ cur = try_run(repo, "rev-parse", "--abbrev-ref", "HEAD")
115
+ if cur and cur != "HEAD":
116
+ return cur
117
+ return sorted(names)[0] if names else ""
118
+
119
+
120
+ def _repo_name(remote, top):
121
+ if remote:
122
+ tail = remote.rstrip("/").rsplit("/", 1)[-1]
123
+ return tail[:-4] if tail.endswith(".git") else tail
124
+ return os.path.basename(top)