code-context-control 2.34.0__py3-none-any.whl → 2.35.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.
- cli/c3.py +1 -1
- cli/mcp_server.py +4 -3
- cli/tools/edits.py +7 -2
- {code_context_control-2.34.0.dist-info → code_context_control-2.35.0.dist-info}/METADATA +2 -2
- {code_context_control-2.34.0.dist-info → code_context_control-2.35.0.dist-info}/RECORD +15 -14
- services/agents.py +145 -0
- services/context_snapshot.py +37 -0
- services/edit_ledger.py +24 -26
- services/git_context.py +243 -0
- services/session_manager.py +17 -0
- services/version_tracker.py +8 -16
- {code_context_control-2.34.0.dist-info → code_context_control-2.35.0.dist-info}/WHEEL +0 -0
- {code_context_control-2.34.0.dist-info → code_context_control-2.35.0.dist-info}/entry_points.txt +0 -0
- {code_context_control-2.34.0.dist-info → code_context_control-2.35.0.dist-info}/licenses/LICENSE +0 -0
- {code_context_control-2.34.0.dist-info → code_context_control-2.35.0.dist-info}/top_level.txt +0 -0
cli/c3.py
CHANGED
cli/mcp_server.py
CHANGED
|
@@ -604,9 +604,10 @@ async def c3_edit(file_path: str, old_string: str = "", new_string: str = "",
|
|
|
604
604
|
async def c3_edits(action: str, file: str = "", change_type: str = "modified",
|
|
605
605
|
summary: str = "", lines_changed: str = "", tags: str = "",
|
|
606
606
|
limit: int = 50, since: str = "", edit_id: str = "",
|
|
607
|
-
tag: str = "", ctx: Context = None) -> str:
|
|
607
|
+
tag: str = "", branch: str = "", ctx: Context = None) -> str:
|
|
608
608
|
"""EDIT HISTORY — inspect the ledger. Different from c3_edit (which writes); this one reads.
|
|
609
|
-
actions: log (append entry), history (recent edits), versions (per-file), stats, tag (mark edit_id).
|
|
609
|
+
actions: log (append entry), history (recent edits), versions (per-file), stats, tag (mark edit_id).
|
|
610
|
+
branch: filter history to edits stamped with a given git branch."""
|
|
610
611
|
svc = _svc(ctx)
|
|
611
612
|
|
|
612
613
|
def finalize(name, args, resp, summ, **kw):
|
|
@@ -615,7 +616,7 @@ async def c3_edits(action: str, file: str = "", change_type: str = "modified",
|
|
|
615
616
|
from cli.tools.edits import handle_edits
|
|
616
617
|
return await asyncio.to_thread(handle_edits, action, file, change_type, summary,
|
|
617
618
|
lines_changed, tags, limit, since, edit_id, tag,
|
|
618
|
-
svc, finalize)
|
|
619
|
+
svc, finalize, branch)
|
|
619
620
|
|
|
620
621
|
|
|
621
622
|
@mcp.tool()
|
cli/tools/edits.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
def handle_edits(action: str, file: str, change_type: str, summary: str,
|
|
5
5
|
lines_changed: str, tags: str, limit: int, since: str,
|
|
6
|
-
edit_id: str, tag: str, svc, finalize) -> str:
|
|
6
|
+
edit_id: str, tag: str, svc, finalize, branch: str = "") -> str:
|
|
7
7
|
"""Route c3_edits actions."""
|
|
8
8
|
ledger = svc.edit_ledger
|
|
9
9
|
if ledger is None:
|
|
@@ -64,12 +64,17 @@ def handle_edits(action: str, file: str, change_type: str, summary: str,
|
|
|
64
64
|
file=file or None,
|
|
65
65
|
limit=limit or 50,
|
|
66
66
|
since=since or None,
|
|
67
|
+
branch=branch or None,
|
|
67
68
|
)
|
|
68
69
|
if not entries:
|
|
69
70
|
return finalize("c3_edits", {"action": "history"}, "No edits found", "0 edits")
|
|
70
|
-
|
|
71
|
+
scope = (f" for {file}" if file else "") + (f" on {branch}" if branch else "")
|
|
72
|
+
lines = [f"[edits:history] {len(entries)} entries" + scope]
|
|
71
73
|
for e in entries:
|
|
72
74
|
ln = f" {e['timestamp'][:19]} | {e['file']} {e['version']} | {e['change_type']} | {e['summary']}"
|
|
75
|
+
br = (e.get("git") or {}).get("branch")
|
|
76
|
+
if br:
|
|
77
|
+
ln += f" @{br}"
|
|
73
78
|
if e.get("tags"):
|
|
74
79
|
ln += f" [{','.join(e['tags'])}]"
|
|
75
80
|
lines.append(ln)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: code-context-control
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.35.0
|
|
4
4
|
Summary: Local code-intelligence layer for AI coding tools (Claude Code, Codex, Gemini, Copilot). Retrieve less, read less, edit safer.
|
|
5
5
|
Author-email: Dimitri Tselenchuk <dtselenc@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -245,7 +245,7 @@ C3 exposes 16 tools as a native MCP server. Your IDE calls them directly:
|
|
|
245
245
|
| `c3_impact` | Blast-radius analysis before edits to shared symbols |
|
|
246
246
|
| `c3_delegate` | Offload heavy work to local Ollama / Codex / Gemini / etc. |
|
|
247
247
|
| `c3_agent` | Multi-step agentic workflows (review, investigate, refactor) |
|
|
248
|
-
| `c3_edits` | Edit-ledger queries + version diffs + restore points |
|
|
248
|
+
| `c3_edits` | Edit-ledger queries + version diffs + restore points + per-branch filter |
|
|
249
249
|
| `c3_bitbucket` | Bitbucket Data Center integration — PRs, branches, builds, repo admin (v2.30.0) |
|
|
250
250
|
| `c3_project` | Cross-project — discover & operate on other c3-installed projects; guarded writes (v2.31.0) |
|
|
251
251
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
cli/__init__.py,sha256=ec66drCZGNMRU4V6ov0zVhYZph1us12Vn8OvG_LJyRY,22
|
|
2
2
|
cli/_hook_utils.py,sha256=1_hTA-Wz62xB8jnSAH4C5TfCkrwEP0g2kq_-oRfQLm4,3724
|
|
3
|
-
cli/c3.py,sha256=
|
|
3
|
+
cli/c3.py,sha256=lHmkT56LCHB7_NRNRXjqP8kXUWuzgfTKN9vevA7uj6M,289101
|
|
4
4
|
cli/docs.html,sha256=JgtBFUuUkvmYowPREYiGhhcRbB5e2UjkRc00MIF0hsU,143653
|
|
5
5
|
cli/edits.html,sha256=UjAhoCmBmQ89cklGvJqzC6eyNP2tc8H6T-e01DVkLvE,43418
|
|
6
6
|
cli/hook_auto_snapshot.py,sha256=amtliVDzKUQr6KBR0pdBA8vXghAV-gKr19jBaJVnP_w,5006
|
|
@@ -17,7 +17,7 @@ cli/hook_terse_advisor.py,sha256=pD7Bap7OYOKqtYz7cX8nWSRLH7ook-tSD2Ov2MNp_sA,590
|
|
|
17
17
|
cli/hub.html,sha256=Hl-XPZGT1mMiKrbX9c5OsEw6mXEumwIB3vp1WlWaplM,183966
|
|
18
18
|
cli/hub_server.py,sha256=L7M3PAQNiRFyqdgL0COXIzIo9lyJTaCZSk-K1I2kZtM,59725
|
|
19
19
|
cli/mcp_proxy.py,sha256=92htuT-p0j-cDTbyqlIJpGoQ85_Aw7UuB8L_Toi_u20,17511
|
|
20
|
-
cli/mcp_server.py,sha256=
|
|
20
|
+
cli/mcp_server.py,sha256=iYUB6rfGjNuiNQP-GecuXyMVa1CEihtu4dV7PhRHWqg,32549
|
|
21
21
|
cli/server.py,sha256=n8CNh444AGuYMnVSSiRK9pirGh84Ap7ZTI8wuRrJCX8,119970
|
|
22
22
|
cli/ui.html,sha256=xcdt74nlFEXx-0Bx6-Okw-WSVZPAXL0iukxU0ytI6CA,5694
|
|
23
23
|
cli/ui_legacy.html,sha256=cI8tC6RKmE2NIJOcsu7CY-zT4VznjcbD6NTjxb_fvUY,378460
|
|
@@ -32,7 +32,7 @@ cli/tools/bitbucket.py,sha256=MBVMnREDhJiUal43cPqLUUPWyS8AGp3v3rNZ291Vkrg,27271
|
|
|
32
32
|
cli/tools/compress.py,sha256=hgBQ6jUwvfGRmcC53vJBz_bbGD0E7T85IxD4q53rj4E,8941
|
|
33
33
|
cli/tools/delegate.py,sha256=zOpa03znY27_y1u7T7wbfgQXPwCDA409z9dJ2ki4esU,46947
|
|
34
34
|
cli/tools/edit.py,sha256=fVIZzBPe3ixKBxcZFU03ur2XKu9rAlBihga2-tmIuWw,13791
|
|
35
|
-
cli/tools/edits.py,sha256
|
|
35
|
+
cli/tools/edits.py,sha256=8zM01TzLmjm7ULQlCmXOmitlJd84zQHVzE0z7UHJUdA,5520
|
|
36
36
|
cli/tools/filter.py,sha256=IEjBdKrHxYVCm4cP0Ao6WZkKhbIBi1uI-mLY627zEUw,11503
|
|
37
37
|
cli/tools/impact.py,sha256=jjWkFTxHu-gBpZZNd2HTdBl22itA6-wwwOZXxk_qBl8,6257
|
|
38
38
|
cli/tools/memory.py,sha256=OdYBcIEFo4sr5aCG0_uO48uyJ-Kzof7LC2Ou1THGFuc,23317
|
|
@@ -57,7 +57,7 @@ cli/ui/components/memory.js,sha256=v5IsHTxLHpXX4xCsUaZ_UPprZEabdgP4jiWc298iV2U,2
|
|
|
57
57
|
cli/ui/components/sessions.js,sha256=FIKtil76B8tCkAmcFV7hlj6GQ_DCJK2jCzvEmdK7NBE,30837
|
|
58
58
|
cli/ui/components/settings.js,sha256=8LVTV2TQl9tcRXhXbtBEJOCBdiyk-x2QASoVYZUAuEA,71442
|
|
59
59
|
cli/ui/components/sidebar.js,sha256=cAY_jwYB-o1X_wWn__VXlG4IegVObuE3NmVsuFWqxtg,7417
|
|
60
|
-
code_context_control-2.
|
|
60
|
+
code_context_control-2.35.0.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
|
|
61
61
|
core/__init__.py,sha256=TSDCEcM4V7gcZVM3w2ykJaqEUch4Dkon-rivV17T73s,2501
|
|
62
62
|
core/config.py,sha256=0RBVni99wqJIxAYU6uweWVOmdI-FJvQ8d3IV5Mp1Muc,12818
|
|
63
63
|
core/ide.py,sha256=9LzsDVK2LL8RVpL40l6oNGiasZ3D8OCU_9i9A0gJKBo,6876
|
|
@@ -87,23 +87,24 @@ oracle/services/tool_registry.py,sha256=V7eP-UeacN3T4We_zbJ8NdFCFCKJVSKJa4zfH02L
|
|
|
87
87
|
services/__init__.py,sha256=3Kn4cZweLm7at8wFdBdZ-Zwo8hHcnVIsmY5f29nzi2Y,116
|
|
88
88
|
services/activity_log.py,sha256=YsW8-HBQEFh2vYTlvnzK7doNsR-XEtBbWXJ-324XigU,3370
|
|
89
89
|
services/agent_base.py,sha256=a-gdSd_jtZtbjXo1WS8CnWCagXgKaGZd5ShcG6s0kT4,4809
|
|
90
|
-
services/agents.py,sha256=
|
|
90
|
+
services/agents.py,sha256=iDYqT4iY4Q_KmWYWGKIjp7F1A_F6so9l6n59Qk7mHmI,73275
|
|
91
91
|
services/auto_memory.py,sha256=v__ZS1e68533_Yv491mZtvuZnheC63q6_uTvWhBw3Lw,14290
|
|
92
92
|
services/benchmark_dashboard.py,sha256=iR-DnqnoKbqHMJ4d-ZkIvJBYfzwTa7r-jzO6j2BYDfQ,27711
|
|
93
93
|
services/bitbucket_client.py,sha256=v8xGEcnIEmURvcg38XwmiCGh7-_QnjhAJEb0te_yZzQ,16107
|
|
94
94
|
services/bitbucket_credentials.py,sha256=2qLA9pQMol4y95y4DJMNBsBBPUsJQCKbLFo2iiCnfvI,7364
|
|
95
95
|
services/claude_md.py,sha256=iL0vUQw-5lxSQehNPvhlkUmcGPeMSCcZqP4OYG_qoYk,35092
|
|
96
96
|
services/compressor.py,sha256=uSVyTYfvxFrRYupzyKj-HzkBP0RwARrGYFz_DnMSEaM,25169
|
|
97
|
-
services/context_snapshot.py,sha256=
|
|
97
|
+
services/context_snapshot.py,sha256=s_klEr1SJYM9u-anMmnoemsYuIF_KUWBjz1zUo0wPgU,15662
|
|
98
98
|
services/conversation_store.py,sha256=vPiMiKAE22RCBSSphgGH9Vx-lPV45SmttOwgVVWahL4,33398
|
|
99
99
|
services/doc_index.py,sha256=kYcE_lQgjgG7CRmqN3Byx7MNmz1JCfm8QsrjH3u7OUI,18614
|
|
100
100
|
services/e2e_benchmark.py,sha256=kHZnatL27pHT1cC1DFW3SY0B6mNpIFdVns7au48Rp04,132867
|
|
101
101
|
services/e2e_evaluator.py,sha256=WRIPu6b1SrSQpHESCnIWrC1wsRp1v4UGaaNcFD1KZ5c,17672
|
|
102
102
|
services/e2e_tasks.py,sha256=Ln5VbGDIcS3NY3aml5ERbgjfjLxEslmRZ8AyaYxpWEo,34524
|
|
103
|
-
services/edit_ledger.py,sha256=
|
|
103
|
+
services/edit_ledger.py,sha256=R0eUA2jOXY_RUuCMT52wA4hTbgaeuYbGwy_xSMxS64k,18027
|
|
104
104
|
services/embedding_index.py,sha256=ZccqCH5WWQnqAmPtO1PB5W2N7OzRZARrctDBAtLgPBg,12769
|
|
105
105
|
services/error_reporting.py,sha256=HZ3ru8i5RLf8nq2R4iRnTs5sm1blUxknSbv5hdxuxs0,4139
|
|
106
106
|
services/file_memory.py,sha256=GnEbwdWE7TUKUR4PpSgHV7cnLys3Fa2bsLvc-XwuFgI,29188
|
|
107
|
+
services/git_context.py,sha256=lhuIvGDBUTKOPqye-olgJfjv538t-jtRZLBAcs7iVoA,9506
|
|
107
108
|
services/hub_service.py,sha256=Ta2ExJP1sePxb7zcHooroYXJKsylm5Ea8vQvts6-cAw,21876
|
|
108
109
|
services/indexer.py,sha256=ZceGqvd1OheN-hvSg4jjjXNcFjCSCgswKf5DmI-xaqA,27044
|
|
109
110
|
services/memory.py,sha256=uH3hWWUCy8p_0hVuJq-pzp2F5qNRLcVOpaFYmap2VFY,12458
|
|
@@ -124,14 +125,14 @@ services/retrieval_broker.py,sha256=9X67VZ_6AkbAzopHuuMFKmP4CGZLnW576kjSKMenBnw,
|
|
|
124
125
|
services/router.py,sha256=Cz10nx2fKTbaGn14mSBePWIDrw5rdcs_1JFYXeik084,15626
|
|
125
126
|
services/runtime.py,sha256=SOUizCDW1FFTDCoaZ1Njozjp25Bhah7lR1f0WYscaw0,11361
|
|
126
127
|
services/session_benchmark.py,sha256=qw_vtDim1hvFdM8Me5EsgU9pTuJhzRjQmh6m7DDnXWY,98989
|
|
127
|
-
services/session_manager.py,sha256=
|
|
128
|
+
services/session_manager.py,sha256=Px7RpTS6zDSuxj2O87o-7tkR8l-faMZxBX1gd5RLHfo,43837
|
|
128
129
|
services/session_preloader.py,sha256=DsTAXMKVtrX9yu1sEFojYDi9-jkSAj1Ylt9JTy57Dow,9883
|
|
129
130
|
services/text_index.py,sha256=r3o4CobTG9jAO9PWazgbWYLY9oi_FgEJ3xwEXrF4KM0,2783
|
|
130
131
|
services/tool_classifier.py,sha256=Fgvq0ZcpnCskwtO8a3YI1MiecPNnw6UbPyJQIUwgfiQ,6512
|
|
131
132
|
services/transcript_index.py,sha256=VQhvgkSyLVEYamXi_YIbiIhBnd0mqFHZlWG2HQu_1EA,12144
|
|
132
133
|
services/validation_cache.py,sha256=skFYR7CkSGvFb2gq6dfxOnvjQzz5Boa3jJWT7UfaIhY,5587
|
|
133
134
|
services/vector_store.py,sha256=o1RdZgRegFWlrr_kgSrY243W4KURmo4gvnP7vrxa2DE,11425
|
|
134
|
-
services/version_tracker.py,sha256=
|
|
135
|
+
services/version_tracker.py,sha256=8yQAcZuJjzq1Efib828FMOjQNIcVQvPQC5KksbWLSUw,9880
|
|
135
136
|
services/watcher.py,sha256=TT8dvUHw1z7Uc2KCbyynkkN4luns6qxsicc2_cj9PM8,6856
|
|
136
137
|
services/bench/__init__.py,sha256=WLEJIWJeaUj6FnH2nDO1qWugJDKfOKeM6WvKLSreYjI,231
|
|
137
138
|
services/bench/external/__init__.py,sha256=XgPS99ztx9igMd6x-1bLykcaePGXctkb6ujQ1MLgKAs,608
|
|
@@ -155,8 +156,8 @@ tui/screens/search_view.py,sha256=MMHjVdlk3HZSuDBSvq8IGrqv_Mh5Us6YqXQ80bcWSMk,19
|
|
|
155
156
|
tui/screens/session_view.py,sha256=eZ1eDwHTvPOck1wCCviixtOaCxIkBT_95ytNNNriGNA,5991
|
|
156
157
|
tui/screens/stats.py,sha256=p81PjzdaIv7hllb8f45-rlVe4lJZwSdIMqu7e86_u5s,6223
|
|
157
158
|
tui/screens/ui_view.py,sha256=1QJCgLh2YfgWIpvzRG1KOGXYEaOYX6ojN61Azjf2oX0,2125
|
|
158
|
-
code_context_control-2.
|
|
159
|
-
code_context_control-2.
|
|
160
|
-
code_context_control-2.
|
|
161
|
-
code_context_control-2.
|
|
162
|
-
code_context_control-2.
|
|
159
|
+
code_context_control-2.35.0.dist-info/METADATA,sha256=BGKYwuZVthBqtj3BfoOXk7OvsfXi10ys2RX03fu90gw,19822
|
|
160
|
+
code_context_control-2.35.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
161
|
+
code_context_control-2.35.0.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
|
|
162
|
+
code_context_control-2.35.0.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
|
|
163
|
+
code_context_control-2.35.0.dist-info/RECORD,,
|
services/agents.py
CHANGED
|
@@ -11,6 +11,7 @@ from collections import Counter
|
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
|
|
13
13
|
from services.agent_base import BackgroundAgent # noqa: F401 — re-exported for consumers
|
|
14
|
+
from services.git_context import GitContext
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
class IndexStalenessAgent(BackgroundAgent):
|
|
@@ -922,6 +923,137 @@ class FileMemoryAgent(BackgroundAgent):
|
|
|
922
923
|
return status
|
|
923
924
|
|
|
924
925
|
|
|
926
|
+
class BranchWatchAgent(BackgroundAgent):
|
|
927
|
+
"""Detects branch / HEAD changes and queues a scoped, targeted re-index.
|
|
928
|
+
|
|
929
|
+
Change detection keys on the HEAD sha, so it fires on checkout, switch,
|
|
930
|
+
pull, and merge — but NOT on ``git fetch`` (which only moves remote refs;
|
|
931
|
+
the working tree is untouched). On a move it queues exactly the files that
|
|
932
|
+
differ between the old and new HEAD (from ``git diff``), restricted to files
|
|
933
|
+
C3 already tracks and that genuinely need re-indexing, then notifies
|
|
934
|
+
(warning on a branch switch, info on a same-branch HEAD move).
|
|
935
|
+
|
|
936
|
+
Every cycle it also queues tracked files that are dirty on disk, catching
|
|
937
|
+
edits made outside C3 (rebase, ``git restore``, another editor) that the
|
|
938
|
+
lazy mtime-on-access path would only notice later. The actual re-extraction
|
|
939
|
+
is done by FileMemoryAgent, which drains the same queue.
|
|
940
|
+
"""
|
|
941
|
+
|
|
942
|
+
def __init__(self, file_memory, notifications, project_path,
|
|
943
|
+
enabled=True, interval=30, max_queue=200, **kwargs):
|
|
944
|
+
super().__init__("BranchWatch", interval, notifications, enabled, **kwargs)
|
|
945
|
+
self.file_memory = file_memory
|
|
946
|
+
self.project_path = project_path
|
|
947
|
+
self.max_queue = max_queue
|
|
948
|
+
self._git = GitContext(project_path)
|
|
949
|
+
self._state_path = Path(project_path) / ".c3" / "branch_state.json"
|
|
950
|
+
self._loaded = False
|
|
951
|
+
self._last = {"branch": None, "head_sha": ""}
|
|
952
|
+
|
|
953
|
+
def _load_state(self):
|
|
954
|
+
if self._loaded:
|
|
955
|
+
return
|
|
956
|
+
self._loaded = True
|
|
957
|
+
try:
|
|
958
|
+
if self._state_path.exists():
|
|
959
|
+
data = json.loads(self._state_path.read_text(encoding="utf-8"))
|
|
960
|
+
self._last = {"branch": data.get("branch"),
|
|
961
|
+
"head_sha": data.get("head_sha", "")}
|
|
962
|
+
except Exception:
|
|
963
|
+
pass
|
|
964
|
+
|
|
965
|
+
def _save_state(self, branch, head_sha):
|
|
966
|
+
self._last = {"branch": branch, "head_sha": head_sha}
|
|
967
|
+
try:
|
|
968
|
+
self._state_path.write_text(
|
|
969
|
+
json.dumps({"branch": branch, "head_sha": head_sha}),
|
|
970
|
+
encoding="utf-8",
|
|
971
|
+
)
|
|
972
|
+
except Exception:
|
|
973
|
+
pass
|
|
974
|
+
|
|
975
|
+
def _queue_changed(self, paths) -> int:
|
|
976
|
+
"""Queue tracked files that still need re-indexing; return count queued.
|
|
977
|
+
|
|
978
|
+
Paths are compared slash-agnostically against the file-memory store and
|
|
979
|
+
filtered through ``needs_update`` so unchanged files (e.g. after a local
|
|
980
|
+
commit where the working tree already matches) are not re-processed.
|
|
981
|
+
"""
|
|
982
|
+
if not paths:
|
|
983
|
+
return 0
|
|
984
|
+
tracked = {t.replace("\\", "/"): t for t in self.file_memory.list_tracked()}
|
|
985
|
+
queued = 0
|
|
986
|
+
for p in paths:
|
|
987
|
+
canonical = tracked.get(p.replace("\\", "/"))
|
|
988
|
+
if canonical is None:
|
|
989
|
+
continue
|
|
990
|
+
try:
|
|
991
|
+
if not self.file_memory.needs_update(canonical):
|
|
992
|
+
continue
|
|
993
|
+
except Exception:
|
|
994
|
+
pass
|
|
995
|
+
self.file_memory.queue_for_update(canonical)
|
|
996
|
+
queued += 1
|
|
997
|
+
if queued >= self.max_queue:
|
|
998
|
+
break
|
|
999
|
+
return queued
|
|
1000
|
+
|
|
1001
|
+
def check(self):
|
|
1002
|
+
st = self._git.state(force=True)
|
|
1003
|
+
if not st.get("available"):
|
|
1004
|
+
return False # no git here — idle backoff
|
|
1005
|
+
self._load_state()
|
|
1006
|
+
cur_branch = st.get("branch")
|
|
1007
|
+
cur_head = st.get("head_sha", "")
|
|
1008
|
+
if not cur_head:
|
|
1009
|
+
return False
|
|
1010
|
+
prev_branch = self._last.get("branch")
|
|
1011
|
+
prev_head = self._last.get("head_sha", "")
|
|
1012
|
+
|
|
1013
|
+
# Catch out-of-band working-tree edits every cycle.
|
|
1014
|
+
dirty_queued = self._queue_changed(self._git.dirty_files())
|
|
1015
|
+
|
|
1016
|
+
# First run for this project — record the baseline without notifying.
|
|
1017
|
+
if not prev_head:
|
|
1018
|
+
self._save_state(cur_branch, cur_head)
|
|
1019
|
+
return False if dirty_queued == 0 else None
|
|
1020
|
+
|
|
1021
|
+
if cur_head == prev_head and cur_branch == prev_branch:
|
|
1022
|
+
return False if dirty_queued == 0 else None
|
|
1023
|
+
|
|
1024
|
+
# HEAD or branch moved — scope the re-index to what actually differs.
|
|
1025
|
+
changed = self._git.changed_files(prev_head, cur_head)
|
|
1026
|
+
if not changed:
|
|
1027
|
+
# Old commit unreachable / diff failed — fall back to dirty set.
|
|
1028
|
+
changed = self._git.dirty_files()
|
|
1029
|
+
queued = self._queue_changed(changed)
|
|
1030
|
+
|
|
1031
|
+
switched = (cur_branch != prev_branch)
|
|
1032
|
+
self._save_state(cur_branch, cur_head)
|
|
1033
|
+
|
|
1034
|
+
new_label = cur_branch or "(detached)"
|
|
1035
|
+
if switched:
|
|
1036
|
+
old_label = prev_branch or (prev_head[:8] if prev_head else "?")
|
|
1037
|
+
self.notify(
|
|
1038
|
+
"warning", "Branch changed",
|
|
1039
|
+
f"{old_label} → {new_label}; queued {queued} tracked file(s) for re-index",
|
|
1040
|
+
replace_if_unacked=True,
|
|
1041
|
+
)
|
|
1042
|
+
else:
|
|
1043
|
+
self.notify(
|
|
1044
|
+
"info", "Index refresh",
|
|
1045
|
+
f"HEAD {prev_head[:8]}→{cur_head[:8]} on {new_label}; "
|
|
1046
|
+
f"queued {queued} file(s) for re-index",
|
|
1047
|
+
replace_if_unacked=True,
|
|
1048
|
+
)
|
|
1049
|
+
return None
|
|
1050
|
+
|
|
1051
|
+
def get_status(self) -> dict:
|
|
1052
|
+
status = super().get_status()
|
|
1053
|
+
status["branch"] = self._git.label()
|
|
1054
|
+
return status
|
|
1055
|
+
|
|
1056
|
+
|
|
925
1057
|
class AutonomyPlannerAgent(BackgroundAgent):
|
|
926
1058
|
"""Builds a prioritized autonomous action plan from recent tool telemetry."""
|
|
927
1059
|
|
|
@@ -1511,6 +1643,19 @@ def create_agents(services, notifications, config=None, ollama=None) -> list:
|
|
|
1511
1643
|
)
|
|
1512
1644
|
)
|
|
1513
1645
|
|
|
1646
|
+
# BranchWatchAgent — needs file_memory's re-index queue.
|
|
1647
|
+
if hasattr(services, 'file_memory') and services.file_memory:
|
|
1648
|
+
agents.append(
|
|
1649
|
+
BranchWatchAgent(
|
|
1650
|
+
file_memory=services.file_memory,
|
|
1651
|
+
notifications=notifications,
|
|
1652
|
+
project_path=getattr(services, 'project_path', None),
|
|
1653
|
+
**_cfg("BranchWatch", {
|
|
1654
|
+
"enabled": True, "interval": 30, "max_queue": 200,
|
|
1655
|
+
}),
|
|
1656
|
+
)
|
|
1657
|
+
)
|
|
1658
|
+
|
|
1514
1659
|
# EditLedgerEnricherAgent — only if edit_ledger is available
|
|
1515
1660
|
if getattr(services, 'edit_ledger', None):
|
|
1516
1661
|
agents.append(
|
services/context_snapshot.py
CHANGED
|
@@ -9,6 +9,7 @@ from datetime import datetime, timezone
|
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
|
|
11
11
|
from core import count_tokens
|
|
12
|
+
from services.git_context import GitContext
|
|
12
13
|
|
|
13
14
|
# Max characters per file structural map stored in snapshot
|
|
14
15
|
_FILE_MAP_MAX_CHARS = 600
|
|
@@ -21,6 +22,7 @@ class ContextSnapshot:
|
|
|
21
22
|
self.project_path = Path(project_path)
|
|
22
23
|
self.data_dir = self.project_path / data_dir
|
|
23
24
|
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
self._git = GitContext(self.project_path)
|
|
24
26
|
|
|
25
27
|
def capture(self, session_mgr, memory_store,
|
|
26
28
|
task_description: str = "",
|
|
@@ -88,11 +90,23 @@ class ContextSnapshot:
|
|
|
88
90
|
# Context budget snapshot
|
|
89
91
|
budget = session.get("context_budget", {})
|
|
90
92
|
|
|
93
|
+
# Git working-tree state — lets restore flag a branch change.
|
|
94
|
+
try:
|
|
95
|
+
gstate = self._git.state(force=True)
|
|
96
|
+
git_info = {
|
|
97
|
+
"branch": gstate.get("branch"),
|
|
98
|
+
"head_sha": gstate.get("head_sha", ""),
|
|
99
|
+
"detached": gstate.get("detached", False),
|
|
100
|
+
}
|
|
101
|
+
except Exception:
|
|
102
|
+
git_info = {"branch": None, "head_sha": "", "detached": False}
|
|
103
|
+
|
|
91
104
|
snapshot = {
|
|
92
105
|
"schema_version": 3,
|
|
93
106
|
"snapshot_id": datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S"),
|
|
94
107
|
"created": datetime.now(timezone.utc).isoformat(),
|
|
95
108
|
"session_id": session_id,
|
|
109
|
+
"git": git_info,
|
|
96
110
|
"task_description": task_description,
|
|
97
111
|
"working_files": working_files or [],
|
|
98
112
|
"custom_notes": custom_notes,
|
|
@@ -152,6 +166,20 @@ class ContextSnapshot:
|
|
|
152
166
|
if "error" in snap:
|
|
153
167
|
return snap
|
|
154
168
|
|
|
169
|
+
# Flag when the working tree has moved off the branch this was taken on.
|
|
170
|
+
snap_git = snap.get("git") or {}
|
|
171
|
+
if snap_git.get("branch"):
|
|
172
|
+
try:
|
|
173
|
+
cur = self._git.state(force=True)
|
|
174
|
+
if (cur.get("available") and cur.get("branch")
|
|
175
|
+
and cur["branch"] != snap_git["branch"]):
|
|
176
|
+
snap["_branch_warning"] = (
|
|
177
|
+
f"snapshot taken on '{snap_git['branch']}', "
|
|
178
|
+
f"now on '{cur['branch']}'"
|
|
179
|
+
)
|
|
180
|
+
except Exception:
|
|
181
|
+
pass
|
|
182
|
+
|
|
155
183
|
# Enrich with live memory recall so cross-session facts are surfaced immediately
|
|
156
184
|
if memory_store and snap.get("task_description"):
|
|
157
185
|
try:
|
|
@@ -252,6 +280,13 @@ class ContextSnapshot:
|
|
|
252
280
|
parts = [f"# Context Restore: {snap.get('task_description', 'N/A')}"]
|
|
253
281
|
parts.append(f"Snapshot: {snap['snapshot_id']} | Session: {snap.get('session_id', '?')}")
|
|
254
282
|
|
|
283
|
+
git = snap.get("git") or {}
|
|
284
|
+
if git.get("head_sha"):
|
|
285
|
+
label = (git.get("branch") or "(detached)") + " @ " + git["head_sha"][:8]
|
|
286
|
+
parts.append(f"Branch: {label}")
|
|
287
|
+
if snap.get("_branch_warning"):
|
|
288
|
+
parts.append(f"\n⚠️ Branch changed since snapshot — {snap['_branch_warning']}")
|
|
289
|
+
|
|
255
290
|
if snap.get("custom_notes"):
|
|
256
291
|
parts.append(f"\n## Notes\n{snap['custom_notes']}")
|
|
257
292
|
|
|
@@ -328,6 +363,8 @@ class ContextSnapshot:
|
|
|
328
363
|
def _compact_briefing(self, snap: dict) -> str:
|
|
329
364
|
"""Level 1: Compact briefing — top decisions + file list."""
|
|
330
365
|
parts = [f"[restore:{snap['snapshot_id']}] {snap.get('task_description', '')}"]
|
|
366
|
+
if snap.get("_branch_warning"):
|
|
367
|
+
parts.append(f"⚠️ branch changed — {snap['_branch_warning']}")
|
|
331
368
|
|
|
332
369
|
plans = snap.get("plans", [])
|
|
333
370
|
if plans:
|
services/edit_ledger.py
CHANGED
|
@@ -15,6 +15,8 @@ from collections import Counter
|
|
|
15
15
|
from datetime import datetime, timezone
|
|
16
16
|
from pathlib import Path
|
|
17
17
|
|
|
18
|
+
from services.git_context import GitContext
|
|
19
|
+
|
|
18
20
|
|
|
19
21
|
class EditLedger:
|
|
20
22
|
"""Tracks every AI edit with version numbering and git context."""
|
|
@@ -23,7 +25,8 @@ class EditLedger:
|
|
|
23
25
|
self.project_path = Path(project_path).resolve()
|
|
24
26
|
self.ledger_file = self.project_path / ".c3" / "edit_ledger.jsonl"
|
|
25
27
|
self.ledger_file.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
-
self.
|
|
28
|
+
self._git = GitContext(self.project_path)
|
|
29
|
+
self._git_root = self._git.git_root
|
|
27
30
|
# In-memory caches — loaded lazily on first use, updated on writes
|
|
28
31
|
self._version_cache: dict[str, int] | None = None # {file: max_version}
|
|
29
32
|
self._total_count: int | None = None
|
|
@@ -75,7 +78,8 @@ class EditLedger:
|
|
|
75
78
|
self._seq_counter += 1
|
|
76
79
|
|
|
77
80
|
# Git info — single combined command when enabled
|
|
78
|
-
git_info = {"commit": "", "author": "", "subject": "", "dirty": False
|
|
81
|
+
git_info = {"commit": "", "author": "", "subject": "", "dirty": False,
|
|
82
|
+
"branch": None, "head_sha": ""}
|
|
79
83
|
diff_summary = ""
|
|
80
84
|
if include_git and self._git_root:
|
|
81
85
|
git_info, diff_summary = self._git_combined(rel)
|
|
@@ -107,9 +111,10 @@ class EditLedger:
|
|
|
107
111
|
return entry
|
|
108
112
|
|
|
109
113
|
def get_history(self, file: str = None, limit: int = 50,
|
|
110
|
-
since: str = None) -> list:
|
|
111
|
-
"""Query edits, optionally filtered by file and/or
|
|
112
|
-
results = self._load_merged(file_filter=file, since_filter=since
|
|
114
|
+
since: str = None, branch: str = None) -> list:
|
|
115
|
+
"""Query edits, optionally filtered by file, time, and/or branch."""
|
|
116
|
+
results = self._load_merged(file_filter=file, since_filter=since,
|
|
117
|
+
branch_filter=branch)
|
|
113
118
|
return results[-limit:]
|
|
114
119
|
|
|
115
120
|
def get_file_versions(self, file: str) -> list:
|
|
@@ -208,31 +213,13 @@ class EditLedger:
|
|
|
208
213
|
continue
|
|
209
214
|
return results[-limit:]
|
|
210
215
|
|
|
211
|
-
def _detect_git_root(self):
|
|
212
|
-
"""Find git root directory."""
|
|
213
|
-
try:
|
|
214
|
-
kwargs = {}
|
|
215
|
-
if sys.platform == "win32":
|
|
216
|
-
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
|
217
|
-
result = subprocess.run(
|
|
218
|
-
["git", "rev-parse", "--show-toplevel"],
|
|
219
|
-
cwd=self.project_path,
|
|
220
|
-
capture_output=True, text=True, timeout=3,
|
|
221
|
-
stdin=subprocess.DEVNULL,
|
|
222
|
-
**kwargs,
|
|
223
|
-
)
|
|
224
|
-
if result.returncode == 0:
|
|
225
|
-
return Path(result.stdout.strip()).resolve()
|
|
226
|
-
except Exception:
|
|
227
|
-
pass
|
|
228
|
-
return None
|
|
229
|
-
|
|
230
216
|
def _git_combined(self, rel_path: str) -> tuple:
|
|
231
217
|
"""Capture git info + diff in a single subprocess call.
|
|
232
218
|
|
|
233
219
|
Returns (git_info_dict, diff_summary_str).
|
|
234
220
|
"""
|
|
235
|
-
info = {"commit": "", "author": "", "subject": "", "dirty": False
|
|
221
|
+
info = {"commit": "", "author": "", "subject": "", "dirty": False,
|
|
222
|
+
"branch": None, "head_sha": ""}
|
|
236
223
|
diff_summary = ""
|
|
237
224
|
abs_path = (self.project_path / rel_path).resolve()
|
|
238
225
|
try:
|
|
@@ -296,11 +283,20 @@ class EditLedger:
|
|
|
296
283
|
except Exception:
|
|
297
284
|
pass
|
|
298
285
|
|
|
286
|
+
# Branch + HEAD from the cached GitContext (cheap; shared TTL cache).
|
|
287
|
+
try:
|
|
288
|
+
gstate = self._git.state()
|
|
289
|
+
info["branch"] = gstate.get("branch")
|
|
290
|
+
info["head_sha"] = gstate.get("head_sha", "")
|
|
291
|
+
except Exception:
|
|
292
|
+
pass
|
|
293
|
+
|
|
299
294
|
return info, diff_summary
|
|
300
295
|
|
|
301
296
|
# ── Async enrichment ──────────────────────────────────────────────
|
|
302
297
|
|
|
303
|
-
def _load_merged(self, file_filter: str = None, since_filter: str = None
|
|
298
|
+
def _load_merged(self, file_filter: str = None, since_filter: str = None,
|
|
299
|
+
branch_filter: str = None) -> list:
|
|
304
300
|
"""Read all base entries with any appended patches merged in.
|
|
305
301
|
|
|
306
302
|
Patch entries are identified by having a 'target_id' field.
|
|
@@ -348,6 +344,8 @@ class EditLedger:
|
|
|
348
344
|
continue
|
|
349
345
|
if since_filter and entry.get("timestamp", "") < since_filter:
|
|
350
346
|
continue
|
|
347
|
+
if branch_filter and (entry.get("git") or {}).get("branch") != branch_filter:
|
|
348
|
+
continue
|
|
351
349
|
results.append(entry)
|
|
352
350
|
results.sort(key=lambda e: e.get("timestamp", ""))
|
|
353
351
|
return results
|
services/git_context.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""GitContext — single source of truth for git working-tree state.
|
|
2
|
+
|
|
3
|
+
Centralizes git-root detection and branch / HEAD / dirty queries so the rest
|
|
4
|
+
of C3 does not re-implement subprocess plumbing (previously duplicated in
|
|
5
|
+
``EditLedger`` and ``VersionTracker``). State is cached for a short TTL because
|
|
6
|
+
several callers — the edit ledger, context snapshots, the branch watcher — ask
|
|
7
|
+
for it in quick succession.
|
|
8
|
+
|
|
9
|
+
All git calls use list-arg ``subprocess.run`` (no shell) with a timeout and the
|
|
10
|
+
Windows ``CREATE_NO_WINDOW`` flag. The ``shell=True`` hang documented for the
|
|
11
|
+
ledger's combined command does not apply to non-shell invocations, so the
|
|
12
|
+
simple ``run(..., timeout=...)`` form is safe here.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _git_kwargs() -> dict:
|
|
22
|
+
kwargs = {}
|
|
23
|
+
if sys.platform == "win32":
|
|
24
|
+
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
|
25
|
+
return kwargs
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class GitContext:
|
|
29
|
+
"""Cached accessor for the git working-tree state of a project path.
|
|
30
|
+
|
|
31
|
+
Branch awareness is deliberately content/ref-derived rather than persisted:
|
|
32
|
+
the index follows whatever HEAD currently points at. See ``state()`` for the
|
|
33
|
+
shape returned to callers.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, project_path, ttl: float = 3.0):
|
|
37
|
+
self.project_path = Path(project_path).resolve()
|
|
38
|
+
self._ttl = ttl
|
|
39
|
+
self._git_root = self._detect_git_root()
|
|
40
|
+
self._cache: dict | None = None
|
|
41
|
+
self._cache_time: float = 0.0
|
|
42
|
+
|
|
43
|
+
# ── git root ──────────────────────────────────────────────────────
|
|
44
|
+
@property
|
|
45
|
+
def git_root(self) -> Path | None:
|
|
46
|
+
return self._git_root
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def available(self) -> bool:
|
|
50
|
+
return self._git_root is not None
|
|
51
|
+
|
|
52
|
+
def _run(self, args: list, timeout: float = 3.0) -> tuple:
|
|
53
|
+
"""Run a git command rooted at git_root (project_path fallback).
|
|
54
|
+
|
|
55
|
+
Returns (returncode, stdout, stderr); (None, '', '') on failure/timeout.
|
|
56
|
+
"""
|
|
57
|
+
cwd = self._git_root or self.project_path
|
|
58
|
+
try:
|
|
59
|
+
proc = subprocess.run(
|
|
60
|
+
["git", *args],
|
|
61
|
+
cwd=str(cwd),
|
|
62
|
+
capture_output=True, text=True, timeout=timeout,
|
|
63
|
+
stdin=subprocess.DEVNULL,
|
|
64
|
+
**_git_kwargs(),
|
|
65
|
+
)
|
|
66
|
+
return proc.returncode, proc.stdout or "", proc.stderr or ""
|
|
67
|
+
except Exception:
|
|
68
|
+
return None, "", ""
|
|
69
|
+
|
|
70
|
+
def _detect_git_root(self) -> Path | None:
|
|
71
|
+
try:
|
|
72
|
+
proc = subprocess.run(
|
|
73
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
74
|
+
cwd=str(self.project_path),
|
|
75
|
+
capture_output=True, text=True, timeout=3,
|
|
76
|
+
stdin=subprocess.DEVNULL,
|
|
77
|
+
**_git_kwargs(),
|
|
78
|
+
)
|
|
79
|
+
if proc.returncode == 0:
|
|
80
|
+
root = (proc.stdout or "").strip()
|
|
81
|
+
if root:
|
|
82
|
+
return Path(root).resolve()
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
# ── working-tree state ────────────────────────────────────────────
|
|
88
|
+
@staticmethod
|
|
89
|
+
def _empty_state() -> dict:
|
|
90
|
+
return {
|
|
91
|
+
"available": False, "git_root": None, "branch": None,
|
|
92
|
+
"detached": False, "head_sha": "", "upstream": None,
|
|
93
|
+
"ahead": 0, "behind": 0, "dirty": False,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
def state(self, force: bool = False) -> dict:
|
|
97
|
+
"""Return cached working-tree state.
|
|
98
|
+
|
|
99
|
+
Keys: ``available``, ``git_root``, ``branch`` (None when detached),
|
|
100
|
+
``detached``, ``head_sha`` (short), ``upstream``, ``ahead``, ``behind``,
|
|
101
|
+
``dirty``. Cached for ``ttl`` seconds; pass ``force=True`` to refresh.
|
|
102
|
+
"""
|
|
103
|
+
now = time.time()
|
|
104
|
+
if not force and self._cache is not None and (now - self._cache_time) < self._ttl:
|
|
105
|
+
return self._cache
|
|
106
|
+
self._cache = self._compute_state()
|
|
107
|
+
self._cache_time = now
|
|
108
|
+
return self._cache
|
|
109
|
+
|
|
110
|
+
def _compute_state(self) -> dict:
|
|
111
|
+
st = self._empty_state()
|
|
112
|
+
if not self._git_root:
|
|
113
|
+
return st
|
|
114
|
+
st["available"] = True
|
|
115
|
+
st["git_root"] = str(self._git_root)
|
|
116
|
+
|
|
117
|
+
rc, out, _ = self._run(["status", "--branch", "--porcelain=v2"])
|
|
118
|
+
if rc != 0:
|
|
119
|
+
# porcelain=v2 unsupported or command failed — degrade gracefully.
|
|
120
|
+
return self._fallback_state(st)
|
|
121
|
+
|
|
122
|
+
dirty = False
|
|
123
|
+
for line in out.splitlines():
|
|
124
|
+
if line.startswith("# branch.oid "):
|
|
125
|
+
sha = line[len("# branch.oid "):].strip()
|
|
126
|
+
if sha and sha != "(initial)":
|
|
127
|
+
st["head_sha"] = sha[:12]
|
|
128
|
+
elif line.startswith("# branch.head "):
|
|
129
|
+
head = line[len("# branch.head "):].strip()
|
|
130
|
+
if head == "(detached)":
|
|
131
|
+
st["detached"] = True
|
|
132
|
+
else:
|
|
133
|
+
st["branch"] = head
|
|
134
|
+
elif line.startswith("# branch.upstream "):
|
|
135
|
+
st["upstream"] = line[len("# branch.upstream "):].strip()
|
|
136
|
+
elif line.startswith("# branch.ab "):
|
|
137
|
+
for token in line[len("# branch.ab "):].split():
|
|
138
|
+
try:
|
|
139
|
+
if token.startswith("+"):
|
|
140
|
+
st["ahead"] = int(token[1:])
|
|
141
|
+
elif token.startswith("-"):
|
|
142
|
+
st["behind"] = int(token[1:])
|
|
143
|
+
except ValueError:
|
|
144
|
+
pass
|
|
145
|
+
elif line and not line.startswith("#"):
|
|
146
|
+
dirty = True
|
|
147
|
+
st["dirty"] = dirty
|
|
148
|
+
return st
|
|
149
|
+
|
|
150
|
+
def _fallback_state(self, st: dict) -> dict:
|
|
151
|
+
"""Best-effort branch/HEAD for git versions without porcelain=v2."""
|
|
152
|
+
rc, out, _ = self._run(["rev-parse", "HEAD"])
|
|
153
|
+
if rc == 0 and out.strip():
|
|
154
|
+
st["head_sha"] = out.strip()[:12]
|
|
155
|
+
rc, out, _ = self._run(["rev-parse", "--abbrev-ref", "HEAD"])
|
|
156
|
+
if rc == 0:
|
|
157
|
+
head = out.strip()
|
|
158
|
+
if head == "HEAD":
|
|
159
|
+
st["detached"] = True
|
|
160
|
+
elif head:
|
|
161
|
+
st["branch"] = head
|
|
162
|
+
rc, out, _ = self._run(["status", "--porcelain"])
|
|
163
|
+
if rc == 0:
|
|
164
|
+
st["dirty"] = bool(out.strip())
|
|
165
|
+
return st
|
|
166
|
+
|
|
167
|
+
# ── convenience accessors ─────────────────────────────────────────
|
|
168
|
+
def branch(self) -> str | None:
|
|
169
|
+
return self.state().get("branch")
|
|
170
|
+
|
|
171
|
+
def head_sha(self) -> str:
|
|
172
|
+
return self.state().get("head_sha", "")
|
|
173
|
+
|
|
174
|
+
def label(self) -> str:
|
|
175
|
+
"""Human-readable 'branch @ shortsha' (or '(detached) @ sha')."""
|
|
176
|
+
st = self.state()
|
|
177
|
+
if not st["available"]:
|
|
178
|
+
return "no-git"
|
|
179
|
+
name = st["branch"] or "(detached)"
|
|
180
|
+
sha = (st["head_sha"] or "")[:8]
|
|
181
|
+
return f"{name} @ {sha}" if sha else name
|
|
182
|
+
|
|
183
|
+
# ── change queries (scoped re-index support) ──────────────────────
|
|
184
|
+
def _to_project_rel(self, git_rel: str) -> str | None:
|
|
185
|
+
"""Convert a git-root-relative path to project-relative POSIX form.
|
|
186
|
+
|
|
187
|
+
Returns None when the path lies outside ``project_path`` (e.g. a sibling
|
|
188
|
+
subdirectory of the repo, or another worktree) so callers never queue
|
|
189
|
+
files they do not track.
|
|
190
|
+
"""
|
|
191
|
+
if not git_rel or not self._git_root:
|
|
192
|
+
return None
|
|
193
|
+
abs_path = (self._git_root / git_rel).resolve()
|
|
194
|
+
try:
|
|
195
|
+
return abs_path.relative_to(self.project_path).as_posix()
|
|
196
|
+
except Exception:
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
def changed_files(self, old_sha: str, new_sha: str) -> list:
|
|
200
|
+
"""Project-relative paths that differ between two commits.
|
|
201
|
+
|
|
202
|
+
Empty list if either sha is missing or the diff fails (e.g. the old
|
|
203
|
+
commit is no longer reachable) — callers should fall back to
|
|
204
|
+
``dirty_files()`` in that case.
|
|
205
|
+
"""
|
|
206
|
+
if not (old_sha and new_sha) or not self._git_root:
|
|
207
|
+
return []
|
|
208
|
+
rc, out, _ = self._run(["diff", "--name-only", f"{old_sha}..{new_sha}"])
|
|
209
|
+
if rc != 0:
|
|
210
|
+
return []
|
|
211
|
+
result = []
|
|
212
|
+
for line in out.splitlines():
|
|
213
|
+
rel = self._to_project_rel(line.strip())
|
|
214
|
+
if rel:
|
|
215
|
+
result.append(rel)
|
|
216
|
+
return result
|
|
217
|
+
|
|
218
|
+
def dirty_files(self) -> list:
|
|
219
|
+
"""Project-relative paths with uncommitted working-tree changes.
|
|
220
|
+
|
|
221
|
+
Includes untracked files. Catches edits made outside C3 (other editors,
|
|
222
|
+
rebases, ``git restore``) that mtime-on-access would only notice later.
|
|
223
|
+
"""
|
|
224
|
+
if not self._git_root:
|
|
225
|
+
return []
|
|
226
|
+
rc, out, _ = self._run(["status", "--porcelain"])
|
|
227
|
+
if rc != 0:
|
|
228
|
+
return []
|
|
229
|
+
result = []
|
|
230
|
+
for line in out.splitlines():
|
|
231
|
+
if len(line) <= 3:
|
|
232
|
+
continue
|
|
233
|
+
path = line[3:].strip()
|
|
234
|
+
# Renames render as 'old -> new'; index the new path.
|
|
235
|
+
if " -> " in path:
|
|
236
|
+
path = path.split(" -> ", 1)[1].strip()
|
|
237
|
+
# git quotes paths containing special characters.
|
|
238
|
+
if len(path) >= 2 and path.startswith('"') and path.endswith('"'):
|
|
239
|
+
path = path[1:-1]
|
|
240
|
+
rel = self._to_project_rel(path)
|
|
241
|
+
if rel:
|
|
242
|
+
result.append(rel)
|
|
243
|
+
return result
|
services/session_manager.py
CHANGED
|
@@ -16,6 +16,7 @@ from typing import Optional
|
|
|
16
16
|
|
|
17
17
|
from core import count_tokens
|
|
18
18
|
from core.ide import detect_ide, load_ide_config
|
|
19
|
+
from services.git_context import GitContext
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
class SessionManager:
|
|
@@ -49,6 +50,7 @@ class SessionManager:
|
|
|
49
50
|
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
50
51
|
self.current_session = None
|
|
51
52
|
self.ollama_client = ollama_client
|
|
53
|
+
self._git = None
|
|
52
54
|
|
|
53
55
|
# Analytics now in a dedicated directory
|
|
54
56
|
analytics_dir = self.project_path / ".c3" / "analytics"
|
|
@@ -94,6 +96,20 @@ class SessionManager:
|
|
|
94
96
|
ide_name = detect_ide(str(self.project_path))
|
|
95
97
|
return ide_name or "claude-code"
|
|
96
98
|
|
|
99
|
+
def _git_state(self) -> dict:
|
|
100
|
+
"""Current git branch/HEAD for stamping on the session (lazy, cached)."""
|
|
101
|
+
try:
|
|
102
|
+
if self._git is None:
|
|
103
|
+
self._git = GitContext(self.project_path)
|
|
104
|
+
st = self._git.state()
|
|
105
|
+
return {
|
|
106
|
+
"branch": st.get("branch"),
|
|
107
|
+
"head_sha": st.get("head_sha", ""),
|
|
108
|
+
"detached": st.get("detached", False),
|
|
109
|
+
}
|
|
110
|
+
except Exception:
|
|
111
|
+
return {"branch": None, "head_sha": "", "detached": False}
|
|
112
|
+
|
|
97
113
|
def start_session(self, description: str = "", source_system: Optional[str] = None) -> dict:
|
|
98
114
|
"""Start a new session."""
|
|
99
115
|
session_id = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
|
@@ -108,6 +124,7 @@ class SessionManager:
|
|
|
108
124
|
"description": description,
|
|
109
125
|
"source_system": source_system_value,
|
|
110
126
|
"source_ide": source_ide,
|
|
127
|
+
"git": self._git_state(),
|
|
111
128
|
"decisions": [],
|
|
112
129
|
"files_touched": [],
|
|
113
130
|
"key_changes": [],
|
services/version_tracker.py
CHANGED
|
@@ -7,6 +7,7 @@ from datetime import datetime, timezone
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
9
|
from core.ide import get_profile
|
|
10
|
+
from services.git_context import GitContext
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
class VersionTracker:
|
|
@@ -20,7 +21,8 @@ class VersionTracker:
|
|
|
20
21
|
self.ide_name = ide_name
|
|
21
22
|
self.store_path = self.project_path / ".c3" / "version_tracker.json"
|
|
22
23
|
self.store_path.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
-
self.
|
|
24
|
+
self._git = GitContext(self.project_path)
|
|
25
|
+
self._git_root = self._git.git_root
|
|
24
26
|
self.state = self._load_state()
|
|
25
27
|
|
|
26
28
|
def scan(self, agent: str = "current", max_files: int | None = None) -> dict:
|
|
@@ -163,21 +165,6 @@ class VersionTracker:
|
|
|
163
165
|
except Exception:
|
|
164
166
|
return ""
|
|
165
167
|
|
|
166
|
-
def _detect_git_root(self) -> Path | None:
|
|
167
|
-
try:
|
|
168
|
-
result = subprocess.run(
|
|
169
|
-
["git", "rev-parse", "--show-toplevel"],
|
|
170
|
-
cwd=self.project_path,
|
|
171
|
-
capture_output=True,
|
|
172
|
-
text=True,
|
|
173
|
-
timeout=3,
|
|
174
|
-
check=True,
|
|
175
|
-
)
|
|
176
|
-
root = (result.stdout or "").strip()
|
|
177
|
-
return Path(root).resolve() if root else None
|
|
178
|
-
except Exception:
|
|
179
|
-
return None
|
|
180
|
-
|
|
181
168
|
def _git_info(self, rel_path: str) -> dict:
|
|
182
169
|
info = {
|
|
183
170
|
"available": bool(self._git_root),
|
|
@@ -187,6 +174,7 @@ class VersionTracker:
|
|
|
187
174
|
"author": "",
|
|
188
175
|
"timestamp": 0,
|
|
189
176
|
"subject": "",
|
|
177
|
+
"branch": None,
|
|
190
178
|
}
|
|
191
179
|
if not self._git_root:
|
|
192
180
|
return info
|
|
@@ -251,6 +239,10 @@ class VersionTracker:
|
|
|
251
239
|
info["subject"] = parts[3]
|
|
252
240
|
except Exception:
|
|
253
241
|
pass
|
|
242
|
+
try:
|
|
243
|
+
info["branch"] = self._git.state().get("branch")
|
|
244
|
+
except Exception:
|
|
245
|
+
pass
|
|
254
246
|
return info
|
|
255
247
|
|
|
256
248
|
def _load_state(self) -> dict:
|
|
File without changes
|
{code_context_control-2.34.0.dist-info → code_context_control-2.35.0.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{code_context_control-2.34.0.dist-info → code_context_control-2.35.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{code_context_control-2.34.0.dist-info → code_context_control-2.35.0.dist-info}/top_level.txt
RENAMED
|
File without changes
|