Skip to content

Latest commit

 

History

History
181 lines (161 loc) · 9.92 KB

File metadata and controls

181 lines (161 loc) · 9.92 KB

GitHub Sync (Auth + Clone + Pull/Push)

Problem

Memento is a local-only markdown editor: it opens a workspace folder on disk and has no network, auth, or git layer. The intended use is as an editor UI over a memory layer stored in a private GitHub repo — the memory the Poke AI agent reads and writes. Today that repo can only be viewed on github.com or cloned by hand. There is no way to authenticate to a private repo, no way to keep the local copy fresh as the agent writes to it remotely, and no way to push the user's occasional edits back.

Goal

Turn Memento into a local editor over a GitHub-backed memory repo, where:

  • The repo is cloned to a fixed, visible local path (~/Desktop/Memento/<repo-name>/) and opened as the workspace.
  • The local clone is plain markdown on disk so any agent (Poke, Claude Code, scripts) can read it directly without git or a token. A pointer file records the active path for programmatic discovery.
  • Auto-fetch keeps the local copy fresh as the agent writes remotely (interval + window focus + manual "fetch now"). Fetch fast-forwards.
  • A subtle status-bar sync control shows sync state and pushes the user's local edits on click (commit all dirty files + push; fetch-then-retry if the remote moved).
  • Auth is a GitHub Personal Access Token (validated, then stored in the OS keychain).
  • Git transport is embedded git2 (libgit2) — no shelling out to system git, no "is git installed" dependency, self-contained in the .app.

Use model (drives the design)

  • Single logical author. The user and the Poke bot both act as the user's GitHub account. There is effectively one author.
  • Agent is the primary writer; the user mostly reads + occasionally edits. Conflicts are rare. Fetch is expected to fast-forward; push is expected to succeed or need one fetch-retry.
  • Therefore: no merge-conflict UI. The only divergence path that needs handling is "local can't fast-forward" → stop and surface an explicit choice, never silently clobber an unpushed edit. (A read-only diff viewer for local changes exists — see the Source Control view below — but there is no three-way conflict resolver.)
  • A Source Control view (full tab, opened from the prominent top-right sync button) lists per-file working-tree changes, shows each file's diff (read-only CodeMirror merge of HEAD vs working tree), offers per-file discard, and commits-all + pushes from a message box. A Stats tab renders a commit-activity heatmap from local git history. Backend commands: github_changed_files, github_file_diff, github_discard_file, github_commit_history (all in commands/github_sync.rs).

Design

Auth (commands/github_auth.rs)

  • Personal Access Token: the user creates a classic PAT with the repo scope (private repo read/write) and pastes it into the connect dialog. The backend validates it with GET /user before storing — an invalid token is rejected and nothing is persisted. No registered GitHub OAuth app (and no compiled-in client id) is required.
  • Token stored in OS keychain via the keyring crate (service com.memento.github, account oauth-token). Never written to the settings store or any plaintext file. Read on demand (crate::github) — there is no in-memory cache, so the keychain is the single source of truth for signed-in state.
  • IPC: github_save_token(token) -> { login } (validates, then writes token to keychain on success), github_signed_in() -> bool, github_sign_out() (deletes token).
  • An HTTP client (ureq) is used for the GET /user validation call. Git transport itself uses git2, not HTTP.

Repo selection + clone (commands/github_sync.rs)

  • After auth: github_list_repos() lists the account's repos (GitHub REST GET /user/repos, private included) so the user can pick the memory repo; a manual owner/name entry is also accepted.
  • github_clone_repo(full_name):
    • Clones via git2 to ~/Desktop/Memento/<repo-name>/ using the keychain token as the credential (RemoteCallbacks::credentialsCred::userpass_plaintext(token, "")).
    • If the destination already exists: when it's an existing clone whose origin matches full_name (repo_matches_origin, comparing owner/repo case-insensitively and ignoring scheme/host/.git), reuse it (rewrite the pointer + open as workspace) instead of re-cloning; an unrelated directory at the path is rejected with AlreadyExists.
    • Writes the pointer file ~/.memento/state.json: { "memoryPath": "<abs clone path>", "repo": "<owner/name>" }.
    • Opens the clone as the workspace (existing restore/open path).
  • No separate sync config is persisted: the active memory repo is simply whichever workspace is open, and the ~/.memento/state.json pointer records the path for external agents. The access token (keychain) is the only stored secret. Sync state is derived live from git2 on the open workspace, so there is nothing to keep coherent across a settings file.

Fetch (auto + manual)

  • github_fetch():
    • git2 fetch from origin, then attempt fast-forward of the checked- out branch to the upstream.
    • If fast-forward succeeds: working tree files are updated → the existing file watcher fires fs:file-changed → UI refreshes (free live update).
    • If the branch cannot fast-forward (local diverged — user has unpushed edits that touch the same history): do not reset. Return a diverged status; the frontend shows a banner ("Local changes diverged from remote — push or discard") and offers push / discard-local actions. Fail explicitly, never auto-clobber.
  • Triggers (frontend): an immediate fetch on app open and on workspace switch (right after the status snapshot, gated on signedIn && isRepo), then an interval timer (~67s, off the round minute so installs don't poll in lockstep), window focus, and tab visibility. All call the same github_fetch; the sync store's phase guard coalesces overlapping triggers. The open/switch fetch is what keeps a reopened app from trusting a stale local HEAD and clobbering remote commits on the next push.
  • github_fetch uses an explicit refs/heads/<b>:refs/remotes/origin/<b> refspec so the remote-tracking ref stays current; github_sync_status then reports ahead/behind commit counts (local vs origin/<branch>) via graph_ahead_behind. The sync button renders behind as an incoming ↓N indicator, and ↓N ↑N together when diverged.
  • No watcher coupling (decided during impl): git's working-tree writes during fetch are intentionally not suppressed. Push is a manual button (never auto-fired) so pulled writes can't start a push loop; the dirty count comes from git status (tree vs HEAD), not watcher events, so fetched files never inflate it; and we want a fetched change to fire fs:file-changed so the open editor reloads fresh remote content. A fast-forward only runs when the tree is clean (see Fetch above), so there is no in-progress edit to lose. This is simpler and more robust than bracketing the checkout in the self-write guard.

Push (status-bar sync control)

  • github_push():
    • Stage all changes (index.add_all), commit with an auto message (memento: sync <n> file(s) <iso-timestamp>; timestamp passed in from the frontend since the backend clock helper is fine here — or chrono), push to origin.
    • One commit per push-press, bundling all dirty files (not per-file).
    • If push is rejected (remote moved since last fetch): run github_fetch first; if it fast-forwards, retry push once. If fetch reports diverged (same file touched both sides), stop and surface the divergence banner — do not force-push.
  • The sync control lives in the status bar / bottom corner. It is both indicator and button:
    • synced (✓), dirty (↑N — N files to push, click to push), fetching/pushing (spinner), diverged (! — opens the banner), offline/error (subtle warning).
    • Dirty count derives from git2 status (working tree vs HEAD).

Frontend

  • stores/sync-store.ts — Zustand slice holding sync state (signedIn, isRepo, phase, branch, dirtyCount, diverged, error); a module-level useWorkspaceStore subscription re-seeds it on workspace change.
  • hooks/use-sync-triggers.ts — owns the interval + focus + visibility listeners (side effects in one place per the guidelines).
  • components/github/connect-store + connect-dialog (onboarding state machine), sync-control (status-bar indicator/button), divergence-banner.
  • IPC wrappers live in lib/tauri.ts alongside the rest.
  • Onboarding flow (first run, no memory configured):
    1. Prompt "Connect GitHub" → paste a Personal Access Token (validated).
    2. List private repos → user selects the memory repo.
    3. Clone to ~/Desktop/Memento/<repo>/ → write pointer file → open as workspace.
    4. Thereafter: auto-open that workspace, auto-fetch running, sync control live.

Known behavior / non-goals

  • No 3-way merge, no conflict-marker editing. The only divergence handling is the explicit push-or-discard banner. This matches the single-author, agent-primary-writer model; revisit only if the user starts editing heavily in parallel with the agent.
  • Discard-local (from the divergence banner) hard-resets the working tree to the remote and is the one destructive action — it is always explicit and confirmed, never automatic.
  • Token has repo scope (full private repo access). A future fine-grained / per-repo scoping pass is out of scope for v1.
  • Multi-repo / multiple memory vaults is out of scope for v1 (one active memory repo at a time); the pointer file is shaped to allow a list later.
  • New crate dependencies: git2, keyring, and an HTTP client (reqwest/ureq). git2 pulls a C dependency (libgit2) that must be vendored/linked in the notarized macOS build.