GitSyncMarks

GitSyncMarks — Sync Logic

Overview

GitSyncMarks implements bidirectional bookmark synchronization using a three-way merge algorithm. Each bookmark is stored as an individual JSON file. The sync engine compares three states — base (last sync), local (browser), and remote (GitHub) — to automatically merge non-conflicting changes.

Core Concept: Three-Way Merge

flowchart TD subgraph inputs [Three States] Base["Base: Last sync snapshot\n(stored in chrome.storage.local)"] Local["Local: Current browser bookmarks\n(via chrome.bookmarks.getTree)"] Remote["Remote: Current GitHub tree\n(via Git Data API)"] end subgraph diff [Compute Diffs] LocalDiff["Local Diff:\nWhat changed locally since base?"] RemoteDiff["Remote Diff:\nWhat changed remotely since base?"] end subgraph merge [Merge] AutoMerge["Auto-merge non-conflicting changes"] Conflict["True conflicts:\nsame file changed differently on both sides"] end Base --> LocalDiff Local --> LocalDiff Base --> RemoteDiff Remote --> RemoteDiff LocalDiff --> AutoMerge RemoteDiff --> AutoMerge AutoMerge --> Conflict

Sync Operations

Push (Local → GitHub)

Full push of all local bookmarks as individual files using atomic commit.

sequenceDiagram participant UI as Popup_or_AutoSync participant SE as SyncEngine participant BM as Bookmarks_API participant GH as Git_Data_API UI->>SE: push() SE->>BM: getTree() SE->>SE: bookmarkTreeToFileMap() SE->>GH: fetchRemoteFileMap() SE->>SE: Compute file changes SE->>GH: atomicCommit(fileChanges) Note over GH: Creates blobs, tree, commit, updates ref SE->>SE: saveSyncState() SE-->>UI: success

Pull (GitHub → Local)

Fetch remote file map, convert to bookmark tree, replace local bookmarks.

sequenceDiagram participant UI as Popup participant SE as SyncEngine participant BM as Bookmarks_API participant GH as Git_Data_API UI->>SE: pull() SE->>GH: fetchRemoteFileMap() SE->>SE: fileMapToBookmarkTree() SE->>BM: replaceLocalBookmarks(roleMap) Note over BM: Remove all children per role folder, recreate from remote SE->>BM: getTree() for fresh state SE->>SE: saveSyncState() SE-->>UI: success

Sync (Bidirectional Three-Way Merge)

sequenceDiagram participant UI as Popup_or_Alarm participant SE as SyncEngine participant BM as Bookmarks_API participant GH as Git_Data_API participant Storage as chrome_storage UI->>SE: sync() SE->>Storage: Load base state (LAST_SYNC_FILES) SE->>BM: getTree() → bookmarkTreeToFileMap() SE->>GH: fetchRemoteFileMap() SE->>SE: computeDiff(base, local) SE->>SE: computeDiff(base, remote) alt No changes on either side SE-->>UI: "All in sync" else Only local changes SE->>GH: atomicCommit(localChanges) else Only remote changes SE->>GH: getLatestCommitSha() (verify fetch not stale) alt remote.commitSha != verifySha SE-->>UI: "All in sync" (skip pull — stale fetch) else Match SE->>BM: replaceLocalBookmarks() end else Both changed SE->>SE: mergeDiffs(localDiff, remoteDiff) alt No conflicts SE->>BM: Apply remote changes locally SE->>GH: Push local changes else Conflicts found SE->>Storage: Set hasConflict = true SE-->>UI: "Conflict: both modified" end end

Diff Computation

computeDiff(base, current) compares two file maps (path → content) and produces:

Category Meaning
added Files in current but not in base
removed Files in base but not in current
modified Files in both but with different content

Generated/meta files (README.md, _index.json, bookmarks.html, feed.xml, dashy-conf.yml, settings.enc) are excluded from diff via DIFF_IGNORE_SUFFIXES. Individual settings files (settings-{id}.enc) are excluded via SETTINGS_ENC_PATTERN.

Merge Rules

mergeDiffs(localDiff, remoteDiff) applies these rules per file path:

Local Remote Action
Added Push to GitHub
Added Create locally
Modified Push to GitHub
Modified Apply locally
Removed Delete on GitHub
Removed Delete locally
Same change Same change No action needed
Different change Different change Conflict
Removed Removed No action needed

Conflict Detection and Resolution

When mergeDiffs finds conflicts (same file changed differently on both sides):

  1. hasConflict flag is set in chrome.storage.local
  2. Popup shows conflict warning with resolution buttons:
    • Local → GitHub (force push) — overwrites remote
    • GitHub → Local (force pull) — overwrites local
  3. The chosen operation clears the conflict flag

First Sync Special Cases

When no base state exists (first sync ever):

Local Remote Action
Has bookmarks Empty repo Push
Empty Has data Pull
Has bookmarks Has data Conflict (user must choose)
Empty Empty Nothing to do

Auto-Sync and Debounce

Bookmark event → triggerAutoSync() → debouncedSync(5000ms)
                                          ↓
                               clearTimeout (if pending)
                                          ↓
                               setTimeout(sync, 5000ms)
  • Default delay: 5 seconds
  • Each new event resets the timer
  • Uses sync() (three-way merge), not just push
  • Suppressed for 10 seconds after a pull (to ignore bookmark events from replaceLocalBookmarks)

Re-Entrancy Guard

A module-level isSyncing boolean prevents concurrent operations. background.js also checks isSyncInProgress() and isAutoSyncSuppressed() before triggering auto-sync.

The replaceLocalBookmarks Algorithm

Uses role-based mapping for cross-browser compatibility:

  1. Get local bookmark tree via chrome.bookmarks.getTree()
  2. Detect each root folder's role via detectRootFolderRole() (uses browser-specific IDs with title fallback)
  3. For each local root folder, get its role and the corresponding remote data: a. Only roles in SYNC_ROLES (toolbar, other) are processed; menu and mobile are ignored. b. GitHub Repos preservation: When githubReposEnabled is on and the target role matches githubReposParent, and Git data does not contain a folder titled GitHubRepos (username) (or any GitHubRepos ( prefix when username is unknown), the local GitHubRepos folder is preserved and merged into the data before replacement c. Remove all existing children (reverse order) d. Recursively recreate from merged remote data
  4. Result: All bookmarks appear in both browsers; GitHubRepos folder is kept on pull when not in Git

Stale-Fetch Guard (Path 8)

When only remote changes exist (path 8), the API response may be cached or eventually consistent. To avoid overwriting local state with stale data (e.g. right after our own push), we re-fetch getLatestCommitSha() before applying. If it differs from the commit we fetched, we skip the pull and treat as "all in sync".

GitHub API requests use cache: no-store to reduce cache-related staleness.

Optimized Remote Fetching

fetchRemoteFileMap() minimizes API calls:

  1. getLatestCommitSha() — 1 call
  2. getCommit() + getTree(recursive=1) — 2 calls → full file list with SHAs
  3. For each file: compare blob SHA with stored base SHA
    • SHA matches → use cached content from base state (0 calls)
    • SHA differsgetBlob() (1 call per changed file)

In the common case (few files changed), this is 3 + N calls where N is the number of changed files.