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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion src/coding_agent_telegram/git_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,45 @@ def _resolved_commit_identity(self, project_path: Path) -> dict[str, str]:

return identity

def _resolved_commit_signing(self, project_path: Path) -> tuple[tuple[str, ...], dict[str, str]]:
signing_args: list[str] = []
signing_env: dict[str, str] = {}

commit_sign = self._run(project_path, "config", "--bool", "--get", "commit.gpgsign")
commit_sign_value = commit_sign.stdout.strip().lower() if commit_sign.returncode == 0 else ""
if commit_sign_value not in {"true", "yes", "on", "1"}:
return (), {}

signing_args.extend(("-c", "commit.gpgsign=true"))
for key in (
"user.signingkey",
"gpg.format",
"gpg.program",
"gpg.ssh.program",
"gpg.ssh.allowedsignersfile",
"gpg.ssh.revocationfile",
"gpg.ssh.defaultkeycommand",
):
result = self._run(project_path, "config", "--get", key)
value = result.stdout.strip() if result.returncode == 0 else ""
if value:
signing_args.extend(("-c", f"{key}={value}"))

gnupghome = os.environ.get("GNUPGHOME", "").strip()
if gnupghome:
signing_env["GNUPGHOME"] = gnupghome
else:
default_gnupg = Path.home() / ".gnupg"
if default_gnupg.exists():
signing_env["GNUPGHOME"] = str(default_gnupg)

for key in ("GPG_TTY", "SSH_AUTH_SOCK"):
value = os.environ.get(key, "").strip()
if value:
signing_env[key] = value

return tuple(signing_args), signing_env

def is_git_repo(self, project_path: Path) -> bool:
result = self._run(project_path, "rev-parse", "--is-inside-work-tree")
return result.returncode == 0 and result.stdout.strip() == "true"
Expand Down Expand Up @@ -508,7 +547,9 @@ def run_safe_commit_command(self, project_path: Path, args: list[str]) -> GitCom
}
)
env.update(self._resolved_commit_identity(project_path))
result = self._run(project_path, *self.SAFE_COMMIT_CONFIG, *args, env=env)
signing_args, signing_env = self._resolved_commit_signing(project_path)
env.update(signing_env)
result = self._run(project_path, *self.SAFE_COMMIT_CONFIG, *signing_args, *args, env=env)

stdout = result.stdout.strip()
stderr = result.stderr.strip()
Expand Down
13 changes: 10 additions & 3 deletions src/coding_agent_telegram/router/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ class CommandRouterBase:
SWITCH_PAGE_SIZE = 5
ALLOWED_COMMIT_SUBCOMMANDS = {"add", "commit", "restore", "rm", "status"}
TRUST_REQUIRED_COMMIT_SUBCOMMANDS = {"add", "restore", "rm"}
ENFORCED_COMMIT_ARGS = ["--no-verify", "--no-post-rewrite", "--no-gpg-sign"]
ENFORCED_COMMIT_ARGS = ["--no-verify", "--no-post-rewrite"]
SAFE_COMMIT_OPTION_RULES = {
"add": {
"flags": {"-A", "--all", "-u", "--update"},
Expand Down Expand Up @@ -284,7 +284,7 @@ async def typing_loop() -> None:
try:
await asyncio.wait_for(
asyncio.gather(*(asyncio.wrap_future(future) for future in pending_progress), return_exceptions=True),
timeout=0.1,
timeout=1.0,
)
except asyncio.TimeoutError:
pass
Expand Down Expand Up @@ -351,7 +351,14 @@ async def publish(info: AgentProgressInfo) -> None:
)
except BadRequest:
message = await context.bot.send_message(chat_id=chat.id, text=message_text)
progress_state["message_id"] = getattr(message, "message_id", None)
message_id = getattr(message, "message_id", None)
if progress_state.get("closed") and message_id is not None and hasattr(context.bot, "delete_message"):
try:
await context.bot.delete_message(chat_id=chat.id, message_id=message_id)
except BadRequest:
pass
return
progress_state["message_id"] = message_id

def notify(info: AgentProgressInfo) -> None:
future = asyncio.run_coroutine_threadsafe(publish(info), loop)
Expand Down
44 changes: 35 additions & 9 deletions tests/test_command_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,13 @@ async def send_chat_action(self, chat_id, action):
self.actions.append((chat_id, action))


class SlowProgressBot(FakeBot):
async def send_message(self, chat_id, text, parse_mode=None, reply_markup=None):
if "Live agent output" in text:
await asyncio.sleep(0.2)
return await super().send_message(chat_id, text, parse_mode=parse_mode, reply_markup=reply_markup)


class FakeGitManager:
def __init__(
self,
Expand Down Expand Up @@ -2946,6 +2953,25 @@ def test_active_session_reuses_single_live_progress_message(tmp_path: Path):
assert bot.edit_count == 1


def test_active_session_deletes_live_progress_message_even_if_progress_send_is_slow(tmp_path: Path):
backend = tmp_path / "backend"
backend.mkdir()
runner = ProgressRunner()
cfg = make_config(tmp_path)
store = SessionStore(cfg.state_file, cfg.state_backup_file)
store.create_session("bot-a", 123, "sess_progress", "progress-session", "backend", "codex")
router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a"))
router.git = FakeGitManager(is_git_repo=False)

update = make_update(text="continue")
bot = SlowProgressBot()
context = SimpleNamespace(args=[], bot=bot)

asyncio.run(router.handle_message(update, context))

assert len(bot.deleted_messages) == 1


def test_second_message_is_queued_while_first_run_is_still_running(tmp_path: Path):
backend = tmp_path / "backend"
backend.mkdir()
Expand Down Expand Up @@ -3472,13 +3498,13 @@ def test_commit_executes_only_valid_git_commands_and_ignores_non_git_segments(tm

assert router.git.safe_git_commands == [
(backend, ["add", "-u"]),
(backend, ["commit", "-m", "safe", "--no-verify", "--no-post-rewrite", "--no-gpg-sign"]),
(backend, ["commit", "-m", "safe", "--no-verify", "--no-post-rewrite"]),
]
assert router.git.git_commands == []
assert bot.messages[-1][1].startswith('<pre><code class="language-bash">')
assert f"${shlex.join(['git', 'add', '-u'])}" in bot.messages[-1][1]
assert html.escape(
f"${shlex.join(['git', 'commit', '-m', 'safe', '--no-verify', '--no-post-rewrite', '--no-gpg-sign'])}"
f"${shlex.join(['git', 'commit', '-m', 'safe', '--no-verify', '--no-post-rewrite'])}"
) in bot.messages[-1][1]
assert "---------------" in bot.messages[-1][1]
assert "[Completed]" in bot.messages[-1][1]
Expand Down Expand Up @@ -3619,11 +3645,11 @@ def test_commit_allows_common_safe_short_forms(tmp_path: Path):

assert router.git.safe_git_commands == [
(backend, ["status", "-sb"]),
(backend, ["commit", "-msafe", "--no-verify", "--no-post-rewrite", "--no-gpg-sign"]),
(backend, ["commit", "-msafe", "--no-verify", "--no-post-rewrite"]),
]
assert f"${shlex.join(['git', 'status', '-sb'])}" in bot.messages[-1][1]
assert html.escape(
f"${shlex.join(['git', 'commit', '-msafe', '--no-verify', '--no-post-rewrite', '--no-gpg-sign'])}"
f"${shlex.join(['git', 'commit', '-msafe', '--no-verify', '--no-post-rewrite'])}"
) in bot.messages[-1][1]
assert "[Completed]" in bot.messages[-1][1]

Expand All @@ -3645,7 +3671,7 @@ def test_commit_allows_shell_style_backslash_newline_continuations(tmp_path: Pat

assert router.git.safe_git_commands == [
(backend, ["add", "src/app.py", "tests/test_app.py"]),
(backend, ["commit", "-m", "safe", "--no-verify", "--no-post-rewrite", "--no-gpg-sign"]),
(backend, ["commit", "-m", "safe", "--no-verify", "--no-post-rewrite"]),
]
assert "[Completed]" in bot.messages[-1][1]

Expand All @@ -3664,11 +3690,11 @@ def test_commit_inserts_enforced_flags_before_pathspec_separator(tmp_path: Path)
assert router.git.safe_git_commands == [
(
backend,
["commit", "-m", "safe", "--no-verify", "--no-post-rewrite", "--no-gpg-sign", "--", "tracked.txt"],
["commit", "-m", "safe", "--no-verify", "--no-post-rewrite", "--", "tracked.txt"],
),
]
assert html.escape(
f"${shlex.join(['git', 'commit', '-m', 'safe', '--no-verify', '--no-post-rewrite', '--no-gpg-sign', '--', 'tracked.txt'])}"
f"${shlex.join(['git', 'commit', '-m', 'safe', '--no-verify', '--no-post-rewrite', '--', 'tracked.txt'])}"
) in bot.messages[-1][1]
assert "[Completed]" in bot.messages[-1][1]

Expand All @@ -3687,11 +3713,11 @@ def test_commit_inserts_enforced_flags_before_implicit_pathspec(tmp_path: Path):
assert router.git.safe_git_commands == [
(
backend,
["commit", "-m", "safe", "--no-verify", "--no-post-rewrite", "--no-gpg-sign", "tracked.txt"],
["commit", "-m", "safe", "--no-verify", "--no-post-rewrite", "tracked.txt"],
),
]
assert html.escape(
f"${shlex.join(['git', 'commit', '-m', 'safe', '--no-verify', '--no-post-rewrite', '--no-gpg-sign', 'tracked.txt'])}"
f"${shlex.join(['git', 'commit', '-m', 'safe', '--no-verify', '--no-post-rewrite', 'tracked.txt'])}"
) in bot.messages[-1][1]
assert "[Completed]" in bot.messages[-1][1]

Expand Down
92 changes: 92 additions & 0 deletions tests/test_git_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,98 @@ def test_run_safe_commit_command_prefers_explicit_git_identity_env(tmp_path: Pat
assert name == "Env User <[email protected]>"


def test_resolved_commit_signing_reads_enabled_signing_config(tmp_path: Path, monkeypatch):
project = tmp_path / "repo"
project.mkdir()
manager = GitWorkspaceManager()

_git(project, "init", "-b", "main")
_git(project, "config", "user.name", "Test User")
_git(project, "config", "user.email", "[email protected]")
_git(project, "config", "commit.gpgsign", "true")
_git(project, "config", "user.signingkey", "ABC123")
_git(project, "config", "gpg.format", "openpgp")
_git(project, "config", "gpg.ssh.allowedsignersfile", str(tmp_path / "allowed_signers"))

monkeypatch.setenv("GNUPGHOME", str(tmp_path / "gnupg"))
monkeypatch.setenv("GPG_TTY", "/dev/ttys001")

signing_args, signing_env = manager._resolved_commit_signing(project)

assert "commit.gpgsign=true" in signing_args
assert "user.signingkey=ABC123" in signing_args
assert "gpg.format=openpgp" in signing_args
assert f"gpg.ssh.allowedsignersfile={tmp_path / 'allowed_signers'}" in signing_args
assert signing_env["GNUPGHOME"] == str(tmp_path / "gnupg")
assert signing_env["GPG_TTY"] == "/dev/ttys001"


def test_run_safe_commit_command_preserves_signing_config_when_enabled(tmp_path: Path, monkeypatch):
project = tmp_path / "repo"
project.mkdir()
manager = GitWorkspaceManager()

_git(project, "init", "-b", "main")
_git(project, "config", "user.name", "Test User")
_git(project, "config", "user.email", "[email protected]")
captured = {}

def fake_run(project_path, *args, env=None):
captured["args"] = args
captured["env"] = dict(env or {})
return SimpleNamespace(returncode=0, stdout="", stderr="")

monkeypatch.setattr(manager, "_run", fake_run)
monkeypatch.setattr(
manager,
"_resolved_commit_signing",
lambda _project_path: (
(
"-c",
"commit.gpgsign=true",
"-c",
"user.signingkey=ABC123",
"-c",
"gpg.ssh.allowedsignersfile=/tmp/allowed_signers",
),
{"GNUPGHOME": "/tmp/gnupg"},
),
)

result = manager.run_safe_commit_command(project, ["commit", "-m", "init"])

assert result.success is True
assert "commit.gpgsign=true" in captured["args"]
assert "user.signingkey=ABC123" in captured["args"]
assert "gpg.ssh.allowedsignersfile=/tmp/allowed_signers" in captured["args"]
assert captured["env"]["GNUPGHOME"] == "/tmp/gnupg"


def test_run_safe_commit_command_does_not_add_signing_config_when_disabled(tmp_path: Path, monkeypatch):
project = tmp_path / "repo"
project.mkdir()
manager = GitWorkspaceManager()

_git(project, "init", "-b", "main")
_git(project, "config", "user.name", "Test User")
_git(project, "config", "user.email", "[email protected]")

captured = {}

def fake_run(project_path, *args, env=None):
captured["args"] = args
captured["env"] = dict(env or {})
return SimpleNamespace(returncode=0, stdout="", stderr="")

monkeypatch.setattr(manager, "_run", fake_run)

result = manager.run_safe_commit_command(project, ["commit", "-m", "init"])

assert result.success is True
assert "commit.gpgsign=true" not in captured["args"]
assert "user.signingkey=ABC123" not in captured["args"]


# ---------------------------------------------------------------------------
# _validate_branch_name
# ---------------------------------------------------------------------------
Expand Down
Loading