Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/coding_agent_telegram/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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+$"))
Expand Down
2 changes: 2 additions & 0 deletions src/coding_agent_telegram/resources/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/coding_agent_telegram/resources/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/coding_agent_telegram/resources/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/coding_agent_telegram/resources/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/coding_agent_telegram/resources/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/coding_agent_telegram/resources/locales/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/coding_agent_telegram/resources/locales/th.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 แล้ว",
Expand Down
2 changes: 2 additions & 0 deletions src/coding_agent_telegram/resources/locales/vi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
2 changes: 2 additions & 0 deletions src/coding_agent_telegram/resources/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "已取消拉取。",
Expand Down
2 changes: 2 additions & 0 deletions src/coding_agent_telegram/resources/locales/zh-HK.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "已取消拉取。",
Expand Down
2 changes: 2 additions & 0 deletions src/coding_agent_telegram/resources/locales/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "已取消拉取。",
Expand Down
86 changes: 86 additions & 0 deletions src/coding_agent_telegram/router/git_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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 <target_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,
Expand Down
49 changes: 49 additions & 0 deletions tests/test_command_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading