diff --git a/README.de.md b/README.de.md index 4bdd204..f162f55 100644 --- a/README.de.md +++ b/README.de.md @@ -331,11 +331,19 @@ Der Bot akzeptiert derzeit: /pull - Nach Bestätigung origin in den Branch der aktiven Session pullen. Wenn zutreffend, aktualisiert der Bot zusätzlich den Standard-Branch. + Nach Bestätigung per Git von origin in den Branch der aktiven Session pullen. Wenn zutreffend, aktualisiert der Bot zusätzlich den Standard-Branch. /push - origin <branch> für die aktuelle aktive Session pushen. Der Bot fragt vor dem Push nach einer Bestätigung. + Per Git origin <branch> für die aktuelle aktive Session pushen. Der Bot fragt vor dem Push nach einer Bestätigung. + + + /log + Die letzten fünf Git-Commits für das Projekt der aktiven Session anzeigen. + + + /reset + Einen lokalen oder origin-Standard-/aktuellen Branch auswählen und anschließend git reset --hard bestätigen. Remote-Ziele werden zuerst gepullt. /abort diff --git a/README.fr.md b/README.fr.md index 1d9531d..cda4279 100644 --- a/README.fr.md +++ b/README.fr.md @@ -331,11 +331,19 @@ Le bot accepte actuellement : /pull - Après confirmation, exécuter un pull depuis origin pour la branche de la session active. Le bot rafraîchit aussi la branche par défaut si nécessaire. + Après confirmation, exécuter un Git pull depuis origin pour la branche de la session active. Le bot rafraîchit aussi la branche par défaut si nécessaire. /push - Pousser origin <branch> pour la session active courante. Le bot demande une confirmation avant le push. + Exécuter un Git push vers origin <branch> pour la session active courante. Le bot demande une confirmation avant le push. + + + /log + Afficher les cinq derniers commits Git du projet de la session active. + + + /reset + Sélectionner la branche par défaut ou courante, locale ou sur origin, puis confirmer un git reset --hard. Les cibles distantes sont d’abord pullées. /abort diff --git a/README.ja.md b/README.ja.md index 79f64cf..0ee22e1 100644 --- a/README.ja.md +++ b/README.ja.md @@ -331,11 +331,19 @@ https://api.telegram.org/bot/getUpdates /pull - 確認後に、アクティブなセッションのブランチで origin から pull します。必要に応じてデフォルト ブランチも更新します。 + 確認後に、アクティブなセッションのブランチで origin から Git pull します。必要に応じてデフォルト ブランチも更新します。 /push - 現在の アクティブなセッション に対して origin <branch> を push します。push 前に bot が確認します。 + 現在の アクティブなセッション に対して origin <branch> へ Git push します。push 前に bot が確認します。 + + + /log + アクティブなセッションの project で最新の Git コミット 5 件を表示します。 + + + /reset + local または origin のデフォルト/現在のブランチを選択し、git reset --hard を確認します。remote の対象は先に pull されます。 /abort diff --git a/README.ko.md b/README.ko.md index 7238f0f..74cb317 100644 --- a/README.ko.md +++ b/README.ko.md @@ -333,11 +333,19 @@ https://api.telegram.org/bot/getUpdates /pull - 확인 후 활성 세션 브랜치에 대해 origin 에서 pull 합니다. 필요하면 기본 브랜치도 함께 새로고칩니다. + 확인 후 활성 세션 브랜치에 대해 origin 에서 Git pull 합니다. 필요하면 기본 브랜치도 함께 새로고칩니다. /push - 현재 활성 세션 에 대해 origin <branch> 를 push 합니다. push 전에 bot 이 확인합니다. + 현재 활성 세션 에 대해 origin <branch> 로 Git push 합니다. push 전에 bot 이 확인합니다. + + + /log + 활성 세션 project 의 최근 Git 커밋 5개를 표시합니다. + + + /reset + local 또는 origin 의 기본/현재 브랜치를 선택한 뒤 git reset --hard 를 확인합니다. remote 대상은 먼저 pull 합니다. /abort diff --git a/README.md b/README.md index 4b20e85..0db35dd 100644 --- a/README.md +++ b/README.md @@ -345,11 +345,19 @@ The bot currently accepts: /pull - Pull from origin for the active session branch after confirmation. The bot also refreshes the default branch when applicable. + Git pull from origin for the active session branch after confirmation. The bot also refreshes the default branch when applicable. /push - Push origin <branch> for the current active session. The bot asks for confirmation before pushing. + Git push origin <branch> for the current active session. The bot asks for confirmation before pushing. + + + /log + Show the top five Git commits for the active session project. + + + /reset + Select a local or origin default/current branch, then confirm a git reset --hard. Remote targets are pulled first. /abort diff --git a/README.nl.md b/README.nl.md index 6d056cb..bc1ffdc 100644 --- a/README.nl.md +++ b/README.nl.md @@ -331,11 +331,19 @@ De bot accepteert momenteel: /pull - Voer na bevestiging een pull uit vanaf origin voor de branch van de actieve sessie. De bot ververst ook de standaardbranch wanneer dat van toepassing is. + Voer na bevestiging een Git pull uit vanaf origin voor de branch van de actieve sessie. De bot ververst ook de standaardbranch wanneer dat van toepassing is. /push - Push origin <branch> voor de huidige actieve sessie. De bot vraagt om bevestiging voordat hij pusht. + Voer een Git push uit naar origin <branch> voor de huidige actieve sessie. De bot vraagt om bevestiging voordat hij pusht. + + + /log + Toon de laatste vijf Git-commits voor het project van de actieve sessie. + + + /reset + Selecteer de lokale of origin-standaard-/huidige branch en bevestig daarna een git reset --hard. Remote doelen worden eerst gepulld. /abort diff --git a/README.th.md b/README.th.md index 2c62bb5..1c209b2 100644 --- a/README.th.md +++ b/README.th.md @@ -331,11 +331,19 @@ https://api.telegram.org/bot/getUpdates /pull - ดึงจาก origin สำหรับ branch ของเซสชันที่ใช้งานอยู่หลังจากยืนยันแล้ว และ bot จะรีเฟรช default branch ให้ด้วยเมื่อเกี่ยวข้อง + Git pull จาก origin สำหรับ branch ของเซสชันที่ใช้งานอยู่หลังจากยืนยันแล้ว และ bot จะรีเฟรช default branch ให้ด้วยเมื่อเกี่ยวข้อง /push - push origin <branch> สำหรับ เซสชันที่ใช้งานอยู่ ปัจจุบัน โดยบอตจะขอการยืนยันก่อน push + Git push ไปยัง origin <branch> สำหรับ เซสชันที่ใช้งานอยู่ ปัจจุบัน โดยบอตจะขอการยืนยันก่อน push + + + /log + แสดง Git commit 5 รายการล่าสุดของ project ในเซสชันที่ใช้งานอยู่ + + + /reset + เลือก default/current branch จาก local หรือ origin แล้วจึงยืนยัน git reset --hard โดยจะ pull เป้าหมาย remote ก่อน /abort diff --git a/README.vi.md b/README.vi.md index 68fa78b..8645762 100644 --- a/README.vi.md +++ b/README.vi.md @@ -331,11 +331,19 @@ Hiện tại bot chấp nhận: /pull - Sau khi xác nhận, thực hiện pull từ origin cho branch của phiên đang hoạt động. Bot cũng làm mới branch mặc định khi cần. + Sau khi xác nhận, thực hiện Git pull từ origin cho branch của phiên đang hoạt động. Bot cũng làm mới branch mặc định khi cần. /push - Push origin <branch> cho phiên hoạt động hiện tại. Bot sẽ hỏi xác nhận trước khi push. + Thực hiện Git push đến origin <branch> cho phiên hoạt động hiện tại. Bot sẽ hỏi xác nhận trước khi push. + + + /log + Hiển thị năm commit Git mới nhất của project trong phiên đang hoạt động. + + + /reset + Chọn branch mặc định/hiện tại ở local hoặc origin, sau đó xác nhận git reset --hard. Mục tiêu remote sẽ được pull trước. /abort diff --git a/README.zh-CN.md b/README.zh-CN.md index bc9da3e..1ddd652 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -333,11 +333,19 @@ bot 当前接受: /pull - 确认后,从 origin 拉取活动会话当前分支。适用时,bot 也会一并刷新默认分支。 + 确认后,通过 Git 从 origin 拉取活动会话当前分支。适用时,bot 也会一并刷新默认分支。 /push - 为当前活动会话执行 origin <branch> push。push 前 bot 会要求确认。 + 为当前活动会话通过 Git push 到 origin <branch>。push 前 bot 会要求确认。 + + + /log + 显示活动会话项目最近的五条 Git 提交。 + + + /reset + 选择 local 或 origin 上的默认/当前分支,然后确认执行 git reset --hard。远程目标会先被拉取。 /abort diff --git a/README.zh-HK.md b/README.zh-HK.md index ec308aa..64a1f7d 100644 --- a/README.zh-HK.md +++ b/README.zh-HK.md @@ -331,11 +331,19 @@ bot 目前接受: /pull - 確認後,從 origin 拉取作用中工作階段目前的分支。適用時,bot 也會一併重新整理預設分支。 + 確認後,透過 Git 從 origin 拉取作用中工作階段目前的分支。適用時,bot 也會一併重新整理預設分支。 /push - 為目前作用中工作階段執行 origin <branch> push。push 前 bot 會要求確認。 + 為目前作用中工作階段透過 Git push 至 origin <branch>。push 前 bot 會要求確認。 + + + /log + 顯示作用中工作階段專案最近的五個 Git 提交。 + + + /reset + 選擇 local 或 origin 上的預設/目前分支,然後確認執行 git reset --hard。遠端目標會先被拉取。 /abort diff --git a/README.zh-TW.md b/README.zh-TW.md index 955350c..f1d6303 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -333,11 +333,19 @@ bot 目前接受: /pull - 確認後,從 origin 拉取作用中工作階段目前的分支。適用時,bot 也會一併重新整理預設分支。 + 確認後,透過 Git 從 origin 拉取作用中工作階段目前的分支。適用時,bot 也會一併重新整理預設分支。 /push - 為目前作用中工作階段執行 origin <branch> push。push 前 bot 會要求確認。 + 為目前作用中工作階段透過 Git push 至 origin <branch>。push 前 bot 會要求確認。 + + + /log + 顯示作用中工作階段專案最近的五個 Git 提交。 + + + /reset + 選擇 local 或 origin 上的預設/目前分支,然後確認執行 git reset --hard。遠端目標會先被拉取。 /abort diff --git a/src/coding_agent_telegram/bot.py b/src/coding_agent_telegram/bot.py index 0cffacf..851b0b4 100644 --- a/src/coding_agent_telegram/bot.py +++ b/src/coding_agent_telegram/bot.py @@ -57,6 +57,8 @@ def default_bot_commands(*, enable_commit_command: bool, locale: str = DEFAULT_L commands.append(BotCommand("commit", translate(locale, "bot.command.commit"))) commands.append(BotCommand("pull", translate(locale, "bot.command.pull"))) commands.append(BotCommand("push", translate(locale, "bot.command.push"))) + commands.append(BotCommand("log", translate(locale, "bot.command.log"))) + commands.append(BotCommand("reset", translate(locale, "bot.command.reset"))) commands.append(BotCommand("abort", translate(locale, "bot.command.abort"))) return commands @@ -164,6 +166,8 @@ async def log_incoming_private_message(update, _context) -> None: app.add_handler(CommandHandler("commit", router.handle_commit, filters=allowed_private)) app.add_handler(CommandHandler("pull", router.handle_pull, filters=allowed_private)) app.add_handler(CommandHandler("push", router.handle_push, filters=allowed_private)) + app.add_handler(CommandHandler("log", router.handle_log, filters=allowed_private)) + app.add_handler(CommandHandler("reset", router.handle_reset, filters=allowed_private)) app.add_handler(CommandHandler("abort", router.handle_abort, filters=allowed_private)) app.add_handler( CallbackQueryHandler( @@ -179,12 +183,18 @@ 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_commit_generate_callback, pattern=r"^commitgen:(confirm|cancel)$")) - app.add_handler(CallbackQueryHandler(router.handle_commit_execute_callback, pattern=r"^commitexec:(confirm|cancel)$")) - app.add_handler(CallbackQueryHandler(router.handle_diff_callback, pattern=r"^diff(?:show|page):\d+$")) + 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+$")) app.add_handler(CallbackQueryHandler(router.handle_switch_page_callback, pattern=r"^switchpage:\d+$")) - app.add_handler(CallbackQueryHandler(router.handle_pull_callback, pattern=r"^pull:(confirm|cancel)$")) - app.add_handler(CallbackQueryHandler(router.handle_push_callback, pattern=r"^push:(confirm|cancel)$")) + app.add_handler(CallbackQueryHandler(router.handle_pull_callback, pattern=r"^pull:(confirm|cancel):[0-9a-f]{12}$")) + app.add_handler(CallbackQueryHandler(router.handle_push_callback, pattern=r"^push:(confirm|cancel):[0-9a-f]{12}$")) + app.add_handler( + CallbackQueryHandler( + router.handle_reset_callback, + pattern=r"^reset:(?:select:[0-9a-f]{12}:(?:local|origin)-(?:default|current)|(?:confirm|cancel):[0-9a-f]{12})$", + ) + ) app.add_handler(CallbackQueryHandler(router.handle_trust_project_callback, pattern=r"^trustproject:(yes|no):")) app.add_handler(MessageHandler(allowed_private & tg_filters.PHOTO, router.handle_photo, block=False)) app.add_handler(MessageHandler(allowed_private & tg_filters.AUDIO, router.handle_audio, block=False)) diff --git a/src/coding_agent_telegram/diff_utils.py b/src/coding_agent_telegram/diff_utils.py index d8a1113..d9cac8a 100644 --- a/src/coding_agent_telegram/diff_utils.py +++ b/src/coding_agent_telegram/diff_utils.py @@ -210,21 +210,33 @@ def _git(project_path: Path, args: list[str]) -> str: return proc.stdout -def _parse_status_paths(output: str) -> list[str]: - paths: list[str] = [] - for line in output.splitlines(): - if len(line) < 4: +def _parse_status_entries(output: str) -> list[tuple[str, str]]: + """Parse ``git status --porcelain=v1 -z`` without losing path characters.""" + records = output.split("\0") + entries: list[tuple[str, str]] = [] + index = 0 + while index < len(records): + record = records[index] + index += 1 + if len(record) < 4: continue - path = line[3:].strip() - if " -> " in path: - path = path.split(" -> ", 1)[1].strip() + status = record[:2] + path = record[3:] + if "R" in status or "C" in status: + # In -z mode the destination is in this record and the source path + # follows as a second NUL-terminated record. + index += 1 if path: - paths.append(path) - return paths + entries.append((status, path)) + return entries + + +def _parse_status_paths(output: str) -> list[str]: + return [path for _status, path in _parse_status_entries(output)] def changed_files(project_path: Path) -> list[str]: - output = _git(project_path, ["status", "--short", "--untracked-files=all"]) + output = _git(project_path, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]) return [ path for path in _parse_status_paths(output) @@ -234,16 +246,10 @@ def changed_files(project_path: Path) -> list[str]: def split_changed_files(project_path: Path) -> tuple[list[str], list[str]]: - output = _git(project_path, ["status", "--short", "--untracked-files=all"]) + output = _git(project_path, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]) tracked: list[str] = [] untracked: list[str] = [] - for line in output.splitlines(): - if len(line) < 4: - continue - status = line[:2] - path = line[3:].strip() - if " -> " in path: - path = path.split(" -> ", 1)[1].strip() + for status, path in _parse_status_entries(output): if ( not path or path.startswith(f"{INTERNAL_APP_DIR}/") diff --git a/src/coding_agent_telegram/resources/locales/de.json b/src/coding_agent_telegram/resources/locales/de.json index 00ba4e2..caeb00e 100644 --- a/src/coding_agent_telegram/resources/locales/de.json +++ b/src/coding_agent_telegram/resources/locales/de.json @@ -6,10 +6,12 @@ "bot.command.diff": "Geänderte Dateinamen gegenüber HEAD anzeigen", "bot.command.model": "Modell für die aktive Sitzung wählen", "bot.command.new": "Neue Sitzung erstellen", - "bot.command.pull": "Die aktuelle Sitzungs-Zweig pullen", + "bot.command.log": "Die letzten 5 Git-Commits anzeigen", + "bot.command.pull": "Git: Die aktuelle Sitzungs-Zweig pullen", "bot.command.project": "Aktuellen Projektordner festlegen", "bot.command.provider": "Anbieter für neue Sitzungen wählen", - "bot.command.push": "Die aktuelle Sitzungs-Zweig pushen", + "bot.command.push": "Git: Die aktuelle Sitzungs-Zweig pushen", + "bot.command.reset": "Git reset --hard auf einen Zweig", "bot.command.switch": "Sitzungen auflisten oder wechseln", "bot.error.command_failed": "⚠️ Befehl fehlgeschlagen. Bitte das Server-Log prüfen.", "bot.error.session_store": "⚠️ {error}", @@ -21,6 +23,8 @@ "common.project_folder_missing": "⚠️ Der Projektordner für diese Sitzung existiert nicht mehr: {project_folder}", "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.detached_head_label": "losgelöster HEAD", "git.cancel_button": "Abbrechen", "git.usage_diff": "Verwendung: /diff", "git.usage_pull": "Verwendung: /pull", @@ -31,12 +35,20 @@ "git.pull_confirm_prompt_with_default": "Zweig `{branch_name}` von `origin` pullen und zusätzlich die Standard-Zweig `{default_branch}` aktualisieren?", "git.pull_in_progress": "Zweig `{branch_name}` wird von `origin` gepullt...", "git.pull_in_progress_with_default": "Zweig `{branch_name}` wird von `origin` gepullt und die Standard-Zweig `{default_branch}` wird aktualisiert...", + "git.reset_cancelled": "Git reset abgebrochen.", + "git.reset_confirm_button": "Reset bestätigen", + "git.reset_confirm_prompt": "Den aktuellen Zweig mit `git reset --hard {target_ref}` zurücksetzen?", + "git.reset_in_progress": "`git reset --hard {target_ref}` wird ausgeführt...", + "git.reset_pull_in_progress": "`{target_ref}` wird vor dem Reset gepullt...", + "git.reset_select_prompt": "Wähle den Zweig, auf den zurückgesetzt werden soll:", "git.push_cancelled": "Push abgebrochen.", "git.push_cancelled_checkout_failed": "Push abgebrochen. Wechsel zu `{branch_name}` ist zuerst fehlgeschlagen.", "git.push_confirm_button": "Push bestätigen", "git.push_confirm_prompt": "Zweig `{branch_name}` nach `origin` pushen?", "git.push_in_progress": "Zweig `{branch_name}` wird nach `origin` gepusht...", "git.usage_push": "Verwendung: /push", + "git.usage_log": "Verwendung: /log", + "git.usage_reset": "Verwendung: /reset", "message.photo_only_codex": "Fotoanhänge werden derzeit nur für Codex-Sitzungen unterstützt.", "message.photo_blocked_by_pending_action": "Eine Aktion aus einer früheren Nachricht wartet noch auf deine Antwort (z. B. eine Compact/Fortsetzen-Abfrage). Löse das bitte zuerst, dann sende dieses Foto erneut.", "message.question_queued": "Frage als Q{question_number} in die Warteschlange gestellt. Sie wird verarbeitet, sobald die aktuelle Agent-Aufgabe abgeschlossen ist.", diff --git a/src/coding_agent_telegram/resources/locales/en.json b/src/coding_agent_telegram/resources/locales/en.json index 2c0fc49..a4e92c9 100644 --- a/src/coding_agent_telegram/resources/locales/en.json +++ b/src/coding_agent_telegram/resources/locales/en.json @@ -6,10 +6,12 @@ "bot.command.diff": "Show changed filenames vs HEAD", "bot.command.model": "Choose the model for the active session", "bot.command.new": "Create a new session", - "bot.command.pull": "Pull the current session branch", + "bot.command.log": "Show the top 5 Git commits", + "bot.command.pull": "Git pull the current session branch", "bot.command.project": "Set the current project folder", "bot.command.provider": "Choose the provider for new sessions", - "bot.command.push": "Push the current session branch", + "bot.command.push": "Git push the current session branch", + "bot.command.reset": "Git reset --hard to a branch", "bot.command.switch": "List sessions or switch to one", "bot.error.command_failed": "⚠️ Command failed. Check the server log for details.", "bot.error.session_store": "⚠️ {error}", @@ -21,6 +23,8 @@ "common.project_busy": "An agent is currently running on project '{project_folder}'.\nOnly /current and /abort are supported until it finishes.", "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.detached_head_label": "detached HEAD", "git.cancel_button": "Cancel", "git.usage_diff": "Usage: /diff", "git.pull_cancelled": "Pull cancelled.", @@ -30,6 +34,12 @@ "git.pull_confirm_prompt_with_default": "Pull branch `{branch_name}` from `origin` and also refresh default branch `{default_branch}`?", "git.pull_in_progress": "Pulling branch `{branch_name}` from `origin`...", "git.pull_in_progress_with_default": "Pulling branch `{branch_name}` from `origin` and refreshing default branch `{default_branch}`...", + "git.reset_cancelled": "Git reset cancelled.", + "git.reset_confirm_button": "Confirm reset", + "git.reset_confirm_prompt": "Reset the current branch with `git reset --hard {target_ref}`?", + "git.reset_in_progress": "Running `git reset --hard {target_ref}`...", + "git.reset_pull_in_progress": "Pulling `{target_ref}` before reset...", + "git.reset_select_prompt": "Select the branch to reset to:", "git.push_cancelled": "Push cancelled.", "git.push_cancelled_checkout_failed": "Push cancelled. Failed to switch to `{branch_name}` first.", "git.push_confirm_button": "Confirm push", @@ -37,6 +47,8 @@ "git.push_in_progress": "Pushing branch `{branch_name}` to `origin`...", "git.usage_pull": "Usage: /pull", "git.usage_push": "Usage: /push", + "git.usage_log": "Usage: /log", + "git.usage_reset": "Usage: /reset", "message.photo_only_codex": "Photo attachments are currently supported only for Codex and Claude sessions.", "message.photo_blocked_by_pending_action": "An action from an earlier message is still waiting for your response (e.g. a compact/proceed prompt). Please resolve that first, then resend this photo.", "message.question_queued": "Question queued as Q{question_number}. It will run after the current agent task finishes.", diff --git a/src/coding_agent_telegram/resources/locales/fr.json b/src/coding_agent_telegram/resources/locales/fr.json index b36f882..04fa1c2 100644 --- a/src/coding_agent_telegram/resources/locales/fr.json +++ b/src/coding_agent_telegram/resources/locales/fr.json @@ -6,10 +6,12 @@ "bot.command.diff": "Afficher les noms de fichiers modifiés par rapport à HEAD", "bot.command.model": "Choisir le modèle pour la session active", "bot.command.new": "Créer une nouvelle session", - "bot.command.pull": "Pull la branche de la session actuelle", + "bot.command.log": "Afficher les 5 derniers commits Git", + "bot.command.pull": "Git pull la branche de la session actuelle", "bot.command.project": "Définir le dossier de projet actuel", "bot.command.provider": "Choisir le fournisseur pour les nouvelles sessions", - "bot.command.push": "Push la branche de la session actuelle", + "bot.command.push": "Git push la branche de la session actuelle", + "bot.command.reset": "Git reset --hard vers une branche", "bot.command.switch": "Lister les sessions ou basculer", "bot.error.command_failed": "⚠️ La commande a échoué. Vérifiez le journal du serveur.", "bot.error.session_store": "⚠️ {error}", @@ -21,6 +23,8 @@ "common.project_folder_missing": "⚠️ Le dossier du projet n’existe plus pour cette session : {project_folder}", "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.detached_head_label": "HEAD détachée", "git.cancel_button": "Annuler", "git.usage_diff": "Utilisation : /diff", "git.usage_pull": "Utilisation : /pull", @@ -31,12 +35,20 @@ "git.pull_confirm_prompt_with_default": "Pull la branche `{branch_name}` depuis `origin` et rafraîchir aussi la branche par défaut `{default_branch}` ?", "git.pull_in_progress": "Pull de la branche `{branch_name}` depuis `origin`...", "git.pull_in_progress_with_default": "Pull de la branche `{branch_name}` depuis `origin` et rafraîchissement de la branche par défaut `{default_branch}`...", + "git.reset_cancelled": "Git reset annulé.", + "git.reset_confirm_button": "Confirmer le reset", + "git.reset_confirm_prompt": "Réinitialiser la branche actuelle avec `git reset --hard {target_ref}` ?", + "git.reset_in_progress": "Exécution de `git reset --hard {target_ref}`...", + "git.reset_pull_in_progress": "Pull de `{target_ref}` avant le reset...", + "git.reset_select_prompt": "Sélectionnez la branche cible du reset :", "git.push_cancelled": "Push annulé.", "git.push_cancelled_checkout_failed": "Push annulé. Échec du basculement vers `{branch_name}`.", "git.push_confirm_button": "Confirmer push", "git.push_confirm_prompt": "Push la branche `{branch_name}` vers `origin` ?", "git.push_in_progress": "Push de la branche `{branch_name}` vers `origin`...", "git.usage_push": "Utilisation : /push", + "git.usage_log": "Utilisation : /log", + "git.usage_reset": "Utilisation : /reset", "message.photo_only_codex": "Les pièces jointes photo sont actuellement prises en charge uniquement pour les sessions Codex.", "message.photo_blocked_by_pending_action": "Une action liée à un message précédent attend encore votre réponse (par exemple une invite compacter/continuer). Veuillez d'abord la résoudre, puis renvoyez cette photo.", "message.question_queued": "Question mise en file d’attente sous Q{question_number}. Elle sera traitée une fois la tâche actuelle terminée.", diff --git a/src/coding_agent_telegram/resources/locales/ja.json b/src/coding_agent_telegram/resources/locales/ja.json index b1f5b46..ca1f4ae 100644 --- a/src/coding_agent_telegram/resources/locales/ja.json +++ b/src/coding_agent_telegram/resources/locales/ja.json @@ -6,10 +6,12 @@ "bot.command.diff": "HEAD との差分があるファイル名を表示", "bot.command.model": "アクティブなセッションのモデルを選択", "bot.command.new": "新しいセッションを作成", - "bot.command.pull": "現在のセッション ブランチ を pull", + "bot.command.log": "最新の Git コミット 5 件を表示", + "bot.command.pull": "Git で現在のセッション ブランチ を pull", "bot.command.project": "現在のプロジェクトフォルダーを設定", "bot.command.provider": "新しいセッションのプロバイダーを選択", - "bot.command.push": "現在のセッション ブランチ を push", + "bot.command.push": "Git で現在のセッション ブランチ を push", + "bot.command.reset": "Git reset --hard でブランチに戻す", "bot.command.switch": "セッション一覧または切り替え", "bot.error.command_failed": "⚠️ コマンドが失敗しました。サーバーログを確認してください。", "bot.error.session_store": "⚠️ {error}", @@ -21,6 +23,8 @@ "common.project_folder_missing": "⚠️ このセッションのプロジェクトフォルダーは存在しなくなりました: {project_folder}", "common.button_expired": "⚠️ このボタンの有効期限が切れました。コマンドを再実行してください。", "git.branch_unknown": "⚠️ 現在のセッションの ブランチ を特定できませんでした。", + "git.branch_discrepancy_warning": "⚠️ ブランチの不一致を検出しました。アクティブセッションのブランチは `{session_branch}` ですが、リポジトリでは `{checked_out_branch}` がチェックアウトされています。このコマンドを実行する前に、使用するブランチへ切り替えてください。", + "git.detached_head_label": "detached HEAD", "git.cancel_button": "キャンセル", "git.usage_diff": "使い方: /diff", "git.usage_pull": "使い方: /pull", @@ -31,12 +35,20 @@ "git.pull_confirm_prompt_with_default": "ブランチ `{branch_name}` を `origin` から pull し、あわせてデフォルト ブランチ `{default_branch}` も更新しますか?", "git.pull_in_progress": "ブランチ `{branch_name}` を `origin` から pull 中...", "git.pull_in_progress_with_default": "ブランチ `{branch_name}` を `origin` から pull し、デフォルト ブランチ `{default_branch}` を更新中...", + "git.reset_cancelled": "Git reset をキャンセルしました。", + "git.reset_confirm_button": "reset を確認", + "git.reset_confirm_prompt": "現在のブランチで `git reset --hard {target_ref}` を実行しますか?", + "git.reset_in_progress": "`git reset --hard {target_ref}` を実行中...", + "git.reset_pull_in_progress": "reset の前に `{target_ref}` を pull 中...", + "git.reset_select_prompt": "reset 先のブランチを選択してください:", "git.push_cancelled": "push をキャンセルしました。", "git.push_cancelled_checkout_failed": "push をキャンセルしました。まず `{branch_name}` への切り替えに失敗しました。", "git.push_confirm_button": "push を確認", "git.push_confirm_prompt": "ブランチ `{branch_name}` を `origin` に push しますか?", "git.push_in_progress": "ブランチ `{branch_name}` を `origin` に push 中...", "git.usage_push": "使い方: /push", + "git.usage_log": "使い方: /log", + "git.usage_reset": "使い方: /reset", "message.photo_only_codex": "写真添付は現在 Codex セッションでのみサポートされています。", "message.photo_blocked_by_pending_action": "以前のメッセージに関する操作がまだあなたの応答待ちです(例: compact/続行 の確認)。先にそれを解決してから、この写真を再送信してください。", "message.question_queued": "質問は Q{question_number} としてキューに追加されました。現在のエージェント処理が終わった後に実行されます。", diff --git a/src/coding_agent_telegram/resources/locales/ko.json b/src/coding_agent_telegram/resources/locales/ko.json index 8787135..39d85b2 100644 --- a/src/coding_agent_telegram/resources/locales/ko.json +++ b/src/coding_agent_telegram/resources/locales/ko.json @@ -6,10 +6,12 @@ "bot.command.diff": "HEAD 대비 변경된 파일 이름 표시", "bot.command.model": "활성 세션용 모델 선택", "bot.command.new": "새 세션 생성", - "bot.command.pull": "현재 세션 브랜치 pull", + "bot.command.log": "최근 Git 커밋 5개 표시", + "bot.command.pull": "Git 현재 세션 브랜치 pull", "bot.command.project": "현재 프로젝트 폴더 설정", "bot.command.provider": "새 세션용 제공자 선택", - "bot.command.push": "현재 세션 브랜치 push", + "bot.command.push": "Git 현재 세션 브랜치 push", + "bot.command.reset": "Git reset --hard로 브랜치 재설정", "bot.command.switch": "세션 목록 보기 또는 전환", "bot.error.command_failed": "⚠️ 명령이 실패했습니다. 서버 로그를 확인하세요.", "bot.error.session_store": "⚠️ {error}", @@ -21,6 +23,8 @@ "common.project_folder_missing": "⚠️ 이 세션의 프로젝트 폴더가 더 이상 존재하지 않습니다: {project_folder}", "common.button_expired": "⚠️ 이 버튼은 만료되었습니다. 명령을 다시 실행해 주세요.", "git.branch_unknown": "⚠️ 현재 세션의 브랜치 를 확인할 수 없습니다.", + "git.branch_discrepancy_warning": "⚠️ 브랜치 불일치가 감지되었습니다. 활성 세션 브랜치는 `{session_branch}` 이지만 저장소에는 현재 `{checked_out_branch}` 가 체크아웃되어 있습니다. 이 명령을 실행하기 전에 원하는 브랜치로 전환하세요.", + "git.detached_head_label": "분리된 HEAD", "git.cancel_button": "취소", "git.usage_diff": "사용법: /diff", "git.usage_pull": "사용법: /pull", @@ -31,12 +35,20 @@ "git.pull_confirm_prompt_with_default": "브랜치 `{branch_name}` 를 `origin` 에서 pull 하고 기본 브랜치 `{default_branch}` 도 함께 새로고침할까요?", "git.pull_in_progress": "브랜치 `{branch_name}` 를 `origin` 에서 pull 하는 중...", "git.pull_in_progress_with_default": "브랜치 `{branch_name}` 를 `origin` 에서 pull 하고 기본 브랜치 `{default_branch}` 를 새로고침하는 중...", + "git.reset_cancelled": "Git reset이 취소되었습니다.", + "git.reset_confirm_button": "reset 확인", + "git.reset_confirm_prompt": "현재 브랜치에서 `git reset --hard {target_ref}` 를 실행할까요?", + "git.reset_in_progress": "`git reset --hard {target_ref}` 실행 중...", + "git.reset_pull_in_progress": "reset 전에 `{target_ref}` 를 pull 하는 중...", + "git.reset_select_prompt": "reset할 대상 브랜치를 선택하세요:", "git.push_cancelled": "push 가 취소되었습니다.", "git.push_cancelled_checkout_failed": "push 가 취소되었습니다. 먼저 `{branch_name}` 로 전환하지 못했습니다.", "git.push_confirm_button": "push 확인", "git.push_confirm_prompt": "브랜치 `{branch_name}` 를 `origin` 으로 push 할까요?", "git.push_in_progress": "브랜치 `{branch_name}` 를 `origin` 으로 push 하는 중...", "git.usage_push": "사용법: /push", + "git.usage_log": "사용법: /log", + "git.usage_reset": "사용법: /reset", "message.photo_only_codex": "사진 첨부는 현재 Codex 세션에서만 지원됩니다.", "message.photo_blocked_by_pending_action": "이전 메시지에서 시작된 작업이 아직 응답을 기다리고 있습니다(예: compact/진행 확인). 먼저 그것을 해결한 뒤 이 사진을 다시 보내주세요.", "message.question_queued": "질문이 Q{question_number} 로 대기열에 추가되었습니다. 현재 에이전트 작업이 끝난 뒤 처리됩니다.", diff --git a/src/coding_agent_telegram/resources/locales/nl.json b/src/coding_agent_telegram/resources/locales/nl.json index 7f07508..e87c24f 100644 --- a/src/coding_agent_telegram/resources/locales/nl.json +++ b/src/coding_agent_telegram/resources/locales/nl.json @@ -6,10 +6,12 @@ "bot.command.diff": "Gewijzigde bestandsnamen ten opzichte van HEAD tonen", "bot.command.model": "Model voor de actieve sessie kiezen", "bot.command.new": "Nieuwe sessie maken", - "bot.command.pull": "De huidige sessietak pullen", + "bot.command.log": "De laatste 5 Git-commits tonen", + "bot.command.pull": "Git: de huidige sessietak pullen", "bot.command.project": "Huidige projectmap instellen", "bot.command.provider": "Aanbieder voor nieuwe sessies kiezen", - "bot.command.push": "De huidige sessietak pushen", + "bot.command.push": "Git: de huidige sessietak pushen", + "bot.command.reset": "Git reset --hard naar een tak", "bot.command.switch": "Sessies tonen of wisselen", "bot.error.command_failed": "⚠️ Opdracht mislukt. Controleer de serverlog.", "bot.error.session_store": "⚠️ {error}", @@ -21,6 +23,8 @@ "common.project_folder_missing": "⚠️ De projectmap voor deze sessie bestaat niet meer: {project_folder}", "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.detached_head_label": "losgekoppelde HEAD", "git.cancel_button": "Annuleren", "git.usage_diff": "Gebruik: /diff", "git.usage_pull": "Gebruik: /pull", @@ -31,12 +35,20 @@ "git.pull_confirm_prompt_with_default": "Tak `{branch_name}` van `origin` pullen en ook de standaardtak `{default_branch}` verversen?", "git.pull_in_progress": "Tak `{branch_name}` wordt van `origin` gepulld...", "git.pull_in_progress_with_default": "Tak `{branch_name}` wordt van `origin` gepulld en de standaardtak `{default_branch}` wordt ververst...", + "git.reset_cancelled": "Git reset geannuleerd.", + "git.reset_confirm_button": "Reset bevestigen", + "git.reset_confirm_prompt": "De huidige tak resetten met `git reset --hard {target_ref}`?", + "git.reset_in_progress": "`git reset --hard {target_ref}` wordt uitgevoerd...", + "git.reset_pull_in_progress": "`{target_ref}` wordt vóór de reset gepulld...", + "git.reset_select_prompt": "Selecteer de tak waarnaar moet worden gereset:", "git.push_cancelled": "Push geannuleerd.", "git.push_cancelled_checkout_failed": "Push geannuleerd. Wisselen naar `{branch_name}` is eerst mislukt.", "git.push_confirm_button": "Push bevestigen", "git.push_confirm_prompt": "Tak `{branch_name}` naar `origin` pushen?", "git.push_in_progress": "Tak `{branch_name}` wordt naar `origin` gepusht...", "git.usage_push": "Gebruik: /push", + "git.usage_log": "Gebruik: /log", + "git.usage_reset": "Gebruik: /reset", "message.photo_only_codex": "Foto-bijlagen worden momenteel alleen ondersteund voor Codex-sessies.", "message.photo_blocked_by_pending_action": "Er wacht nog een actie van een eerder bericht op je antwoord (bijv. een compact/doorgaan-prompt). Los dat eerst op en stuur deze foto daarna opnieuw.", "message.question_queued": "Vraag in de wachtrij geplaatst als Q{question_number}. Deze wordt verwerkt nadat de huidige agenttaak is voltooid.", diff --git a/src/coding_agent_telegram/resources/locales/th.json b/src/coding_agent_telegram/resources/locales/th.json index 2cb110f..3c3fa82 100644 --- a/src/coding_agent_telegram/resources/locales/th.json +++ b/src/coding_agent_telegram/resources/locales/th.json @@ -6,10 +6,12 @@ "bot.command.diff": "แสดงชื่อไฟล์ที่เปลี่ยนไปเมื่อเทียบกับ HEAD", "bot.command.model": "เลือก model สำหรับเซสชันที่ใช้งานอยู่", "bot.command.new": "สร้างเซสชันใหม่", - "bot.command.pull": "pull เซสชัน สาขา ปัจจุบัน", + "bot.command.log": "แสดง 5 Git commit ล่าสุด", + "bot.command.pull": "Git pull เซสชัน สาขา ปัจจุบัน", "bot.command.project": "ตั้งค่าโฟลเดอร์โปรเจ็กต์ปัจจุบัน", "bot.command.provider": "เลือกผู้ให้บริการสำหรับเซสชันใหม่", - "bot.command.push": "push เซสชัน สาขา ปัจจุบัน", + "bot.command.push": "Git push เซสชัน สาขา ปัจจุบัน", + "bot.command.reset": "Git reset --hard ไปยังสาขา", "bot.command.switch": "แสดงรายการเซสชันหรือสลับเซสชัน", "bot.error.command_failed": "⚠️ คำสั่งล้มเหลว โปรดตรวจสอบบันทึกของเซิร์ฟเวอร์", "bot.error.session_store": "⚠️ {error}", @@ -21,6 +23,8 @@ "common.project_folder_missing": "⚠️ โฟลเดอร์โปรเจ็กต์สำหรับเซสชันนี้ไม่มีอยู่แล้ว: {project_folder}", "common.button_expired": "⚠️ ปุ่มนี้หมดอายุแล้ว โปรดลองสั่งคำสั่งอีกครั้ง", "git.branch_unknown": "⚠️ ไม่สามารถระบุ สาขา ของ เซสชัน ปัจจุบันได้", + "git.branch_discrepancy_warning": "⚠️ ตรวจพบสาขาไม่ตรงกัน สาขาของเซสชันที่ใช้งานคือ `{session_branch}` แต่ repository กำลัง checkout `{checked_out_branch}` อยู่ โปรดสลับไปยังสาขาที่ต้องการก่อนเรียกใช้คำสั่งนี้", + "git.detached_head_label": "HEAD แบบ detached", "git.cancel_button": "ยกเลิก", "git.pull_cancelled": "ยกเลิก pull แล้ว", "git.pull_completed": "pull เสร็จแล้ว", @@ -29,6 +33,12 @@ "git.pull_confirm_prompt_with_default": "ต้องการ pull default สาขา `{default_branch}` และ สาขา `{branch_name}` จาก `origin` หรือไม่?", "git.pull_in_progress": "กำลัง pull สาขา `{branch_name}` จาก `origin`...", "git.pull_in_progress_with_default": "กำลัง pull default สาขา `{default_branch}` และ สาขา `{branch_name}` จาก `origin`...", + "git.reset_cancelled": "ยกเลิก Git reset แล้ว", + "git.reset_confirm_button": "ยืนยัน reset", + "git.reset_confirm_prompt": "ต้องการ reset สาขาปัจจุบันด้วย `git reset --hard {target_ref}` หรือไม่?", + "git.reset_in_progress": "กำลังเรียกใช้ `git reset --hard {target_ref}`...", + "git.reset_pull_in_progress": "กำลัง pull `{target_ref}` ก่อน reset...", + "git.reset_select_prompt": "เลือกสาขาที่ต้องการ reset ไปยัง:", "git.push_cancelled": "ยกเลิก push แล้ว", "git.push_cancelled_checkout_failed": "ยกเลิก push แล้ว เนื่องจากสลับไป `{branch_name}` ไม่สำเร็จก่อน", "git.push_confirm_button": "ยืนยัน push", @@ -37,6 +47,8 @@ "git.usage_diff": "วิธีใช้: /diff", "git.usage_pull": "วิธีใช้: /pull", "git.usage_push": "วิธีใช้: /push", + "git.usage_log": "วิธีใช้: /log", + "git.usage_reset": "วิธีใช้: /reset", "message.photo_only_codex": "ขณะนี้รองรับไฟล์แนบรูปภาพเฉพาะสำหรับเซสชัน Codex เท่านั้น", "message.photo_blocked_by_pending_action": "มีการดำเนินการจากข้อความก่อนหน้านี้ที่ยังรอการตอบกลับของคุณอยู่ (เช่น พรอมต์ compact/ดำเนินการต่อ) กรุณาจัดการสิ่งนั้นให้เสร็จก่อน แล้วค่อยส่งรูปนี้ใหม่อีกครั้ง", "message.question_queued": "จัดคิวคำถามเป็น Q{question_number} แล้ว จะประมวลผลหลังจากงานเอเจนต์ปัจจุบันเสร็จสิ้น", diff --git a/src/coding_agent_telegram/resources/locales/vi.json b/src/coding_agent_telegram/resources/locales/vi.json index 3cf4d7d..26481f8 100644 --- a/src/coding_agent_telegram/resources/locales/vi.json +++ b/src/coding_agent_telegram/resources/locales/vi.json @@ -6,10 +6,12 @@ "bot.command.diff": "Hiển thị tên file đã thay đổi so với HEAD", "bot.command.model": "Chọn model cho phiên đang hoạt động", "bot.command.new": "Tạo phiên mới", - "bot.command.pull": "Pull nhánh của phiên hiện tại", + "bot.command.log": "Hiển thị 5 commit Git mới nhất", + "bot.command.pull": "Git pull nhánh của phiên hiện tại", "bot.command.project": "Đặt thư mục dự án hiện tại", "bot.command.provider": "Chọn nhà cung cấp cho phiên mới", - "bot.command.push": "Push nhánh của phiên hiện tại", + "bot.command.push": "Git push nhánh của phiên hiện tại", + "bot.command.reset": "Git reset --hard về một nhánh", "bot.command.switch": "Liệt kê hoặc chuyển phiên", "bot.error.command_failed": "⚠️ Lệnh thất bại. Hãy kiểm tra log của máy chủ.", "bot.error.session_store": "⚠️ {error}", @@ -21,6 +23,8 @@ "common.project_folder_missing": "⚠️ Thư mục dự án của phiên này không còn tồn tại: {project_folder}", "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.detached_head_label": "HEAD tách rời", "git.cancel_button": "Hủy", "git.pull_cancelled": "Đã hủy pull.", "git.pull_completed": "Đã pull xong.", @@ -29,6 +33,12 @@ "git.pull_confirm_prompt_with_default": "Pull nhánh mặc định `{default_branch}` và nhánh `{branch_name}` từ `origin`?", "git.pull_in_progress": "Đang pull nhánh `{branch_name}` từ `origin`...", "git.pull_in_progress_with_default": "Đang pull nhánh mặc định `{default_branch}` và nhánh `{branch_name}` từ `origin`...", + "git.reset_cancelled": "Đã hủy Git reset.", + "git.reset_confirm_button": "Xác nhận reset", + "git.reset_confirm_prompt": "Reset nhánh hiện tại bằng `git reset --hard {target_ref}`?", + "git.reset_in_progress": "Đang chạy `git reset --hard {target_ref}`...", + "git.reset_pull_in_progress": "Đang pull `{target_ref}` trước khi reset...", + "git.reset_select_prompt": "Chọn nhánh đích để reset:", "git.push_cancelled": "Đã hủy push.", "git.push_cancelled_checkout_failed": "Đã hủy push. Không thể chuyển sang `{branch_name}` trước.", "git.push_confirm_button": "Xác nhận push", @@ -37,6 +47,8 @@ "git.usage_diff": "Cách dùng: /diff", "git.usage_pull": "Cách dùng: /pull", "git.usage_push": "Cách dùng: /push", + "git.usage_log": "Cách dùng: /log", + "git.usage_reset": "Cách dùng: /reset", "message.photo_only_codex": "Hiện tại tệp đính kèm ảnh chỉ được hỗ trợ cho các phiên Codex.", "message.photo_blocked_by_pending_action": "Một hành động từ tin nhắn trước đó vẫn đang chờ phản hồi của bạn (ví dụ: lời nhắc compact/tiếp tục). Vui lòng giải quyết việc đó trước, sau đó gửi lại ảnh này.", "message.question_queued": "Câu hỏi đã được xếp hàng dưới dạng Q{question_number}. Nó sẽ được xử lý sau khi tác vụ hiện tại của tác nhân hoàn tất.", diff --git a/src/coding_agent_telegram/resources/locales/zh-CN.json b/src/coding_agent_telegram/resources/locales/zh-CN.json index 2d0c8be..1048a51 100644 --- a/src/coding_agent_telegram/resources/locales/zh-CN.json +++ b/src/coding_agent_telegram/resources/locales/zh-CN.json @@ -6,10 +6,12 @@ "bot.command.diff": "显示相对 HEAD 已变更的文件名", "bot.command.model": "为当前活动会话选择模型", "bot.command.new": "创建新会话", - "bot.command.pull": "拉取当前会话分支", + "bot.command.log": "显示最近 5 条 Git 提交", + "bot.command.pull": "Git 拉取当前会话分支", "bot.command.project": "设置当前项目目录", "bot.command.provider": "为新会话选择提供方", - "bot.command.push": "推送当前会话分支", + "bot.command.push": "Git 推送当前会话分支", + "bot.command.reset": "Git reset --hard 到指定分支", "bot.command.switch": "列出会话或切换会话", "bot.error.command_failed": "⚠️ 命令失败。请检查服务器日志。", "bot.error.session_store": "⚠️ {error}", @@ -21,6 +23,8 @@ "common.project_folder_missing": "⚠️ 此会话对应的项目目录已不存在:{project_folder}", "common.button_expired": "⚠️ 此按钮已过期。请重新执行命令。", "git.branch_unknown": "⚠️ 无法确定当前会话的分支。", + "git.branch_discrepancy_warning": "⚠️ 检测到分支不一致。活动会话分支为 `{session_branch}`,但仓库当前检出的分支为 `{checked_out_branch}`。请先切换到预期分支,再运行此命令。", + "git.detached_head_label": "分离的 HEAD", "git.cancel_button": "取消", "git.pull_cancelled": "已取消拉取。", "git.pull_completed": "已完成拉取。", @@ -29,6 +33,12 @@ "git.pull_confirm_prompt_with_default": "要从 `origin` 拉取默认分支 `{default_branch}` 和分支 `{branch_name}` 吗?", "git.pull_in_progress": "正在从 `origin` 拉取分支 `{branch_name}`...", "git.pull_in_progress_with_default": "正在从 `origin` 拉取默认分支 `{default_branch}` 和分支 `{branch_name}`...", + "git.reset_cancelled": "已取消 Git reset。", + "git.reset_confirm_button": "确认 reset", + "git.reset_confirm_prompt": "要对当前分支执行 `git reset --hard {target_ref}` 吗?", + "git.reset_in_progress": "正在执行 `git reset --hard {target_ref}`...", + "git.reset_pull_in_progress": "正在 reset 前拉取 `{target_ref}`...", + "git.reset_select_prompt": "请选择 reset 的目标分支:", "git.push_cancelled": "已取消推送。", "git.push_cancelled_checkout_failed": "已取消 push。先切换到 `{branch_name}` 失败。", "git.push_confirm_button": "确认推送", @@ -37,6 +47,8 @@ "git.usage_diff": "用法:/diff", "git.usage_pull": "用法:/pull", "git.usage_push": "用法:/push", + "git.usage_log": "用法:/log", + "git.usage_reset": "用法:/reset", "message.photo_only_codex": "当前仅 Codex 会话支持图片附件。", "message.photo_blocked_by_pending_action": "之前一条消息触发的操作仍在等待你的回复(例如 compact/继续 的确认提示)。请先处理完那个,再重新发送这张图片。", "message.question_queued": "问题已加入队列,编号为 Q{question_number}。当前代理任务完成后将开始处理。", diff --git a/src/coding_agent_telegram/resources/locales/zh-HK.json b/src/coding_agent_telegram/resources/locales/zh-HK.json index 70c04ec..109c455 100644 --- a/src/coding_agent_telegram/resources/locales/zh-HK.json +++ b/src/coding_agent_telegram/resources/locales/zh-HK.json @@ -6,10 +6,12 @@ "bot.command.diff": "顯示相對 HEAD 已變更的檔案名稱", "bot.command.model": "為使用中工作階段選擇模型", "bot.command.new": "建立新工作階段", - "bot.command.pull": "拉取目前工作階段分支", + "bot.command.log": "顯示最近 5 個 Git 提交", + "bot.command.pull": "Git 拉取目前工作階段分支", "bot.command.project": "設定目前專案資料夾", "bot.command.provider": "為新工作階段選擇供應方", - "bot.command.push": "推送目前工作階段分支", + "bot.command.push": "Git 推送目前工作階段分支", + "bot.command.reset": "Git reset --hard 至指定分支", "bot.command.switch": "列出工作階段或切換", "bot.error.command_failed": "⚠️ 指令失敗。請檢查伺服器日誌。", "bot.error.session_store": "⚠️ {error}", @@ -21,6 +23,8 @@ "common.project_folder_missing": "⚠️ 此工作階段的專案資料夾已不存在:{project_folder}", "common.button_expired": "⚠️ 此按鈕已過期。請重新執行命令。", "git.branch_unknown": "⚠️ 無法判斷目前工作階段的分支。", + "git.branch_discrepancy_warning": "⚠️ 偵測到分支不一致。目前工作階段分支為 `{session_branch}`,但儲存庫目前檢出的分支為 `{checked_out_branch}`。請先切換至預期分支,再執行此指令。", + "git.detached_head_label": "分離的 HEAD", "git.cancel_button": "取消", "git.pull_cancelled": "已取消拉取。", "git.pull_completed": "已完成拉取。", @@ -29,6 +33,12 @@ "git.pull_confirm_prompt_with_default": "要從 `origin` 拉取預設分支 `{default_branch}` 與分支 `{branch_name}` 嗎?", "git.pull_in_progress": "正在從 `origin` 拉取分支 `{branch_name}`...", "git.pull_in_progress_with_default": "正在從 `origin` 拉取預設分支 `{default_branch}` 與分支 `{branch_name}`...", + "git.reset_cancelled": "已取消 Git reset。", + "git.reset_confirm_button": "確認 reset", + "git.reset_confirm_prompt": "要對目前分支執行 `git reset --hard {target_ref}` 嗎?", + "git.reset_in_progress": "正在執行 `git reset --hard {target_ref}`...", + "git.reset_pull_in_progress": "正在 reset 前拉取 `{target_ref}`...", + "git.reset_select_prompt": "請選擇 reset 的目標分支:", "git.push_cancelled": "已取消推送。", "git.push_cancelled_checkout_failed": "已取消 push。先切換到 `{branch_name}` 失敗。", "git.push_confirm_button": "確認推送", @@ -37,6 +47,8 @@ "git.usage_diff": "用法:/diff", "git.usage_pull": "用法:/pull", "git.usage_push": "用法:/push", + "git.usage_log": "用法:/log", + "git.usage_reset": "用法:/reset", "message.photo_only_codex": "目前只有 Codex 工作階段支援圖片附件。", "message.photo_blocked_by_pending_action": "先前一則訊息觸發的操作仍在等待你的回覆(例如 compact/繼續 的確認提示)。請先處理完那個,再重新傳送這張圖片。", "message.question_queued": "問題已加入佇列,編號為 Q{question_number}。目前代理工作完成後會開始處理。", diff --git a/src/coding_agent_telegram/resources/locales/zh-TW.json b/src/coding_agent_telegram/resources/locales/zh-TW.json index b214281..740421a 100644 --- a/src/coding_agent_telegram/resources/locales/zh-TW.json +++ b/src/coding_agent_telegram/resources/locales/zh-TW.json @@ -6,10 +6,12 @@ "bot.command.diff": "顯示相對 HEAD 已變更的檔案名稱", "bot.command.model": "為使用中的工作階段選擇模型", "bot.command.new": "建立新工作階段", - "bot.command.pull": "拉取目前工作階段分支", + "bot.command.log": "顯示最近 5 個 Git 提交", + "bot.command.pull": "Git 拉取目前工作階段分支", "bot.command.project": "設定目前專案資料夾", "bot.command.provider": "為新工作階段選擇提供者", - "bot.command.push": "推送目前工作階段分支", + "bot.command.push": "Git 推送目前工作階段分支", + "bot.command.reset": "Git reset --hard 至指定分支", "bot.command.switch": "列出工作階段或切換", "bot.error.command_failed": "⚠️ 指令失敗。請檢查伺服器日誌。", "bot.error.session_store": "⚠️ {error}", @@ -21,6 +23,8 @@ "common.project_folder_missing": "⚠️ 此工作階段的專案資料夾已不存在:{project_folder}", "common.button_expired": "⚠️ 此按鈕已過期。請重新執行命令。", "git.branch_unknown": "⚠️ 無法判斷目前工作階段的分支。", + "git.branch_discrepancy_warning": "⚠️ 偵測到分支不一致。目前工作階段分支為 `{session_branch}`,但儲存庫目前簽出的分支為 `{checked_out_branch}`。請先切換至預期分支,再執行此指令。", + "git.detached_head_label": "分離的 HEAD", "git.cancel_button": "取消", "git.pull_cancelled": "已取消拉取。", "git.pull_completed": "已完成拉取。", @@ -29,6 +33,12 @@ "git.pull_confirm_prompt_with_default": "要從 `origin` 拉取預設分支 `{default_branch}` 與分支 `{branch_name}` 嗎?", "git.pull_in_progress": "正在從 `origin` 拉取分支 `{branch_name}`...", "git.pull_in_progress_with_default": "正在從 `origin` 拉取預設分支 `{default_branch}` 與分支 `{branch_name}`...", + "git.reset_cancelled": "已取消 Git reset。", + "git.reset_confirm_button": "確認 reset", + "git.reset_confirm_prompt": "要對目前分支執行 `git reset --hard {target_ref}` 嗎?", + "git.reset_in_progress": "正在執行 `git reset --hard {target_ref}`...", + "git.reset_pull_in_progress": "正在 reset 前拉取 `{target_ref}`...", + "git.reset_select_prompt": "請選擇 reset 的目標分支:", "git.push_cancelled": "已取消推送。", "git.push_cancelled_checkout_failed": "已取消 push。先切換到 `{branch_name}` 失敗。", "git.push_confirm_button": "確認推送", @@ -37,6 +47,8 @@ "git.usage_diff": "用法:/diff", "git.usage_pull": "用法:/pull", "git.usage_push": "用法:/push", + "git.usage_log": "用法:/log", + "git.usage_reset": "用法:/reset", "message.photo_only_codex": "目前只有 Codex 工作階段支援圖片附件。", "message.photo_blocked_by_pending_action": "先前一則訊息觸發的操作仍在等待你的回覆(例如 compact/繼續 的確認提示)。請先處理完那個,再重新傳送這張圖片。", "message.question_queued": "問題已加入佇列,編號為 Q{question_number}。目前代理工作完成後會開始處理。", diff --git a/src/coding_agent_telegram/router/git_commands.py b/src/coding_agent_telegram/router/git_commands.py index 7c8d948..ebe673d 100644 --- a/src/coding_agent_telegram/router/git_commands.py +++ b/src/coding_agent_telegram/router/git_commands.py @@ -3,6 +3,8 @@ import asyncio import html import os +import secrets +from contextlib import asynccontextmanager from types import SimpleNamespace from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update @@ -16,6 +18,12 @@ class GitCommandMixin: DIFF_BUTTON_PAGE_SIZE = 10 + MAX_DIFF_SNAPSHOTS = 500 + MAX_COMMIT_GENERATION_PROMPTS = 500 + MAX_GENERATED_COMMIT_COMMANDS = 500 + MAX_GIT_CONFIRMATIONS = 500 + MAX_RESET_PROMPTS = 500 + MAX_RESET_SELECTIONS = 500 COMMIT_GENERATION_PROMPT = ( 'Execute: Analyze and compare to git HEAD, then Generate a git commit command for the files you changed in this task, with a detailed changelog-style commit message. ' 'Only include files you intentionally modified for this task. ' @@ -34,9 +42,23 @@ def _diff_button_label(index: int, path: str, *, max_name_length: int = 20) -> s name = f"{name[: max_name_length - 1]}…" return f"{index}. {name}" - def _build_diff_button_rows(self, update: Update, tracked_files: list[str], *, page: int) -> list[list[InlineKeyboardButton]]: + @staticmethod + def _diff_display_path(path: str, *, max_length: int = 100) -> str: + display = path.replace("\n", "\\n").replace("\r", "\\r") + if len(display) > max_length: + return f"{display[: max_length - 1]}…" + return display + + def _build_diff_button_rows( + self, + update: Update, + tracked_files: list[str], + *, + page: int, + token: str, + total_pages: int, + ) -> list[list[InlineKeyboardButton]]: rows: list[list[InlineKeyboardButton]] = [] - total_pages = max(1, (len(tracked_files) + self.DIFF_BUTTON_PAGE_SIZE - 1) // self.DIFF_BUTTON_PAGE_SIZE) page = min(max(page, 0), total_pages - 1) start = page * self.DIFF_BUTTON_PAGE_SIZE page_files = tracked_files[start : start + self.DIFF_BUTTON_PAGE_SIZE] @@ -46,15 +68,15 @@ def _build_diff_button_rows(self, update: Update, tracked_files: list[str], *, p [ InlineKeyboardButton( self._diff_button_label(absolute_index, path), - callback_data=f"diffshow:{absolute_index - 1}", + callback_data=f"diffshow:{token}:{absolute_index - 1}", ) ] ) nav_row: list[InlineKeyboardButton] = [] if page > 0: - nav_row.append(InlineKeyboardButton(self._t(update, "diff.button_prev_page"), callback_data=f"diffpage:{page - 1}")) + nav_row.append(InlineKeyboardButton(self._t(update, "diff.button_prev_page"), callback_data=f"diffpage:{token}:{page - 1}")) if page < total_pages - 1: - nav_row.append(InlineKeyboardButton(self._t(update, "diff.button_next_page"), callback_data=f"diffpage:{page + 1}")) + nav_row.append(InlineKeyboardButton(self._t(update, "diff.button_next_page"), callback_data=f"diffpage:{token}:{page + 1}")) if nav_row: rows.append(nav_row) return rows @@ -68,11 +90,15 @@ def _build_diff_message( tracked_files: list[str], untracked_files: list[str], page: int, + token: str, ) -> tuple[str, InlineKeyboardMarkup | None]: - total_pages = max(1, (len(tracked_files) + self.DIFF_BUTTON_PAGE_SIZE - 1) // self.DIFF_BUTTON_PAGE_SIZE) + tracked_pages = (len(tracked_files) + self.DIFF_BUTTON_PAGE_SIZE - 1) // self.DIFF_BUTTON_PAGE_SIZE + untracked_pages = (len(untracked_files) + self.DIFF_BUTTON_PAGE_SIZE - 1) // self.DIFF_BUTTON_PAGE_SIZE + total_pages = max(1, tracked_pages, untracked_pages) page = min(max(page, 0), total_pages - 1) start = page * self.DIFF_BUTTON_PAGE_SIZE page_files = tracked_files[start : start + self.DIFF_BUTTON_PAGE_SIZE] + page_untracked_files = untracked_files[start : start + self.DIFF_BUTTON_PAGE_SIZE] lines = [ self._t(update, "diff.session_label", session_name=session["name"]), f"{self._t(update, 'diff.project_label', project_folder=session['project_folder'])} <{branch_name}>", @@ -90,17 +116,37 @@ def _build_diff_message( total=len(tracked_files), ) ) - lines.extend(f"{start + index}. {path}" for index, path in enumerate(page_files, start=1)) + lines.extend( + f"{start + index}. {self._diff_display_path(path)}" + for index, path in enumerate(page_files, start=1) + ) else: lines.append(f"- {self._t(update, 'diff.none')}") lines.extend(["", self._t(update, "diff.untracked_files")]) - if untracked_files: - lines.extend(f"- {path}" for path in untracked_files) + if page_untracked_files: + if len(untracked_files) > len(page_untracked_files): + lines.append( + self._t( + update, + "diff.tracked_files_page_info", + start=start + 1, + end=start + len(page_untracked_files), + total=len(untracked_files), + ) + ) + lines.extend(f"- {self._diff_display_path(path)}" for path in page_untracked_files) else: lines.append(f"- {self._t(update, 'diff.none')}") - if tracked_files: + if page_files: lines.extend(["", self._t(update, "diff.click_button_to_see_file_diff")]) - reply_markup = InlineKeyboardMarkup(self._build_diff_button_rows(update, tracked_files, page=page)) if tracked_files else None + rows = self._build_diff_button_rows( + update, + tracked_files, + page=page, + token=token, + total_pages=total_pages, + ) + reply_markup = InlineKeyboardMarkup(rows) if rows else None return "\n".join(lines), reply_markup async def _refresh_branch_with_checkout( @@ -122,13 +168,224 @@ async def _refresh_branch_with_checkout( return False, result.message, () return True, result.message, tuple(result.warnings) - def _generated_commit_commands(self) -> dict[int, dict[str, str]]: + def _generated_commit_commands(self) -> dict[str, dict[str, str]]: commands = getattr(self, "_chat_generated_commit_commands", None) if not isinstance(commands, dict): commands = {} self._chat_generated_commit_commands = commands return commands + def _commit_generation_prompts(self) -> dict[str, dict[str, str]]: + prompts = getattr(self, "_chat_commit_generation_prompts", None) + if not isinstance(prompts, dict): + prompts = {} + self._chat_commit_generation_prompts = prompts + return prompts + + def _diff_snapshots(self) -> dict[str, dict[str, object]]: + snapshots = getattr(self, "_chat_diff_snapshots", None) + if not isinstance(snapshots, dict): + snapshots = {} + self._chat_diff_snapshots = snapshots + return snapshots + + def _git_confirmations(self) -> dict[str, dict[str, str]]: + confirmations = getattr(self, "_chat_git_confirmations", None) + if not isinstance(confirmations, dict): + confirmations = {} + self._chat_git_confirmations = confirmations + return confirmations + + @staticmethod + def _new_unique_token(records: dict[str, object]) -> str: + while True: + token = secrets.token_hex(6) + if token not in records: + return token + + @staticmethod + def _escape_markdown_code_value(value: object) -> str: + return str(value).replace("\\", "\\\\").replace("`", "\\`") + + @staticmethod + def _store_bounded_record(records: dict[str, object], token: str, payload: object, *, limit: int) -> None: + if len(records) >= limit: + records.pop(next(iter(records)), None) + records[token] = payload + + def _register_git_confirmation( + self, + *, + chat_id: int, + session: dict[str, object], + action: str, + branch_name: str, + default_branch: str = "", + ) -> str: + chat_state = self.deps.store.get_chat_state(self.deps.bot_id, chat_id) + confirmations = self._git_confirmations() + token = self._new_unique_token(confirmations) + self._store_bounded_record( + confirmations, + token, + { + "action": action, + "chat_id": str(chat_id), + "session_id": str(chat_state.get("active_session_id") or ""), + "project_folder": str(session["project_folder"]), + "branch_name": branch_name, + "default_branch": default_branch, + }, + limit=self.MAX_GIT_CONFIRMATIONS, + ) + return token + + def _get_git_confirmation(self, token: str, *, chat_id: int, action: str) -> dict[str, str] | None: + confirmation = self._git_confirmations().get(token) + if ( + confirmation is None + or confirmation.get("chat_id") != str(chat_id) + or confirmation.get("action") != action + ): + return None + return confirmation + + def _reset_selections(self) -> dict[str, dict[str, str]]: + selections = getattr(self, "_chat_reset_selections", None) + if not isinstance(selections, dict): + selections = {} + self._chat_reset_selections = selections + return selections + + def _reset_prompts(self) -> dict[str, dict[str, str]]: + prompts = getattr(self, "_chat_reset_prompts", None) + if not isinstance(prompts, dict): + prompts = {} + self._chat_reset_prompts = prompts + return prompts + + @asynccontextmanager + async def _workspace_git_operation_lock( + self, + update: Update, + context: ContextTypes.DEFAULT_TYPE, + project_folder: str, + ): + lock = self._workspace_locks.setdefault(project_folder, asyncio.Lock()) + if lock.locked(): + await send_text( + update, + context, + self._t(update, "common.project_busy", project_folder=project_folder), + ) + yield False + return + async with lock: + yield True + + def _new_reset_selection_token(self) -> str: + selections = self._reset_selections() + while True: + token = secrets.token_hex(6) + if token not in selections: + return token + + @staticmethod + def _reset_target(current_branch: str, default_branch: str, target_kind: str) -> tuple[str, bool] | None: + targets = { + "local-default": (default_branch, False), + "origin-default": (f"origin/{default_branch}", True), + "local-current": (current_branch, False), + "origin-current": (f"origin/{current_branch}", True), + } + target = targets.get(target_kind) + if target is None or not target[0] or target[0] == "origin/": + return None + return target + + async def _restore_reset_branch( + self, + project_path, + branch_name: str, + ) -> tuple[bool, str | None]: + if self.git.current_branch(project_path) == branch_name: + return True, None + checkout = await asyncio.to_thread(self.git.checkout_branch, project_path, branch_name) + if checkout.success: + return True, None + return False, checkout.message + + async def _warn_if_session_branch_discrepancy( + self, + update: Update, + context: ContextTypes.DEFAULT_TYPE, + session: dict[str, object], + project_path, + ) -> bool: + session_branch = str(session.get("branch_name") or "").strip() + checked_out_branch = str(self.git.current_branch(project_path) or "").strip() + 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") + await send_text( + update, + context, + self._t( + update, + "git.branch_discrepancy_warning", + session_branch=session_branch, + checked_out_branch=checked_out_label, + ), + ) + return True + + async def _execute_confirmed_reset( + self, + update: Update, + context: ContextTypes.DEFAULT_TYPE, + query, + selection: dict[str, str], + project_path, + ) -> None: + target_ref = selection["target_ref"] + if selection["is_origin"] == "True": + await query.edit_message_text( + self._t(update, "git.reset_pull_in_progress", target_ref=self._escape_markdown_code_value(target_ref)), + parse_mode="Markdown", + ) + ok, message, warnings = await self._refresh_branch_with_checkout( + update, + context, + project_path=project_path, + branch_name=selection["target_branch"], + ) + restored, restore_message = await self._restore_reset_branch(project_path, selection["reset_branch"]) + if not restored: + await send_text(update, context, restore_message or self._t(update, "bot.error.command_failed")) + return + if not ok: + await send_text(update, context, message or self._t(update, "bot.error.command_failed")) + return + if warnings: + await send_text(update, context, "\n".join([self._t(update, "project.refresh_warnings"), *[f"- {warning}" for warning in warnings]])) + return + else: + restored, restore_message = await self._restore_reset_branch(project_path, selection["reset_branch"]) + if not restored: + await query.edit_message_text(restore_message or self._t(update, "bot.error.command_failed")) + return + + await query.edit_message_text( + self._t(update, "git.reset_in_progress", target_ref=self._escape_markdown_code_value(target_ref)), + parse_mode="Markdown", + ) + result = await asyncio.to_thread(self.git.run_git_command, project_path, ["reset", "--hard", target_ref]) + await send_html_text( + update, + context, + self._bash_block(self._format_git_response([(["reset", "--hard", target_ref], result)], [])), + ) + def _extract_generated_commit_command(self, assistant_text: str) -> str | None: for segment in split_assistant_output(assistant_text or ""): if segment.kind != "code": @@ -171,17 +428,33 @@ async def handle_commit(self, update: Update, context: ContextTypes.DEFAULT_TYPE ) if session is None or project_path is None: return + if await self._warn_if_session_branch_discrepancy(update, context, session, project_path): + return + chat_state = self.deps.store.get_chat_state(self.deps.bot_id, update.effective_chat.id) + prompts = self._commit_generation_prompts() + token = self._new_unique_token(prompts) + self._store_bounded_record( + prompts, + token, + { + "chat_id": str(update.effective_chat.id), + "session_id": str(chat_state.get("active_session_id") or ""), + "project_folder": str(session["project_folder"]), + "branch_name": str(session.get("branch_name") or self.git.current_branch(project_path) or ""), + }, + limit=self.MAX_COMMIT_GENERATION_PROMPTS, + ) confirm_markup = InlineKeyboardMarkup( [ [ InlineKeyboardButton( self._t(update, "git.commit_generate_button"), - callback_data="commitgen:confirm", + callback_data=f"commitgen:confirm:{token}", **self._affirmative_inline_button_kwargs(), ), InlineKeyboardButton( self._t(update, "git.cancel_button"), - callback_data="commitgen:cancel", + callback_data=f"commitgen:cancel:{token}", **self._negative_inline_button_kwargs(), ), ] @@ -201,6 +474,8 @@ async def handle_commit(self, update: Update, context: ContextTypes.DEFAULT_TYPE ) if session is None or project_path is None: return + if await self._warn_if_session_branch_discrepancy(update, context, session, project_path): + return commands, ignored = self._validated_commit_commands(raw) if not commands: @@ -217,20 +492,27 @@ async def handle_commit(self, update: Update, context: ContextTypes.DEFAULT_TYPE await send_text(update, context, self._t(update, "git.unsafe_path_arguments")) return - command_results: list[tuple[list[str], object]] = [] - for args in commands: - executed_args = self._effective_git_args(args) - result = await asyncio.to_thread(self.git.run_safe_commit_command, project_path, executed_args) - command_results.append((executed_args, result)) - if not result.success: - await send_html_text( - update, - context, - self._bash_block(self._format_git_response(command_results, ignored)), - ) + async with self._workspace_git_operation_lock( + update, + context, + str(session["project_folder"]), + ) as acquired: + if not acquired: return + command_results: list[tuple[list[str], object]] = [] + for args in commands: + executed_args = self._effective_git_args(args) + result = await asyncio.to_thread(self.git.run_safe_commit_command, project_path, executed_args) + command_results.append((executed_args, result)) + if not result.success: + await send_html_text( + update, + context, + self._bash_block(self._format_git_response(command_results, ignored)), + ) + return - await send_html_text(update, context, self._bash_block(self._format_git_response(command_results, ignored))) + await send_html_text(update, context, self._bash_block(self._format_git_response(command_results, ignored))) @require_allowed_chat() async def handle_diff(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -245,12 +527,30 @@ async def handle_diff(self, update: Update, context: ContextTypes.DEFAULT_TYPE) ) if session is None or project_path is None: return + if await self._warn_if_session_branch_discrepancy(update, context, session, project_path): + return branch_name = session.get("branch_name") or self.git.current_branch(project_path) or self._t( update, "status.current_branch_placeholder", ) tracked_files, untracked_files = split_changed_files(project_path) + chat_state = self.deps.store.get_chat_state(self.deps.bot_id, update.effective_chat.id) + snapshots = self._diff_snapshots() + token = self._new_unique_token(snapshots) + self._store_bounded_record( + snapshots, + token, + { + "chat_id": str(update.effective_chat.id), + "session_id": str(chat_state.get("active_session_id") or ""), + "project_folder": str(session["project_folder"]), + "branch_name": str(branch_name), + "tracked_files": tuple(tracked_files), + "untracked_files": tuple(untracked_files), + }, + limit=self.MAX_DIFF_SNAPSHOTS, + ) text, reply_markup = self._build_diff_message( update, session, @@ -258,6 +558,7 @@ async def handle_diff(self, update: Update, context: ContextTypes.DEFAULT_TYPE) tracked_files=tracked_files, untracked_files=untracked_files, page=0, + token=token, ) await context.bot.send_message( chat_id=update.effective_chat.id, @@ -274,30 +575,53 @@ async def handle_diff_callback(self, update: Update, context: ContextTypes.DEFAU await query.answer() data = (query.data or "").strip() - if data.startswith("diffpage:"): - try: - page = int(data.partition(":")[2]) - except ValueError: - return - session, project_path = await self._active_session_project_or_notify( - update, - context, - require_git_repo=True, - ) - if session is None or project_path is None: - return + parts = data.split(":") + if len(parts) != 3: + return + action, token, raw_index = parts + snapshot = self._diff_snapshots().get(token) + if snapshot is None or snapshot.get("chat_id") != str(update.effective_chat.id): + await query.edit_message_text(self._t(update, "common.button_expired")) + return + try: + index = int(raw_index) + except ValueError: + return + + session, project_path = await self._active_session_project_or_notify( + update, + context, + require_git_repo=True, + ) + if session is None or project_path is None: + return + chat_state = self.deps.store.get_chat_state(self.deps.bot_id, update.effective_chat.id) + active_branch = str(session.get("branch_name") or self.git.current_branch(project_path) or "").strip() + if ( + str(chat_state.get("active_session_id") or "") != snapshot.get("session_id") + or str(session["project_folder"]) != snapshot.get("project_folder") + or active_branch != snapshot.get("branch_name") + ): + await query.edit_message_text(self._t(update, "common.button_expired")) + return + if await self._warn_if_session_branch_discrepancy(update, context, session, project_path): + return + + tracked_files = list(snapshot.get("tracked_files") or ()) + untracked_files = list(snapshot.get("untracked_files") or ()) + if action == "diffpage": branch_name = session.get("branch_name") or self.git.current_branch(project_path) or self._t( update, "status.current_branch_placeholder", ) - tracked_files, untracked_files = split_changed_files(project_path) text, reply_markup = self._build_diff_message( update, session, branch_name=branch_name, tracked_files=tracked_files, untracked_files=untracked_files, - page=page, + page=index, + token=token, ) await query.edit_message_text( text=html.escape(text), @@ -305,27 +629,13 @@ async def handle_diff_callback(self, update: Update, context: ContextTypes.DEFAU reply_markup=reply_markup, ) return - if not data.startswith("diffshow:"): + if action != "diffshow": return - try: - file_index = int(data.partition(":")[2]) - except ValueError: - return - - session, project_path = await self._active_session_project_or_notify( - update, - context, - require_git_repo=True, - ) - if session is None or project_path is None: - return - - tracked_files, _ = split_changed_files(project_path) - if file_index < 0 or file_index >= len(tracked_files): + if index < 0 or index >= len(tracked_files): await send_text(update, context, self._t(update, "diff.none")) return - file_path = tracked_files[file_index] + file_path = tracked_files[index] diffs = collect_diffs(project_path, [file_path], include_cached=True) if not diffs: await send_text(update, context, self._t(update, "diff.none")) @@ -357,12 +667,43 @@ async def handle_commit_generate_callback(self, update: Update, context: Context await query.answer() action = (query.data or "").strip() - if action == "commitgen:cancel": + parts = action.split(":") + if len(parts) != 3 or parts[0] != "commitgen" or parts[1] not in {"confirm", "cancel"}: + return + _, choice, token = parts + prompt = self._commit_generation_prompts().get(token) + if prompt is None or prompt.get("chat_id") != str(update.effective_chat.id): + await query.edit_message_text(self._t(update, "common.button_expired")) + return + if choice == "cancel": + self._commit_generation_prompts().pop(token, None) await query.edit_message_text(self._t(update, "git.commit_generate_cancelled")) return - if action != "commitgen:confirm": + if await self._notify_if_current_project_busy(update, context): return + session, project_path = await self._active_session_project_or_notify( + update, + context, + require_git_repo=True, + ) + if session is None or project_path is None: + return + chat_state = self.deps.store.get_chat_state(self.deps.bot_id, update.effective_chat.id) + active_session_id = str(chat_state.get("active_session_id") or "").strip() + active_branch = str(session.get("branch_name") or self.git.current_branch(project_path) or "").strip() + if ( + active_session_id != prompt["session_id"] + or str(session["project_folder"]) != prompt["project_folder"] + or active_branch != prompt["branch_name"] + ): + self._commit_generation_prompts().pop(token, None) + await query.edit_message_text(self._t(update, "git.commit_execute_context_changed")) + return + if await self._warn_if_session_branch_discrepancy(update, context, session, project_path): + return + self._commit_generation_prompts().pop(token, None) + generated_command = await self._generate_commit_command_with_provider(update, context) if generated_command is None: await query.edit_message_text(self._t(update, "git.no_valid_commit_commands")) @@ -377,23 +718,39 @@ async def handle_commit_generate_callback(self, update: Update, context: Context if not isinstance(session, dict): await query.edit_message_text(self._t(update, "common.no_active_session")) return - - self._generated_commit_commands()[update.effective_chat.id] = { - "command": generated_command, - "session_id": active_session_id, - "project_folder": str(session.get("project_folder") or ""), - } + generated_branch = str(session.get("branch_name") or self.git.current_branch(project_path) or "").strip() + if ( + active_session_id != prompt["session_id"] + or str(session.get("project_folder") or "") != prompt["project_folder"] + or generated_branch != prompt["branch_name"] + ): + await query.edit_message_text(self._t(update, "git.commit_execute_context_changed")) + return + commands = self._generated_commit_commands() + command_token = self._new_unique_token(commands) + self._store_bounded_record( + commands, + command_token, + { + "chat_id": str(update.effective_chat.id), + "command": generated_command, + "session_id": active_session_id, + "project_folder": str(session.get("project_folder") or ""), + "branch_name": generated_branch, + }, + limit=self.MAX_GENERATED_COMMIT_COMMANDS, + ) execute_markup = InlineKeyboardMarkup( [ [ InlineKeyboardButton( self._t(update, "git.commit_execute_button"), - callback_data="commitexec:confirm", + callback_data=f"commitexec:confirm:{command_token}", **self._affirmative_inline_button_kwargs(), ), InlineKeyboardButton( self._t(update, "git.cancel_button"), - callback_data="commitexec:cancel", + callback_data=f"commitexec:cancel:{command_token}", **self._negative_inline_button_kwargs(), ), ] @@ -410,6 +767,8 @@ async def _generate_commit_command_with_provider(self, update: Update, context: ) if session is None or project_path is None: return + if await self._warn_if_session_branch_discrepancy(update, context, session, project_path): + return result = await self.runtime.run_active_session(update, context, user_message=self.COMMIT_GENERATION_PROMPT) if result is None or not result.success: @@ -424,30 +783,49 @@ async def handle_commit_execute_callback(self, update: Update, context: ContextT await query.answer() action = (query.data or "").strip() - if action == "commitexec:cancel": + parts = action.split(":") + if len(parts) != 3 or parts[0] != "commitexec" or parts[1] not in {"confirm", "cancel"}: + return + _, choice, token = parts + payload = self._generated_commit_commands().get(token) + if payload is None or payload.get("chat_id") != str(update.effective_chat.id): + await query.edit_message_text(self._t(update, "common.button_expired")) + return + if choice == "cancel": + self._generated_commit_commands().pop(token, None) await query.edit_message_text(self._t(update, "git.commit_generate_cancelled")) return - if action != "commitexec:confirm": + if await self._notify_if_current_project_busy(update, context): return - - payload = self._generated_commit_commands().get(update.effective_chat.id) - if payload is None: - await query.edit_message_text(self._t(update, "git.no_valid_commit_commands")) + active_session, active_project_path = await self._active_session_project_or_notify( + update, + context, + require_git_repo=True, + ) + if active_session is None or active_project_path is None: return chat_state = self.deps.store.get_chat_state(self.deps.bot_id, update.effective_chat.id) active_session_id = str(chat_state.get("active_session_id") or "").strip() - active_session = chat_state.get("sessions", {}).get(active_session_id) if active_session_id else None - active_project_folder = str(active_session.get("project_folder") or "") if isinstance(active_session, dict) else "" + active_project_folder = str(active_session.get("project_folder") or "") + active_branch = str(active_session.get("branch_name") or self.git.current_branch(active_project_path) or "").strip() if ( active_session_id != str(payload.get("session_id") or "") or active_project_folder != str(payload.get("project_folder") or "") + or active_branch != str(payload.get("branch_name") or "") ): - self._generated_commit_commands().pop(update.effective_chat.id, None) + self._generated_commit_commands().pop(token, None) await query.edit_message_text(self._t(update, "git.commit_execute_context_changed")) return + if await self._warn_if_session_branch_discrepancy( + update, + context, + active_session, + active_project_path, + ): + return command = str(payload.get("command") or "").strip() if not command: - self._generated_commit_commands().pop(update.effective_chat.id, None) + self._generated_commit_commands().pop(token, None) await query.edit_message_text(self._t(update, "git.no_valid_commit_commands")) return @@ -460,7 +838,7 @@ async def handle_commit_execute_callback(self, update: Update, context: ContextT try: await self.handle_commit(synthetic_update, synthetic_context) finally: - self._generated_commit_commands().pop(update.effective_chat.id, None) + self._generated_commit_commands().pop(token, None) @require_allowed_chat() async def handle_push(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -477,23 +855,31 @@ async def handle_push(self, update: Update, context: ContextTypes.DEFAULT_TYPE) ) if session is None or project_path is None: return + if await self._warn_if_session_branch_discrepancy(update, context, session, project_path): + return branch_name = session.get("branch_name") or self.git.current_branch(project_path) if not branch_name: await send_text(update, context, self._t(update, "git.branch_unknown")) return + token = self._register_git_confirmation( + chat_id=update.effective_chat.id, + session=session, + action="push", + branch_name=str(branch_name), + ) confirm_markup = InlineKeyboardMarkup( [ [ InlineKeyboardButton( self._t(update, "git.push_confirm_button"), - callback_data="push:confirm", + callback_data=f"push:confirm:{token}", **self._affirmative_inline_button_kwargs(), ), InlineKeyboardButton( self._t(update, "git.cancel_button"), - callback_data="push:cancel", + callback_data=f"push:cancel:{token}", **self._negative_inline_button_kwargs(), ), ] @@ -501,11 +887,236 @@ async def handle_push(self, update: Update, context: ContextTypes.DEFAULT_TYPE) ) await context.bot.send_message( chat_id=update.effective_chat.id, - text=self._t(update, "git.push_confirm_prompt", branch_name=branch_name), + text=self._t(update, "git.push_confirm_prompt", branch_name=self._escape_markdown_code_value(branch_name)), parse_mode="Markdown", reply_markup=confirm_markup, ) + @require_allowed_chat() + async def handle_log(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if await self._notify_if_current_project_busy(update, context): + return + if context.args: + await send_text(update, context, self._t(update, "git.usage_log")) + return + + session, project_path = await self._active_session_project_or_notify( + update, + context, + require_git_repo=True, + ) + if session is None or project_path is None: + return + if await self._warn_if_session_branch_discrepancy(update, context, session, project_path): + return + + result = await asyncio.to_thread(self.git.run_git_command, project_path, ["log", "-5", "--oneline"]) + await send_html_text( + update, + context, + self._bash_block(self._format_git_response([(["log", "-5", "--oneline"], result)], [])), + ) + + @require_allowed_chat() + async def handle_reset(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if await self._notify_if_current_project_busy(update, context): + return + if context.args: + await send_text(update, context, self._t(update, "git.usage_reset")) + return + + session, project_path = await self._active_session_project_or_notify( + update, + context, + require_git_repo=True, + ) + if session is None or project_path is None: + return + if await self._warn_if_session_branch_discrepancy(update, context, session, project_path): + return + if not self.deps.store.is_project_trusted(session["project_folder"]): + await send_text(update, context, self._t(update, "git.project_not_trusted_for_mutation")) + return + + current_branch = str(session.get("branch_name") or self.git.current_branch(project_path) or "").strip() + default_branch = str(self.git.default_branch(project_path) or current_branch).strip() + if not current_branch or not default_branch: + await send_text(update, context, self._t(update, "git.branch_unknown")) + return + + chat_state = self.deps.store.get_chat_state(self.deps.bot_id, update.effective_chat.id) + prompts = self._reset_prompts() + token = self._new_unique_token(prompts) + self._store_bounded_record( + prompts, + token, + { + "chat_id": str(update.effective_chat.id), + "session_id": str(chat_state.get("active_session_id") or ""), + "project_folder": str(session["project_folder"]), + "current_branch": current_branch, + "default_branch": default_branch, + }, + limit=self.MAX_RESET_PROMPTS, + ) + + rows = [ + [InlineKeyboardButton(f"local/{default_branch}", callback_data=f"reset:select:{token}:local-default")], + [InlineKeyboardButton(f"origin/{default_branch}", callback_data=f"reset:select:{token}:origin-default")], + [InlineKeyboardButton(f"local/{current_branch}", callback_data=f"reset:select:{token}:local-current")], + [InlineKeyboardButton(f"origin/{current_branch}", callback_data=f"reset:select:{token}:origin-current")], + ] + await context.bot.send_message( + chat_id=update.effective_chat.id, + text=self._t(update, "git.reset_select_prompt"), + reply_markup=InlineKeyboardMarkup(rows), + ) + + @require_allowed_chat(answer_callback=True) + async def handle_reset_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + query = update.callback_query + if query is None: + return + + await query.answer() + + action = (query.data or "").strip() + if action.startswith("reset:select:"): + if await self._notify_if_current_project_busy(update, context): + return + parts = action.split(":") + if len(parts) != 4: + return + _, _, token, target_kind = parts + prompt = self._reset_prompts().get(token) + if prompt is None or prompt.get("chat_id") != str(update.effective_chat.id): + await query.edit_message_text(self._t(update, "common.button_expired")) + return + session, project_path = await self._active_session_project_or_notify( + update, + context, + require_git_repo=True, + ) + if session is None or project_path is None: + return + chat_state = self.deps.store.get_chat_state(self.deps.bot_id, update.effective_chat.id) + if ( + str(chat_state.get("active_session_id") or "") != prompt["session_id"] + or str(session["project_folder"]) != prompt["project_folder"] + or str(session.get("branch_name") or self.git.current_branch(project_path) or "").strip() + != prompt["current_branch"] + ): + self._reset_prompts().pop(token, None) + await query.edit_message_text(self._t(update, "common.button_expired")) + return + if await self._warn_if_session_branch_discrepancy(update, context, session, project_path): + return + if not self.deps.store.is_project_trusted(session["project_folder"]): + await query.edit_message_text(self._t(update, "git.project_not_trusted_for_mutation")) + return + target = self._reset_target(prompt["current_branch"], prompt["default_branch"], target_kind) + if target is None: + await query.edit_message_text(self._t(update, "git.branch_unknown")) + return + target_ref, is_origin = target + reset_branch = prompt["current_branch"] + if not reset_branch: + await query.edit_message_text(self._t(update, "git.branch_unknown")) + return + self._reset_prompts().pop(token, None) + token = self._new_reset_selection_token() + selections = self._reset_selections() + self._store_bounded_record( + selections, + token, + { + "chat_id": str(update.effective_chat.id), + "session_id": str(chat_state.get("active_session_id") or ""), + "project_folder": str(session["project_folder"]), + "reset_branch": reset_branch, + "target_ref": target_ref, + "target_branch": target_ref.removeprefix("origin/") if is_origin else target_ref, + "is_origin": str(is_origin), + }, + limit=self.MAX_RESET_SELECTIONS, + ) + markup = InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton( + self._t(update, "git.reset_confirm_button"), + callback_data=f"reset:confirm:{token}", + **self._affirmative_inline_button_kwargs(), + ), + InlineKeyboardButton( + self._t(update, "git.cancel_button"), + callback_data=f"reset:cancel:{token}", + **self._negative_inline_button_kwargs(), + ), + ] + ] + ) + await query.edit_message_text( + self._t(update, "git.reset_confirm_prompt", target_ref=self._escape_markdown_code_value(target_ref)), + parse_mode="Markdown", + reply_markup=markup, + ) + return + + if action.startswith("reset:cancel:"): + token = action.removeprefix("reset:cancel:") + selection = self._reset_selections().get(token) + if selection is None or selection.get("chat_id") != str(update.effective_chat.id): + await query.edit_message_text(self._t(update, "common.button_expired")) + return + self._reset_selections().pop(token, None) + await query.edit_message_text(self._t(update, "git.reset_cancelled")) + return + if not action.startswith("reset:confirm:"): + return + + token = action.removeprefix("reset:confirm:") + selection = self._reset_selections().get(token) + if selection is None or selection.get("chat_id") != str(update.effective_chat.id): + await query.edit_message_text(self._t(update, "common.button_expired")) + return + if await self._notify_if_current_project_busy(update, context): + return + session, project_path = await self._active_session_project_or_notify( + update, + context, + require_git_repo=True, + ) + if session is None or project_path is None: + return + chat_state = self.deps.store.get_chat_state(self.deps.bot_id, update.effective_chat.id) + if ( + str(chat_state.get("active_session_id") or "") != selection["session_id"] + or str(session["project_folder"]) != selection["project_folder"] + or str(session.get("branch_name") or self.git.current_branch(project_path) or "").strip() + != selection["reset_branch"] + ): + self._reset_selections().pop(token, None) + await query.edit_message_text(self._t(update, "common.button_expired")) + return + if await self._warn_if_session_branch_discrepancy(update, context, session, project_path): + return + if not self.deps.store.is_project_trusted(session["project_folder"]): + await query.edit_message_text(self._t(update, "git.project_not_trusted_for_mutation")) + return + async with self._workspace_git_operation_lock( + update, + context, + selection["project_folder"], + ) as acquired: + if not acquired: + return + selection = self._reset_selections().pop(token, None) + if selection is None: + await query.edit_message_text(self._t(update, "common.button_expired")) + return + await self._execute_confirmed_reset(update, context, query, selection, project_path) + @require_allowed_chat() async def handle_pull(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if await self._notify_if_current_project_busy(update, context): @@ -521,6 +1132,8 @@ async def handle_pull(self, update: Update, context: ContextTypes.DEFAULT_TYPE) ) if session is None or project_path is None: return + if await self._warn_if_session_branch_discrepancy(update, context, session, project_path): + return branch_name = session.get("branch_name") or self.git.current_branch(project_path) if not branch_name: @@ -528,6 +1141,13 @@ async def handle_pull(self, update: Update, context: ContextTypes.DEFAULT_TYPE) return default_branch = self.git.default_branch(project_path) or branch_name + token = self._register_git_confirmation( + chat_id=update.effective_chat.id, + session=session, + action="pull", + branch_name=str(branch_name), + default_branch=str(default_branch), + ) prompt_key = "git.pull_confirm_prompt_with_default" if default_branch and default_branch != branch_name else "git.pull_confirm_prompt" confirm_markup = InlineKeyboardMarkup( @@ -535,12 +1155,12 @@ async def handle_pull(self, update: Update, context: ContextTypes.DEFAULT_TYPE) [ InlineKeyboardButton( self._t(update, "git.pull_confirm_button"), - callback_data="pull:confirm", + callback_data=f"pull:confirm:{token}", **self._affirmative_inline_button_kwargs(), ), InlineKeyboardButton( self._t(update, "git.cancel_button"), - callback_data="pull:cancel", + callback_data=f"pull:cancel:{token}", **self._negative_inline_button_kwargs(), ), ] @@ -551,8 +1171,8 @@ async def handle_pull(self, update: Update, context: ContextTypes.DEFAULT_TYPE) text=self._t( update, prompt_key, - branch_name=branch_name, - default_branch=default_branch, + branch_name=self._escape_markdown_code_value(branch_name), + default_branch=self._escape_markdown_code_value(default_branch), ), parse_mode="Markdown", reply_markup=confirm_markup, @@ -564,11 +1184,22 @@ async def handle_pull_callback(self, update: Update, context: ContextTypes.DEFAU if query is None: return + await query.answer() + action = (query.data or "").strip() - if action == "pull:cancel": + parts = action.split(":") + if len(parts) != 3 or parts[0] != "pull" or parts[1] not in {"confirm", "cancel"}: + return + _, choice, token = parts + confirmation = self._get_git_confirmation(token, chat_id=update.effective_chat.id, action="pull") + if confirmation is None: + await query.edit_message_text(self._t(update, "common.button_expired")) + return + if choice == "cancel": + self._git_confirmations().pop(token, None) await query.edit_message_text(self._t(update, "git.pull_cancelled")) return - if action != "pull:confirm": + if await self._notify_if_current_project_busy(update, context): return session, project_path = await self._active_session_project_or_notify( @@ -578,59 +1209,86 @@ async def handle_pull_callback(self, update: Update, context: ContextTypes.DEFAU ) if session is None or project_path is None: return - - branch_name = session.get("branch_name") or self.git.current_branch(project_path) - if not branch_name: - await query.edit_message_text(self._t(update, "git.branch_unknown")) + chat_state = self.deps.store.get_chat_state(self.deps.bot_id, update.effective_chat.id) + if ( + str(chat_state.get("active_session_id") or "") != confirmation["session_id"] + or str(session["project_folder"]) != confirmation["project_folder"] + or str(session.get("branch_name") or self.git.current_branch(project_path) or "").strip() + != confirmation["branch_name"] + ): + self._git_confirmations().pop(token, None) + await query.edit_message_text(self._t(update, "common.button_expired")) return + if await self._warn_if_session_branch_discrepancy(update, context, session, project_path): + return + async with self._workspace_git_operation_lock( + update, + context, + confirmation["project_folder"], + ) as acquired: + if not acquired: + return + confirmation = self._git_confirmations().pop(token, None) + if confirmation is None: + await query.edit_message_text(self._t(update, "common.button_expired")) + return - default_branch = self.git.default_branch(project_path) or branch_name - prompt_key = "git.pull_in_progress_with_default" if default_branch and default_branch != branch_name else "git.pull_in_progress" - await query.edit_message_text( - self._t( - update, - prompt_key, - branch_name=branch_name, - default_branch=default_branch, - ), - parse_mode="Markdown", - ) + branch_name = confirmation["branch_name"] + if not branch_name: + await query.edit_message_text(self._t(update, "git.branch_unknown")) + return - completed_messages: list[str] = [] - warnings: list[str] = [] + default_branch = confirmation["default_branch"] or branch_name + prompt_key = "git.pull_in_progress_with_default" if default_branch and default_branch != branch_name else "git.pull_in_progress" + await query.edit_message_text( + self._t( + update, + prompt_key, + branch_name=self._escape_markdown_code_value(branch_name), + default_branch=self._escape_markdown_code_value(default_branch), + ), + parse_mode="Markdown", + ) + + completed_messages: list[str] = [] + warnings: list[str] = [] + + if default_branch and default_branch != branch_name: + ok, message, branch_warnings = await self._refresh_branch_with_checkout( + update, + context, + project_path=project_path, + branch_name=default_branch, + ) + if not ok: + await send_text(update, context, message or self._t(update, "bot.error.command_failed")) + return + if message and not branch_warnings: + completed_messages.append(message) + warnings.extend(branch_warnings) - if default_branch and default_branch != branch_name: ok, message, branch_warnings = await self._refresh_branch_with_checkout( update, context, project_path=project_path, - branch_name=default_branch, + branch_name=branch_name, ) if not ok: await send_text(update, context, message or self._t(update, "bot.error.command_failed")) return - if message: + if message and not branch_warnings: completed_messages.append(message) warnings.extend(branch_warnings) - ok, message, branch_warnings = await self._refresh_branch_with_checkout( - update, - context, - project_path=project_path, - branch_name=branch_name, - ) - if not ok: - await send_text(update, context, message or self._t(update, "bot.error.command_failed")) - return - if message: - completed_messages.append(message) - warnings.extend(branch_warnings) - - lines = completed_messages or [self._t(update, "git.pull_completed")] - if warnings: - lines.extend(["", self._t(update, "project.refresh_warnings")]) - lines.extend(f"- {warning}" for warning in warnings) - await send_text(update, context, "\n".join(lines)) + lines = list(completed_messages) + if not lines and not warnings: + lines.append(self._t(update, "git.pull_completed")) + if warnings: + if lines: + lines.append("") + lines.append(self._t(update, "project.refresh_warnings")) + lines.extend(f"- {warning}" for warning in warnings) + await send_text(update, context, "\n".join(lines)) @require_allowed_chat(answer_callback=True) async def handle_push_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -638,11 +1296,22 @@ async def handle_push_callback(self, update: Update, context: ContextTypes.DEFAU if query is None: return + await query.answer() + action = (query.data or "").strip() - if action == "push:cancel": + parts = action.split(":") + if len(parts) != 3 or parts[0] != "push" or parts[1] not in {"confirm", "cancel"}: + return + _, choice, token = parts + confirmation = self._get_git_confirmation(token, chat_id=update.effective_chat.id, action="push") + if confirmation is None: + await query.edit_message_text(self._t(update, "common.button_expired")) + return + if choice == "cancel": + self._git_confirmations().pop(token, None) await query.edit_message_text(self._t(update, "git.push_cancelled")) return - if action != "push:confirm": + if await self._notify_if_current_project_busy(update, context): return session, project_path = await self._active_session_project_or_notify( @@ -652,27 +1321,57 @@ async def handle_push_callback(self, update: Update, context: ContextTypes.DEFAU ) if session is None or project_path is None: return - - branch_name = session.get("branch_name") or self.git.current_branch(project_path) - if not branch_name: - await query.edit_message_text(self._t(update, "git.branch_unknown")) + chat_state = self.deps.store.get_chat_state(self.deps.bot_id, update.effective_chat.id) + if ( + str(chat_state.get("active_session_id") or "") != confirmation["session_id"] + or str(session["project_folder"]) != confirmation["project_folder"] + or str(session.get("branch_name") or self.git.current_branch(project_path) or "").strip() + != confirmation["branch_name"] + ): + self._git_confirmations().pop(token, None) + await query.edit_message_text(self._t(update, "common.button_expired")) + return + if await self._warn_if_session_branch_discrepancy(update, context, session, project_path): return + async with self._workspace_git_operation_lock( + update, + context, + confirmation["project_folder"], + ) as acquired: + if not acquired: + return + confirmation = self._git_confirmations().pop(token, None) + if confirmation is None: + await query.edit_message_text(self._t(update, "common.button_expired")) + return - current_branch = self.git.current_branch(project_path) - if current_branch != branch_name: - checkout = await asyncio.to_thread(self.git.checkout_branch, project_path, branch_name) - if not checkout.success: - await query.edit_message_text( - self._t(update, "git.push_cancelled_checkout_failed", branch_name=branch_name), - parse_mode="Markdown", - ) - await send_html_text(update, context, self._bash_block(self._format_git_response([(["checkout", branch_name], checkout)], []))) + branch_name = confirmation["branch_name"] + if not branch_name: + await query.edit_message_text(self._t(update, "git.branch_unknown")) return - await query.edit_message_text(self._t(update, "git.push_in_progress", branch_name=branch_name), parse_mode="Markdown") - result = await asyncio.to_thread(self.git.push_branch, project_path, branch_name) - await send_html_text( - update, - context, - self._bash_block(self._format_git_response([(["push", "origin", branch_name], result)], [])), - ) + current_branch = self.git.current_branch(project_path) + if current_branch != branch_name: + checkout = await asyncio.to_thread(self.git.checkout_branch, project_path, branch_name) + if not checkout.success: + await query.edit_message_text( + self._t( + update, + "git.push_cancelled_checkout_failed", + branch_name=self._escape_markdown_code_value(branch_name), + ), + parse_mode="Markdown", + ) + await send_html_text(update, context, self._bash_block(self._format_git_response([(["checkout", branch_name], checkout)], []))) + return + + await query.edit_message_text( + self._t(update, "git.push_in_progress", branch_name=self._escape_markdown_code_value(branch_name)), + parse_mode="Markdown", + ) + result = await asyncio.to_thread(self.git.push_branch, project_path, branch_name) + await send_html_text( + update, + context, + self._bash_block(self._format_git_response([(["push", "origin", branch_name], result)], [])), + ) diff --git a/tests/test_bot.py b/tests/test_bot.py index c18afa2..cf1ad4b 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -5,8 +5,11 @@ def test_default_bot_commands_hide_commit_and_push_when_disabled(): commands = default_bot_commands(enable_commit_command=False) names = [command.command for command in commands] - assert names == ["provider", "model", "project", "branch", "current", "status", "new", "switch", "compact", "diff", "pull", "push", "abort"] + assert names == ["provider", "model", "project", "branch", "current", "status", "new", "switch", "compact", "diff", "pull", "push", "log", "reset", "abort"] assert "commit" not in names + descriptions = {command.command: command.description for command in commands} + assert descriptions["pull"] == "Git pull the current session branch" + assert all("Git" in descriptions[name] for name in ("pull", "push", "log", "reset")) def test_default_bot_commands_show_commit_and_push_when_enabled(): @@ -27,6 +30,8 @@ def test_default_bot_commands_show_commit_and_push_when_enabled(): "commit", "pull", "push", + "log", + "reset", "abort", ] diff --git a/tests/test_command_router.py b/tests/test_command_router.py index c941ed2..bd96a38 100644 --- a/tests/test_command_router.py +++ b/tests/test_command_router.py @@ -6043,6 +6043,22 @@ def _run_pull_command(router: CommandRouter, *, args: list[str] | None = None) - return bot +def _run_log_command(router: CommandRouter, *, args: list[str] | None = None) -> FakeBot: + update = make_update(text="/log" if not args else "/log " + " ".join(args)) + bot = FakeBot() + context = SimpleNamespace(args=args or [], bot=bot) + asyncio.run(router.handle_log(update, context)) + return bot + + +def _run_reset_command(router: CommandRouter, *, args: list[str] | None = None) -> FakeBot: + update = make_update(text="/reset" if not args else "/reset " + " ".join(args)) + bot = FakeBot() + context = SimpleNamespace(args=args or [], bot=bot) + asyncio.run(router.handle_reset(update, context)) + return bot + + def _run_diff_command(router: CommandRouter, *, args: list[str] | None = None) -> FakeBot: update = make_update(text="/diff" if not args else "/diff " + " ".join(args)) bot = FakeBot() @@ -6062,6 +6078,14 @@ def test_commit_executes_only_valid_git_commands_and_ignores_non_git_segments(tm ], ), ) + lock_states = [] + original_run_safe_commit_command = router.git.run_safe_commit_command + + def run_safe_commit_command(project_path, args): + lock_states.append(router._workspace_locks["backend"].locked()) + return original_run_safe_commit_command(project_path, args) + + router.git.run_safe_commit_command = run_safe_commit_command bot = _run_commit_command(router, '/commit git add -u && rm -rf / && git commit -m "safe"') @@ -6080,6 +6104,7 @@ def test_commit_executes_only_valid_git_commands_and_ignores_non_git_segments(tm assert "[telegram-enhance 5b9a263] safe" in bot.messages[-1][1] assert "Ignored non-git commands:" in bot.messages[-1][1] assert "- rm -rf /" in bot.messages[-1][1] + assert lock_states == [True, True] def test_commit_is_rejected_when_disabled(tmp_path: Path): @@ -6404,6 +6429,23 @@ def test_push_uses_current_session_branch(tmp_path: Path): assert buttons[1].api_kwargs == {"style": "danger"} +def test_push_escapes_backticks_in_branch_name_for_markdown(tmp_path: Path): + backend = (tmp_path / "backend").resolve() + backend.mkdir() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + branch_name = "feature/foo`bar" + store.create_session("bot-a", 123, "sess_push", "push-session", "backend", "codex", branch_name=branch_name) + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=DummyRunner(), bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=True, current_branch=branch_name) + router.runtime.git = router.git + + bot = _run_push_command(router) + + assert bot.messages[-1][1] == "Push branch `feature/foo\\`bar` to `origin`?" + assert bot.messages[-1][2] == "Markdown" + + def test_push_confirmation_executes_push(tmp_path: Path): backend = (tmp_path / "backend").resolve() backend.mkdir() @@ -6419,11 +6461,21 @@ def test_push_confirmation_executes_push(tmp_path: Path): push_result=SimpleNamespace(success=True, message="Pushed branch 'feature-1' to origin.", current_branch="feature-1"), ) router.runtime.git = router.git + lock_states = [] + original_push_branch = router.git.push_branch + + def push_branch(project_path, branch_name): + lock_states.append(router._workspace_locks["backend"].locked()) + return original_push_branch(project_path, branch_name) + + router.git.push_branch = push_branch + prompt_bot = _run_push_command(router) + confirm_callback_data = prompt_bot.messages[-1][3].inline_keyboard[0][0].callback_data edited = [] update = SimpleNamespace( effective_chat=SimpleNamespace(id=123, type="private"), callback_query=SimpleNamespace( - data="push:confirm", + data=confirm_callback_data, answer=None, edit_message_text=None, ), @@ -6447,6 +6499,7 @@ async def fake_edit(text, parse_mode=None): assert bot.messages[-1][1].startswith('
')
     assert f"${shlex.join(['git', 'push', 'origin', 'feature-1'])}" in bot.messages[-1][1]
     assert "[Completed]" in bot.messages[-1][1]
+    assert lock_states == [True]
 
 
 def test_push_confirmation_cancel_does_not_push(tmp_path: Path):
@@ -6459,11 +6512,13 @@ def test_push_confirmation_cancel_does_not_push(tmp_path: Path):
     router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
     router.git = FakeGitManager(is_git_repo=True, current_branch="feature-1")
     router.runtime.git = router.git
+    prompt_bot = _run_push_command(router)
+    cancel_callback_data = prompt_bot.messages[-1][3].inline_keyboard[0][1].callback_data
     edited = []
     update = SimpleNamespace(
         effective_chat=SimpleNamespace(id=123, type="private"),
         callback_query=SimpleNamespace(
-            data="push:cancel",
+            data=cancel_callback_data,
             answer=None,
             edit_message_text=None,
         ),
@@ -6485,6 +6540,38 @@ async def fake_edit(text):
     assert edited == ["Push cancelled."]
 
 
+def test_push_confirmation_expires_when_active_session_branch_changes(tmp_path: Path):
+    backend = (tmp_path / "backend").resolve()
+    backend.mkdir()
+    cfg = make_config(tmp_path)
+    store = SessionStore(cfg.state_file, cfg.state_backup_file)
+    store.create_session("bot-a", 123, "sess_push", "push-session", "backend", "codex", branch_name="feature-1")
+    router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=DummyRunner(), bot_id="bot-a"))
+    router.git = FakeGitManager(is_git_repo=True, current_branch="feature-1")
+    router.runtime.git = router.git
+    prompt_bot = _run_push_command(router)
+    callback_data = prompt_bot.messages[-1][3].inline_keyboard[0][0].callback_data
+    store.set_active_session_branch("bot-a", 123, "feature-2")
+    router.git._current_branch = "feature-2"
+
+    edited = []
+
+    async def fake_answer():
+        return None
+
+    async def fake_edit(text, parse_mode=None):
+        edited.append(text)
+
+    update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(data=callback_data, answer=fake_answer, edit_message_text=fake_edit),
+    )
+    asyncio.run(router.handle_push_callback(update, SimpleNamespace(args=[], bot=FakeBot())))
+
+    assert "expired" in edited[-1].lower()
+    assert router.git.push_calls == []
+
+
 def test_pull_refreshes_active_session_branch(tmp_path: Path):
     backend = (tmp_path / "backend").resolve()
     backend.mkdir()
@@ -6509,8 +6596,8 @@ def test_pull_refreshes_active_session_branch(tmp_path: Path):
     assert router.git.refresh_calls == []
     assert bot.messages[-1][1] == "Pull branch `feature-1` from `origin`?"
     buttons = bot.messages[-1][3].inline_keyboard[0]
-    assert buttons[0].callback_data == "pull:confirm"
-    assert buttons[1].callback_data == "pull:cancel"
+    assert buttons[0].callback_data.startswith("pull:confirm:")
+    assert buttons[1].callback_data.startswith("pull:cancel:")
     assert buttons[0].text == "Confirm pull"
     assert buttons[1].text == "Cancel"
 
@@ -6542,7 +6629,7 @@ def test_pull_confirmation_refreshes_default_and_session_branch(tmp_path: Path):
     router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
     router.git = FakeGitManager(
         is_git_repo=True,
-        current_branch="main",
+        current_branch="feature-1",
         default_branch="develop",
         checkout_result=SimpleNamespace(success=True, message="Checked out branch"),
     )
@@ -6551,12 +6638,22 @@ def test_pull_confirmation_refreshes_default_and_session_branch(tmp_path: Path):
         warnings=("git fetch origin failed.",),
     )
     router.runtime.git = router.git
+    lock_states = []
+    original_refresh_current_branch = router.git.refresh_current_branch
+
+    def refresh_current_branch(project_path):
+        lock_states.append(router._workspace_locks["backend"].locked())
+        return original_refresh_current_branch(project_path)
+
+    router.git.refresh_current_branch = refresh_current_branch
+    prompt_bot = _run_pull_command(router)
+    confirm_callback_data = prompt_bot.messages[-1][3].inline_keyboard[0][0].callback_data
 
     edited = []
     update = SimpleNamespace(
         effective_chat=SimpleNamespace(id=123, type="private"),
         callback_query=SimpleNamespace(
-            data="pull:confirm",
+            data=confirm_callback_data,
             answer=None,
             edit_message_text=None,
         ),
@@ -6580,11 +6677,11 @@ async def fake_edit(text, parse_mode=None):
         (backend, "develop"),
         (backend, "feature-1"),
     ]
-    assert "Updated branch 'develop' from origin." in bot.messages[-1][1]
-    assert "Updated branch 'feature-1' from origin." in bot.messages[-1][1]
+    assert "Updated branch" not in bot.messages[-1][1]
     assert "Refresh warnings:" in bot.messages[-1][1]
     assert "- git fetch origin failed." in bot.messages[-1][1]
     assert router.git.push_calls == []
+    assert lock_states == [True, True]
 
 
 def test_pull_confirmation_cancel_does_not_refresh(tmp_path: Path):
@@ -6597,11 +6694,13 @@ def test_pull_confirmation_cancel_does_not_refresh(tmp_path: Path):
     router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
     router.git = FakeGitManager(is_git_repo=True, current_branch="feature-1")
     router.runtime.git = router.git
+    prompt_bot = _run_pull_command(router)
+    cancel_callback_data = prompt_bot.messages[-1][3].inline_keyboard[0][1].callback_data
     edited = []
     update = SimpleNamespace(
         effective_chat=SimpleNamespace(id=123, type="private"),
         callback_query=SimpleNamespace(
-            data="pull:cancel",
+            data=cancel_callback_data,
             answer=None,
             edit_message_text=None,
         ),
@@ -6624,6 +6723,377 @@ async def fake_edit(text):
     assert router.git.refresh_calls == []
 
 
+def test_pull_confirmation_expires_when_active_session_branch_changes(tmp_path: Path):
+    backend = (tmp_path / "backend").resolve()
+    backend.mkdir()
+    cfg = make_config(tmp_path)
+    store = SessionStore(cfg.state_file, cfg.state_backup_file)
+    store.create_session("bot-a", 123, "sess_pull", "pull-session", "backend", "codex", branch_name="feature-1")
+    router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=DummyRunner(), bot_id="bot-a"))
+    router.git = FakeGitManager(is_git_repo=True, current_branch="feature-1")
+    router.runtime.git = router.git
+    prompt_bot = _run_pull_command(router)
+    callback_data = prompt_bot.messages[-1][3].inline_keyboard[0][0].callback_data
+    store.set_active_session_branch("bot-a", 123, "feature-2")
+    router.git._current_branch = "feature-2"
+
+    edited = []
+
+    async def fake_answer():
+        return None
+
+    async def fake_edit(text, parse_mode=None):
+        edited.append(text)
+
+    update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(data=callback_data, answer=fake_answer, edit_message_text=fake_edit),
+    )
+    asyncio.run(router.handle_pull_callback(update, SimpleNamespace(args=[], bot=FakeBot())))
+
+    assert "expired" in edited[-1].lower()
+    assert router.git.refresh_calls == []
+
+
+def test_log_shows_top_five_commits(tmp_path: Path):
+    router, backend = _make_commit_router(tmp_path, git_manager=FakeGitManager(is_git_repo=True))
+
+    bot = _run_log_command(router)
+
+    assert router.git.git_commands == [(backend, ["log", "-5", "--oneline"])]
+    assert f"${shlex.join(['git', 'log', '-5', '--oneline'])}" in bot.messages[-1][1]
+
+
+def test_reset_selects_four_targets_and_confirms_before_reset(tmp_path: Path):
+    router, backend = _make_commit_router(
+        tmp_path,
+        git_manager=FakeGitManager(
+            is_git_repo=True,
+            current_branch="feature-1",
+            default_branch="main",
+            checkout_result=SimpleNamespace(success=True, message="Checked out branch"),
+        ),
+    )
+    router.runtime.git = router.git
+
+    bot = _run_reset_command(router)
+
+    keyboard = bot.messages[-1][3].inline_keyboard
+    assert [[button.text for button in row] for row in keyboard] == [
+        ["local/main"],
+        ["origin/main"],
+        ["local/feature-1"],
+        ["origin/feature-1"],
+    ]
+
+    edited = []
+    select_callback_data = keyboard[1][0].callback_data
+    select_update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(data=select_callback_data, answer=None, edit_message_text=None),
+    )
+
+    async def fake_answer():
+        return None
+
+    async def fake_edit(text, parse_mode=None, reply_markup=None):
+        edited.append((text, parse_mode, reply_markup))
+
+    select_update.callback_query.answer = fake_answer
+    select_update.callback_query.edit_message_text = fake_edit
+    asyncio.run(router.handle_reset_callback(select_update, SimpleNamespace(args=[], bot=bot)))
+
+    assert edited[-1][0] == "Reset the current branch with `git reset --hard origin/main`?"
+    confirm_callback_data = edited[-1][2].inline_keyboard[0][0].callback_data
+    assert confirm_callback_data.startswith("reset:confirm:")
+    assert router.git.git_commands == []
+
+    confirm_update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(data=confirm_callback_data, answer=fake_answer, edit_message_text=fake_edit),
+    )
+    asyncio.run(router.handle_reset_callback(confirm_update, SimpleNamespace(args=[], bot=bot)))
+
+    assert router.git.refresh_calls == [(backend, "main")]
+    assert router.git.git_commands == [(backend, ["reset", "--hard", "origin/main"])]
+    assert router.git.current_branch(backend) == "feature-1"
+
+
+def test_reset_confirmation_is_bound_to_its_selected_target(tmp_path: Path):
+    router, backend = _make_commit_router(
+        tmp_path,
+        git_manager=FakeGitManager(
+            is_git_repo=True,
+            current_branch="feature-1",
+            default_branch="main",
+            checkout_result=SimpleNamespace(success=True, message="Checked out branch"),
+        ),
+    )
+    router.runtime.git = router.git
+    bot = FakeBot()
+    edited = []
+
+    async def fake_answer():
+        return None
+
+    async def fake_edit(text, parse_mode=None, reply_markup=None):
+        edited.append((text, parse_mode, reply_markup))
+
+    async def select(callback_data: str) -> str:
+        update = SimpleNamespace(
+            effective_chat=SimpleNamespace(id=123, type="private"),
+            callback_query=SimpleNamespace(data=callback_data, answer=fake_answer, edit_message_text=fake_edit),
+        )
+        await router.handle_reset_callback(update, SimpleNamespace(args=[], bot=bot))
+        return edited[-1][2].inline_keyboard[0][0].callback_data
+
+    first_keyboard = _run_reset_command(router).messages[-1][3].inline_keyboard
+    first_confirmation = asyncio.run(select(first_keyboard[1][0].callback_data))
+    second_keyboard = _run_reset_command(router).messages[-1][3].inline_keyboard
+    second_confirmation = asyncio.run(select(second_keyboard[2][0].callback_data))
+
+    assert first_confirmation != second_confirmation
+
+    confirm_update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(data=first_confirmation, answer=fake_answer, edit_message_text=fake_edit),
+    )
+    asyncio.run(router.handle_reset_callback(confirm_update, SimpleNamespace(args=[], bot=bot)))
+
+    assert router.git.git_commands == [(backend, ["reset", "--hard", "origin/main"])]
+
+
+def test_reset_selection_expires_when_active_session_branch_changes(tmp_path: Path):
+    router, _ = _make_commit_router(
+        tmp_path,
+        git_manager=FakeGitManager(is_git_repo=True, current_branch="feature-1", default_branch="main"),
+    )
+    router.runtime.git = router.git
+    callback_data = _run_reset_command(router).messages[-1][3].inline_keyboard[0][0].callback_data
+    router.deps.store.set_active_session_branch("bot-a", 123, "feature-2")
+    router.git._current_branch = "feature-2"
+    edited = []
+
+    async def fake_answer():
+        return None
+
+    async def fake_edit(text, parse_mode=None, reply_markup=None):
+        edited.append(text)
+
+    update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(data=callback_data, answer=fake_answer, edit_message_text=fake_edit),
+    )
+    asyncio.run(router.handle_reset_callback(update, SimpleNamespace(args=[], bot=FakeBot())))
+
+    assert "expired" in edited[-1].lower()
+    assert router._reset_selections() == {}
+
+
+def test_reset_confirmation_survives_retryable_branch_discrepancy(tmp_path: Path):
+    router, backend = _make_commit_router(
+        tmp_path,
+        git_manager=FakeGitManager(is_git_repo=True, current_branch="feature-1", default_branch="main"),
+    )
+    router.deps.store.set_active_session_branch("bot-a", 123, "feature-1")
+    router.runtime.git = router.git
+    bot = FakeBot()
+    edited = []
+
+    async def fake_answer():
+        return None
+
+    async def fake_edit(text, parse_mode=None, reply_markup=None):
+        edited.append((text, parse_mode, reply_markup))
+
+    select_callback = _run_reset_command(router).messages[-1][3].inline_keyboard[2][0].callback_data
+    select_update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(data=select_callback, answer=fake_answer, edit_message_text=fake_edit),
+    )
+    asyncio.run(router.handle_reset_callback(select_update, SimpleNamespace(args=[], bot=bot)))
+    confirm_callback = edited[-1][2].inline_keyboard[0][0].callback_data
+    confirm_update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(data=confirm_callback, answer=fake_answer, edit_message_text=fake_edit),
+    )
+
+    router.git._current_branch = "main"
+    asyncio.run(router.handle_reset_callback(confirm_update, SimpleNamespace(args=[], bot=bot)))
+
+    assert "Branch discrepancy detected" in bot.messages[-1][1]
+    assert router.git.git_commands == []
+
+    router.git._current_branch = "feature-1"
+    asyncio.run(router.handle_reset_callback(confirm_update, SimpleNamespace(args=[], bot=bot)))
+
+    assert router.git.git_commands == [(backend, ["reset", "--hard", "feature-1"])]
+
+
+def test_reset_confirmation_stops_when_project_becomes_busy(tmp_path: Path):
+    router, backend = _make_commit_router(
+        tmp_path,
+        git_manager=FakeGitManager(is_git_repo=True, current_branch="feature-1", default_branch="main"),
+    )
+    router.runtime.git = router.git
+    bot = FakeBot()
+    edited = []
+
+    async def fake_answer():
+        return None
+
+    async def fake_edit(text, parse_mode=None, reply_markup=None):
+        edited.append((text, parse_mode, reply_markup))
+
+    select_callback_data = _run_reset_command(router).messages[-1][3].inline_keyboard[2][0].callback_data
+    select_update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(data=select_callback_data, answer=fake_answer, edit_message_text=fake_edit),
+    )
+    asyncio.run(router.handle_reset_callback(select_update, SimpleNamespace(args=[], bot=bot)))
+    confirm_callback_data = edited[-1][2].inline_keyboard[0][0].callback_data
+    router._is_project_busy = lambda _chat_id: True
+
+    confirm_update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        message=None,
+        callback_query=SimpleNamespace(data=confirm_callback_data, answer=fake_answer, edit_message_text=fake_edit),
+    )
+    asyncio.run(router.handle_reset_callback(confirm_update, SimpleNamespace(args=[], bot=bot)))
+
+    assert router.git.git_commands == []
+    assert f"An agent is currently running on project '{backend.name}'." in bot.messages[-1][1]
+
+
+def test_reset_restores_session_branch_when_origin_pull_fails(tmp_path: Path):
+    router, backend = _make_commit_router(
+        tmp_path,
+        git_manager=FakeGitManager(
+            is_git_repo=True,
+            current_branch="feature-1",
+            default_branch="main",
+            checkout_result=SimpleNamespace(success=True, message="Checked out branch"),
+        ),
+    )
+    router.git.refresh_result = SimpleNamespace(success=True, warnings=("git pull failed for branch: main",))
+    router.runtime.git = router.git
+    bot = FakeBot()
+    edited = []
+
+    async def fake_answer():
+        return None
+
+    async def fake_edit(text, parse_mode=None, reply_markup=None):
+        edited.append((text, parse_mode, reply_markup))
+
+    select_callback_data = _run_reset_command(router).messages[-1][3].inline_keyboard[1][0].callback_data
+    select_update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(data=select_callback_data, answer=fake_answer, edit_message_text=fake_edit),
+    )
+    asyncio.run(router.handle_reset_callback(select_update, SimpleNamespace(args=[], bot=bot)))
+    confirm_callback_data = edited[-1][2].inline_keyboard[0][0].callback_data
+    confirm_update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(data=confirm_callback_data, answer=fake_answer, edit_message_text=fake_edit),
+    )
+
+    asyncio.run(router.handle_reset_callback(confirm_update, SimpleNamespace(args=[], bot=bot)))
+
+    assert router.git.current_branch(backend) == "feature-1"
+    assert router.git.git_commands == []
+    assert "git pull failed for branch: main" in bot.messages[-1][1]
+
+
+@pytest.mark.parametrize("command_name", ["commit", "diff", "log", "pull", "push", "reset"])
+def test_git_commands_warn_and_stop_on_session_branch_discrepancy(tmp_path: Path, command_name: str):
+    router, _ = _make_commit_router(
+        tmp_path,
+        git_manager=FakeGitManager(is_git_repo=True, current_branch="main", default_branch="main"),
+    )
+    router.deps.store.set_active_session_branch("bot-a", 123, "feature-1")
+    router.runtime.git = router.git
+
+    if command_name == "commit":
+        bot = _run_commit_command(router, "/commit git status")
+    elif command_name == "diff":
+        bot = _run_diff_command(router)
+    elif command_name == "log":
+        bot = _run_log_command(router)
+    elif command_name == "pull":
+        bot = _run_pull_command(router)
+    elif command_name == "push":
+        bot = _run_push_command(router)
+    else:
+        bot = _run_reset_command(router)
+
+    assert "Branch discrepancy detected" in bot.messages[-1][1]
+    assert "main" in bot.messages[-1][1]
+    assert "git status" not in bot.messages[-1][1]
+    assert router.git.git_commands == []
+    assert router.git.safe_git_commands == []
+    assert router.git.push_calls == []
+    assert router.git.refresh_calls == []
+
+
+def test_git_command_warns_when_repository_has_detached_head(tmp_path: Path):
+    router, _ = _make_commit_router(
+        tmp_path,
+        git_manager=FakeGitManager(is_git_repo=True, current_branch=None, default_branch="main"),
+    )
+    router.deps.store.set_active_session_branch("bot-a", 123, "feature-1")
+    router.runtime.git = router.git
+
+    bot = _run_log_command(router)
+
+    assert "Branch discrepancy detected" in bot.messages[-1][1]
+    assert "detached HEAD" in bot.messages[-1][1]
+    assert router.git.git_commands == []
+
+
+def test_reset_acknowledges_callback_and_holds_workspace_lock_during_reset(tmp_path: Path):
+    router, backend = _make_commit_router(
+        tmp_path,
+        git_manager=FakeGitManager(is_git_repo=True, current_branch="feature-1", default_branch="main"),
+    )
+    router.runtime.git = router.git
+    bot = FakeBot()
+    edited = []
+    answers = []
+    lock_states = []
+
+    async def fake_answer():
+        answers.append(True)
+
+    async def fake_edit(text, parse_mode=None, reply_markup=None):
+        edited.append((text, parse_mode, reply_markup))
+
+    def run_git_command(project_path, args):
+        lock_states.append(router._workspace_locks["backend"].locked())
+        router.git.git_commands.append((project_path, args))
+        return SimpleNamespace(success=True, message="reset complete")
+
+    router.git.run_git_command = run_git_command
+    select_callback_data = _run_reset_command(router).messages[-1][3].inline_keyboard[2][0].callback_data
+    select_update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(data=select_callback_data, answer=fake_answer, edit_message_text=fake_edit),
+    )
+    asyncio.run(router.handle_reset_callback(select_update, SimpleNamespace(args=[], bot=bot)))
+    confirm_callback_data = edited[-1][2].inline_keyboard[0][0].callback_data
+    confirm_update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(data=confirm_callback_data, answer=fake_answer, edit_message_text=fake_edit),
+    )
+
+    asyncio.run(router.handle_reset_callback(confirm_update, SimpleNamespace(args=[], bot=bot)))
+
+    assert len(answers) == 2
+    assert lock_states == [True]
+    assert router.git.git_commands == [(backend, ["reset", "--hard", "feature-1"])]
+    assert not router._workspace_locks["backend"].locked()
+
+
 def test_diff_lists_tracked_and_untracked_filenames(monkeypatch, tmp_path: Path):
     backend = (tmp_path / "backend").resolve()
     backend.mkdir()
@@ -6653,7 +7123,9 @@ def test_diff_lists_tracked_and_untracked_filenames(monkeypatch, tmp_path: Path)
     labels = [button.text for row in reply_markup.inline_keyboard for button in row]
     callback_data = [button.callback_data for row in reply_markup.inline_keyboard for button in row]
     assert labels == ["1. app.py"]
-    assert callback_data == ["diffshow:0"]
+    assert len(callback_data) == 1
+    assert callback_data[0].startswith("diffshow:")
+    assert callback_data[0].endswith(":0")
 
 
 def test_diff_callback_sends_selected_file_diff(monkeypatch, tmp_path: Path):
@@ -6678,11 +7150,13 @@ def test_diff_callback_sends_selected_file_diff(monkeypatch, tmp_path: Path):
         if include_cached
         else [],
     )
+    prompt_bot = _run_diff_command(router)
+    show_callback_data = prompt_bot.messages[-1][3].inline_keyboard[1][0].callback_data
 
     update = SimpleNamespace(
         effective_chat=SimpleNamespace(id=123, type="private"),
         callback_query=SimpleNamespace(
-            data="diffshow:1",
+            data=show_callback_data,
             answer=None,
         ),
     )
@@ -6701,6 +7175,102 @@ async def fake_answer():
     assert "new" in bot.messages[-1][1]
 
 
+def test_diff_callback_uses_the_file_snapshot_shown_to_the_user(monkeypatch, tmp_path: Path):
+    backend = (tmp_path / "backend").resolve()
+    backend.mkdir()
+    cfg = make_config(tmp_path)
+    store = SessionStore(cfg.state_file, cfg.state_backup_file)
+    store.create_session("bot-a", 123, "sess_diff", "diff-session", "backend", "codex", branch_name="feature-1")
+    router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=DummyRunner(), bot_id="bot-a"))
+    router.git = FakeGitManager(is_git_repo=True, current_branch="feature-1")
+    router.runtime.git = router.git
+    changed_files = ["src/first.py", "src/selected.py"]
+    monkeypatch.setattr(
+        "coding_agent_telegram.router.git_commands.split_changed_files",
+        lambda _project_path: (list(changed_files), []),
+    )
+    collected_files = []
+
+    def fake_collect(_project_path, files, *, against_ref=None, include_cached=False):
+        collected_files.extend(files)
+        return [SimpleNamespace(path=files[0], diff="--- a/file\n+++ b/file\n@@\n-old\n+new")]
+
+    monkeypatch.setattr("coding_agent_telegram.router.git_commands.collect_diffs", fake_collect)
+    prompt_bot = _run_diff_command(router)
+    callback_data = prompt_bot.messages[-1][3].inline_keyboard[1][0].callback_data
+    changed_files[:] = ["src/replacement.py"]
+
+    async def fake_answer():
+        return None
+
+    update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(data=callback_data, answer=fake_answer),
+    )
+    asyncio.run(router.handle_diff_callback(update, SimpleNamespace(args=[], bot=FakeBot())))
+
+    assert collected_files == ["src/selected.py"]
+
+
+def test_diff_snapshot_expires_when_same_session_switches_branch(monkeypatch, tmp_path: Path):
+    backend = (tmp_path / "backend").resolve()
+    backend.mkdir()
+    cfg = make_config(tmp_path)
+    store = SessionStore(cfg.state_file, cfg.state_backup_file)
+    store.create_session("bot-a", 123, "sess_diff", "diff-session", "backend", "codex", branch_name="feature-1")
+    router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=DummyRunner(), bot_id="bot-a"))
+    router.git = FakeGitManager(is_git_repo=True, current_branch="feature-1")
+    router.runtime.git = router.git
+    monkeypatch.setattr(
+        "coding_agent_telegram.router.git_commands.split_changed_files",
+        lambda _project_path: (["src/app.py"], []),
+    )
+    callback_data = _run_diff_command(router).messages[-1][3].inline_keyboard[0][0].callback_data
+    store.set_active_session_branch("bot-a", 123, "feature-2")
+    router.git._current_branch = "feature-2"
+    edited = []
+
+    async def fake_answer():
+        return None
+
+    async def fake_edit(text, parse_mode=None, reply_markup=None):
+        edited.append(text)
+
+    update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(data=callback_data, answer=fake_answer, edit_message_text=fake_edit),
+    )
+    asyncio.run(router.handle_diff_callback(update, SimpleNamespace(args=[], bot=FakeBot())))
+
+    assert "expired" in edited[-1].lower()
+
+
+def test_diff_paginates_untracked_files_and_bounds_message_size(monkeypatch, tmp_path: Path):
+    backend = (tmp_path / "backend").resolve()
+    backend.mkdir()
+    cfg = make_config(tmp_path)
+    store = SessionStore(cfg.state_file, cfg.state_backup_file)
+    store.create_session("bot-a", 123, "sess_diff", "diff-session", "backend", "codex", branch_name="feature-1")
+    router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=DummyRunner(), bot_id="bot-a"))
+    router.git = FakeGitManager(is_git_repo=True, current_branch="feature-1")
+    router.runtime.git = router.git
+    untracked_files = [f"notes/{index}-{'x' * 240}.txt" for index in range(25)]
+    monkeypatch.setattr(
+        "coding_agent_telegram.router.git_commands.split_changed_files",
+        lambda _project_path: ([], untracked_files),
+    )
+
+    bot = _run_diff_command(router)
+
+    assert "Showing 1-10 of 25." in bot.messages[-1][1]
+    assert "notes/0-" in bot.messages[-1][1]
+    assert "notes/10-" not in bot.messages[-1][1]
+    assert len(bot.messages[-1][1]) < 4096
+    reply_markup = bot.messages[-1][3]
+    assert reply_markup is not None
+    assert reply_markup.inline_keyboard[-1][0].text == "Next"
+
+
 def test_diff_limits_buttons_to_ten_per_page(monkeypatch, tmp_path: Path):
     backend = (tmp_path / "backend").resolve()
     backend.mkdir()
@@ -6727,9 +7297,9 @@ def test_diff_limits_buttons_to_ten_per_page(monkeypatch, tmp_path: Path):
     assert [len(row) for row in rows[:-1]] == [1] * 10
     assert len(file_buttons) == 10
     assert [button.text for button in file_buttons[:3]] == ["1. file_1.py", "2. file_2.py", "3. file_3.py"]
-    assert [button.callback_data for button in file_buttons[-2:]] == ["diffshow:8", "diffshow:9"]
+    assert [button.callback_data.rsplit(":", 1)[1] for button in file_buttons[-2:]] == ["8", "9"]
     assert [button.text for button in nav_buttons] == ["Next"]
-    assert [button.callback_data for button in nav_buttons] == ["diffpage:1"]
+    assert [button.callback_data.rsplit(":", 1)[1] for button in nav_buttons] == ["1"]
     assert "Showing 1-10 of 12." in bot.messages[-1][1]
     assert "10. src/file_10.py" in bot.messages[-1][1]
     assert "11. src/file_11.py" not in bot.messages[-1][1]
@@ -6750,12 +7320,14 @@ def test_diff_pagination_edits_message_for_next_page(monkeypatch, tmp_path: Path
         "coding_agent_telegram.router.git_commands.split_changed_files",
         lambda _project_path: (tracked_files, []),
     )
+    prompt_bot = _run_diff_command(router)
+    next_callback_data = prompt_bot.messages[-1][3].inline_keyboard[-1][0].callback_data
 
     edited = []
     update = SimpleNamespace(
         effective_chat=SimpleNamespace(id=123, type="private"),
         callback_query=SimpleNamespace(
-            data="diffpage:1",
+            data=next_callback_data,
             answer=None,
             edit_message_text=None,
         ),
@@ -6782,7 +7354,8 @@ async def fake_edit(text, parse_mode=None, reply_markup=None):
     labels = [button.text for row in reply_markup.inline_keyboard for button in row]
     callback_data = [button.callback_data for row in reply_markup.inline_keyboard for button in row]
     assert "Prev" in labels
-    assert callback_data[-1] == "diffpage:0"
+    assert callback_data[-1].startswith("diffpage:")
+    assert callback_data[-1].endswith(":0")
 
 
 def test_diff_sends_usage_when_extra_args_provided(tmp_path: Path):
@@ -7433,10 +8006,11 @@ def test_commit_no_args_shows_generate_prompt(monkeypatch, tmp_path: Path):
     assert reply_markup is not None
     buttons = reply_markup.inline_keyboard[0]
     assert buttons[0].text == "Generate command"
-    assert buttons[0].callback_data == "commitgen:confirm"
+    assert buttons[0].callback_data.startswith("commitgen:confirm:")
     assert buttons[0].api_kwargs == {"style": "primary"}
     assert buttons[1].text == "Cancel"
-    assert buttons[1].callback_data == "commitgen:cancel"
+    assert buttons[1].callback_data.startswith("commitgen:cancel:")
+    assert buttons[0].callback_data.rsplit(":", 1)[1] == buttons[1].callback_data.rsplit(":", 1)[1]
     assert buttons[1].api_kwargs == {"style": "danger"}
 
 
@@ -7452,12 +8026,14 @@ async def fake_run_active_session(_update, _context, *, user_message, image_path
         )
 
     router.runtime.run_active_session = fake_run_active_session
+    prompt_bot = _run_commit_command(router, "/commit")
+    generate_callback_data = prompt_bot.messages[-1][3].inline_keyboard[0][0].callback_data
 
     edited = []
     update = SimpleNamespace(
         effective_chat=SimpleNamespace(id=123, type="private"),
         callback_query=SimpleNamespace(
-            data="commitgen:confirm",
+            data=generate_callback_data,
             answer=None,
             edit_message_text=None,
         ),
@@ -7477,20 +8053,23 @@ async def fake_edit(text):
     asyncio.run(router.handle_commit_generate_callback(update, context))
 
     assert edited == ["Generated commit command below."]
-    assert router._generated_commit_commands()[123] == {
+    command_token = bot.messages[-1][3].inline_keyboard[0][0].callback_data.rsplit(":", 1)[1]
+    assert router._generated_commit_commands()[command_token] == {
+        "chat_id": "123",
         "command": 'git add src/app.py && git commit -m "Update app"',
         "session_id": "sess_commit",
         "project_folder": "backend",
+        "branch_name": "",
     }
     assert bot.messages[-1][1] == "Do you want to execute the commit?"
     reply_markup = bot.messages[-1][3]
     assert reply_markup is not None
     buttons = reply_markup.inline_keyboard[0]
     assert buttons[0].text == "Execute commit"
-    assert buttons[0].callback_data == "commitexec:confirm"
+    assert buttons[0].callback_data == f"commitexec:confirm:{command_token}"
     assert buttons[0].api_kwargs == {"style": "primary"}
     assert buttons[1].text == "Cancel"
-    assert buttons[1].callback_data == "commitexec:cancel"
+    assert buttons[1].callback_data == f"commitexec:cancel:{command_token}"
     assert buttons[1].api_kwargs == {"style": "danger"}
 
 
@@ -7518,17 +8097,20 @@ def test_commit_execute_callback_runs_generated_commit_command(monkeypatch, tmp_
             ],
         ),
     )
-    router._generated_commit_commands()[123] = {
+    token = "0123456789ab"
+    router._generated_commit_commands()[token] = {
+        "chat_id": "123",
         "command": 'git add src/app.py && git commit -m "Update app"',
         "session_id": "sess_commit",
         "project_folder": "backend",
+        "branch_name": "",
     }
 
     edited = []
     update = SimpleNamespace(
         effective_chat=SimpleNamespace(id=123, type="private"),
         callback_query=SimpleNamespace(
-            data="commitexec:confirm",
+            data=f"commitexec:confirm:{token}",
             answer=None,
             edit_message_text=None,
         ),
@@ -7560,17 +8142,20 @@ def test_commit_execute_callback_rejects_when_active_session_changes(tmp_path: P
     router, _ = _make_commit_router(tmp_path, git_manager=FakeGitManager(is_git_repo=True))
     (tmp_path / "frontend").mkdir()
     router.deps.store.create_session("bot-a", 123, "sess_other", "other-session", "frontend", "codex")
-    router._generated_commit_commands()[123] = {
+    token = "0123456789ab"
+    router._generated_commit_commands()[token] = {
+        "chat_id": "123",
         "command": 'git add src/app.py && git commit -m "Update app"',
         "session_id": "sess_commit",
         "project_folder": "backend",
+        "branch_name": "",
     }
 
     edited = []
     update = SimpleNamespace(
         effective_chat=SimpleNamespace(id=123, type="private"),
         callback_query=SimpleNamespace(
-            data="commitexec:confirm",
+            data=f"commitexec:confirm:{token}",
             answer=None,
             edit_message_text=None,
         ),
@@ -7591,7 +8176,114 @@ async def fake_edit(text):
 
     assert edited == ["The active session or project changed. Please generate the commit command again."]
     assert router.git.safe_git_commands == []
-    assert 123 not in router._generated_commit_commands()
+    assert token not in router._generated_commit_commands()
+
+
+def test_commit_generation_prompt_expires_when_branch_changes(tmp_path: Path):
+    router, _ = _make_commit_router(
+        tmp_path,
+        git_manager=FakeGitManager(is_git_repo=True, current_branch="feature-1"),
+    )
+    router.deps.store.set_active_session_branch("bot-a", 123, "feature-1")
+    prompt_bot = _run_commit_command(router, "/commit")
+    callback_data = prompt_bot.messages[-1][3].inline_keyboard[0][0].callback_data
+    router.deps.store.set_active_session_branch("bot-a", 123, "feature-2")
+    router.git._current_branch = "feature-2"
+    run_calls = []
+
+    async def fake_run_active_session(*args, **kwargs):
+        run_calls.append((args, kwargs))
+        return None
+
+    router.runtime.run_active_session = fake_run_active_session
+    edited = []
+
+    async def fake_answer():
+        return None
+
+    async def fake_edit(text):
+        edited.append(text)
+
+    update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(data=callback_data, answer=fake_answer, edit_message_text=fake_edit),
+    )
+    asyncio.run(router.handle_commit_generate_callback(update, SimpleNamespace(args=[], bot=FakeBot())))
+
+    assert edited == ["The active session or project changed. Please generate the commit command again."]
+    assert run_calls == []
+
+
+def test_commit_execute_cancel_consumes_only_its_token(tmp_path: Path):
+    router, _ = _make_commit_router(tmp_path, git_manager=FakeGitManager(is_git_repo=True))
+    cancelled_token = "0123456789ab"
+    other_token = "abcdef012345"
+    payload = {
+        "chat_id": "123",
+        "command": 'git add src/app.py && git commit -m "Update app"',
+        "session_id": "sess_commit",
+        "project_folder": "backend",
+        "branch_name": "",
+    }
+    router._generated_commit_commands()[cancelled_token] = dict(payload)
+    router._generated_commit_commands()[other_token] = dict(payload)
+    edited = []
+
+    async def fake_answer():
+        return None
+
+    async def fake_edit(text):
+        edited.append(text)
+
+    update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(
+            data=f"commitexec:cancel:{cancelled_token}",
+            answer=fake_answer,
+            edit_message_text=fake_edit,
+        ),
+    )
+    asyncio.run(router.handle_commit_execute_callback(update, SimpleNamespace(args=[], bot=FakeBot())))
+
+    assert cancelled_token not in router._generated_commit_commands()
+    assert other_token in router._generated_commit_commands()
+    assert edited == ["Commit command generation cancelled."]
+
+
+def test_commit_execute_rejects_when_branch_changes(tmp_path: Path):
+    router, _ = _make_commit_router(
+        tmp_path,
+        git_manager=FakeGitManager(is_git_repo=True, current_branch="feature-2"),
+    )
+    router.deps.store.set_active_session_branch("bot-a", 123, "feature-2")
+    token = "0123456789ab"
+    router._generated_commit_commands()[token] = {
+        "chat_id": "123",
+        "command": 'git add src/app.py && git commit -m "Update app"',
+        "session_id": "sess_commit",
+        "project_folder": "backend",
+        "branch_name": "feature-1",
+    }
+    edited = []
+
+    async def fake_answer():
+        return None
+
+    async def fake_edit(text):
+        edited.append(text)
+
+    update = SimpleNamespace(
+        effective_chat=SimpleNamespace(id=123, type="private"),
+        callback_query=SimpleNamespace(
+            data=f"commitexec:confirm:{token}",
+            answer=fake_answer,
+            edit_message_text=fake_edit,
+        ),
+    )
+    asyncio.run(router.handle_commit_execute_callback(update, SimpleNamespace(args=[], bot=FakeBot())))
+
+    assert edited == ["The active session or project changed. Please generate the commit command again."]
+    assert router.git.safe_git_commands == []
 
 
 def test_commit_no_valid_git_commands_found(tmp_path: Path):
@@ -7692,7 +8384,7 @@ async def fake_edit(text, parse_mode=None):
     assert bot.messages == []
 
 
-def test_push_callback_empty_branch_warns(tmp_path: Path):
+def test_push_empty_branch_warns(tmp_path: Path):
     backend = (tmp_path / "backend").resolve()
     backend.mkdir()
     runner = DummyRunner()
@@ -7704,33 +8396,12 @@ def test_push_callback_empty_branch_warns(tmp_path: Path):
     router.git = FakeGitManager(is_git_repo=True, current_branch=None)
     router.runtime.git = router.git
 
-    edited = []
-    update = SimpleNamespace(
-        effective_chat=SimpleNamespace(id=123, type="private"),
-        callback_query=SimpleNamespace(
-            data="push:confirm",
-            answer=None,
-            edit_message_text=None,
-        ),
-    )
-    bot = FakeBot()
-    context = SimpleNamespace(args=[], bot=bot)
-
-    async def fake_answer():
-        return None
-
-    async def fake_edit(text, parse_mode=None):
-        edited.append(text)
-
-    update.callback_query.answer = fake_answer
-    update.callback_query.edit_message_text = fake_edit
-
-    asyncio.run(router.handle_push_callback(update, context))
+    bot = _run_push_command(router)
 
-    assert any("Could not determine the branch" in e for e in edited)
+    assert "Could not determine the branch" in bot.messages[-1][1]
 
 
-def test_push_callback_checkout_failure_sends_edit(tmp_path: Path):
+def test_push_warns_instead_of_checking_out_session_branch(tmp_path: Path):
     backend = (tmp_path / "backend").resolve()
     backend.mkdir()
     runner = DummyRunner()
@@ -7738,7 +8409,7 @@ def test_push_callback_checkout_failure_sends_edit(tmp_path: Path):
     store = SessionStore(cfg.state_file, cfg.state_backup_file)
     store.create_session("bot-a", 123, "sess_push", "push-session", "backend", "codex", branch_name="feature-x")
     router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
-    # current_branch differs from session branch so checkout is attempted
+    # A discrepancy is reported instead of silently checking out another branch.
     router.git = FakeGitManager(
         is_git_repo=True,
         current_branch="main",
@@ -7746,30 +8417,11 @@ def test_push_callback_checkout_failure_sends_edit(tmp_path: Path):
     )
     router.runtime.git = router.git
 
-    edited = []
-    update = SimpleNamespace(
-        effective_chat=SimpleNamespace(id=123, type="private"),
-        callback_query=SimpleNamespace(
-            data="push:confirm",
-            answer=None,
-            edit_message_text=None,
-        ),
-    )
-    bot = FakeBot()
-    context = SimpleNamespace(args=[], bot=bot)
-
-    async def fake_answer():
-        return None
-
-    async def fake_edit(text, parse_mode=None):
-        edited.append(text)
-
-    update.callback_query.answer = fake_answer
-    update.callback_query.edit_message_text = fake_edit
-
-    asyncio.run(router.handle_push_callback(update, context))
+    bot = _run_push_command(router)
 
-    assert any("Push cancelled" in e for e in edited)
+    assert "Branch discrepancy detected" in bot.messages[-1][1]
+    assert "feature-x" in bot.messages[-1][1]
+    assert "main" in bot.messages[-1][1]
     assert router.git.push_calls == []
 
 
diff --git a/tests/test_diff_chunking.py b/tests/test_diff_chunking.py
index 36099ea..063de33 100644
--- a/tests/test_diff_chunking.py
+++ b/tests/test_diff_chunking.py
@@ -56,15 +56,21 @@ def test_build_summary_includes_branch_next_to_project():
 
 
 def test_parse_status_paths_includes_renames_and_untracked():
-    output = " M src/app.py\n?? src/new.py\nR  old.py -> new.py\n"
+    output = " M src/app.py\0?? src/new.py\0R  new.py\0old.py\0"
     assert _parse_status_paths(output) == ["src/app.py", "src/new.py", "new.py"]
 
 
+def test_parse_status_paths_preserves_unquoted_special_filenames_from_z_mode():
+    output = " M café file.py\0?? trailing-space \0"
+
+    assert _parse_status_paths(output) == ["café file.py", "trailing-space "]
+
+
 def test_split_changed_files_separates_tracked_and_untracked(monkeypatch, tmp_path: Path):
     monkeypatch.setattr(
         diff_utils_module,
         "_git",
-        lambda _project_path, _args: " M src/app.py\n?? src/new.py\nR  old.py -> new.py\n",
+        lambda _project_path, _args: " M src/app.py\0?? src/new.py\0R  new.py\0old.py\0",
     )
 
     tracked, untracked = split_changed_files(tmp_path)