diff --git a/CLAUDE.md b/CLAUDE.md index 40e6ee1..0a14f64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,8 +6,9 @@ and sync peer-to-peer through a GitHub repo used as dumb storage. - Package name: `todo` (Dart SDK `^3.12.2`). - Git remote: `origin` → `github.com/kuhyx/todo-app`. -- The note **content** also syncs to a separate private repo `kuhyx/todo-sync` - via the in-app GitHub sync (changeset files); that is data, not this codebase. +- The note **content** also syncs to a separate private repo `kuhyx/syncs` + (under `todo-sync/`) via the in-app GitHub sync (changeset files); that is + data, not this codebase. ## Git workflow (repo-specific — overrides global rules) @@ -42,7 +43,8 @@ Three layers under `lib/`, each single-purpose: AND-combined), `NoteSort`, `watchNotes`/`watchCount` streams, and `importNotes` (safe newer-wins merge by id). - `sync/` — GitHub-as-storage sync. - - `sync_service.dart`: each device owns one file `changesets/.json` + - `sync_service.dart`: each device owns one file + `todo-sync/changesets/.json` holding its full CRDT changeset. No two devices write the same file ⇒ no git merge conflicts; convergence is the CRDT layer's job (pull every other device's changeset and `merge()`, which is commutative + idempotent). diff --git a/lib/sync/sync_service.dart b/lib/sync/sync_service.dart index fb0e5ba..d76fa6d 100644 --- a/lib/sync/sync_service.dart +++ b/lib/sync/sync_service.dart @@ -22,14 +22,15 @@ class SyncResult { /// Synchronises a [NoteRepository] with a GitHub repo used as dumb storage. /// -/// Design: each device owns exactly one file, `changesets/.json`, +/// Design: each device owns exactly one file, +/// `todo-sync/changesets/.json`, /// holding its full CRDT changeset. Because no two devices ever write the /// same file, there are **no git-level merge conflicts**. Data convergence /// is handled entirely by the CRDT layer: pulling every other device's /// changeset and [NoteRepository.merge]-ing it is commutative and /// idempotent, so repeated syncs in any order converge to the same state. class SyncService { - const SyncService({this.changesetDir = 'changesets'}); + const SyncService({this.changesetDir = 'todo-sync/changesets'}); /// Directory in the repo under which per-device changeset files live. final String changesetDir; diff --git a/lib/sync/sync_settings.dart b/lib/sync/sync_settings.dart index e5c6e3d..40715fa 100644 --- a/lib/sync/sync_settings.dart +++ b/lib/sync/sync_settings.dart @@ -52,14 +52,24 @@ class SyncSettings { /// path on Android and use libsecret on Linux. static const _secure = FlutterSecureStorage(); - /// Loads settings, defaulting the repo to `kuhyx/todo-sync` and the client id + /// Loads settings, defaulting the repo to `kuhyx/syncs` and the client id /// to the baked-in [defaultClientId] so first run (and any reinstall) needs /// nothing but a single "Connect GitHub" tap. + /// + /// A device that already persisted the old standalone `todo-sync` repo + /// name (before it was folded into the shared `syncs` monorepo) is + /// migrated to `syncs` here and the corrected value is written straight + /// back to prefs, so this only ever runs once per device. static Future load() async { final prefs = await SharedPreferences.getInstance(); + var repo = prefs.getString(_kRepo) ?? 'syncs'; + if (repo == 'todo-sync') { + repo = 'syncs'; + await prefs.setString(_kRepo, repo); + } return SyncSettings( owner: prefs.getString(_kOwner) ?? 'kuhyx', - repo: prefs.getString(_kRepo) ?? 'todo-sync', + repo: repo, token: await _loadToken(prefs), clientId: prefs.getString(_kClientId) ?? defaultClientId, ); diff --git a/lib/ui/settings_screen.dart b/lib/ui/settings_screen.dart index 430b27e..6beaea3 100644 --- a/lib/ui/settings_screen.dart +++ b/lib/ui/settings_screen.dart @@ -250,7 +250,7 @@ class _SettingsScreenState extends State { const SizedBox(height: 4), Text( 'Authorize in your browser — no token to paste. Syncs to ' - 'kuhyx/todo-sync by default.', + 'kuhyx/syncs by default.', style: Theme.of(context).textTheme.bodySmall, ), const SizedBox(height: 12), diff --git a/test/settings_screen_test.dart b/test/settings_screen_test.dart index 5772682..579d76f 100644 --- a/test/settings_screen_test.dart +++ b/test/settings_screen_test.dart @@ -78,7 +78,7 @@ void main() { WidgetTester tester, { SyncSettings initial = const SyncSettings( owner: 'kuhyx', - repo: 'todo-sync', + repo: 'syncs', token: 't', ), http.Client? httpClient, diff --git a/test/sync_service_test.dart b/test/sync_service_test.dart index 5ef9ad5..7dceadf 100644 --- a/test/sync_service_test.dart +++ b/test/sync_service_test.dart @@ -43,7 +43,7 @@ void main() { { 'type': 'file', 'name': otherFile, - 'path': 'changesets/$otherFile', + 'path': 'todo-sync/changesets/$otherFile', 'sha': 'sha-other', }, ]); @@ -54,7 +54,7 @@ void main() { putCount++; return http.Response('{}', 200); } - if (req.url.path.endsWith('/contents/changesets')) { + if (req.url.path.endsWith('/contents/todo-sync/changesets')) { return http.Response(listResponse, 200); // directory listing } return http.Response(fileResponse, 200); // the other device's file @@ -122,7 +122,7 @@ void main() { { 'type': 'file', 'name': ownFile, - 'path': 'changesets/$ownFile', + 'path': 'todo-sync/changesets/$ownFile', 'sha': 'own-sha-123', }, ]); diff --git a/test/sync_settings_test.dart b/test/sync_settings_test.dart index 74c350c..5516ff4 100644 --- a/test/sync_settings_test.dart +++ b/test/sync_settings_test.dart @@ -9,19 +9,26 @@ void main() { // binding up first (widget tests get this for free via testWidgets). TestWidgetsFlutterBinding.ensureInitialized(); - test( - 'load returns the kuhyx/todo-sync defaults on a fresh install', - () async { - SharedPreferences.setMockInitialValues({}); - installFakeSecureStorage(); - final s = await SyncSettings.load(); - expect(s.owner, 'kuhyx'); - expect(s.repo, 'todo-sync'); - expect(s.token, ''); - // Client id defaults to the baked-in OAuth App id (one-tap connect). - expect(s.clientId, SyncSettings.defaultClientId); - }, - ); + test('load returns the kuhyx/syncs defaults on a fresh install', () async { + SharedPreferences.setMockInitialValues({}); + installFakeSecureStorage(); + final s = await SyncSettings.load(); + expect(s.owner, 'kuhyx'); + expect(s.repo, 'syncs'); + expect(s.token, ''); + // Client id defaults to the baked-in OAuth App id (one-tap connect). + expect(s.clientId, SyncSettings.defaultClientId); + }); + + test('load migrates a persisted old todo-sync repo value to syncs', () async { + SharedPreferences.setMockInitialValues({'sync.repo': 'todo-sync'}); + installFakeSecureStorage(); + final s = await SyncSettings.load(); + expect(s.repo, 'syncs'); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('sync.repo'), 'syncs'); + }); test('save stores the token in the keystore, not in prefs', () async { SharedPreferences.setMockInitialValues({}); diff --git a/tool/device_flow_check.dart b/tool/device_flow_check.dart index 1b72fe9..c7e35fc 100644 --- a/tool/device_flow_check.dart +++ b/tool/device_flow_check.dart @@ -12,7 +12,7 @@ import 'package:http/http.dart' as http; const _clientId = 'Ov23li9tF2R46PqzJgch'; const _scope = 'repo'; const _owner = 'kuhyx'; -const _repo = 'todo-sync'; +const _repo = 'syncs'; Future main() async { final code = await http.post( diff --git a/tool/sync_smoke.dart b/tool/sync_smoke.dart index a21cdcd..c63634b 100644 --- a/tool/sync_smoke.dart +++ b/tool/sync_smoke.dart @@ -24,9 +24,9 @@ Future main() async { } // Throwaway directory so we never pollute the real `changesets/`. - const service = SyncService(changesetDir: 'changesets_smoketest'); + const service = SyncService(changesetDir: 'todo-sync/changesets_smoketest'); GitHubClient client() => - GitHubClient(owner: 'kuhyx', repo: 'todo-sync', token: token); + GitHubClient(owner: 'kuhyx', repo: 'syncs', token: token); final deviceA = await NoteRepository.openInMemory(); final deviceB = await NoteRepository.openInMemory(); @@ -59,7 +59,9 @@ Future main() async { // Cleanup: remove the throwaway changeset files. final cleanup = client(); - for (final f in await cleanup.listDirectory('changesets_smoketest')) { + for (final f in await cleanup.listDirectory( + 'todo-sync/changesets_smoketest', + )) { await cleanup.deleteFile(f.path, f.sha, message: 'smoke test cleanup'); } stdout.writeln('Cleaned up throwaway changeset files.');