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)