one-ctx 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.
- ctx/__init__.py +1 -0
- ctx/cli.py +159 -0
- ctx/database.py +292 -0
- ctx/git.py +106 -0
- ctx/llm.py +416 -0
- ctx/server.py +357 -0
- one_ctx-0.1.0.dist-info/METADATA +182 -0
- one_ctx-0.1.0.dist-info/RECORD +11 -0
- one_ctx-0.1.0.dist-info/WHEEL +4 -0
- one_ctx-0.1.0.dist-info/entry_points.txt +2 -0
- one_ctx-0.1.0.dist-info/licenses/LICENSE +21 -0
ctx/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""ctx — Combined Context MCP Server."""
|
ctx/cli.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
if sys.platform == 'win32':
|
|
3
|
+
import asyncio
|
|
4
|
+
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
|
5
|
+
import sys
|
|
6
|
+
import anyio
|
|
7
|
+
import click
|
|
8
|
+
from ctx.database import (
|
|
9
|
+
init_project, get_project, reset_project, list_projects,
|
|
10
|
+
search_projects, search_logs,
|
|
11
|
+
)
|
|
12
|
+
from ctx.server import mcp_server
|
|
13
|
+
from ctx.git import get_git_summary
|
|
14
|
+
import mcp.server.stdio
|
|
15
|
+
|
|
16
|
+
@click.group()
|
|
17
|
+
def cli():
|
|
18
|
+
"""ctx -- Combined Context MCP Server."""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
@cli.command()
|
|
22
|
+
@click.option("--port", default=7337, help="Port to run the server on")
|
|
23
|
+
@click.option("--host", default="0.0.0.0", help="Host to bind to")
|
|
24
|
+
def serve(port, host):
|
|
25
|
+
"""Start the HTTP/SSE server."""
|
|
26
|
+
import uvicorn
|
|
27
|
+
print(f"[ctx] server starting on http://{host}:{port}")
|
|
28
|
+
print(f" SSE endpoint: http://localhost:{port}/sse")
|
|
29
|
+
print(f" Messages endpoint: http://localhost:{port}/messages/")
|
|
30
|
+
print(f" Health check: http://localhost:{port}/health\n")
|
|
31
|
+
print("Add to your MCP config:")
|
|
32
|
+
print(f' {{"mcpServers": {{"ctx": {{"url": "http://localhost:{port}/sse"}}}}}}\n')
|
|
33
|
+
|
|
34
|
+
uvicorn.run("ctx.server:app", host=host, port=port, log_level="info")
|
|
35
|
+
|
|
36
|
+
@cli.command()
|
|
37
|
+
def stdio():
|
|
38
|
+
"""Start the stdio server (for direct VS Code / Cline / Codex integration)."""
|
|
39
|
+
async def run():
|
|
40
|
+
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
|
|
41
|
+
await mcp_server.run(
|
|
42
|
+
read_stream,
|
|
43
|
+
write_stream,
|
|
44
|
+
mcp_server.create_initialization_options()
|
|
45
|
+
)
|
|
46
|
+
anyio.run(run)
|
|
47
|
+
|
|
48
|
+
@cli.command()
|
|
49
|
+
@click.argument("project")
|
|
50
|
+
@click.option("--path", default="", help="Path to the project's git repo on disk")
|
|
51
|
+
def init(project, path):
|
|
52
|
+
"""Initialize a new project context."""
|
|
53
|
+
result = init_project(project, repo_path=path)
|
|
54
|
+
if "error" in result:
|
|
55
|
+
print(f"[!] {result['error']}")
|
|
56
|
+
return
|
|
57
|
+
print(f"[OK] Project '{project}' initialized.")
|
|
58
|
+
if path:
|
|
59
|
+
print(f" Git repo linked: {path}")
|
|
60
|
+
print(" Start any AI tool and call ctx_get to load context.")
|
|
61
|
+
|
|
62
|
+
@cli.command()
|
|
63
|
+
@click.argument("project", required=False)
|
|
64
|
+
def status(project):
|
|
65
|
+
"""View the context for a project, or list all projects."""
|
|
66
|
+
if not project:
|
|
67
|
+
projects = list_projects()
|
|
68
|
+
if not projects:
|
|
69
|
+
print("No projects found.")
|
|
70
|
+
return
|
|
71
|
+
print("=== Tracked Projects ===")
|
|
72
|
+
for p in projects:
|
|
73
|
+
print(f"- {p['project']} (last updated: {p['updated_at']})")
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
data = get_project(project)
|
|
77
|
+
if "error" in data:
|
|
78
|
+
print(f"Error: {data['error']}")
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
print(f"=== {project} ===")
|
|
82
|
+
print(f"\n[WHAT]\n{data.get('what', '(empty)') or '(empty)'}")
|
|
83
|
+
print(f"\n[DONE]\n{data.get('done', '(empty)') or '(empty)'}")
|
|
84
|
+
print(f"\n[NOW]\n{data.get('now', '(empty)') or '(empty)'}")
|
|
85
|
+
print(f"\n[MAP]\n{data.get('map', '(empty)') or '(no files mapped)'}")
|
|
86
|
+
|
|
87
|
+
# Show git info if repo_path is set
|
|
88
|
+
repo_path = data.get("repo_path", "")
|
|
89
|
+
if repo_path:
|
|
90
|
+
print(f"\n[GIT] repo: {repo_path}")
|
|
91
|
+
git_info = get_git_summary(repo_path)
|
|
92
|
+
if git_info:
|
|
93
|
+
print(f" Branch: {git_info['branch']}")
|
|
94
|
+
if git_info.get("recent_commits"):
|
|
95
|
+
print(f" Recent commits:")
|
|
96
|
+
for c in git_info["recent_commits"][:3]:
|
|
97
|
+
print(f" {c['hash']} {c['message']}")
|
|
98
|
+
changed = git_info.get("changed_files", {})
|
|
99
|
+
total_changes = len(changed.get("staged", [])) + len(changed.get("unstaged", [])) + len(changed.get("untracked", []))
|
|
100
|
+
if total_changes:
|
|
101
|
+
print(f" Changed files: {total_changes}")
|
|
102
|
+
else:
|
|
103
|
+
print(" (git not available or not a repo)")
|
|
104
|
+
|
|
105
|
+
@cli.command()
|
|
106
|
+
@click.argument("project")
|
|
107
|
+
def reset(project):
|
|
108
|
+
"""Reset a project's context back to empty."""
|
|
109
|
+
reset_project(project)
|
|
110
|
+
print(f"[OK] Project '{project}' has been reset to empty.")
|
|
111
|
+
|
|
112
|
+
@cli.command(name="list")
|
|
113
|
+
def list_cmd():
|
|
114
|
+
"""List all tracked projects."""
|
|
115
|
+
projects = list_projects()
|
|
116
|
+
if not projects:
|
|
117
|
+
print("No projects found.")
|
|
118
|
+
return
|
|
119
|
+
print("=== Tracked Projects ===")
|
|
120
|
+
for p in projects:
|
|
121
|
+
print(f"- {p['project']} (last updated: {p['updated_at']})")
|
|
122
|
+
|
|
123
|
+
@cli.command()
|
|
124
|
+
@click.argument("project")
|
|
125
|
+
def delete(project):
|
|
126
|
+
"""Permanently delete a project."""
|
|
127
|
+
from ctx.database import delete_project
|
|
128
|
+
result = delete_project(project)
|
|
129
|
+
if "error" in result:
|
|
130
|
+
print(f"[!] {result['error']}")
|
|
131
|
+
else:
|
|
132
|
+
print(f"[OK] Project '{project}' deleted.")
|
|
133
|
+
|
|
134
|
+
@cli.command()
|
|
135
|
+
@click.argument("query")
|
|
136
|
+
def search(query):
|
|
137
|
+
"""Search across all projects' context and history."""
|
|
138
|
+
project_matches = search_projects(query)
|
|
139
|
+
log_matches = search_logs(query)
|
|
140
|
+
|
|
141
|
+
if not project_matches and not log_matches:
|
|
142
|
+
print(f"No results found for '{query}'.")
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
if project_matches:
|
|
146
|
+
print(f"=== Projects matching '{query}' ===")
|
|
147
|
+
for m in project_matches:
|
|
148
|
+
buckets = ", ".join(m["matched_buckets"])
|
|
149
|
+
print(f"- {m['project']} (found in: {buckets})")
|
|
150
|
+
|
|
151
|
+
if log_matches:
|
|
152
|
+
print(f"\n=== History matching '{query}' ===")
|
|
153
|
+
for m in log_matches:
|
|
154
|
+
print(f"- [{m['project']}] [{m['tool_name']} @ {m['timestamp'][:16]}] {m['summary'][:100]}")
|
|
155
|
+
|
|
156
|
+
print(f"\nTotal: {len(project_matches)} projects, {len(log_matches)} history entries")
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
cli()
|
ctx/database.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""SQLite storage layer for ctx.
|
|
2
|
+
|
|
3
|
+
Stores project context in four buckets: WHAT, DONE, NOW, MAP.
|
|
4
|
+
Everything is local — one .db file per installation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sqlite3
|
|
8
|
+
import os
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Default DB location: ~/.ctx/ctx.db
|
|
15
|
+
DEFAULT_DB_DIR = Path.home() / ".ctx"
|
|
16
|
+
DEFAULT_DB_PATH = DEFAULT_DB_DIR / "ctx.db"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _get_db_path() -> Path:
|
|
20
|
+
"""Get the database path, respecting CTX_DB_PATH env var."""
|
|
21
|
+
custom = os.environ.get("CTX_DB_PATH")
|
|
22
|
+
if custom:
|
|
23
|
+
return Path(custom)
|
|
24
|
+
return DEFAULT_DB_PATH
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_connection() -> sqlite3.Connection:
|
|
28
|
+
"""Get a SQLite connection, creating the DB and tables if needed."""
|
|
29
|
+
db_path = _get_db_path()
|
|
30
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
|
|
32
|
+
conn = sqlite3.connect(str(db_path))
|
|
33
|
+
conn.row_factory = sqlite3.Row
|
|
34
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
35
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
36
|
+
|
|
37
|
+
conn.execute("""
|
|
38
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
39
|
+
name TEXT PRIMARY KEY,
|
|
40
|
+
what TEXT NOT NULL DEFAULT '',
|
|
41
|
+
done TEXT NOT NULL DEFAULT '',
|
|
42
|
+
now TEXT NOT NULL DEFAULT '',
|
|
43
|
+
map TEXT NOT NULL DEFAULT '',
|
|
44
|
+
repo_path TEXT NOT NULL DEFAULT '',
|
|
45
|
+
created_at TEXT NOT NULL,
|
|
46
|
+
updated_at TEXT NOT NULL
|
|
47
|
+
)
|
|
48
|
+
""")
|
|
49
|
+
|
|
50
|
+
conn.execute("""
|
|
51
|
+
CREATE TABLE IF NOT EXISTS update_log (
|
|
52
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
53
|
+
project TEXT NOT NULL,
|
|
54
|
+
tool_name TEXT NOT NULL DEFAULT 'unknown',
|
|
55
|
+
summary TEXT NOT NULL,
|
|
56
|
+
timestamp TEXT NOT NULL,
|
|
57
|
+
FOREIGN KEY (project) REFERENCES projects(name) ON DELETE CASCADE
|
|
58
|
+
)
|
|
59
|
+
""")
|
|
60
|
+
|
|
61
|
+
# --- Migration: add columns for existing databases ---
|
|
62
|
+
try:
|
|
63
|
+
conn.execute("SELECT map FROM projects LIMIT 1")
|
|
64
|
+
except sqlite3.OperationalError:
|
|
65
|
+
conn.execute("ALTER TABLE projects ADD COLUMN map TEXT NOT NULL DEFAULT ''")
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
conn.execute("SELECT repo_path FROM projects LIMIT 1")
|
|
69
|
+
except sqlite3.OperationalError:
|
|
70
|
+
conn.execute("ALTER TABLE projects ADD COLUMN repo_path TEXT NOT NULL DEFAULT ''")
|
|
71
|
+
|
|
72
|
+
conn.commit()
|
|
73
|
+
return conn
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def init_project(name: str, repo_path: str = "") -> dict:
|
|
77
|
+
"""Create a new project entry. Returns the project dict."""
|
|
78
|
+
conn = get_connection()
|
|
79
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
80
|
+
try:
|
|
81
|
+
conn.execute(
|
|
82
|
+
"INSERT INTO projects (name, repo_path, created_at, updated_at) VALUES (?, ?, ?, ?)",
|
|
83
|
+
(name, repo_path, now, now),
|
|
84
|
+
)
|
|
85
|
+
conn.commit()
|
|
86
|
+
except sqlite3.IntegrityError:
|
|
87
|
+
# If project exists and repo_path is being set, update it
|
|
88
|
+
if repo_path:
|
|
89
|
+
conn.execute(
|
|
90
|
+
"UPDATE projects SET repo_path = ?, updated_at = ? WHERE name = ?",
|
|
91
|
+
(repo_path, now, name),
|
|
92
|
+
)
|
|
93
|
+
conn.commit()
|
|
94
|
+
else:
|
|
95
|
+
conn.close()
|
|
96
|
+
return {"error": f"Project '{name}' already exists."}
|
|
97
|
+
result = get_project(name, conn)
|
|
98
|
+
conn.close()
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_project(name: str, conn: Optional[sqlite3.Connection] = None) -> dict:
|
|
103
|
+
"""Get the full context snapshot for a project."""
|
|
104
|
+
should_close = conn is None
|
|
105
|
+
if conn is None:
|
|
106
|
+
conn = get_connection()
|
|
107
|
+
|
|
108
|
+
row = conn.execute(
|
|
109
|
+
"SELECT * FROM projects WHERE name = ?", (name,)
|
|
110
|
+
).fetchone()
|
|
111
|
+
|
|
112
|
+
if should_close:
|
|
113
|
+
conn.close()
|
|
114
|
+
|
|
115
|
+
if row is None:
|
|
116
|
+
return {"error": f"Project '{name}' not found."}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
"project": row["name"],
|
|
120
|
+
"what": row["what"],
|
|
121
|
+
"done": row["done"],
|
|
122
|
+
"now": row["now"],
|
|
123
|
+
"map": row["map"],
|
|
124
|
+
"repo_path": row["repo_path"],
|
|
125
|
+
"created_at": row["created_at"],
|
|
126
|
+
"updated_at": row["updated_at"],
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def update_project(
|
|
131
|
+
name: str, what: str, done: str, now: str, map_: str,
|
|
132
|
+
tool_name: str, summary: str, repo_path: Optional[str] = None,
|
|
133
|
+
) -> dict:
|
|
134
|
+
"""Update a project's context buckets after merge."""
|
|
135
|
+
conn = get_connection()
|
|
136
|
+
ts = datetime.now(timezone.utc).isoformat()
|
|
137
|
+
|
|
138
|
+
if repo_path is not None:
|
|
139
|
+
cursor = conn.execute(
|
|
140
|
+
"""UPDATE projects
|
|
141
|
+
SET what = ?, done = ?, now = ?, map = ?, repo_path = ?, updated_at = ?
|
|
142
|
+
WHERE name = ?""",
|
|
143
|
+
(what, done, now, map_, repo_path, ts, name),
|
|
144
|
+
)
|
|
145
|
+
else:
|
|
146
|
+
cursor = conn.execute(
|
|
147
|
+
"""UPDATE projects
|
|
148
|
+
SET what = ?, done = ?, now = ?, map = ?, updated_at = ?
|
|
149
|
+
WHERE name = ?""",
|
|
150
|
+
(what, done, now, map_, ts, name),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
if cursor.rowcount == 0:
|
|
154
|
+
conn.close()
|
|
155
|
+
return {"error": f"Project '{name}' not found. Run `ctx init {name}` first."}
|
|
156
|
+
|
|
157
|
+
# Log the raw update for audit
|
|
158
|
+
conn.execute(
|
|
159
|
+
"INSERT INTO update_log (project, tool_name, summary, timestamp) VALUES (?, ?, ?, ?)",
|
|
160
|
+
(name, tool_name, summary, ts),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
conn.commit()
|
|
164
|
+
result = get_project(name, conn)
|
|
165
|
+
conn.close()
|
|
166
|
+
return result
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def update_project_map(name: str, map_content: str) -> dict:
|
|
170
|
+
"""Update only the MAP bucket for a project (for ctx_map tool)."""
|
|
171
|
+
conn = get_connection()
|
|
172
|
+
ts = datetime.now(timezone.utc).isoformat()
|
|
173
|
+
|
|
174
|
+
cursor = conn.execute(
|
|
175
|
+
"UPDATE projects SET map = ?, updated_at = ? WHERE name = ?",
|
|
176
|
+
(map_content, ts, name),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if cursor.rowcount == 0:
|
|
180
|
+
conn.close()
|
|
181
|
+
return {"error": f"Project '{name}' not found."}
|
|
182
|
+
|
|
183
|
+
conn.commit()
|
|
184
|
+
result = get_project(name, conn)
|
|
185
|
+
conn.close()
|
|
186
|
+
return result
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def reset_project(name: str) -> dict:
|
|
190
|
+
"""Wipe a project's context back to empty."""
|
|
191
|
+
conn = get_connection()
|
|
192
|
+
ts = datetime.now(timezone.utc).isoformat()
|
|
193
|
+
|
|
194
|
+
cursor = conn.execute(
|
|
195
|
+
"UPDATE projects SET what = '', done = '', now = '', map = '', updated_at = ? WHERE name = ?",
|
|
196
|
+
(ts, name),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
if cursor.rowcount == 0:
|
|
200
|
+
conn.close()
|
|
201
|
+
return {"error": f"Project '{name}' not found."}
|
|
202
|
+
|
|
203
|
+
# Also clear the log
|
|
204
|
+
conn.execute("DELETE FROM update_log WHERE project = ?", (name,))
|
|
205
|
+
conn.commit()
|
|
206
|
+
conn.close()
|
|
207
|
+
return {"status": "reset", "project": name}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def list_projects() -> list[dict]:
|
|
211
|
+
"""List all known projects with basic info."""
|
|
212
|
+
conn = get_connection()
|
|
213
|
+
rows = conn.execute(
|
|
214
|
+
"SELECT name, updated_at FROM projects ORDER BY updated_at DESC"
|
|
215
|
+
).fetchall()
|
|
216
|
+
conn.close()
|
|
217
|
+
return [{"project": r["name"], "updated_at": r["updated_at"]} for r in rows]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def delete_project(name: str) -> dict:
|
|
221
|
+
"""Permanently delete a project and its logs."""
|
|
222
|
+
conn = get_connection()
|
|
223
|
+
cursor = conn.execute("DELETE FROM projects WHERE name = ?", (name,))
|
|
224
|
+
if cursor.rowcount == 0:
|
|
225
|
+
conn.close()
|
|
226
|
+
return {"error": f"Project '{name}' not found."}
|
|
227
|
+
conn.commit()
|
|
228
|
+
conn.close()
|
|
229
|
+
return {"status": "deleted", "project": name}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
# Cross-project search
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
def search_projects(query: str) -> list[dict]:
|
|
237
|
+
"""Search across all projects' context buckets (what/done/now/map)."""
|
|
238
|
+
conn = get_connection()
|
|
239
|
+
pattern = f"%{query}%"
|
|
240
|
+
rows = conn.execute(
|
|
241
|
+
"""SELECT name, what, done, now, map, updated_at FROM projects
|
|
242
|
+
WHERE what LIKE ? OR done LIKE ? OR now LIKE ? OR map LIKE ?
|
|
243
|
+
ORDER BY updated_at DESC""",
|
|
244
|
+
(pattern, pattern, pattern, pattern),
|
|
245
|
+
).fetchall()
|
|
246
|
+
conn.close()
|
|
247
|
+
|
|
248
|
+
results = []
|
|
249
|
+
for r in rows:
|
|
250
|
+
# Build a list of which buckets matched
|
|
251
|
+
matches = []
|
|
252
|
+
q_lower = query.lower()
|
|
253
|
+
if q_lower in r["what"].lower():
|
|
254
|
+
matches.append("what")
|
|
255
|
+
if q_lower in r["done"].lower():
|
|
256
|
+
matches.append("done")
|
|
257
|
+
if q_lower in r["now"].lower():
|
|
258
|
+
matches.append("now")
|
|
259
|
+
if q_lower in r["map"].lower():
|
|
260
|
+
matches.append("map")
|
|
261
|
+
|
|
262
|
+
results.append({
|
|
263
|
+
"project": r["name"],
|
|
264
|
+
"matched_buckets": matches,
|
|
265
|
+
"updated_at": r["updated_at"],
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
return results
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def search_logs(query: str) -> list[dict]:
|
|
272
|
+
"""Search across all projects' update history."""
|
|
273
|
+
conn = get_connection()
|
|
274
|
+
pattern = f"%{query}%"
|
|
275
|
+
rows = conn.execute(
|
|
276
|
+
"""SELECT project, tool_name, summary, timestamp FROM update_log
|
|
277
|
+
WHERE summary LIKE ?
|
|
278
|
+
ORDER BY timestamp DESC
|
|
279
|
+
LIMIT 20""",
|
|
280
|
+
(pattern,),
|
|
281
|
+
).fetchall()
|
|
282
|
+
conn.close()
|
|
283
|
+
|
|
284
|
+
return [
|
|
285
|
+
{
|
|
286
|
+
"project": r["project"],
|
|
287
|
+
"tool_name": r["tool_name"],
|
|
288
|
+
"summary": r["summary"],
|
|
289
|
+
"timestamp": r["timestamp"],
|
|
290
|
+
}
|
|
291
|
+
for r in rows
|
|
292
|
+
]
|
ctx/git.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Git integration for ctx — Combined Context.
|
|
2
|
+
|
|
3
|
+
Detects branch, recent commits, and changed files for a project's repo.
|
|
4
|
+
All functions return None gracefully if git is unavailable or the path
|
|
5
|
+
is not a git repository. Zero dependencies — uses subprocess only.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _run_git(repo_path: str, *args: str, timeout: int = 5) -> Optional[str]:
|
|
14
|
+
"""Run a git command in the given repo, return stdout or None on failure."""
|
|
15
|
+
try:
|
|
16
|
+
result = subprocess.run(
|
|
17
|
+
["git", *args],
|
|
18
|
+
cwd=repo_path,
|
|
19
|
+
capture_output=True,
|
|
20
|
+
text=True,
|
|
21
|
+
timeout=timeout,
|
|
22
|
+
)
|
|
23
|
+
if result.returncode == 0:
|
|
24
|
+
return result.stdout.strip()
|
|
25
|
+
return None
|
|
26
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_git_repo(repo_path: str) -> bool:
|
|
31
|
+
"""Check if the given path is inside a git repository."""
|
|
32
|
+
return _run_git(repo_path, "rev-parse", "--git-dir") is not None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_branch(repo_path: str) -> Optional[str]:
|
|
36
|
+
"""Get the current branch name (e.g. 'main', 'feature-auth')."""
|
|
37
|
+
return _run_git(repo_path, "rev-parse", "--abbrev-ref", "HEAD")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_recent_commits(repo_path: str, n: int = 5) -> Optional[list[dict]]:
|
|
41
|
+
"""Get the last N commit messages with hash and author.
|
|
42
|
+
|
|
43
|
+
Returns a list of dicts: [{"hash": "abc123", "author": "name", "message": "..."}]
|
|
44
|
+
"""
|
|
45
|
+
output = _run_git(
|
|
46
|
+
repo_path, "log", f"-{n}",
|
|
47
|
+
"--format=%h|%an|%s",
|
|
48
|
+
"--no-merges",
|
|
49
|
+
)
|
|
50
|
+
if output is None:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
commits = []
|
|
54
|
+
for line in output.splitlines():
|
|
55
|
+
parts = line.split("|", 2)
|
|
56
|
+
if len(parts) == 3:
|
|
57
|
+
commits.append({
|
|
58
|
+
"hash": parts[0],
|
|
59
|
+
"author": parts[1],
|
|
60
|
+
"message": parts[2],
|
|
61
|
+
})
|
|
62
|
+
return commits
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_changed_files(repo_path: str) -> Optional[dict]:
|
|
66
|
+
"""Get uncommitted changes (staged + unstaged + untracked).
|
|
67
|
+
|
|
68
|
+
Returns: {"staged": [...], "unstaged": [...], "untracked": [...]}
|
|
69
|
+
"""
|
|
70
|
+
staged = _run_git(repo_path, "diff", "--cached", "--name-only")
|
|
71
|
+
unstaged = _run_git(repo_path, "diff", "--name-only")
|
|
72
|
+
untracked = _run_git(repo_path, "ls-files", "--others", "--exclude-standard")
|
|
73
|
+
|
|
74
|
+
if staged is None and unstaged is None and untracked is None:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
"staged": [f for f in (staged or "").splitlines() if f],
|
|
79
|
+
"unstaged": [f for f in (unstaged or "").splitlines() if f],
|
|
80
|
+
"untracked": [f for f in (untracked or "").splitlines() if f],
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_git_summary(repo_path: str) -> Optional[dict]:
|
|
85
|
+
"""Get a complete git status summary for a repo.
|
|
86
|
+
|
|
87
|
+
Returns None if the path is not a git repo.
|
|
88
|
+
Otherwise returns: {
|
|
89
|
+
"branch": "main",
|
|
90
|
+
"recent_commits": [...],
|
|
91
|
+
"changed_files": {...},
|
|
92
|
+
}
|
|
93
|
+
"""
|
|
94
|
+
if not repo_path or not Path(repo_path).exists():
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
if not is_git_repo(repo_path):
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
"branch": get_branch(repo_path) or "unknown",
|
|
102
|
+
"recent_commits": get_recent_commits(repo_path, n=5) or [],
|
|
103
|
+
"changed_files": get_changed_files(repo_path) or {
|
|
104
|
+
"staged": [], "unstaged": [], "untracked": []
|
|
105
|
+
},
|
|
106
|
+
}
|