From 9344fae85f6b4bcaff07e28fb4c68a3ac87a5c66 Mon Sep 17 00:00:00 2001
From: DCHA Agent <259406208+dcha-agent@users.noreply.github.com>
Date: Thu, 17 Sep 2026 03:07:56 +0800
Subject: [PATCH] Add safe Git log and reset workflows
Add /log and two-step /reset flows, refresh remote targets before hard reset, and localize command descriptions and documentation.
Guard Git commands against session branch discrepancies, detached HEAD states, stale callbacks, branch changes, and concurrent workspace mutations.
Bind commit, diff, pull, push, and reset actions to tokenized session context and paginate bounded diff file listings.
Parse NUL-delimited Git status output for special filenames and escape branch references safely for Telegram Markdown.
Expand regression coverage across callback lifecycle, locking, localization, pagination, and failure paths.
---
README.de.md | 12 +-
README.fr.md | 12 +-
README.ja.md | 12 +-
README.ko.md | 12 +-
README.md | 12 +-
README.nl.md | 12 +-
README.th.md | 12 +-
README.vi.md | 12 +-
README.zh-CN.md | 12 +-
README.zh-HK.md | 12 +-
README.zh-TW.md | 12 +-
src/coding_agent_telegram/bot.py | 20 +-
src/coding_agent_telegram/diff_utils.py | 42 +-
.../resources/locales/de.json | 16 +-
.../resources/locales/en.json | 16 +-
.../resources/locales/fr.json | 16 +-
.../resources/locales/ja.json | 16 +-
.../resources/locales/ko.json | 16 +-
.../resources/locales/nl.json | 16 +-
.../resources/locales/th.json | 16 +-
.../resources/locales/vi.json | 16 +-
.../resources/locales/zh-CN.json | 16 +-
.../resources/locales/zh-HK.json | 16 +-
.../resources/locales/zh-TW.json | 16 +-
.../router/git_commands.py | 993 +++++++++++++++---
tests/test_bot.py | 7 +-
tests/test_command_router.py | 802 ++++++++++++--
tests/test_diff_chunking.py | 10 +-
28 files changed, 1890 insertions(+), 292 deletions(-)
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:
/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)