Skip to content

CS-10618: Implement bidirectional realm sync command#4419

Open
FadhlanR wants to merge 3 commits intomainfrom
cs-10618-reimplement-boxel-realm-sync-command
Open

CS-10618: Implement bidirectional realm sync command#4419
FadhlanR wants to merge 3 commits intomainfrom
cs-10618-reimplement-boxel-realm-sync-command

Conversation

@FadhlanR
Copy link
Copy Markdown
Contributor

Summary

  • Add boxel realm sync command for bidirectional syncing between a local directory and a Boxel realm
  • Uses manifest-based change detection to classify files as changed/added/deleted on both local and remote sides
  • Supports conflict resolution strategies: --prefer-local, --prefer-remote, --prefer-newest
  • Supports --delete flag to sync deletions and --dry-run for previewing changes
  • Extracts shared sync-manifest utilities (SyncManifest, computeFileHash, loadManifest, saveManifest, pathExists) from push command into sync-manifest.ts
  • Includes integration tests

Test plan

  • Run realm sync integration tests
  • Manual test: sync a local directory with a realm (both directions)
  • Verify conflict resolution with --prefer-local, --prefer-remote, --prefer-newest
  • Verify --dry-run previews without making changes
  • Verify existing realm push still works after manifest extraction

🤖 Generated with Claude Code

FadhlanR and others added 3 commits April 16, 2026 16:25
Add `boxel realm sync` for bidirectional syncing between a local directory
and a Boxel realm. Uses a manifest-based approach to detect changes on both
sides, with conflict resolution strategies (--prefer-local, --prefer-remote,
--prefer-newest). Extracts shared sync-manifest utilities from push command.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Use 'update' instead of 'add' in atomic upload when file exists on
  remote but no manifest is present (first sync with overlapping files)
- Add 1s delay in establishBaseline to avoid same-second mtime collisions
- Account for realm-default index.json in manifest assertions
- Stabilize no-op test by syncing once before checking for no checkpoint

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@FadhlanR FadhlanR marked this pull request as ready for review April 16, 2026 10:34
@FadhlanR FadhlanR requested a review from a team April 16, 2026 10:34
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4d0c7f70a8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +253 to +257
if (totalOps === 0) {
console.log('\nEverything is up to date');
if (!this.options.dryRun && !effectiveManifest) {
// First sync with no changes needed - still write manifest
await this.writeManifest(localHashes, remoteMtimes);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Block manifest writes when conflicts are skipped

When conflicts are skipped (no --prefer-* strategy), totalOps can still be 0, so this branch reports “Everything is up to date” and writes a first-sync manifest. In that scenario, conflicting local/remote versions get recorded as if they were synchronized, so later runs classify them as unchanged and never surface the conflict again. Skip-success paths should not write a manifest while skippedConflicts is non-empty.

Useful? React with 👍 / 👎.

Comment on lines +92 to +97
const [localFiles, localFilesWithMtimes, remoteMtimes, manifest] =
await Promise.all([
this.getLocalFileList(),
this.getLocalFileListWithMtimes(),
this.getRemoteMtimes(),
loadManifest(this.options.localDir),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fall back to remote listing when mtimes are unavailable

Sync state is derived from remoteMtimes, but getRemoteMtimes() can legitimately return an empty map when the _mtimes endpoint is unavailable (as handled in RealmSyncBase). With this code path, remote files become invisible to classification and add/update selection, so remote-only files are never pulled and existing remote files may be treated as add operations (causing atomic 409 failures). The command should use getRemoteFileList() as a fallback source of remote existence data.

Useful? React with 👍 / 👎.

// Phase 6: Update manifest
if (!this.options.dryRun && !this.hasError) {
// Recompute hashes for pulled files and update manifest
const updatedHashes = new Map(localHashes);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve unsynced deletions when rebuilding manifest

Rebuilding updatedHashes from current localHashes drops any manifest entries for files that were deleted locally but intentionally not propagated (for example, no --delete and no prefer-local deletion). If another file changes in the same run, this rewrite forgets that pending deletion and the next sync can re-pull the remote copy unexpectedly. Manifest updates should be based on prior manifest state plus executed actions, not only the current local file set.

Useful? React with 👍 / 👎.

@habdelra habdelra requested a review from Copilot April 16, 2026 14:11
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's not add this to the repo

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new boxel realm sync command to support bidirectional syncing between a local directory and a Boxel realm using a persisted sync manifest, with conflict-resolution flags and integration coverage.

Changes:

  • Introduce realm sync command implementing bidirectional classification (push/pull/deletes/conflicts) and checkpointing.
  • Extract shared manifest helpers into src/lib/sync-manifest.ts and refactor realm push to use them.
  • Add integration tests covering bidirectional sync, conflicts, deletion behavior, dry-run, and manifest updates.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
packages/boxel-cli/src/commands/realm/sync.ts New bidirectional sync command + conflict strategies, manifest update, and checkpoint creation.
packages/boxel-cli/src/lib/sync-manifest.ts New shared utilities for reading/writing the .boxel-sync.json manifest and hashing files.
packages/boxel-cli/src/commands/realm/push.ts Refactor to use shared sync-manifest utilities instead of inline implementations.
packages/boxel-cli/src/commands/realm/index.ts Register the new sync subcommand under boxel realm.
packages/boxel-cli/tests/integration/realm-sync.test.ts New integration test suite for sync behavior and conflict/deletion scenarios.
docs/cs-10618-realm-sync-plan.md Design/plan document for the new sync command and test coverage.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +475 to +523
private determineAction(
local: SideStatus,
remote: SideStatus,
_relativePath: string,
): SyncAction {
// Both unchanged
if (local === 'unchanged' && remote === 'unchanged') return 'noop';

// One side changed, other unchanged
if (local === 'changed' && remote === 'unchanged') return 'push';
if (local === 'unchanged' && remote === 'changed') return 'pull';

// One side added, other doesn't exist
if (local === 'added' && remote === 'unchanged') return 'push';
if (local === 'unchanged' && remote === 'added') return 'pull';

// Both changed or both added — conflict
if (
(local === 'changed' && remote === 'changed') ||
(local === 'added' && remote === 'added')
) {
return 'conflict';
}

// Deletions
if (local === 'deleted' && remote === 'unchanged') {
return this.syncOptions.deleteSync || this.syncOptions.preferLocal
? 'push-delete'
: 'noop';
}
if (local === 'unchanged' && remote === 'deleted') {
return this.syncOptions.deleteSync || this.syncOptions.preferRemote
? 'pull-delete'
: 'noop';
}

// Delete vs change conflicts
if (local === 'deleted' && remote === 'changed') return 'conflict';
if (local === 'changed' && remote === 'deleted') return 'conflict';

// Both deleted
if (local === 'deleted' && remote === 'deleted') return 'noop';

// Added vs deleted (shouldn't normally happen but handle gracefully)
if (local === 'added' && remote === 'deleted') return 'push';
if (local === 'deleted' && remote === 'added') return 'pull';

return 'noop';
}
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The action matrix in determineAction() doesn’t handle combinations like local='changed' + remote='added' (or local='added' + remote='changed'), which can occur when remote “in manifest” detection differs from local detection (e.g., manifest missing remoteMtimes entries). These cases currently fall through to noop, silently skipping real changes. Add explicit handling (likely conflict or a deterministic push/pull choice) so unexpected state combinations can’t be ignored.

Copilot uses AI. Check for mistakes.
Comment on lines +499 to +508
// Deletions
if (local === 'deleted' && remote === 'unchanged') {
return this.syncOptions.deleteSync || this.syncOptions.preferLocal
? 'push-delete'
: 'noop';
}
if (local === 'unchanged' && remote === 'deleted') {
return this.syncOptions.deleteSync || this.syncOptions.preferRemote
? 'pull-delete'
: 'noop';
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deletions are executed even when --delete is not set: a local deletion with --prefer-local triggers push-delete, and a remote deletion with --prefer-remote triggers pull-delete. This makes --prefer-* act like an implicit “sync deletions” toggle, which is surprising and risks unintended data loss (and it contradicts the PR description that --delete controls deletion syncing). Consider requiring deleteSync for non-conflict deletions, and limiting --prefer-* to resolving true conflicts only.

Suggested change
// Deletions
if (local === 'deleted' && remote === 'unchanged') {
return this.syncOptions.deleteSync || this.syncOptions.preferLocal
? 'push-delete'
: 'noop';
}
if (local === 'unchanged' && remote === 'deleted') {
return this.syncOptions.deleteSync || this.syncOptions.preferRemote
? 'pull-delete'
: 'noop';
// Deletions are only synchronized when deleteSync is explicitly enabled.
// preferLocal / preferRemote should only affect true conflict resolution.
if (local === 'deleted' && remote === 'unchanged') {
return this.syncOptions.deleteSync ? 'push-delete' : 'noop';
}
if (local === 'unchanged' && remote === 'deleted') {
return this.syncOptions.deleteSync ? 'pull-delete' : 'noop';

Copilot uses AI. Check for mistakes.
Comment on lines +92 to +98
const [localFiles, localFilesWithMtimes, remoteMtimes, manifest] =
await Promise.all([
this.getLocalFileList(),
this.getLocalFileListWithMtimes(),
this.getRemoteMtimes(),
loadManifest(this.options.localDir),
]);
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

State gathering does two full local traversals (getLocalFileList() and getLocalFileListWithMtimes()) even though mtimes are only needed for --prefer-newest conflict resolution. Consider deriving localFiles from the getLocalFileListWithMtimes() result (or only collecting mtimes when preferNewest is enabled and conflicts exist) to reduce filesystem IO on large directories.

Suggested change
const [localFiles, localFilesWithMtimes, remoteMtimes, manifest] =
await Promise.all([
this.getLocalFileList(),
this.getLocalFileListWithMtimes(),
this.getRemoteMtimes(),
loadManifest(this.options.localDir),
]);
const [localFilesWithMtimes, remoteMtimes, manifest] = await Promise.all([
this.getLocalFileListWithMtimes(),
this.getRemoteMtimes(),
loadManifest(this.options.localDir),
]);
const localFiles = new Set(localFilesWithMtimes.keys());

Copilot uses AI. Check for mistakes.
Comment on lines +79 to +101
console.log('Testing realm access...');
try {
await this.getRemoteFileList('');
} catch (error) {
console.error('Failed to access realm:', error);
throw new Error(
'Cannot proceed with sync: Authentication or access failed. ' +
'Please check your Matrix credentials and realm permissions.',
);
}
console.log('Realm access verified');

// Phase 1: Gather state
const [localFiles, localFilesWithMtimes, remoteMtimes, manifest] =
await Promise.all([
this.getLocalFileList(),
this.getLocalFileListWithMtimes(),
this.getRemoteMtimes(),
loadManifest(this.options.localDir),
]);

console.log(`Found ${localFiles.size} local files`);
console.log(`Found ${remoteMtimes.size} remote files`);
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

realm sync currently relies on the realm’s /_mtimes endpoint to discover remote files (getRemoteMtimes()), but RealmSyncBase.getRemoteMtimes() returns an empty map on 404 or fetch errors. In that case this command will treat the remote as having zero files (no pulls, and potentially pushes that overwrite remote-only content). Consider falling back to getRemoteFileList('') (already called for access-check) to at least enumerate remote paths, or fail fast with a clear error when /_mtimes is unavailable so users don’t get a misleading/unsafe sync.

Copilot uses AI. Check for mistakes.
Comment on lines +461 to +470
const inManifest = manifest?.remoteMtimes?.[relativePath] !== undefined;

if (hasRemote && inManifest) {
return remoteMtimes.get(relativePath) ===
manifest!.remoteMtimes![relativePath]
? 'unchanged'
: 'changed';
}
if (hasRemote && !inManifest) return 'added';
if (!hasRemote && inManifest) return 'deleted';
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remote change classification uses only manifest.remoteMtimes to decide whether a remote file is “in manifest”. If the manifest exists but lacks remoteMtimes (e.g., created when /_mtimes was unavailable), every existing remote file becomes remoteStatus='added', which then interacts badly with the action matrix (e.g., localStatus='changed' + remoteStatus='added' falls through to noop). Consider treating a manifest without remoteMtimes as unusable for remote classification (treat as first sync), or using manifest.files as a secondary “known paths” set and handling the missing-mtime case explicitly (e.g., classify as changed/conflict requiring a strategy).

Suggested change
const inManifest = manifest?.remoteMtimes?.[relativePath] !== undefined;
if (hasRemote && inManifest) {
return remoteMtimes.get(relativePath) ===
manifest!.remoteMtimes![relativePath]
? 'unchanged'
: 'changed';
}
if (hasRemote && !inManifest) return 'added';
if (!hasRemote && inManifest) return 'deleted';
const manifestRemoteMtime = manifest?.remoteMtimes?.[relativePath];
const inManifestRemote = manifestRemoteMtime !== undefined;
const inManifestFiles = manifest?.files[relativePath] !== undefined;
const knownInManifest = inManifestRemote || inManifestFiles;
if (hasRemote && inManifestRemote) {
return remoteMtimes.get(relativePath) === manifestRemoteMtime
? 'unchanged'
: 'changed';
}
if (hasRemote && knownInManifest) {
// The file path is known from the manifest, but we do not have a
// previous remote mtime to compare against. Treat it as changed rather
// than added so sync planning can resolve it explicitly.
return 'changed';
}
if (hasRemote && !knownInManifest) return 'added';
if (!hasRemote && knownInManifest) return 'deleted';

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants