From 5480e723354c31e2a38dd2757d5d2c75a04e9618 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 11 Jul 2026 23:41:10 +0200 Subject: [PATCH] GitHubClient: add canAccessRepo() + deleteFile() (v0.3.0) Make the shared client a superset of what todo's and diet_guard-app's local GitHubClient copies provide, so every app can drop its copy and sync through one client. canAccessRepo() is a lightweight connection test (bare repo GET, never throws); deleteFile() resolves the file's own sha and no-ops when absent. Mirrored on the Dart and Python sides. 90 Dart + 78 Python tests, 100%. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019KwUCPESvE5CjCtmUFz8xQ --- crdt-sync/crdt_sync/_github.py | 38 +++++++++++++ crdt-sync/crdt_sync/tests/test_github.py | 52 ++++++++++++++++++ crdt-sync/pyproject.toml | 2 +- crdt_sync_dart/lib/src/github_client.dart | 30 ++++++++++ crdt_sync_dart/pubspec.yaml | 2 +- crdt_sync_dart/test/github_client_test.dart | 61 +++++++++++++++++++++ 6 files changed, 183 insertions(+), 2 deletions(-) diff --git a/crdt-sync/crdt_sync/_github.py b/crdt-sync/crdt_sync/_github.py index a1a0ade..18e15e7 100644 --- a/crdt-sync/crdt_sync/_github.py +++ b/crdt-sync/crdt_sync/_github.py @@ -192,3 +192,41 @@ class GitHubSyncClient: for item in data if isinstance(item, dict) and isinstance(item.get("name"), str) ] + + def can_access_repo(self) -> bool: + """Return whether the token can read this repo (a connection test). + + Hits the bare repo endpoint, so it succeeds even before any file has + been pushed. Never raises -- a network failure or missing repo is + reported as ``False``. + """ + return self._repo_exists() + + def delete_file(self, path: str, *, message: str = "crdt_sync: delete") -> None: + """Delete the file at ``path``, resolving its current sha itself. + + A no-op if ``path`` does not exist, so a repeated cleanup is safe. + + Args: + path: A repo-relative file path. + message: The commit message for the deletion. + + Raises: + GitHubSyncError: On any non-2xx response or network error. + """ + sha = self._existing_sha(path) + if sha is None: + return + try: + response = requests.delete( + self._contents_url(path), + headers=self._headers, + json={"message": message, "sha": sha}, + timeout=self._timeout_seconds, + ) + except requests.RequestException as exc: + msg = f"network error deleting {path}" + raise GitHubSyncError(msg) from exc + if not response.ok: + msg = f"DELETE {path} failed: {response.status_code}" + raise GitHubSyncError(msg) diff --git a/crdt-sync/crdt_sync/tests/test_github.py b/crdt-sync/crdt_sync/tests/test_github.py index 439b49b..db7e176 100644 --- a/crdt-sync/crdt_sync/tests/test_github.py +++ b/crdt-sync/crdt_sync/tests/test_github.py @@ -168,3 +168,55 @@ class TestPutFileText: pytest.raises(GitHubSyncError), ): _client().put_file_text("devices/pc/log.json", "{}", message="m") + + +class TestCanAccessRepo: + def test_true_when_repo_endpoint_ok(self) -> None: + with _patch_get(_response(200)): + assert _client().can_access_repo() is True + + def test_false_when_repo_is_missing(self) -> None: + with _patch_get(_response(404)): + assert _client().can_access_repo() is False + + def test_false_on_a_network_error(self) -> None: + with _patch_get_raises(): + assert _client().can_access_repo() is False + + +class TestDeleteFile: + def test_deletes_an_existing_file_resolving_its_own_sha(self) -> None: + with ( + _patch_get(_response(200, {"sha": "abc123"})), + patch.object(_github.requests, "delete", return_value=_response(200)), + ): + _client().delete_file("devices/pc/log.json") + + def test_is_a_no_op_when_the_file_is_absent(self) -> None: + # sha GET 404, repo-exists GET 200 -> None sha -> no DELETE sent. + with ( + _patch_get(_response(404), _response(200)), + patch.object(_github.requests, "delete") as mock_delete, + ): + _client().delete_file("devices/pc/gone.json") + mock_delete.assert_not_called() + + def test_raises_on_a_delete_non_2xx_response(self) -> None: + with ( + _patch_get(_response(200, {"sha": "abc123"})), + patch.object(_github.requests, "delete", return_value=_response(500)), + pytest.raises(GitHubSyncError), + ): + _client().delete_file("devices/pc/log.json") + + def test_raises_on_a_delete_network_error(self) -> None: + with ( + _patch_get(_response(200, {"sha": "abc123"})), + patch.object( + _github.requests, + "delete", + side_effect=requests.ConnectionError("offline"), + ), + pytest.raises(GitHubSyncError), + ): + _client().delete_file("devices/pc/log.json") diff --git a/crdt-sync/pyproject.toml b/crdt-sync/pyproject.toml index 882a888..6d26293 100644 --- a/crdt-sync/pyproject.toml +++ b/crdt-sync/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "crdt-sync" -version = "0.2.0" +version = "0.3.0" description = "Shared CRDT merge scheme + GitHub-Contents-API sync transport for cross-device apps" requires-python = ">=3.10" dependencies = ["requests"] diff --git a/crdt_sync_dart/lib/src/github_client.dart b/crdt_sync_dart/lib/src/github_client.dart index c30df5b..70b491e 100644 --- a/crdt_sync_dart/lib/src/github_client.dart +++ b/crdt_sync_dart/lib/src/github_client.dart @@ -167,5 +167,35 @@ class GitHubClient { } } + /// Returns whether the configured token can read this repo. + /// + /// A lightweight connection test for a settings "Test connection" button: it + /// hits the bare repo endpoint, so it succeeds even before any file has been + /// pushed. Never throws -- a network failure or missing repo returns false. + Future canAccessRepo() => _repoExists(); + + /// Deletes the file at [path], resolving its current sha itself so callers + /// don't have to track it. A no-op if [path] does not exist. + Future deleteFile( + String path, { + String message = 'crdt_sync: delete', + }) async { + final sha = await _existingSha(path); + if (sha == null) return; + late final http.Response res; + try { + res = await _http.delete( + _contentsUri(path), + headers: _headers, + body: jsonEncode({'message': message, 'sha': sha}), + ); + } on http.ClientException catch (exc) { + throw GitHubSyncError('network error deleting $path: $exc'); + } + if (res.statusCode < 200 || res.statusCode >= 300) { + throw GitHubSyncError('DELETE $path failed: ${res.statusCode}'); + } + } + void close() => _http.close(); } diff --git a/crdt_sync_dart/pubspec.yaml b/crdt_sync_dart/pubspec.yaml index d8fbb39..05ba1b0 100644 --- a/crdt_sync_dart/pubspec.yaml +++ b/crdt_sync_dart/pubspec.yaml @@ -2,7 +2,7 @@ name: crdt_sync description: >- Shared CRDT merge scheme + GitHub-Contents-API sync transport for cross-device apps. Pure Dart, no Flutter dependency. -version: 0.2.0 +version: 0.3.0 publish_to: 'none' environment: diff --git a/crdt_sync_dart/test/github_client_test.dart b/crdt_sync_dart/test/github_client_test.dart index 6f70145..76c7c74 100644 --- a/crdt_sync_dart/test/github_client_test.dart +++ b/crdt_sync_dart/test/github_client_test.dart @@ -19,6 +19,8 @@ GitHubClient _client( List getResponses, { http.Response? putResponse, Object? putError, + http.Response? deleteResponse, + Object? deleteError, }) { var getIndex = 0; final mock = http_testing.MockClient((request) async { @@ -34,6 +36,10 @@ GitHubClient _client( if (putError != null) throw putError; return putResponse!; } + if (request.method == 'DELETE') { + if (deleteError != null) throw deleteError; + return deleteResponse!; + } throw UnsupportedError('unexpected method ${request.method}'); }); return GitHubClient( @@ -192,4 +198,59 @@ void main() { ); }); }); + + group('canAccessRepo', () { + test('is true when the repo endpoint returns 2xx', () async { + expect(await _client([_response(200)]).canAccessRepo(), isTrue); + }); + + test('is false when the repo is missing (404)', () async { + expect(await _client([_response(404)]).canAccessRepo(), isFalse); + }); + + test('is false on a network error', () async { + expect(await _client([_networkError]).canAccessRepo(), isFalse); + }); + }); + + group('deleteFile', () { + test('deletes an existing file (resolves its own sha)', () async { + final client = _client( + [_response(200, {'sha': 'abc123'})], + deleteResponse: _response(200), + ); + await expectLater(client.deleteFile('devices/pc/log.json'), completes); + }); + + test('is a no-op when the file does not exist', () async { + // sha lookup 404s, repo-exists check 200s -> null sha -> no DELETE sent. + final client = _client([ + _response(404), + _response(200), + ], deleteError: StateError('DELETE must not be called')); + await expectLater(client.deleteFile('devices/pc/gone.json'), completes); + }); + + test('throws GitHubSyncError on a delete non-2xx response', () async { + final client = _client( + [_response(200, {'sha': 'abc123'})], + deleteResponse: _response(500), + ); + expect( + () => client.deleteFile('devices/pc/log.json'), + throwsA(isA()), + ); + }); + + test('throws GitHubSyncError on a delete network exception', () async { + final client = _client( + [_response(200, {'sha': 'abc123'})], + deleteError: http.ClientException('offline'), + ); + expect( + () => client.deleteFile('devices/pc/log.json'), + throwsA(isA()), + ); + }); + }); }