From ae287ca3f1a00b0f5682f3edfd206e34471d83c1 Mon Sep 17 00:00:00 2001 From: Florian DAVID Date: Tue, 23 Jun 2026 09:44:31 +0200 Subject: [PATCH] fix(git-backup): make push target configurable via git_backup.remote/branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #63. The after_save git backup pushed with a bare `git push`, relying on the branch's upstream tracking. Fine for the standard `origin main` setup, but brittle for users with multiple remotes or non-standard tracking — memory could land on the wrong remote. - New `git_backup.remote` / `git_backup.branch` config keys. Both empty (the default) preserves the historical bare-push behaviour. - All three push sites route through a single `_push()` helper. - Remote-URL change validation (#67) now keys off the configured remote instead of hardcoded `origin`, so it guards the remote actually pushed to. - First push logs the resolved target: `(remote 'X', branch 'Y')`. - README documents both keys. - New test proves the push routes to the configured remote and not origin. Co-Authored-By: Max --- README.md | 2 ++ hooks.d/after_save/50-git-backup.sh | 26 +++++++++++++--- tests/test_git_backup_hook.py | 47 +++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a31011f..4a15f43 100644 --- a/README.md +++ b/README.md @@ -212,6 +212,8 @@ Put cross-project preferences (timezone, cooldowns) in `~/.remember/config.json` | `cooldowns.save_seconds` | `120` | Minimum seconds between saves | | `cooldowns.ndc_seconds` | `3600` | Compression interval (hourly) | | `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. | | `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 4ce3e79..0f46614 100755 --- a/hooks.d/after_save/50-git-backup.sh +++ b/hooks.d/after_save/50-git-backup.sh @@ -87,6 +87,22 @@ fi # Prevent outer git env vars from overriding git -C behaviour. unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE + # ── Configurable push target (#63) ──────────────────────────────────────── + # git_backup.remote / git_backup.branch let users with multiple remotes or a + # non-standard tracking config pin exactly where memory is pushed. Both empty + # (the default) → bare `git push`, relying on the branch's upstream tracking. + GIT_BACKUP_REMOTE=$(config ".git_backup.remote" "") + GIT_BACKUP_BRANCH=$(config ".git_backup.branch" "") + REMOTE_NAME="${GIT_BACKUP_REMOTE:-origin}" + + _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 + else + GIT_TERMINAL_PROMPT=0 git -C "$REPO_ROOT" push >/dev/null 2>&1 + fi + } + # Remove the bootstrap-written per-slug .gitignore (contains "*") that was placed # to prevent commits when memory lived inside a project repo. In external git-backup # mode it blocks all staging; the root-level .gitignore covers logs/tmp exclusions. @@ -125,7 +141,7 @@ fi # an attacker-controlled remote. Set git_backup.allow_remote_change=true to # override (e.g. when intentionally re-pointing to a new private repo). REMOTE_STATE_FILE="$REPO_ROOT/.git-backup-remote" - CURRENT_REMOTE=$(git -C "$REPO_ROOT" remote get-url origin 2>/dev/null || true) + CURRENT_REMOTE=$(git -C "$REPO_ROOT" remote get-url "$REMOTE_NAME" 2>/dev/null || true) ALLOW_REMOTE_CHANGE=$(config ".git_backup.allow_remote_change" "false") if [ -z "$CURRENT_REMOTE" ]; then @@ -133,8 +149,8 @@ fi elif [ ! -f "$REMOTE_STATE_FILE" ]; then # First push — record the URL and proceed. echo "$CURRENT_REMOTE" > "$REMOTE_STATE_FILE" - log "git-backup" "git backup configured to push to: $CURRENT_REMOTE" - if GIT_TERMINAL_PROMPT=0 git -C "$REPO_ROOT" push >/dev/null 2>&1; then + log "git-backup" "git backup configured to push to: $CURRENT_REMOTE (remote '$REMOTE_NAME', branch '${GIT_BACKUP_BRANCH:-}')" + if _push; then log "git-backup" "pushed $SLUG" else log "git-backup" "push deferred (will retry next backup)" @@ -146,7 +162,7 @@ fi # Explicit override — update state file and push. echo "$CURRENT_REMOTE" > "$REMOTE_STATE_FILE" log "git-backup" "remote URL changed (allow_remote_change=true): $CURRENT_REMOTE" - if GIT_TERMINAL_PROMPT=0 git -C "$REPO_ROOT" push >/dev/null 2>&1; then + if _push; then log "git-backup" "pushed $SLUG" else log "git-backup" "push deferred (will retry next backup)" @@ -156,7 +172,7 @@ fi fi else # Remote matches recorded URL — safe to push. - if GIT_TERMINAL_PROMPT=0 git -C "$REPO_ROOT" push >/dev/null 2>&1; then + if _push; then log "git-backup" "pushed $SLUG" else log "git-backup" "push deferred (will retry next backup)" diff --git a/tests/test_git_backup_hook.py b/tests/test_git_backup_hook.py index 4921f28..10a65df 100644 --- a/tests/test_git_backup_hook.py +++ b/tests/test_git_backup_hook.py @@ -561,3 +561,50 @@ def test_push_to_changed_remote_allowed_when_override_set(self, tmp_path): log_content = log_files[0].read_text() assert "allow_remote_change=true" in log_content assert "push aborted" not in log_content + + +class TestGitBackupConfigurablePushTarget: + """Tests for configurable git_backup.remote / git_backup.branch (#63).""" + + def test_push_routes_to_configured_remote(self, tmp_path): + """git_backup.remote pushes to that remote, not the default origin, and logs the resolved target.""" + home, remember, origin = make_external_remember_repo(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() + + # A second, explicitly-configured remote distinct from origin. + backup = tmp_path / "backup-remote.git" + subprocess.run(["git", "init", "-q", "--bare", str(backup)], check=True, capture_output=True) + _git(remember, ["remote", "add", "backup", str(backup)]) + _git(remember, ["push", "-q", "-u", "backup", "main"]) + + cfg = tmp_path / "remote-config.json" + cfg.write_text('{"cooldowns": {"git_backup_seconds": 0}, "git_backup": {"remote": "backup"}}') + + result = _run_hook(slug_dir, project, home, config_path=cfg) + assert result.returncode == 0 + wait_for_lock_release(remember / ".git-backup.lock") + + # Local commit was made. + assert len(_commit_log(remember)) == 2 + + # The configured remote received the auto commit; origin did NOT. + # Bare repos: read the explicit main ref (their default HEAD may be master). + def _ref_count(bare, ref="refs/heads/main"): + r = subprocess.run(["git", "-C", str(bare), "rev-list", "--count", ref], + capture_output=True, text=True) + return int(r.stdout.strip()) if r.returncode == 0 else 0 + assert _ref_count(backup) == 2, "configured 'backup' remote should have the auto commit" + assert _ref_count(origin) == 1, "default origin must NOT receive the push when a remote is configured" + + # State file records the configured remote's URL, and the log names the resolved target. + assert (remember / ".git-backup-remote").read_text().strip() == str(backup) + log_files = list((slug_dir / "logs").glob("memory-*.log")) + assert log_files + log_content = log_files[0].read_text() + assert "remote 'backup'" in log_content