diff --git a/src/coding_agent_telegram/bot.py b/src/coding_agent_telegram/bot.py index 851b0b4..75d6069 100644 --- a/src/coding_agent_telegram/bot.py +++ b/src/coding_agent_telegram/bot.py @@ -183,6 +183,7 @@ async def log_incoming_private_message(update, _context) -> None: app.add_handler(CallbackQueryHandler(router.handle_long_gap_callback, pattern=r"^longgap:(compact|proceed|switch)$", block=False)) app.add_handler(CallbackQueryHandler(router.handle_branch_source_callback, pattern=r"^branchsource:[0-9a-f]{12}$", block=False)) app.add_handler(CallbackQueryHandler(router.handle_branch_discrepancy_callback, pattern=r"^branchdiscrepancy:(stored|current)$", block=False)) + app.add_handler(CallbackQueryHandler(router.handle_git_branch_discrepancy_callback, pattern=r"^gitbranchdiscrepancy:(stored|current)$", block=False)) app.add_handler(CallbackQueryHandler(router.handle_commit_generate_callback, pattern=r"^commitgen:(confirm|cancel):[0-9a-f]{12}$")) app.add_handler(CallbackQueryHandler(router.handle_commit_execute_callback, pattern=r"^commitexec:(confirm|cancel):[0-9a-f]{12}$")) app.add_handler(CallbackQueryHandler(router.handle_diff_callback, pattern=r"^diff(?:show|page):[0-9a-f]{12}:\d+$")) diff --git a/src/coding_agent_telegram/resources/locales/de.json b/src/coding_agent_telegram/resources/locales/de.json index caeb00e..3c49da0 100644 --- a/src/coding_agent_telegram/resources/locales/de.json +++ b/src/coding_agent_telegram/resources/locales/de.json @@ -24,6 +24,8 @@ "common.button_expired": "⚠️ Diese Schaltfläche ist abgelaufen. Bitte führe den Befehl erneut aus.", "git.branch_unknown": "⚠️ Der Zweig der aktuellen Sitzung konnte nicht ermittelt werden.", "git.branch_discrepancy_warning": "⚠️ Zweigabweichung erkannt. Der Zweig der aktiven Sitzung ist `{session_branch}`, im Repository ist jedoch `{checked_out_branch}` ausgecheckt. Wechsle vor diesem Befehl zum gewünschten Zweig.", + "git.branch_discrepancy_switch_to_session": "Zu {branch_name} wechseln", + "git.branch_discrepancy_use_current": "Sitzungszweig zu {branch_name} ändern", "git.detached_head_label": "losgelöster HEAD", "git.cancel_button": "Abbrechen", "git.usage_diff": "Verwendung: /diff", diff --git a/src/coding_agent_telegram/resources/locales/en.json b/src/coding_agent_telegram/resources/locales/en.json index a4e92c9..2465183 100644 --- a/src/coding_agent_telegram/resources/locales/en.json +++ b/src/coding_agent_telegram/resources/locales/en.json @@ -24,6 +24,8 @@ "common.project_folder_missing": "⚠️ Project folder no longer exists for this session: {project_folder}", "git.branch_unknown": "⚠️ Could not determine the branch for the current session.", "git.branch_discrepancy_warning": "⚠️ Branch discrepancy detected. The active session branch is `{session_branch}`, but the repository currently has `{checked_out_branch}` checked out. Switch to the intended branch before running this command.", + "git.branch_discrepancy_switch_to_session": "Switch to {branch_name}", + "git.branch_discrepancy_use_current": "Change session branch to {branch_name}", "git.detached_head_label": "detached HEAD", "git.cancel_button": "Cancel", "git.usage_diff": "Usage: /diff", diff --git a/src/coding_agent_telegram/resources/locales/fr.json b/src/coding_agent_telegram/resources/locales/fr.json index 04fa1c2..ffefa47 100644 --- a/src/coding_agent_telegram/resources/locales/fr.json +++ b/src/coding_agent_telegram/resources/locales/fr.json @@ -24,6 +24,8 @@ "common.button_expired": "⚠️ Ce bouton a expiré. Veuillez relancer la commande.", "git.branch_unknown": "⚠️ Impossible de déterminer la branche de la session actuelle.", "git.branch_discrepancy_warning": "⚠️ Divergence de branche détectée. La branche de la session active est `{session_branch}`, mais le dépôt utilise actuellement `{checked_out_branch}`. Basculez sur la branche voulue avant d’exécuter cette commande.", + "git.branch_discrepancy_switch_to_session": "Basculer vers {branch_name}", + "git.branch_discrepancy_use_current": "Définir la branche de session sur {branch_name}", "git.detached_head_label": "HEAD détachée", "git.cancel_button": "Annuler", "git.usage_diff": "Utilisation : /diff", diff --git a/src/coding_agent_telegram/resources/locales/ja.json b/src/coding_agent_telegram/resources/locales/ja.json index ca1f4ae..45eca7c 100644 --- a/src/coding_agent_telegram/resources/locales/ja.json +++ b/src/coding_agent_telegram/resources/locales/ja.json @@ -24,6 +24,8 @@ "common.button_expired": "⚠️ このボタンの有効期限が切れました。コマンドを再実行してください。", "git.branch_unknown": "⚠️ 現在のセッションの ブランチ を特定できませんでした。", "git.branch_discrepancy_warning": "⚠️ ブランチの不一致を検出しました。アクティブセッションのブランチは `{session_branch}` ですが、リポジトリでは `{checked_out_branch}` がチェックアウトされています。このコマンドを実行する前に、使用するブランチへ切り替えてください。", + "git.branch_discrepancy_switch_to_session": "{branch_name} に切り替える", + "git.branch_discrepancy_use_current": "セッションのブランチを {branch_name} に変更", "git.detached_head_label": "detached HEAD", "git.cancel_button": "キャンセル", "git.usage_diff": "使い方: /diff", diff --git a/src/coding_agent_telegram/resources/locales/ko.json b/src/coding_agent_telegram/resources/locales/ko.json index 39d85b2..c039491 100644 --- a/src/coding_agent_telegram/resources/locales/ko.json +++ b/src/coding_agent_telegram/resources/locales/ko.json @@ -24,6 +24,8 @@ "common.button_expired": "⚠️ 이 버튼은 만료되었습니다. 명령을 다시 실행해 주세요.", "git.branch_unknown": "⚠️ 현재 세션의 브랜치 를 확인할 수 없습니다.", "git.branch_discrepancy_warning": "⚠️ 브랜치 불일치가 감지되었습니다. 활성 세션 브랜치는 `{session_branch}` 이지만 저장소에는 현재 `{checked_out_branch}` 가 체크아웃되어 있습니다. 이 명령을 실행하기 전에 원하는 브랜치로 전환하세요.", + "git.branch_discrepancy_switch_to_session": "{branch_name}(으)로 전환", + "git.branch_discrepancy_use_current": "세션 브랜치를 {branch_name}(으)로 변경", "git.detached_head_label": "분리된 HEAD", "git.cancel_button": "취소", "git.usage_diff": "사용법: /diff", diff --git a/src/coding_agent_telegram/resources/locales/nl.json b/src/coding_agent_telegram/resources/locales/nl.json index e87c24f..72c28f5 100644 --- a/src/coding_agent_telegram/resources/locales/nl.json +++ b/src/coding_agent_telegram/resources/locales/nl.json @@ -24,6 +24,8 @@ "common.button_expired": "⚠️ Deze knop is verlopen. Voer de opdracht opnieuw uit.", "git.branch_unknown": "⚠️ De tak voor de huidige sessie kon niet worden bepaald.", "git.branch_discrepancy_warning": "⚠️ Takverschil gedetecteerd. De actieve sessietak is `{session_branch}`, maar in de repository is momenteel `{checked_out_branch}` uitgecheckt. Schakel naar de gewenste tak voordat je deze opdracht uitvoert.", + "git.branch_discrepancy_switch_to_session": "Schakel naar {branch_name}", + "git.branch_discrepancy_use_current": "Wijzig sessietak naar {branch_name}", "git.detached_head_label": "losgekoppelde HEAD", "git.cancel_button": "Annuleren", "git.usage_diff": "Gebruik: /diff", diff --git a/src/coding_agent_telegram/resources/locales/th.json b/src/coding_agent_telegram/resources/locales/th.json index 3c3fa82..35f06c6 100644 --- a/src/coding_agent_telegram/resources/locales/th.json +++ b/src/coding_agent_telegram/resources/locales/th.json @@ -24,6 +24,8 @@ "common.button_expired": "⚠️ ปุ่มนี้หมดอายุแล้ว โปรดลองสั่งคำสั่งอีกครั้ง", "git.branch_unknown": "⚠️ ไม่สามารถระบุ สาขา ของ เซสชัน ปัจจุบันได้", "git.branch_discrepancy_warning": "⚠️ ตรวจพบสาขาไม่ตรงกัน สาขาของเซสชันที่ใช้งานคือ `{session_branch}` แต่ repository กำลัง checkout `{checked_out_branch}` อยู่ โปรดสลับไปยังสาขาที่ต้องการก่อนเรียกใช้คำสั่งนี้", + "git.branch_discrepancy_switch_to_session": "สลับไปยัง {branch_name}", + "git.branch_discrepancy_use_current": "เปลี่ยนสาขาเซสชันเป็น {branch_name}", "git.detached_head_label": "HEAD แบบ detached", "git.cancel_button": "ยกเลิก", "git.pull_cancelled": "ยกเลิก pull แล้ว", diff --git a/src/coding_agent_telegram/resources/locales/vi.json b/src/coding_agent_telegram/resources/locales/vi.json index 26481f8..97537fc 100644 --- a/src/coding_agent_telegram/resources/locales/vi.json +++ b/src/coding_agent_telegram/resources/locales/vi.json @@ -24,6 +24,8 @@ "common.button_expired": "⚠️ Nút này đã hết hạn. Vui lòng chạy lại lệnh.", "git.branch_unknown": "⚠️ Không thể xác định nhánh của phiên hiện tại.", "git.branch_discrepancy_warning": "⚠️ Phát hiện nhánh không khớp. Nhánh của phiên đang hoạt động là `{session_branch}`, nhưng kho hiện đang checkout `{checked_out_branch}`. Hãy chuyển sang nhánh mong muốn trước khi chạy lệnh này.", + "git.branch_discrepancy_switch_to_session": "Chuyển sang {branch_name}", + "git.branch_discrepancy_use_current": "Đổi nhánh phiên thành {branch_name}", "git.detached_head_label": "HEAD tách rời", "git.cancel_button": "Hủy", "git.pull_cancelled": "Đã hủy pull.", diff --git a/src/coding_agent_telegram/resources/locales/zh-CN.json b/src/coding_agent_telegram/resources/locales/zh-CN.json index 1048a51..1c66ba1 100644 --- a/src/coding_agent_telegram/resources/locales/zh-CN.json +++ b/src/coding_agent_telegram/resources/locales/zh-CN.json @@ -24,6 +24,8 @@ "common.button_expired": "⚠️ 此按钮已过期。请重新执行命令。", "git.branch_unknown": "⚠️ 无法确定当前会话的分支。", "git.branch_discrepancy_warning": "⚠️ 检测到分支不一致。活动会话分支为 `{session_branch}`,但仓库当前检出的分支为 `{checked_out_branch}`。请先切换到预期分支,再运行此命令。", + "git.branch_discrepancy_switch_to_session": "切换到 {branch_name}", + "git.branch_discrepancy_use_current": "将会话分支改为 {branch_name}", "git.detached_head_label": "分离的 HEAD", "git.cancel_button": "取消", "git.pull_cancelled": "已取消拉取。", diff --git a/src/coding_agent_telegram/resources/locales/zh-HK.json b/src/coding_agent_telegram/resources/locales/zh-HK.json index 109c455..f35e275 100644 --- a/src/coding_agent_telegram/resources/locales/zh-HK.json +++ b/src/coding_agent_telegram/resources/locales/zh-HK.json @@ -24,6 +24,8 @@ "common.button_expired": "⚠️ 此按鈕已過期。請重新執行命令。", "git.branch_unknown": "⚠️ 無法判斷目前工作階段的分支。", "git.branch_discrepancy_warning": "⚠️ 偵測到分支不一致。目前工作階段分支為 `{session_branch}`,但儲存庫目前檢出的分支為 `{checked_out_branch}`。請先切換至預期分支,再執行此指令。", + "git.branch_discrepancy_switch_to_session": "切換至 {branch_name}", + "git.branch_discrepancy_use_current": "將工作階段分支改為 {branch_name}", "git.detached_head_label": "分離的 HEAD", "git.cancel_button": "取消", "git.pull_cancelled": "已取消拉取。", diff --git a/src/coding_agent_telegram/resources/locales/zh-TW.json b/src/coding_agent_telegram/resources/locales/zh-TW.json index 740421a..d4281b8 100644 --- a/src/coding_agent_telegram/resources/locales/zh-TW.json +++ b/src/coding_agent_telegram/resources/locales/zh-TW.json @@ -24,6 +24,8 @@ "common.button_expired": "⚠️ 此按鈕已過期。請重新執行命令。", "git.branch_unknown": "⚠️ 無法判斷目前工作階段的分支。", "git.branch_discrepancy_warning": "⚠️ 偵測到分支不一致。目前工作階段分支為 `{session_branch}`,但儲存庫目前簽出的分支為 `{checked_out_branch}`。請先切換至預期分支,再執行此指令。", + "git.branch_discrepancy_switch_to_session": "切換至 {branch_name}", + "git.branch_discrepancy_use_current": "將工作階段分支改為 {branch_name}", "git.detached_head_label": "分離的 HEAD", "git.cancel_button": "取消", "git.pull_cancelled": "已取消拉取。", diff --git a/src/coding_agent_telegram/router/git_commands.py b/src/coding_agent_telegram/router/git_commands.py index ebe673d..f265c9f 100644 --- a/src/coding_agent_telegram/router/git_commands.py +++ b/src/coding_agent_telegram/router/git_commands.py @@ -11,6 +11,7 @@ from telegram.ext import ContextTypes from coding_agent_telegram.diff_utils import chunk_fenced_diff, collect_diffs, split_changed_files +from coding_agent_telegram.filters import resolve_project_path from coding_agent_telegram.telegram_sender import send_code_block, send_html_text, send_text, split_assistant_output from .base import require_allowed_chat @@ -327,6 +328,25 @@ async def _warn_if_session_branch_discrepancy( if not session_branch or session_branch == checked_out_branch: return False checked_out_label = checked_out_branch or self._t(update, "git.detached_head_label") + buttons = [ + [ + InlineKeyboardButton( + self._t(update, "git.branch_discrepancy_switch_to_session", branch_name=session_branch), + callback_data="gitbranchdiscrepancy:stored", + ) + ] + ] + # There is no branch that can be recorded for a detached HEAD, so only + # offer the safe restoration action in that case. + if checked_out_branch: + buttons.append( + [ + InlineKeyboardButton( + self._t(update, "git.branch_discrepancy_use_current", branch_name=checked_out_branch), + callback_data="gitbranchdiscrepancy:current", + ) + ] + ) await send_text( update, context, @@ -336,9 +356,75 @@ async def _warn_if_session_branch_discrepancy( session_branch=session_branch, checked_out_branch=checked_out_label, ), + reply_markup=InlineKeyboardMarkup(buttons), ) return True + @require_allowed_chat(answer_callback=True) + async def handle_git_branch_discrepancy_callback( + self, update: Update, context: ContextTypes.DEFAULT_TYPE + ) -> None: + """Resolve a discrepancy reported by a Git command via the /branch path.""" + query = update.callback_query + if query is None or query.data is None: + return + await query.answer() + + choice = query.data.partition("gitbranchdiscrepancy:")[2] + if choice not in {"stored", "current"}: + return + + chat_id = update.effective_chat.id + chat_state = self.deps.store.get_chat_state(self.deps.bot_id, chat_id) + active_session_id = chat_state.get("active_session_id") + session = chat_state.get("sessions", {}).get(active_session_id) if active_session_id else None + if not isinstance(session, dict): + await query.edit_message_text(self._t(update, "branch_resolution.no_active_session")) + return + + project_folder = str(session.get("project_folder") or "").strip() + project_path = resolve_project_path(self.deps.cfg.workspace_root, project_folder) + if not project_path.exists() or not project_path.is_dir(): + await query.edit_message_text( + self._t(update, "project.project_folder_missing_retry", project_folder=project_folder) + ) + return + if self._is_project_busy(chat_id): + await query.edit_message_text(self._t(update, "common.project_busy", project_folder=project_folder)) + return + + stored_branch = str(session.get("branch_name") or "").strip() + current_branch = str(self.git.current_branch(project_path) or "").strip() + target_branch = stored_branch if choice == "stored" else current_branch + if not target_branch: + await query.edit_message_text(self._t(update, "git.branch_unknown")) + return + + # This is the operation performed by /branch : prefer a + # local branch, otherwise prepare the matching origin branch. + source_kind = "local" if self.git.local_branch_exists(project_path, target_branch) else "origin" + result = await asyncio.to_thread( + self.git.prepare_branch_from_source, + project_path, + source_kind=source_kind, + source_branch=target_branch, + new_branch=target_branch, + ) + if not result.success: + await query.edit_message_text(result.message) + return + + self.deps.store.set_current_branch(self.deps.bot_id, chat_id, result.current_branch) + self.deps.store.set_active_session_branch(self.deps.bot_id, chat_id, result.current_branch or "") + await query.edit_message_text( + "\n".join( + [ + result.message, + self._t(update, "project.current_branch_html", branch_name=result.current_branch), + ] + ) + ) + async def _execute_confirmed_reset( self, update: Update, diff --git a/tests/test_command_router.py b/tests/test_command_router.py index bd96a38..96bfa14 100644 --- a/tests/test_command_router.py +++ b/tests/test_command_router.py @@ -7034,6 +7034,55 @@ def test_git_commands_warn_and_stop_on_session_branch_discrepancy(tmp_path: Path assert router.git.safe_git_commands == [] assert router.git.push_calls == [] assert router.git.refresh_calls == [] + keyboard = bot.messages[-1][3] + assert [button.callback_data for row in keyboard.inline_keyboard for button in row] == [ + "gitbranchdiscrepancy:stored", + "gitbranchdiscrepancy:current", + ] + + +def test_git_branch_discrepancy_stored_choice_switches_branch_like_branch_command(tmp_path: Path): + router, _ = _make_commit_router( + tmp_path, + git_manager=FakeGitManager( + is_git_repo=True, + current_branch="main", + default_branch="main", + local_branches=["main", "feature-1"], + prepare_from_source_result=SimpleNamespace( + success=True, + message="Switched to existing local branch 'feature-1'.", + current_branch="feature-1", + ), + ), + ) + router.deps.store.set_active_session_branch("bot-a", 123, "feature-1") + edited = [] + + async def fake_answer(): + return None + + async def fake_edit(text, reply_markup=None): + edited.append((text, reply_markup)) + + query = SimpleNamespace( + data="gitbranchdiscrepancy:stored", + answer=fake_answer, + edit_message_text=fake_edit, + ) + update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=query, + message=None, + ) + + asyncio.run(router.handle_git_branch_discrepancy_callback(update, SimpleNamespace(args=[], bot=FakeBot()))) + + assert router.git.prepare_from_source_calls[-1][1:] == ("local", "feature-1", "feature-1") + state = router.deps.store.get_chat_state("bot-a", 123) + assert state["current_branch"] == "feature-1" + assert state["sessions"]["sess_commit"]["branch_name"] == "feature-1" + assert "Current branch: feature-1" in edited[-1][0] def test_git_command_warns_when_repository_has_detached_head(tmp_path: Path):