graphkeeper-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.
- graphkeeper/__init__.py +71 -0
- graphkeeper/build.py +48 -0
- graphkeeper/cli.py +167 -0
- graphkeeper/formatters.py +125 -0
- graphkeeper/git.py +207 -0
- graphkeeper/graphify.py +176 -0
- graphkeeper/py.typed +0 -0
- graphkeeper/query.py +114 -0
- graphkeeper/store.py +121 -0
- graphkeeper/types.py +149 -0
- graphkeeper_cli-0.1.0.dist-info/METADATA +264 -0
- graphkeeper_cli-0.1.0.dist-info/RECORD +15 -0
- graphkeeper_cli-0.1.0.dist-info/WHEEL +4 -0
- graphkeeper_cli-0.1.0.dist-info/entry_points.txt +2 -0
- graphkeeper_cli-0.1.0.dist-info/licenses/LICENSE +191 -0
graphkeeper/__init__.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GraphKeeper library API.
|
|
3
|
+
|
|
4
|
+
from graphkeeper import build, query_co_change
|
|
5
|
+
|
|
6
|
+
result = build(".")
|
|
7
|
+
print(f"{result.store.commits_analyzed} commit(s) analyzed")
|
|
8
|
+
|
|
9
|
+
co_change = query_co_change(result.store, "src/git.py")
|
|
10
|
+
for row in co_change.results:
|
|
11
|
+
print(row.count, row.file)
|
|
12
|
+
|
|
13
|
+
This is the Python port of the graphkeeper-cli npm package
|
|
14
|
+
(https://www.npmjs.com/package/graphkeeper-cli). Both distributions mine
|
|
15
|
+
`git log` for the same file-level co-change signal and, when graphify
|
|
16
|
+
(https://github.com/Graphify-Labs/graphify) is installed, enrich the same
|
|
17
|
+
store with its symbol/call-graph output; see
|
|
18
|
+
https://github.com/RudrenduPaul/GraphKeeper for the canonical
|
|
19
|
+
documentation and the original TypeScript source. `graphkeeper.cli` is a
|
|
20
|
+
thin argument-parsing wrapper over the same functions exported here, so
|
|
21
|
+
anything scriptable from the command line is also usable directly from
|
|
22
|
+
Python.
|
|
23
|
+
"""
|
|
24
|
+
from .build import build
|
|
25
|
+
from .git import GitError, mine_co_change, unquote_git_path
|
|
26
|
+
from .graphify import detect_graphify, run_graphify_enrichment
|
|
27
|
+
from .query import find_graphify_node, normalize_file_arg, query_calls, query_co_change
|
|
28
|
+
from .store import PathSafetyError, graph_file_path, read_store, resolve_repo_root, write_store
|
|
29
|
+
from .types import (
|
|
30
|
+
BuildOptions,
|
|
31
|
+
BuildResult,
|
|
32
|
+
CallsQueryResult,
|
|
33
|
+
CoChangeEdge,
|
|
34
|
+
CoChangeQueryResult,
|
|
35
|
+
CoChangeResultRow,
|
|
36
|
+
GraphifyEnrichment,
|
|
37
|
+
GraphifyEdge,
|
|
38
|
+
GraphifyNode,
|
|
39
|
+
GraphKeeperStore,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
__version__ = "0.1.0"
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"build",
|
|
46
|
+
"query_co_change",
|
|
47
|
+
"query_calls",
|
|
48
|
+
"find_graphify_node",
|
|
49
|
+
"normalize_file_arg",
|
|
50
|
+
"detect_graphify",
|
|
51
|
+
"run_graphify_enrichment",
|
|
52
|
+
"mine_co_change",
|
|
53
|
+
"unquote_git_path",
|
|
54
|
+
"GitError",
|
|
55
|
+
"resolve_repo_root",
|
|
56
|
+
"read_store",
|
|
57
|
+
"write_store",
|
|
58
|
+
"graph_file_path",
|
|
59
|
+
"PathSafetyError",
|
|
60
|
+
"GraphKeeperStore",
|
|
61
|
+
"CoChangeEdge",
|
|
62
|
+
"GraphifyNode",
|
|
63
|
+
"GraphifyEdge",
|
|
64
|
+
"GraphifyEnrichment",
|
|
65
|
+
"BuildOptions",
|
|
66
|
+
"BuildResult",
|
|
67
|
+
"CoChangeQueryResult",
|
|
68
|
+
"CoChangeResultRow",
|
|
69
|
+
"CallsQueryResult",
|
|
70
|
+
"__version__",
|
|
71
|
+
]
|
graphkeeper/build.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Builds (or rebuilds) the GraphKeeper store for a repo.
|
|
3
|
+
|
|
4
|
+
Ported from src/build.ts.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from .git import mine_co_change
|
|
12
|
+
from .graphify import run_graphify_enrichment
|
|
13
|
+
from .store import resolve_repo_root, write_store
|
|
14
|
+
from .types import BuildOptions, BuildResult, GraphifyEnrichment, GraphKeeperStore
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build(target_path: str, options: Optional[BuildOptions] = None) -> BuildResult:
|
|
18
|
+
"""Builds (or rebuilds) the GraphKeeper store for a repo: mines `git
|
|
19
|
+
log` for file-level co-change, optionally enriches it with graphify's
|
|
20
|
+
symbol/call graph if graphify is installed, and writes the merged
|
|
21
|
+
result to `.graphkeeper/graph.json`."""
|
|
22
|
+
opts = options or BuildOptions()
|
|
23
|
+
repo_root = resolve_repo_root(target_path)
|
|
24
|
+
|
|
25
|
+
mined = mine_co_change(repo_root, opts.max_files_per_commit)
|
|
26
|
+
|
|
27
|
+
if opts.skip_graphify:
|
|
28
|
+
graphify = GraphifyEnrichment(
|
|
29
|
+
enriched=False,
|
|
30
|
+
version=None,
|
|
31
|
+
skipped_reason="graphify enrichment skipped via --no-graphify.",
|
|
32
|
+
)
|
|
33
|
+
else:
|
|
34
|
+
graphify = run_graphify_enrichment(repo_root)
|
|
35
|
+
|
|
36
|
+
store = GraphKeeperStore(
|
|
37
|
+
version=1,
|
|
38
|
+
generated_at=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
39
|
+
repo_path=repo_root,
|
|
40
|
+
commits_analyzed=mined.commits_analyzed,
|
|
41
|
+
commits_skipped=mined.commits_skipped,
|
|
42
|
+
co_change=mined.co_change,
|
|
43
|
+
file_commit_counts=mined.file_commit_counts,
|
|
44
|
+
graphify=graphify,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
output_path = write_store(repo_root, store)
|
|
48
|
+
return BuildResult(store=store, output_path=output_path)
|
graphkeeper/cli.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Argument-parsing wrapper over graphkeeper's build/query library functions.
|
|
4
|
+
Console entry point: `graphkeeper <command> [options]`, installed via the
|
|
5
|
+
`graphkeeper` console-script defined in python/pyproject.toml.
|
|
6
|
+
|
|
7
|
+
Ported from src/cli.ts (which uses `commander`); this port uses the stdlib
|
|
8
|
+
`argparse` to avoid a CLI-framework dependency. Subcommands, flags,
|
|
9
|
+
defaults, and exit codes are kept identical to the npm CLI's `--help`
|
|
10
|
+
output and behavior: `build [path]`, `query co-change <file>`, and
|
|
11
|
+
`query calls <symbol>`.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from typing import List, NoReturn, Optional
|
|
19
|
+
|
|
20
|
+
from .build import build
|
|
21
|
+
from .formatters import (
|
|
22
|
+
format_build_json,
|
|
23
|
+
format_build_text,
|
|
24
|
+
format_calls_json,
|
|
25
|
+
format_calls_text,
|
|
26
|
+
format_co_change_json,
|
|
27
|
+
format_co_change_text,
|
|
28
|
+
parse_positive_int,
|
|
29
|
+
)
|
|
30
|
+
from .query import query_calls, query_co_change
|
|
31
|
+
from .store import read_store, resolve_repo_root
|
|
32
|
+
from .types import BuildOptions
|
|
33
|
+
|
|
34
|
+
VERSION = "0.1.0"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _report_error(err: Exception, as_json: bool) -> NoReturn:
|
|
38
|
+
message = str(err)
|
|
39
|
+
if as_json:
|
|
40
|
+
sys.stderr.write(json.dumps({"error": message}, indent=2) + "\n")
|
|
41
|
+
else:
|
|
42
|
+
sys.stderr.write(f"Error: {message}\n")
|
|
43
|
+
sys.exit(2)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_parser() -> "tuple[argparse.ArgumentParser, argparse.ArgumentParser]":
|
|
47
|
+
parser = argparse.ArgumentParser(
|
|
48
|
+
prog="graphkeeper",
|
|
49
|
+
description=(
|
|
50
|
+
"Local-only CLI that mines git history for file-level co-change patterns and builds a "
|
|
51
|
+
"queryable knowledge graph for AI coding agents, with optional enrichment from graphify's "
|
|
52
|
+
"symbol/call-graph output when it is installed."
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument("-V", "--version", action="version", version=VERSION)
|
|
56
|
+
|
|
57
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
58
|
+
|
|
59
|
+
build_cmd = subparsers.add_parser(
|
|
60
|
+
"build",
|
|
61
|
+
description="Mine git history for co-change and (if available) merge in graphify's symbol/call graph",
|
|
62
|
+
)
|
|
63
|
+
build_cmd.add_argument("path", nargs="?", default=".", help="path to the target repo")
|
|
64
|
+
build_cmd.add_argument("--json", action="store_true", help="emit machine-readable JSON instead of human-readable text")
|
|
65
|
+
build_cmd.add_argument(
|
|
66
|
+
"--max-files-per-commit",
|
|
67
|
+
default=None,
|
|
68
|
+
help="skip commits touching more than this many files (default: 100)",
|
|
69
|
+
)
|
|
70
|
+
build_cmd.add_argument(
|
|
71
|
+
"--no-graphify",
|
|
72
|
+
dest="graphify",
|
|
73
|
+
action="store_false",
|
|
74
|
+
default=True,
|
|
75
|
+
help="skip graphify enrichment even if graphify is installed",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
query_cmd = subparsers.add_parser("query", description="Query the GraphKeeper store built by `graphkeeper build`")
|
|
79
|
+
query_subparsers = query_cmd.add_subparsers(dest="query_command")
|
|
80
|
+
|
|
81
|
+
co_change_cmd = query_subparsers.add_parser(
|
|
82
|
+
"co-change",
|
|
83
|
+
description="List files that historically change alongside <file>, ranked by co-change frequency",
|
|
84
|
+
)
|
|
85
|
+
co_change_cmd.add_argument("file", help="repo-relative file path, e.g. src/git.py")
|
|
86
|
+
co_change_cmd.add_argument("--json", action="store_true", help="emit machine-readable JSON instead of human-readable text")
|
|
87
|
+
co_change_cmd.add_argument("--limit", default=None, help="cap the number of results")
|
|
88
|
+
co_change_cmd.add_argument(
|
|
89
|
+
"--graph", default=None, help="path to a specific graph.json (default: <cwd>/.graphkeeper/graph.json)"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
calls_cmd = query_subparsers.add_parser(
|
|
93
|
+
"calls",
|
|
94
|
+
description="Show callers/callees of <symbol> (requires graphify enrichment from `graphkeeper build`)",
|
|
95
|
+
)
|
|
96
|
+
calls_cmd.add_argument("symbol", help="symbol name, e.g. parse_config")
|
|
97
|
+
calls_cmd.add_argument("--json", action="store_true", help="emit machine-readable JSON instead of human-readable text")
|
|
98
|
+
calls_cmd.add_argument(
|
|
99
|
+
"--graph", default=None, help="path to a specific graph.json (default: <cwd>/.graphkeeper/graph.json)"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return parser, query_cmd
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _run_build(args: argparse.Namespace) -> int:
|
|
106
|
+
try:
|
|
107
|
+
max_files_per_commit = (
|
|
108
|
+
parse_positive_int(args.max_files_per_commit, "--max-files-per-commit")
|
|
109
|
+
if args.max_files_per_commit
|
|
110
|
+
else None
|
|
111
|
+
)
|
|
112
|
+
result = build(
|
|
113
|
+
args.path,
|
|
114
|
+
BuildOptions(max_files_per_commit=max_files_per_commit, skip_graphify=not args.graphify),
|
|
115
|
+
)
|
|
116
|
+
print(format_build_json(result) if args.json else format_build_text(result))
|
|
117
|
+
return 0
|
|
118
|
+
except Exception as err: # noqa: BLE001 -- mirrors src/cli.ts's catch-all reportError()
|
|
119
|
+
_report_error(err, bool(args.json))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _run_query_co_change(args: argparse.Namespace) -> int:
|
|
123
|
+
try:
|
|
124
|
+
limit = parse_positive_int(args.limit, "--limit") if args.limit else None
|
|
125
|
+
repo_root = resolve_repo_root(".")
|
|
126
|
+
store = read_store(repo_root, args.graph)
|
|
127
|
+
result = query_co_change(store, args.file, limit)
|
|
128
|
+
print(format_co_change_json(result) if args.json else format_co_change_text(result))
|
|
129
|
+
return 0 if result.results else 1
|
|
130
|
+
except Exception as err: # noqa: BLE001
|
|
131
|
+
_report_error(err, bool(args.json))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _run_query_calls(args: argparse.Namespace) -> int:
|
|
135
|
+
try:
|
|
136
|
+
repo_root = resolve_repo_root(".")
|
|
137
|
+
store = read_store(repo_root, args.graph)
|
|
138
|
+
result = query_calls(store, args.symbol)
|
|
139
|
+
print(format_calls_json(result) if args.json else format_calls_text(result))
|
|
140
|
+
return 0 if (result.available and result.node) else 1
|
|
141
|
+
except Exception as err: # noqa: BLE001
|
|
142
|
+
_report_error(err, bool(args.json))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def run_cli(argv: Optional[List[str]] = None) -> int:
|
|
146
|
+
parser, query_cmd = build_parser()
|
|
147
|
+
args = parser.parse_args(argv)
|
|
148
|
+
|
|
149
|
+
if args.command == "build":
|
|
150
|
+
return _run_build(args)
|
|
151
|
+
if args.command == "query":
|
|
152
|
+
if args.query_command == "co-change":
|
|
153
|
+
return _run_query_co_change(args)
|
|
154
|
+
if args.query_command == "calls":
|
|
155
|
+
return _run_query_calls(args)
|
|
156
|
+
query_cmd.print_help()
|
|
157
|
+
return 0
|
|
158
|
+
parser.print_help()
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def main() -> None:
|
|
163
|
+
sys.exit(run_cli(sys.argv[1:]))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
if __name__ == "__main__":
|
|
167
|
+
main()
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Human-readable and JSON output formatters, plus small CLI argument-parsing
|
|
3
|
+
helpers.
|
|
4
|
+
|
|
5
|
+
Ported from src/cli-lib.ts.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
from .types import BuildResult, CallsQueryResult, CoChangeQueryResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_positive_int(raw: str, flag_name: str) -> int:
|
|
15
|
+
stripped = raw.strip()
|
|
16
|
+
try:
|
|
17
|
+
n = int(stripped)
|
|
18
|
+
except ValueError:
|
|
19
|
+
n = None
|
|
20
|
+
if n is None or n <= 0 or str(n) != stripped:
|
|
21
|
+
raise ValueError(f'Invalid {flag_name} value "{raw}". Expected a positive integer.')
|
|
22
|
+
return n
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def format_build_text(result: BuildResult) -> str:
|
|
26
|
+
store = result.store
|
|
27
|
+
lines = [f"GraphKeeper build complete: {store.repo_path}", ""]
|
|
28
|
+
skipped_note = (
|
|
29
|
+
f" ({store.commits_skipped} commit(s) skipped: too many files changed)"
|
|
30
|
+
if store.commits_skipped > 0
|
|
31
|
+
else ""
|
|
32
|
+
)
|
|
33
|
+
lines.append(
|
|
34
|
+
f"Co-change graph: {store.commits_analyzed} commit(s) analyzed, "
|
|
35
|
+
f"{len(store.co_change)} file pair(s) found{skipped_note}"
|
|
36
|
+
)
|
|
37
|
+
if store.graphify.enriched:
|
|
38
|
+
version = store.graphify.version or "unknown"
|
|
39
|
+
lines.append(
|
|
40
|
+
f"graphify enrichment: included (v{version}) -- "
|
|
41
|
+
f"{len(store.graphify.nodes)} node(s), {len(store.graphify.edges)} edge(s)"
|
|
42
|
+
)
|
|
43
|
+
else:
|
|
44
|
+
lines.append(f"graphify enrichment: skipped -- {store.graphify.skipped_reason or 'unknown reason'}")
|
|
45
|
+
lines.append("")
|
|
46
|
+
lines.append(f"Wrote {result.output_path}")
|
|
47
|
+
return "\n".join(lines)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def format_build_json(result: BuildResult) -> str:
|
|
51
|
+
store = result.store
|
|
52
|
+
payload = {
|
|
53
|
+
"repoPath": store.repo_path,
|
|
54
|
+
"outputPath": result.output_path,
|
|
55
|
+
"generatedAt": store.generated_at,
|
|
56
|
+
"commitsAnalyzed": store.commits_analyzed,
|
|
57
|
+
"commitsSkipped": store.commits_skipped,
|
|
58
|
+
"coChangePairs": len(store.co_change),
|
|
59
|
+
"graphify": {
|
|
60
|
+
"enriched": store.graphify.enriched,
|
|
61
|
+
"version": store.graphify.version,
|
|
62
|
+
"skippedReason": store.graphify.skipped_reason,
|
|
63
|
+
"nodes": len(store.graphify.nodes),
|
|
64
|
+
"edges": len(store.graphify.edges),
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
return json.dumps(payload, indent=2)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def format_co_change_text(result: CoChangeQueryResult) -> str:
|
|
71
|
+
lines = [f'Files that historically change alongside "{result.file}":', ""]
|
|
72
|
+
if not result.results:
|
|
73
|
+
lines.append(" (no co-change data found for this file -- check the path, or run `graphkeeper build` first)")
|
|
74
|
+
else:
|
|
75
|
+
for r in result.results:
|
|
76
|
+
lines.append(f" {str(r.count).rjust(4)} {r.file}")
|
|
77
|
+
return "\n".join(lines)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def format_co_change_json(result: CoChangeQueryResult) -> str:
|
|
81
|
+
payload = {"file": result.file, "results": [{"file": r.file, "count": r.count} for r in result.results]}
|
|
82
|
+
return json.dumps(payload, indent=2)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def format_calls_text(result: CallsQueryResult) -> str:
|
|
86
|
+
if not result.available:
|
|
87
|
+
return "\n".join(
|
|
88
|
+
[
|
|
89
|
+
f'Call-graph query for "{result.symbol}" is not available.',
|
|
90
|
+
"",
|
|
91
|
+
result.unavailable_reason or "graphify enrichment is required for this query type.",
|
|
92
|
+
]
|
|
93
|
+
)
|
|
94
|
+
if not result.node:
|
|
95
|
+
return f'No symbol matching "{result.symbol}" was found in the graphify graph.'
|
|
96
|
+
|
|
97
|
+
label = result.node.get("label", result.symbol)
|
|
98
|
+
source_file = result.node.get("source_file") or "unknown location"
|
|
99
|
+
lines = [f"{label} ({source_file})", ""]
|
|
100
|
+
lines.append(f"Calls ({len(result.calls)}):")
|
|
101
|
+
for c in result.calls:
|
|
102
|
+
node = c.get("node")
|
|
103
|
+
edge = c.get("edge") or {}
|
|
104
|
+
target = node.get("label") if node else edge.get("target")
|
|
105
|
+
lines.append(f" --> {target}")
|
|
106
|
+
lines.append("")
|
|
107
|
+
lines.append(f"Called by ({len(result.called_by)}):")
|
|
108
|
+
for c in result.called_by:
|
|
109
|
+
node = c.get("node")
|
|
110
|
+
edge = c.get("edge") or {}
|
|
111
|
+
source = node.get("label") if node else edge.get("source")
|
|
112
|
+
lines.append(f" <-- {source}")
|
|
113
|
+
return "\n".join(lines)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def format_calls_json(result: CallsQueryResult) -> str:
|
|
117
|
+
payload = {
|
|
118
|
+
"symbol": result.symbol,
|
|
119
|
+
"available": result.available,
|
|
120
|
+
"unavailableReason": result.unavailable_reason,
|
|
121
|
+
"node": result.node,
|
|
122
|
+
"calls": result.calls,
|
|
123
|
+
"calledBy": result.called_by,
|
|
124
|
+
}
|
|
125
|
+
return json.dumps(payload, indent=2)
|
graphkeeper/git.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Mines `git log` history for file-level co-change: pairs of files that were
|
|
3
|
+
modified together in the same commit.
|
|
4
|
+
|
|
5
|
+
Ported from src/git.ts. Every `git` invocation uses an argv list passed
|
|
6
|
+
directly to the OS (`subprocess.run`, `shell=False`), never a shell string,
|
|
7
|
+
so commit messages, file paths, or repo paths can never be interpreted as
|
|
8
|
+
shell syntax.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import subprocess
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
from .types import CoChangeEdge
|
|
17
|
+
|
|
18
|
+
# Marker used to split `git log` output into per-commit chunks. Chosen to be
|
|
19
|
+
# extremely unlikely to appear in a commit message or file path.
|
|
20
|
+
COMMIT_MARKER = "@@GK-COMMIT@@"
|
|
21
|
+
|
|
22
|
+
DEFAULT_MAX_FILES_PER_COMMIT = 100
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class GitError(Exception):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _run_git(repo_path: str, args: List[str]) -> "subprocess.CompletedProcess[str]":
|
|
30
|
+
try:
|
|
31
|
+
return subprocess.run(
|
|
32
|
+
["git", "-C", repo_path, *args],
|
|
33
|
+
capture_output=True,
|
|
34
|
+
text=True,
|
|
35
|
+
check=False,
|
|
36
|
+
)
|
|
37
|
+
except FileNotFoundError as err:
|
|
38
|
+
raise GitError("git is not installed or not on PATH.") from err
|
|
39
|
+
except OSError as err:
|
|
40
|
+
raise GitError(f"Failed to run git: {err}") from err
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def assert_is_git_repo(repo_path: str) -> None:
|
|
44
|
+
"""Raises GitError if `repo_path` is not inside a git working tree."""
|
|
45
|
+
result = _run_git(repo_path, ["rev-parse", "--is-inside-work-tree"])
|
|
46
|
+
if result.returncode != 0:
|
|
47
|
+
raise GitError(f'"{repo_path}" is not a git repository. {result.stderr.strip()}'.strip())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def unquote_git_path(raw: str) -> str:
|
|
51
|
+
"""Unquotes a git `--name-only` path entry. Git C-quotes paths
|
|
52
|
+
containing unusual bytes (non-ASCII when core.quotePath is on, quotes,
|
|
53
|
+
backslashes, control characters) by wrapping them in double quotes with
|
|
54
|
+
backslash/octal escapes. Plain paths are returned unchanged."""
|
|
55
|
+
if len(raw) < 2 or raw[0] != '"' or raw[-1] != '"':
|
|
56
|
+
return raw
|
|
57
|
+
inner = raw[1:-1]
|
|
58
|
+
# Octal escapes are individual *bytes* of a (possibly multi-byte UTF-8)
|
|
59
|
+
# sequence, so this must accumulate raw bytes and UTF-8-decode once at
|
|
60
|
+
# the end -- decoding each escaped byte independently would mangle any
|
|
61
|
+
# non-ASCII character spanning more than one byte.
|
|
62
|
+
byte_values: List[int] = []
|
|
63
|
+
i = 0
|
|
64
|
+
n = len(inner)
|
|
65
|
+
while i < n:
|
|
66
|
+
ch = inner[i]
|
|
67
|
+
if ch != "\\":
|
|
68
|
+
# core.quotePath=true escapes every byte >= 0x80, so an
|
|
69
|
+
# unescaped character here is always plain ASCII.
|
|
70
|
+
byte_values.append(ord(ch))
|
|
71
|
+
i += 1
|
|
72
|
+
continue
|
|
73
|
+
nxt = inner[i + 1] if i + 1 < n else None
|
|
74
|
+
if nxt is None:
|
|
75
|
+
byte_values.append(ord(ch))
|
|
76
|
+
i += 1
|
|
77
|
+
continue
|
|
78
|
+
if "0" <= nxt <= "7":
|
|
79
|
+
octal = inner[i + 1 : i + 4]
|
|
80
|
+
try:
|
|
81
|
+
code = int(octal, 8)
|
|
82
|
+
except ValueError:
|
|
83
|
+
code = ord(nxt)
|
|
84
|
+
byte_values.append(code)
|
|
85
|
+
i += 4
|
|
86
|
+
continue
|
|
87
|
+
if nxt == "n":
|
|
88
|
+
byte_values.append(0x0A)
|
|
89
|
+
elif nxt == "t":
|
|
90
|
+
byte_values.append(0x09)
|
|
91
|
+
elif nxt == "\\":
|
|
92
|
+
byte_values.append(0x5C)
|
|
93
|
+
elif nxt == '"':
|
|
94
|
+
byte_values.append(0x22)
|
|
95
|
+
else:
|
|
96
|
+
byte_values.append(ord(nxt))
|
|
97
|
+
i += 2
|
|
98
|
+
return bytes(byte_values).decode("utf-8")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _has_no_nul_byte(p: str) -> bool:
|
|
102
|
+
"""True if every character code in `p` is non-zero (i.e. no embedded
|
|
103
|
+
NUL bytes)."""
|
|
104
|
+
return "\0" not in p
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _is_safe_tracked_path(p: str) -> bool:
|
|
108
|
+
"""Rejects path entries that could escape the repo tree or aren't real
|
|
109
|
+
tracked files."""
|
|
110
|
+
if not p:
|
|
111
|
+
return False
|
|
112
|
+
if not _has_no_nul_byte(p):
|
|
113
|
+
return False
|
|
114
|
+
if p.startswith("/") or p.startswith("~"):
|
|
115
|
+
return False
|
|
116
|
+
segments = p.split("/")
|
|
117
|
+
if any(s == ".." for s in segments):
|
|
118
|
+
return False
|
|
119
|
+
return True
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class CoChangeMiningResult:
|
|
124
|
+
co_change: List[CoChangeEdge] = field(default_factory=list)
|
|
125
|
+
file_commit_counts: Dict[str, int] = field(default_factory=dict)
|
|
126
|
+
commits_analyzed: int = 0
|
|
127
|
+
commits_skipped: int = 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def mine_co_change(repo_path: str, max_files_per_commit: Optional[int] = None) -> CoChangeMiningResult:
|
|
131
|
+
"""Mines `git log` history for file-level co-change: pairs of files that
|
|
132
|
+
were modified together in the same commit. This is GraphKeeper's own
|
|
133
|
+
signal, distinct from (and complementary to) symbol/call-graph
|
|
134
|
+
extraction."""
|
|
135
|
+
effective_max = max_files_per_commit if max_files_per_commit is not None else DEFAULT_MAX_FILES_PER_COMMIT
|
|
136
|
+
assert_is_git_repo(repo_path)
|
|
137
|
+
|
|
138
|
+
result = _run_git(
|
|
139
|
+
repo_path,
|
|
140
|
+
[
|
|
141
|
+
"-c",
|
|
142
|
+
"core.quotePath=true",
|
|
143
|
+
"log",
|
|
144
|
+
"--no-merges",
|
|
145
|
+
f"--pretty=format:{COMMIT_MARKER}%H",
|
|
146
|
+
"--name-only",
|
|
147
|
+
],
|
|
148
|
+
)
|
|
149
|
+
if result.returncode != 0:
|
|
150
|
+
raise GitError(f"git log failed: {result.stderr.strip()}")
|
|
151
|
+
|
|
152
|
+
stdout = result.stdout or ""
|
|
153
|
+
if stdout.strip() == "":
|
|
154
|
+
return CoChangeMiningResult()
|
|
155
|
+
|
|
156
|
+
# Keyed by (a, b) tuples -- each commit's own file list is sorted before
|
|
157
|
+
# pairing, so a < b lexicographically within every pair, making the
|
|
158
|
+
# tuple an unambiguous key across all commits (no JSON-stringify trick
|
|
159
|
+
# needed, unlike the TypeScript original, since Python tuples are
|
|
160
|
+
# natively hashable).
|
|
161
|
+
pair_counts: Dict[tuple, int] = {}
|
|
162
|
+
file_commit_counts: Dict[str, int] = {}
|
|
163
|
+
commits_analyzed = 0
|
|
164
|
+
commits_skipped = 0
|
|
165
|
+
|
|
166
|
+
chunks = stdout.split(COMMIT_MARKER)[1:]
|
|
167
|
+
for chunk in chunks:
|
|
168
|
+
newline_index = chunk.find("\n")
|
|
169
|
+
files_raw = "" if newline_index == -1 else chunk[newline_index + 1 :]
|
|
170
|
+
seen = []
|
|
171
|
+
seen_set = set()
|
|
172
|
+
for line in files_raw.split("\n"):
|
|
173
|
+
candidate = line.strip()
|
|
174
|
+
if not candidate:
|
|
175
|
+
continue
|
|
176
|
+
unquoted = unquote_git_path(candidate)
|
|
177
|
+
if not _is_safe_tracked_path(unquoted):
|
|
178
|
+
continue
|
|
179
|
+
if unquoted not in seen_set:
|
|
180
|
+
seen_set.add(unquoted)
|
|
181
|
+
seen.append(unquoted)
|
|
182
|
+
files = sorted(seen)
|
|
183
|
+
|
|
184
|
+
if len(files) == 0:
|
|
185
|
+
continue
|
|
186
|
+
if len(files) > effective_max:
|
|
187
|
+
commits_skipped += 1
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
commits_analyzed += 1
|
|
191
|
+
for f in files:
|
|
192
|
+
file_commit_counts[f] = file_commit_counts.get(f, 0) + 1
|
|
193
|
+
for idx in range(len(files)):
|
|
194
|
+
for jdx in range(idx + 1, len(files)):
|
|
195
|
+
a, b = files[idx], files[jdx]
|
|
196
|
+
key = (a, b)
|
|
197
|
+
pair_counts[key] = pair_counts.get(key, 0) + 1
|
|
198
|
+
|
|
199
|
+
co_change = [CoChangeEdge(a=a, b=b, count=count) for (a, b), count in pair_counts.items()]
|
|
200
|
+
co_change.sort(key=lambda e: e.count, reverse=True)
|
|
201
|
+
|
|
202
|
+
return CoChangeMiningResult(
|
|
203
|
+
co_change=co_change,
|
|
204
|
+
file_commit_counts=file_commit_counts,
|
|
205
|
+
commits_analyzed=commits_analyzed,
|
|
206
|
+
commits_skipped=commits_skipped,
|
|
207
|
+
)
|