diff --git a/README.md b/README.md index 4a15f43..75e5934 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,7 @@ Put cross-project preferences (timezone, cooldowns) in `~/.remember/config.json` | `cooldowns.git_backup_seconds` | `900` | Minimum seconds between auto-backup commits (no-op if `~/.remember/` is not a git repo) | | `git_backup.remote` | _(empty)_ | Remote to push memory backups to. Empty → bare `git push`, relying on the branch's upstream tracking (the standard `origin main` setup). Set this if you have multiple remotes or a non-standard tracking config. | | `git_backup.branch` | _(empty)_ | Branch to push to. Only used when `git_backup.remote` is set; empty pushes the current branch. The resolved remote/branch is logged on the first push. | +| `git_backup.gpg_sign` | `false` | Sign auto-backup commits. Default passes `--no-gpg-sign` so background commits never hang on a passphrase prompt. Set `true` only with non-interactive signing (e.g. a hardware key) to honour your global `commit.gpgSign`. | | `thresholds.min_human_messages` | `3` | Minimum messages before saving | | `thresholds.delta_lines_trigger` | `50` | Tool call output lines that trigger auto-save | | `thresholds.extract_max_bytes` | `300000` | Max UTF-8 size of the session extract sent to Haiku. Larger extracts are truncated to their most-recent tail so a very long session can't overflow the model's context window and silently stall saves. `0` disables the cap. | diff --git a/hooks.d/after_save/50-git-backup.sh b/hooks.d/after_save/50-git-backup.sh index 0f46614..8ffa405 100755 --- a/hooks.d/after_save/50-git-backup.sh +++ b/hooks.d/after_save/50-git-backup.sh @@ -95,6 +95,15 @@ fi GIT_BACKUP_BRANCH=$(config ".git_backup.branch" "") REMOTE_NAME="${GIT_BACKUP_REMOTE:-origin}" + # ── Configurable commit signing (#62) ───────────────────────────────────── + # We pass --no-gpg-sign by default so background commits never hang on a + # passphrase prompt. Users with non-interactive signing (e.g. a hardware key) + # can set git_backup.gpg_sign=true to drop the flag and honour their own + # commit.gpgSign config. Empty flag (unquoted) = no extra arg. + GIT_BACKUP_GPG_SIGN=$(config ".git_backup.gpg_sign" "false") + GPG_SIGN_FLAG="--no-gpg-sign" + [ "$GIT_BACKUP_GPG_SIGN" = "true" ] && GPG_SIGN_FLAG="" + _push() { if [ -n "$GIT_BACKUP_REMOTE" ]; then GIT_TERMINAL_PROMPT=0 git -C "$REPO_ROOT" push "$GIT_BACKUP_REMOTE" ${GIT_BACKUP_BRANCH:+"$GIT_BACKUP_BRANCH"} >/dev/null 2>&1 @@ -125,7 +134,7 @@ fi fi TS=$(_remember_date '+%H:%M') - if git -C "$REPO_ROOT" commit --no-gpg-sign \ + if git -C "$REPO_ROOT" commit $GPG_SIGN_FLAG \ -m "auto: $SLUG $TS" \ -- "$SLUG/" >/dev/null 2>&1; then log "git-backup" "committed $SLUG" diff --git a/tests/test_git_backup_hook.py b/tests/test_git_backup_hook.py index 10a65df..134cdb9 100644 --- a/tests/test_git_backup_hook.py +++ b/tests/test_git_backup_hook.py @@ -608,3 +608,80 @@ def _ref_count(bare, ref="refs/heads/main"): assert log_files log_content = log_files[0].read_text() assert "remote 'backup'" in log_content + + +def _configure_fake_gpg(remember: Path, tmp_path: Path) -> None: + """Point the repo at a stub gpg that emits a fake signature and enable commit.gpgsign. + + Lets us assert signing behaviour deterministically in CI without a real GPG key: + git embeds whatever the stub prints (and trusts the SIG_CREATED status line). + """ + fake = tmp_path / "fakegpg.sh" + fake.write_text( + "#!/bin/sh\n" + 'echo "[GNUPG:] SIG_CREATED G" >&2\n' + "cat <<'SIG'\n" + "-----BEGIN PGP SIGNATURE-----\n" + "\n" + "fakefakefake\n" + "-----END PGP SIGNATURE-----\n" + "SIG\n" + ) + fake.chmod(0o755) + _git(remember, ["config", "gpg.program", str(fake)]) + _git(remember, ["config", "commit.gpgsign", "true"]) + _git(remember, ["config", "user.signingkey", "FAKEKEY"]) + + +def _is_signed(repo: Path, ref: str = "HEAD") -> bool: + """True if the commit object carries a gpgsig header.""" + r = subprocess.run( + ["git", "-C", str(repo), "cat-file", "-p", ref], + capture_output=True, text=True, check=True, + ) + return "gpgsig" in r.stdout + + +class TestGitBackupGpgSign: + """Tests for configurable commit signing via git_backup.gpg_sign (#62).""" + + def test_default_commit_is_not_signed(self, tmp_path): + """Default (no git_backup.gpg_sign): --no-gpg-sign is passed, overriding repo commit.gpgsign.""" + home, remember, remote = make_external_remember_repo(tmp_path) + _configure_fake_gpg(remember, tmp_path) + slug = "test-slug" + slug_dir = remember / slug + slug_dir.mkdir() + (slug_dir / "now.md").write_text("## 10:00 | test\nMemory.\n") + + project = tmp_path / "project" + project.mkdir() + cfg = _make_config(tmp_path, cooldown=0) + + result = _run_hook(slug_dir, project, home, config_path=cfg) + assert result.returncode == 0 + wait_for_lock_release(remember / ".git-backup.lock") + + assert len(_commit_log(remember)) == 2 + assert not _is_signed(remember), "default commit must stay unsigned (--no-gpg-sign)" + + def test_gpg_sign_true_honors_user_signing(self, tmp_path): + """git_backup.gpg_sign=true omits --no-gpg-sign, so the repo's commit.gpgsign is honored.""" + home, remember, remote = make_external_remember_repo(tmp_path) + _configure_fake_gpg(remember, tmp_path) + slug = "test-slug" + slug_dir = remember / slug + slug_dir.mkdir() + (slug_dir / "now.md").write_text("## 10:00 | test\nMemory.\n") + + project = tmp_path / "project" + project.mkdir() + cfg = tmp_path / "gpg-config.json" + cfg.write_text('{"cooldowns": {"git_backup_seconds": 0}, "git_backup": {"gpg_sign": true}}') + + result = _run_hook(slug_dir, project, home, config_path=cfg) + assert result.returncode == 0 + wait_for_lock_release(remember / ".git-backup.lock") + + assert len(_commit_log(remember)) == 2 + assert _is_signed(remember), "gpg_sign=true must let the repo sign the commit"