zerottmm 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.
- zerottmm/__init__.py +38 -0
- zerottmm/ai_analysis.py +149 -0
- zerottmm/cli.py +212 -0
- zerottmm/gitingest.py +219 -0
- zerottmm/gitutils.py +117 -0
- zerottmm/index.py +160 -0
- zerottmm/metrics.py +66 -0
- zerottmm/search.py +121 -0
- zerottmm/store.py +380 -0
- zerottmm/trace.py +178 -0
- zerottmm-0.1.0.dist-info/METADATA +176 -0
- zerottmm-0.1.0.dist-info/RECORD +15 -0
- zerottmm-0.1.0.dist-info/WHEEL +5 -0
- zerottmm-0.1.0.dist-info/entry_points.txt +2 -0
- zerottmm-0.1.0.dist-info/top_level.txt +1 -0
zerottmm/__init__.py
ADDED
@@ -0,0 +1,38 @@
|
|
1
|
+
"""Top‑level package for ttmm.
|
2
|
+
|
3
|
+
`ttmm` (Time‑to‑Mental‑Model) helps you build a mental model of a Python codebase
|
4
|
+
faster. It can index a repository, compute hotspots, navigate static call graphs,
|
5
|
+
run dynamic traces and answer natural language questions about your code. The core
|
6
|
+
functionality lives in submodules:
|
7
|
+
|
8
|
+
* `ttmm.index` – parse and index a Python repository
|
9
|
+
* `ttmm.store` – SQLite persistence layer
|
10
|
+
* `ttmm.metrics` – compute cyclomatic complexity and other metrics
|
11
|
+
* `ttmm.gitutils` – git churn calculations
|
12
|
+
* `ttmm.trace` – runtime tracing using `sys.settrace`
|
13
|
+
* `ttmm.search` – tiny TF‑IDF search over your codebase
|
14
|
+
* `ttmm.cli` – command line entry point
|
15
|
+
|
16
|
+
Importing this package will expose the `__version__` attribute. For most use cases
|
17
|
+
you should call into `ttmm.cli` via the `ttmm` command line, or import functions
|
18
|
+
from the specific submodules.
|
19
|
+
"""
|
20
|
+
|
21
|
+
from importlib.metadata import version, PackageNotFoundError
|
22
|
+
|
23
|
+
try: # pragma: no cover - during development version metadata may be missing
|
24
|
+
__version__ = version(__name__)
|
25
|
+
except PackageNotFoundError:
|
26
|
+
__version__ = "0.0.0"
|
27
|
+
|
28
|
+
__all__ = [
|
29
|
+
"index",
|
30
|
+
"store",
|
31
|
+
"metrics",
|
32
|
+
"gitutils",
|
33
|
+
"trace",
|
34
|
+
"search",
|
35
|
+
"gitingest",
|
36
|
+
"ai_analysis",
|
37
|
+
"cli",
|
38
|
+
]
|
zerottmm/ai_analysis.py
ADDED
@@ -0,0 +1,149 @@
|
|
1
|
+
"""AI-powered code analysis using OpenAI API.
|
2
|
+
|
3
|
+
This module provides AI-enhanced analysis capabilities for ttmm,
|
4
|
+
allowing users to get natural language explanations and insights
|
5
|
+
about their codebases using OpenAI's GPT models.
|
6
|
+
"""
|
7
|
+
|
8
|
+
from __future__ import annotations
|
9
|
+
|
10
|
+
from typing import Dict, List, Optional
|
11
|
+
|
12
|
+
|
13
|
+
def analyze_code_with_ai(
|
14
|
+
api_key: str,
|
15
|
+
analysis_type: str,
|
16
|
+
hotspots_context: List[str],
|
17
|
+
repo_info: Dict,
|
18
|
+
custom_prompt: Optional[str] = None,
|
19
|
+
) -> str:
|
20
|
+
"""Analyze code using OpenAI API.
|
21
|
+
|
22
|
+
Parameters
|
23
|
+
----------
|
24
|
+
api_key : str
|
25
|
+
OpenAI API key
|
26
|
+
analysis_type : str
|
27
|
+
Type of analysis to perform
|
28
|
+
hotspots_context : List[str]
|
29
|
+
List of hotspot descriptions
|
30
|
+
repo_info : Dict
|
31
|
+
Repository metadata
|
32
|
+
custom_prompt : str, optional
|
33
|
+
Custom analysis prompt
|
34
|
+
|
35
|
+
Returns
|
36
|
+
-------
|
37
|
+
str
|
38
|
+
AI analysis result
|
39
|
+
"""
|
40
|
+
try:
|
41
|
+
import openai
|
42
|
+
except ImportError:
|
43
|
+
return ("❌ **OpenAI library not installed**\n\n"
|
44
|
+
"Please install it with: `pip install openai`")
|
45
|
+
|
46
|
+
# Set up OpenAI client
|
47
|
+
client = openai.OpenAI(api_key=api_key)
|
48
|
+
|
49
|
+
# Prepare context
|
50
|
+
repo_context = f"""
|
51
|
+
Repository Information:
|
52
|
+
- Path: {repo_info.get('path', 'Unknown')}
|
53
|
+
- Remote URL: {repo_info.get('remote_url', 'Local repository')}
|
54
|
+
- Branch: {repo_info.get('branch', 'Unknown')}
|
55
|
+
- Commit: {repo_info.get('commit', 'Unknown')}
|
56
|
+
|
57
|
+
Top Code Hotspots (high complexity functions):
|
58
|
+
{chr(10).join(hotspots_context[:5])}
|
59
|
+
"""
|
60
|
+
|
61
|
+
# Define analysis prompts
|
62
|
+
analysis_prompts = {
|
63
|
+
"Explain hotspots": (
|
64
|
+
"Analyze the code hotspots listed above. Explain what makes these functions "
|
65
|
+
"complex and suggest potential improvements or areas that might need attention. "
|
66
|
+
"Focus on maintainability and potential refactoring opportunities."
|
67
|
+
),
|
68
|
+
"Summarize architecture": (
|
69
|
+
"Based on the hotspots and repository information, provide a high-level "
|
70
|
+
"architectural summary of this codebase. Identify the main components, "
|
71
|
+
"patterns, and overall structure."
|
72
|
+
),
|
73
|
+
"Identify design patterns": (
|
74
|
+
"Analyze the code hotspots and identify any design patterns being used. "
|
75
|
+
"Comment on the appropriateness of these patterns and suggest alternatives "
|
76
|
+
"if beneficial."
|
77
|
+
),
|
78
|
+
"Find potential issues": (
|
79
|
+
"Review the code hotspots for potential issues like performance bottlenecks, "
|
80
|
+
"security concerns, maintainability problems, or technical debt. Provide "
|
81
|
+
"specific recommendations."
|
82
|
+
),
|
83
|
+
"Custom analysis": custom_prompt or "Provide a general analysis of the codebase.",
|
84
|
+
}
|
85
|
+
|
86
|
+
prompt = analysis_prompts.get(analysis_type, analysis_prompts["Custom analysis"])
|
87
|
+
|
88
|
+
try:
|
89
|
+
response = client.chat.completions.create(
|
90
|
+
model="gpt-3.5-turbo",
|
91
|
+
messages=[
|
92
|
+
{
|
93
|
+
"role": "system",
|
94
|
+
"content": (
|
95
|
+
"You are a senior software engineer helping to analyze a Python "
|
96
|
+
"codebase. Provide clear, actionable insights based on the code "
|
97
|
+
"metrics and hotspots provided. Be concise but thorough."
|
98
|
+
)
|
99
|
+
},
|
100
|
+
{
|
101
|
+
"role": "user",
|
102
|
+
"content": f"{repo_context}\n\nAnalysis Request: {prompt}"
|
103
|
+
}
|
104
|
+
],
|
105
|
+
max_tokens=1000,
|
106
|
+
temperature=0.3,
|
107
|
+
)
|
108
|
+
|
109
|
+
return response.choices[0].message.content or "No analysis generated."
|
110
|
+
|
111
|
+
except openai.OpenAIError as e:
|
112
|
+
return f"❌ **OpenAI API Error**: {str(e)}"
|
113
|
+
except Exception as e:
|
114
|
+
return f"❌ **Analysis Error**: {str(e)}"
|
115
|
+
|
116
|
+
|
117
|
+
def test_openai_connection(api_key: str) -> tuple[bool, str]:
|
118
|
+
"""Test if OpenAI API key is valid.
|
119
|
+
|
120
|
+
Parameters
|
121
|
+
----------
|
122
|
+
api_key : str
|
123
|
+
OpenAI API key to test
|
124
|
+
|
125
|
+
Returns
|
126
|
+
-------
|
127
|
+
tuple[bool, str]
|
128
|
+
(success, message)
|
129
|
+
"""
|
130
|
+
try:
|
131
|
+
import openai
|
132
|
+
except ImportError:
|
133
|
+
return False, "OpenAI library not installed"
|
134
|
+
|
135
|
+
try:
|
136
|
+
client = openai.OpenAI(api_key=api_key)
|
137
|
+
# Make a minimal API call to test the key
|
138
|
+
client.chat.completions.create(
|
139
|
+
model="gpt-3.5-turbo",
|
140
|
+
messages=[{"role": "user", "content": "Hello"}],
|
141
|
+
max_tokens=5
|
142
|
+
)
|
143
|
+
return True, "API key is valid"
|
144
|
+
except openai.AuthenticationError:
|
145
|
+
return False, "Invalid API key"
|
146
|
+
except openai.OpenAIError as e:
|
147
|
+
return False, f"OpenAI API error: {str(e)}"
|
148
|
+
except Exception as e:
|
149
|
+
return False, f"Connection error: {str(e)}"
|
zerottmm/cli.py
ADDED
@@ -0,0 +1,212 @@
|
|
1
|
+
"""Command line interface for ttmm.
|
2
|
+
|
3
|
+
This module exposes a ``main`` function that can be installed as a
|
4
|
+
console script entry point. It supports subcommands for indexing,
|
5
|
+
listing hotspots, resolving callers/callees, running traces and
|
6
|
+
answering questions.
|
7
|
+
"""
|
8
|
+
|
9
|
+
from __future__ import annotations
|
10
|
+
|
11
|
+
import argparse
|
12
|
+
import sys
|
13
|
+
from typing import List
|
14
|
+
|
15
|
+
from . import index, store, trace, search, gitingest
|
16
|
+
|
17
|
+
|
18
|
+
def do_index(args: argparse.Namespace) -> None:
|
19
|
+
repo_path = _resolve_repo_path(args.path)
|
20
|
+
if repo_path is None:
|
21
|
+
print(f"Failed to fetch repository: {args.path}")
|
22
|
+
sys.exit(1)
|
23
|
+
|
24
|
+
try:
|
25
|
+
index.index_repo(repo_path)
|
26
|
+
print(f"Indexed repository {args.path}")
|
27
|
+
finally:
|
28
|
+
# Clean up temp directory if it was a remote fetch
|
29
|
+
if repo_path != args.path and repo_path.startswith('/tmp'):
|
30
|
+
gitingest.cleanup_temp_repo(repo_path)
|
31
|
+
|
32
|
+
|
33
|
+
def do_hotspots(args: argparse.Namespace) -> None:
|
34
|
+
repo_path = _resolve_repo_path(args.path, temp_ok=False)
|
35
|
+
if repo_path is None:
|
36
|
+
print(f"Repository not found or not indexed: {args.path}")
|
37
|
+
sys.exit(1)
|
38
|
+
|
39
|
+
conn = store.connect(repo_path)
|
40
|
+
try:
|
41
|
+
rows = store.get_hotspots(conn, limit=args.limit)
|
42
|
+
if not rows:
|
43
|
+
print("No hotspot data found. Did you index the repository?")
|
44
|
+
return
|
45
|
+
for row in rows:
|
46
|
+
score = row["complexity"] * (1.0 + (row["churn"] or 0) ** 0.5)
|
47
|
+
complexity_info = f"complexity={row['complexity']:.1f}"
|
48
|
+
churn_info = f"churn={row['churn']:.3f}, score={score:.2f}"
|
49
|
+
print(
|
50
|
+
f"{row['qualname']} ({row['file_path']}:{row['lineno']}) – {complexity_info}, "
|
51
|
+
f"{churn_info}"
|
52
|
+
)
|
53
|
+
finally:
|
54
|
+
store.close(conn)
|
55
|
+
|
56
|
+
|
57
|
+
def do_callers(args: argparse.Namespace) -> None:
|
58
|
+
repo_path = _resolve_repo_path(args.path, temp_ok=False)
|
59
|
+
if repo_path is None:
|
60
|
+
print(f"Repository not found or not indexed: {args.path}")
|
61
|
+
sys.exit(1)
|
62
|
+
|
63
|
+
conn = store.connect(repo_path)
|
64
|
+
try:
|
65
|
+
sid = store.resolve_symbol(conn, args.symbol)
|
66
|
+
if sid is None:
|
67
|
+
print(f"Symbol '{args.symbol}' not found")
|
68
|
+
return
|
69
|
+
callers = store.get_callers(conn, sid)
|
70
|
+
if not callers:
|
71
|
+
print("No callers found.")
|
72
|
+
else:
|
73
|
+
for qualname, path in callers:
|
74
|
+
print(f"{qualname} ({path})")
|
75
|
+
finally:
|
76
|
+
store.close(conn)
|
77
|
+
|
78
|
+
|
79
|
+
def do_callees(args: argparse.Namespace) -> None:
|
80
|
+
repo_path = _resolve_repo_path(args.path, temp_ok=False)
|
81
|
+
if repo_path is None:
|
82
|
+
print(f"Repository not found or not indexed: {args.path}")
|
83
|
+
sys.exit(1)
|
84
|
+
|
85
|
+
conn = store.connect(repo_path)
|
86
|
+
try:
|
87
|
+
sid = store.resolve_symbol(conn, args.symbol)
|
88
|
+
if sid is None:
|
89
|
+
print(f"Symbol '{args.symbol}' not found")
|
90
|
+
return
|
91
|
+
callees = store.get_callees(conn, sid)
|
92
|
+
if not callees:
|
93
|
+
print("No callees found.")
|
94
|
+
else:
|
95
|
+
for name, path, unresolved in callees:
|
96
|
+
suffix = " (unresolved)" if unresolved else ""
|
97
|
+
loc = f" ({path})" if path else ""
|
98
|
+
print(f"{name}{loc}{suffix}")
|
99
|
+
finally:
|
100
|
+
store.close(conn)
|
101
|
+
|
102
|
+
|
103
|
+
def do_trace(args: argparse.Namespace) -> None:
|
104
|
+
repo_path = _resolve_repo_path(args.path, temp_ok=False)
|
105
|
+
if repo_path is None:
|
106
|
+
print(f"Repository not found or not indexed: {args.path}")
|
107
|
+
sys.exit(1)
|
108
|
+
|
109
|
+
# Flatten args after '--'
|
110
|
+
target_args: List[str] = args.args if hasattr(args, "args") else []
|
111
|
+
trace.run_tracing(repo_path, module=args.module, script=args.script, args=target_args)
|
112
|
+
print("Trace completed")
|
113
|
+
|
114
|
+
|
115
|
+
def do_answer(args: argparse.Namespace) -> None:
|
116
|
+
repo_path = _resolve_repo_path(args.path, temp_ok=False)
|
117
|
+
if repo_path is None:
|
118
|
+
print(f"Repository not found or not indexed: {args.path}")
|
119
|
+
sys.exit(1)
|
120
|
+
|
121
|
+
results = search.answer_question(repo_path, args.question, top=args.limit, include_scores=True)
|
122
|
+
if not results:
|
123
|
+
print("No relevant symbols found.")
|
124
|
+
else:
|
125
|
+
for qualname, path, lineno, score in results:
|
126
|
+
print(f"{qualname} ({path}:{lineno}) – score={score:.2f}")
|
127
|
+
|
128
|
+
|
129
|
+
def _resolve_repo_path(path_or_url: str, temp_ok: bool = True) -> str | None:
|
130
|
+
"""Resolve a path or URL to a local repository path.
|
131
|
+
|
132
|
+
For URLs, fetches the repository. For local paths, validates existence.
|
133
|
+
|
134
|
+
Parameters
|
135
|
+
----------
|
136
|
+
path_or_url : str
|
137
|
+
Local path, Git URL, or GitIngest URL
|
138
|
+
temp_ok : bool
|
139
|
+
Whether to allow temporary directory creation for remote repos
|
140
|
+
|
141
|
+
Returns
|
142
|
+
-------
|
143
|
+
str or None
|
144
|
+
Local path to repository, or None if resolution failed
|
145
|
+
"""
|
146
|
+
import os
|
147
|
+
|
148
|
+
# If it's a local path that exists, return it
|
149
|
+
if os.path.exists(path_or_url):
|
150
|
+
return os.path.abspath(path_or_url)
|
151
|
+
|
152
|
+
# If temp directories are not allowed, only work with local paths
|
153
|
+
if not temp_ok:
|
154
|
+
return None
|
155
|
+
|
156
|
+
# Try to fetch as a remote repository
|
157
|
+
return gitingest.fetch_repository(path_or_url)
|
158
|
+
|
159
|
+
|
160
|
+
def main(argv: List[str] | None = None) -> None:
|
161
|
+
parser = argparse.ArgumentParser(
|
162
|
+
prog="ttmm", description="Time‑to‑Mental‑Model code reading assistant"
|
163
|
+
)
|
164
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
165
|
+
# index
|
166
|
+
p_index = sub.add_parser("index", help="Index a Python repository")
|
167
|
+
p_index.add_argument(
|
168
|
+
"path", help="Path, Git URL, or GitIngest URL of repository"
|
169
|
+
)
|
170
|
+
p_index.set_defaults(func=do_index)
|
171
|
+
# hotspots
|
172
|
+
p_hot = sub.add_parser("hotspots", help="Show hottest functions/methods")
|
173
|
+
p_hot.add_argument("path", help="Path to repository")
|
174
|
+
p_hot.add_argument("--limit", type=int, default=10, help="Number of results to show")
|
175
|
+
p_hot.set_defaults(func=do_hotspots)
|
176
|
+
# callers
|
177
|
+
p_callers = sub.add_parser("callers", help="Show functions that call the given symbol")
|
178
|
+
p_callers.add_argument("path", help="Path to repository")
|
179
|
+
p_callers.add_argument("symbol", help="Fully qualified or simple symbol name")
|
180
|
+
p_callers.set_defaults(func=do_callers)
|
181
|
+
# callees
|
182
|
+
p_callees = sub.add_parser("callees", help="Show functions called by the given symbol")
|
183
|
+
p_callees.add_argument("path", help="Path to repository")
|
184
|
+
p_callees.add_argument("symbol", help="Fully qualified or simple symbol name")
|
185
|
+
p_callees.set_defaults(func=do_callees)
|
186
|
+
# trace
|
187
|
+
p_trace = sub.add_parser(
|
188
|
+
"trace", help="Trace runtime execution of a module function or script"
|
189
|
+
)
|
190
|
+
p_trace.add_argument("path", help="Path to repository")
|
191
|
+
group = p_trace.add_mutually_exclusive_group(required=True)
|
192
|
+
group.add_argument(
|
193
|
+
"--module", help="Module entry point in form pkg.module:func or pkg.module to run"
|
194
|
+
)
|
195
|
+
group.add_argument("--script", help="Relative path to a Python script to run")
|
196
|
+
p_trace.add_argument(
|
197
|
+
"args", nargs=argparse.REMAINDER,
|
198
|
+
help="Arguments passed to the module function or script"
|
199
|
+
)
|
200
|
+
p_trace.set_defaults(func=do_trace)
|
201
|
+
# answer
|
202
|
+
p_answer = sub.add_parser("answer", help="Answer a question about the codebase")
|
203
|
+
p_answer.add_argument("path", help="Path to repository")
|
204
|
+
p_answer.add_argument("question", help="Natural language question or keywords")
|
205
|
+
p_answer.add_argument("--limit", type=int, default=5, help="Number of answers to return")
|
206
|
+
p_answer.set_defaults(func=do_answer)
|
207
|
+
args = parser.parse_args(argv)
|
208
|
+
args.func(args)
|
209
|
+
|
210
|
+
|
211
|
+
if __name__ == "__main__":
|
212
|
+
main()
|
zerottmm/gitingest.py
ADDED
@@ -0,0 +1,219 @@
|
|
1
|
+
"""GitIngest integration for ttmm.
|
2
|
+
|
3
|
+
This module handles fetching repositories from GitIngest URLs and other
|
4
|
+
remote sources. It parses various Git hosting URLs (GitHub, GitLab, etc.)
|
5
|
+
and provides functionality to download and extract repositories for analysis.
|
6
|
+
"""
|
7
|
+
|
8
|
+
from __future__ import annotations
|
9
|
+
|
10
|
+
import os
|
11
|
+
import re
|
12
|
+
import tempfile
|
13
|
+
import shutil
|
14
|
+
import subprocess
|
15
|
+
from typing import Optional, Tuple
|
16
|
+
from urllib.parse import urlparse, parse_qs
|
17
|
+
|
18
|
+
|
19
|
+
def _is_git_url(url: str) -> bool:
|
20
|
+
"""Check if a string looks like a Git repository URL."""
|
21
|
+
git_patterns = [
|
22
|
+
r'https?://github\.com/[^/]+/[^/]+',
|
23
|
+
r'https?://gitlab\.com/[^/]+/[^/]+',
|
24
|
+
r'https?://bitbucket\.org/[^/]+/[^/]+',
|
25
|
+
r'git@github\.com:[^/]+/[^/]+\.git',
|
26
|
+
r'git@gitlab\.com:[^/]+/[^/]+\.git',
|
27
|
+
r'https?://.*\.git$',
|
28
|
+
]
|
29
|
+
return any(re.match(pattern, url) for pattern in git_patterns)
|
30
|
+
|
31
|
+
|
32
|
+
def _parse_gitingest_url(url: str) -> Optional[Tuple[str, Optional[str], Optional[str]]]:
|
33
|
+
"""Parse a GitIngest URL to extract repository info.
|
34
|
+
|
35
|
+
Returns (repo_url, branch, subpath) or None if not a GitIngest URL.
|
36
|
+
"""
|
37
|
+
if 'gitingest.com' not in url:
|
38
|
+
return None
|
39
|
+
|
40
|
+
parsed = urlparse(url)
|
41
|
+
if not parsed.query:
|
42
|
+
return None
|
43
|
+
|
44
|
+
query_params = parse_qs(parsed.query)
|
45
|
+
repo_url = query_params.get('url', [None])[0]
|
46
|
+
branch = query_params.get('branch', [None])[0]
|
47
|
+
subpath = query_params.get('subpath', [None])[0]
|
48
|
+
|
49
|
+
if repo_url:
|
50
|
+
return repo_url, branch, subpath
|
51
|
+
return None
|
52
|
+
|
53
|
+
|
54
|
+
def _normalize_repo_url(url: str) -> str:
|
55
|
+
"""Normalize a repository URL for cloning."""
|
56
|
+
# Convert SSH URLs to HTTPS for easier cloning
|
57
|
+
if url.startswith('git@'):
|
58
|
+
# git@github.com:owner/repo.git -> https://github.com/owner/repo.git
|
59
|
+
ssh_pattern = r'git@([^:]+):([^/]+)/([^/]+)(?:\.git)?'
|
60
|
+
match = re.match(ssh_pattern, url)
|
61
|
+
if match:
|
62
|
+
host, owner, repo = match.groups()
|
63
|
+
return f'https://{host}/{owner}/{repo}.git'
|
64
|
+
|
65
|
+
# Ensure .git suffix for HTTPS URLs
|
66
|
+
if not url.endswith('.git') and _is_git_url(url):
|
67
|
+
return url + '.git'
|
68
|
+
|
69
|
+
return url
|
70
|
+
|
71
|
+
|
72
|
+
def _clone_repository(
|
73
|
+
repo_url: str, target_dir: str, branch: Optional[str] = None, shallow: bool = True
|
74
|
+
) -> bool:
|
75
|
+
"""Clone a repository to the target directory.
|
76
|
+
|
77
|
+
Returns True if successful, False otherwise.
|
78
|
+
"""
|
79
|
+
try:
|
80
|
+
cmd = ['git', 'clone']
|
81
|
+
if shallow:
|
82
|
+
cmd.extend(['--depth', '1'])
|
83
|
+
if branch:
|
84
|
+
cmd.extend(['--branch', branch])
|
85
|
+
cmd.extend([repo_url, target_dir])
|
86
|
+
|
87
|
+
result = subprocess.run(
|
88
|
+
cmd, capture_output=True, text=True, timeout=300
|
89
|
+
)
|
90
|
+
return result.returncode == 0
|
91
|
+
except (
|
92
|
+
subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError
|
93
|
+
):
|
94
|
+
return False
|
95
|
+
|
96
|
+
|
97
|
+
def fetch_repository(url_or_path: str, target_dir: Optional[str] = None) -> Optional[str]:
|
98
|
+
"""Fetch a repository from a URL or return a local path.
|
99
|
+
|
100
|
+
Handles GitIngest URLs, direct Git URLs, and local paths.
|
101
|
+
|
102
|
+
Parameters
|
103
|
+
----------
|
104
|
+
url_or_path : str
|
105
|
+
GitIngest URL, Git repository URL, or local file path.
|
106
|
+
target_dir : str, optional
|
107
|
+
Directory to clone into. If None, uses a temporary directory.
|
108
|
+
|
109
|
+
Returns
|
110
|
+
-------
|
111
|
+
str or None
|
112
|
+
Path to the repository directory, or None if fetch failed.
|
113
|
+
"""
|
114
|
+
# If it's a local path, return as-is
|
115
|
+
if os.path.exists(url_or_path):
|
116
|
+
return os.path.abspath(url_or_path)
|
117
|
+
|
118
|
+
# Parse GitIngest URL
|
119
|
+
gitingest_info = _parse_gitingest_url(url_or_path)
|
120
|
+
if gitingest_info:
|
121
|
+
repo_url, branch, subpath = gitingest_info
|
122
|
+
if not repo_url:
|
123
|
+
return None
|
124
|
+
else:
|
125
|
+
# Check if it's a direct Git URL
|
126
|
+
if _is_git_url(url_or_path):
|
127
|
+
repo_url = url_or_path
|
128
|
+
branch = None
|
129
|
+
subpath = None
|
130
|
+
else:
|
131
|
+
return None
|
132
|
+
|
133
|
+
# Normalize the repository URL
|
134
|
+
repo_url = _normalize_repo_url(repo_url)
|
135
|
+
|
136
|
+
# Create target directory
|
137
|
+
if target_dir is None:
|
138
|
+
target_dir = tempfile.mkdtemp(prefix='ttmm_repo_')
|
139
|
+
else:
|
140
|
+
os.makedirs(target_dir, exist_ok=True)
|
141
|
+
|
142
|
+
# Clone the repository
|
143
|
+
clone_success = _clone_repository(repo_url, target_dir, branch)
|
144
|
+
if not clone_success:
|
145
|
+
if target_dir.startswith(tempfile.gettempdir()):
|
146
|
+
shutil.rmtree(target_dir, ignore_errors=True)
|
147
|
+
return None
|
148
|
+
|
149
|
+
# Handle subpath if specified
|
150
|
+
if subpath:
|
151
|
+
subpath_full = os.path.join(target_dir, subpath)
|
152
|
+
if os.path.exists(subpath_full):
|
153
|
+
return subpath_full
|
154
|
+
|
155
|
+
return target_dir
|
156
|
+
|
157
|
+
|
158
|
+
def cleanup_temp_repo(repo_path: str) -> None:
|
159
|
+
"""Clean up a temporary repository directory."""
|
160
|
+
if repo_path and repo_path.startswith(tempfile.gettempdir()):
|
161
|
+
shutil.rmtree(repo_path, ignore_errors=True)
|
162
|
+
|
163
|
+
|
164
|
+
def get_repo_info(repo_path: str) -> dict:
|
165
|
+
"""Extract basic information about a repository.
|
166
|
+
|
167
|
+
Returns a dictionary with repository metadata.
|
168
|
+
"""
|
169
|
+
info = {
|
170
|
+
'path': repo_path,
|
171
|
+
'is_git': False,
|
172
|
+
'remote_url': None,
|
173
|
+
'branch': None,
|
174
|
+
'commit': None,
|
175
|
+
}
|
176
|
+
|
177
|
+
# Check if it's a git repository
|
178
|
+
git_dir = os.path.join(repo_path, '.git')
|
179
|
+
if os.path.exists(git_dir):
|
180
|
+
info['is_git'] = True
|
181
|
+
|
182
|
+
try:
|
183
|
+
# Get remote URL
|
184
|
+
result = subprocess.run(
|
185
|
+
['git', 'remote', 'get-url', 'origin'],
|
186
|
+
cwd=repo_path,
|
187
|
+
capture_output=True,
|
188
|
+
text=True,
|
189
|
+
timeout=10
|
190
|
+
)
|
191
|
+
if result.returncode == 0:
|
192
|
+
info['remote_url'] = result.stdout.strip()
|
193
|
+
|
194
|
+
# Get current branch
|
195
|
+
result = subprocess.run(
|
196
|
+
['git', 'branch', '--show-current'],
|
197
|
+
cwd=repo_path,
|
198
|
+
capture_output=True,
|
199
|
+
text=True,
|
200
|
+
timeout=10
|
201
|
+
)
|
202
|
+
if result.returncode == 0:
|
203
|
+
info['branch'] = result.stdout.strip()
|
204
|
+
|
205
|
+
# Get current commit
|
206
|
+
result = subprocess.run(
|
207
|
+
['git', 'rev-parse', 'HEAD'],
|
208
|
+
cwd=repo_path,
|
209
|
+
capture_output=True,
|
210
|
+
text=True,
|
211
|
+
timeout=10
|
212
|
+
)
|
213
|
+
if result.returncode == 0:
|
214
|
+
info['commit'] = result.stdout.strip()[:8]
|
215
|
+
|
216
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
217
|
+
pass
|
218
|
+
|
219
|
+
return info
|