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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
26 changes: 21 additions & 5 deletions hooks.d/after_save/50-git-backup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -125,16 +141,16 @@ 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
log "git-backup" "no remote configured, skipping push"
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:-<upstream tracking>}')"
if _push; then
log "git-backup" "pushed $SLUG"
else
log "git-backup" "push deferred (will retry next backup)"
Expand All @@ -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)"
Expand All @@ -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)"
Expand Down
47 changes: 47 additions & 0 deletions tests/test_git_backup_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading