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 CHANGED
@@ -85,7 +85,7 @@ console = Console() if HAS_RICH else None
85
85
  # Config
86
86
  CONFIG_DIR = ".c3"
87
87
  CONFIG_FILE = ".c3/config.json"
88
- __version__ = "2.34.0"
88
+ __version__ = "2.35.0"
89
89
 
90
90
 
91
91
  def _command_deps() -> CommandDeps:
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
- lines = [f"[edits:history] {len(entries)} entries" + (f" for {file}" if file else "")]
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.34.0
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=HjB3HjT9iFIy9WHLZbJx6cYruhxUs2iiWFw9T2Y1ECQ,289101
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=5cyXOhH_CxJFCpG13AsOEvtdLZAM8_OiI9ZH1xXtZLs,32454
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=-Tv5eqw_X-dYc9l4kFWr_8vX1TAUb9QNMv0fpy0rXjQ,5304
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.34.0.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
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=f4mvmLZ1Awds3RbYjESSZFtheoybHkBb_oagvnMLyaM,67485
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=upxrxcBUPX7MrOlgUo7oD9rvm2H1SJLK8FI1tgHrAjg,14045
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=WKfsRApgZCXz5cKeKC3JVro4j9EPmW9vgmpsZDrqFm8,17961
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=TVG1Jf5FuVSeVrnCKTPXEDnyPp4bjRqDl4bWXP6_Cf4,43190
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=Q_2Z2Fw2VkSyWmL9QF2SU9z7Y1w1zQ-80iqX93SsHn0,10130
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.34.0.dist-info/METADATA,sha256=jxMVmOACpAqMpGaXua2LZPZdLxHrkefk02mqcaRMNKI,19802
159
- code_context_control-2.34.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
160
- code_context_control-2.34.0.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
161
- code_context_control-2.34.0.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
162
- code_context_control-2.34.0.dist-info/RECORD,,
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(
@@ -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._git_root = self._detect_git_root()
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 time."""
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) -> list:
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
@@ -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
@@ -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": [],
@@ -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._git_root = self._detect_git_root()
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: