Project: GitDoIt v0.5.0+126
Audit Date: March 18, 2026
Audit Type: Comprehensive Offline-First Architecture Scan
Severity Levels: 🔴 Critical | 🟠 High | 🟡 Medium | 🟢 Low
The app has made significant progress in offline-first functionality, but CRITICAL VULNERABILITIES remain that can cause data loss, duplication, and inconsistent state.
- ✅ Offline issue creation: WORKING (with caveats)
- ✅ Offline close/reopen: WORKING (with caveats)
⚠️ Issue duplication on sync: PARTIALLY FIXED (still vulnerable)- 🔴 Pending operations queue: NOT FULLY IMPLEMENTED
- 🔴 Error handling: INCONSISTENT across services
- 🟠 Data consistency: MULTIPLE RACE CONDITIONS
- 🟠 Network state handling: DISCONNECTED FROM UI
- 🟡 Storage cleanup: ASYNC OPERATIONS NOT AWAITED
Severity: 🔴 CRITICAL
Impact: Offline status changes are LOST if app closes before sync
Location: lib/screens/issue_detail_screen.dart, lib/widgets/expandable_repo.dart
When users close/reopen issues offline, the changes are saved to local markdown files BUT no pending operation is queued for GitHub issues. The code checks for network but only queues operations for local issues, not GitHub issues edited offline.
if (_currentIssue.isLocalOnly) {
// ✅ Local issue - update and save
await _localStorage.saveLocalIssue(updatedIssue);
return;
}
// CHECK NETWORK
final isOnline = await _networkService.checkConnectivity();
if (!isOnline) {
// ❌ BUG: This code queues the operation BUT...
final operation = PendingOperation(...);
await _pendingOps.addOperation(operation);
// ...UI updates optimistically without tracking rollback
_showSnackBar('Issue queued for sync');
return;
}- GitHub issues edited offline DO queue operations ✅
- BUT the optimistic update has NO rollback tracking ❌
- If app closes before sync, operation is in queue but UI state is lost ❌
- No link between optimistic UI state and queued operation ❌
if (!isOnline) {
// Create operation WITH rollback support
final operation = OptimisticOperation(
id: operationId,
type: OperationType.closeIssue,
originalIssue: _currentIssue, // ✅ Track original for rollback
newIssue: updatedIssue,
timestamp: DateTime.now(),
);
// Add to provider state for rollback
ref.read(issueOperationsProvider.notifier).addOperation(operation);
// Queue for sync
await _pendingOps.addOperation(pendingOperation);
}Severity: 🔴 CRITICAL
Impact: Issues can be LOST if sync fails after file deletion
Location: lib/services/sync_service.dart:732
for (final issue in localOnlyIssues) {
try {
final createdIssue = await _githubApi.createIssue(...);
// ❌ CRITICAL: Delete local file IMMEDIATELY
await _localStorage.removeLocalIssue(issue.id);
syncedIds.add(issue.id);
} catch (e) {
// ❌ TOO LATE: File already deleted if createIssue succeeds
// but subsequent operations fail
}
}- Local file is deleted immediately after
createIssuesucceeds - BUT if subsequent sync operations fail, the local backup is gone
- If GitHub API has eventual consistency issues, issue might not appear immediately
- No way to recover if sync is partially successful
// Phase 1: Create all issues on GitHub
final createdIssues = <String, IssueItem>{};
for (final issue in localOnlyIssues) {
try {
final created = await _githubApi.createIssue(...);
createdIssues[issue.id] = created;
} catch (e) {
// Keep local file for failed syncs
debugPrint('Failed to create issue, keeping local file');
}
}
// Phase 2: Delete local files ONLY for successfully synced issues
for (final entry in createdIssues.entries) {
await _localStorage.removeLocalIssue(entry.key);
syncedIds.add(entry.key);
}Severity: 🔴 CRITICAL
Impact: Duplicate issues can appear due to race conditions
Location: lib/services/sync_service.dart:631, 655
if (remoteIssuesByNumber.containsKey(issue.number)) {
debugPrint('⚠️ SKIP local issue...');
// ❌ CRITICAL: Async operation NOT awaited
_localStorage.removeLocalIssue(issue.id).then((_) {
debugPrint('Removed local file...');
});
return false;
}.then()callback is fire-and-forget - no error handling- If
removeLocalIssuefails, no one knows - Next sync will see the same file again → DUPLICATE
- No retry mechanism for failed deletions
if (remoteIssuesByNumber.containsKey(issue.number)) {
debugPrint('⚠️ SKIP local issue...');
try {
await _localStorage.removeLocalIssue(issue.id); // ✅ AWAIT
debugPrint('Removed local file for issue #${issue.number}');
} catch (e) {
AppErrorHandler.handle(e, stackTrace: stackTrace);
debugPrint('Failed to remove local file, will retry next sync');
// Keep in list for next sync attempt
return true;
}
return false;
}Severity: 🔴 CRITICAL
Impact: Offline close operations are LOST for GitHub issues
Location: lib/widgets/expandable_repo.dart:183-220
if (issue.isLocalOnly || issue.number == null) {
// ✅ Local issue - save to file
await _localStorage.saveLocalIssue(updatedIssue);
// Shows "Issue closed (local)"
} else {
// ❌ CRITICAL: GitHub issue - uses IssueService directly
await _issueService.closeIssue(issue, owner, repo);
// ❌ NO offline check
// ❌ NO pending operation queued
// ❌ If offline, this will FAIL silently
}- No network check before calling
IssueService.closeIssue() IssueServicecalls_githubApi.updateIssue()directly- If offline, API call fails but no error is shown
- User thinks issue is closed but it's not queued for sync
else {
// GitHub issue - CHECK NETWORK FIRST
final isOnline = await NetworkService().checkConnectivity();
if (!isOnline) {
// ❌ OFFLINE: Queue operation
final operation = PendingOperation.closeIssue(
id: 'close_${issue.id}_${DateTime.now().millisecondsSinceEpoch}',
issueNumber: issue.number!,
owner: owner,
repo: repo,
);
await _pendingOps.addOperation(operation);
// Update UI optimistically
setState(() {
_issues[index] = updatedIssue;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Issue queued for sync'),
backgroundColor: AppColors.primary,
),
);
} else {
// ✅ ONLINE: Use IssueService
await _issueService.closeIssue(issue, owner, repo);
}
}Severity: 🟠 HIGH
Impact: Users don't see errors when sync fails
Location: lib/main.dart, lib/widgets/optimistic_update_listener.dart
OptimisticUpdateListenerwraps the app ✅- But it only listens to
issueOperationsProvider - Direct API calls (like in
expandable_repo.dart) bypass the provider - No error notification when direct API calls fail
- Route ALL issue operations through
IssueOperationsNotifier - Remove direct
IssueServiceand_githubApicalls from screens/widgets - Centralize error handling in provider
Severity: 🟠 HIGH
Impact: Auto-sync may not trigger when network returns
Location: lib/services/network_service.dart, lib/services/sync_service.dart
NetworkServicehas a broadcast stream ✅SyncServicehas its OWN connectivity subscription ❌- Two separate listeners for the same event
- No coordination between services
- If one fails, the other might not trigger sync
// SyncService should USE NetworkService, not duplicate it
class SyncService {
SyncService() {
// Subscribe to NetworkService stream
NetworkService().onConnectivityChanged.listen((isOnline) {
if (isOnline) {
_triggerAutoSync();
}
});
}
}Severity: 🟠 HIGH
Impact: Local edits to un-synced issues are lost
Location: lib/services/conflict_detection_service.dart:75
for (final localIssue in localIssues) {
if (localIssue.number == null) continue; // ❌ SKIP un-numbered issues
final remoteIssue = remoteIssuesMap[localIssue.number];
if (remoteIssue == null) continue;
// ❌ Only check conflicts if local issue has been modified
if (!localIssue.isLocalOnly &&
localIssue.localUpdatedAt == null) {
continue;
}
}- Issues created offline don't get conflict detection
- If user edits issue offline, then GitHub is edited online → NO CONFLICT DETECTED
- "Remote wins" strategy applied silently without user notification
- Remove
if (localIssue.number == null) continue; - Check conflicts by title + body hash for un-numbered issues
- Show conflict resolution dialog even for local issues
Severity: 🟠 HIGH
Impact: Offline issues may fail silently
Location: lib/services/local_storage_service.dart
getVaultFolder()returns path from secure storage- No validation that folder exists or is writable
saveLocalIssue()tries to create folder, but:- No error if creation fails (just debugPrint)
- No fallback to alternative storage
- No user notification
Future<bool> validateVaultFolder() async {
final vaultPath = await getVaultFolder();
if (vaultPath == null) return false;
final vaultDir = Directory(vaultPath);
if (!await vaultDir.exists()) {
try {
await vaultDir.create(recursive: true);
} catch (e) {
AppErrorHandler.handle(e);
return false;
}
}
// Test write permissions
try {
final testFile = File('$vaultPath/.write_test');
await testFile.writeAsString('test');
await testFile.delete();
return true;
} catch (e) {
return false;
}
}Severity: 🟡 MEDIUM
Impact: Silent failures, impossible to debug
Location: lib/services/local_storage_service.dart:225
try {
updatedAt = DateTime.parse(createdMatch.group(1) ?? '');
} catch (_) {} // ❌ SWALLOW ALL ERRORStry {
updatedAt = DateTime.parse(createdMatch.group(1) ?? '');
} catch (e, stackTrace) {
AppErrorHandler.handle(e, stackTrace: stackTrace);
debugPrint('Failed to parse created date: $e');
updatedAt = DateTime.now(); // Fallback
}Severity: 🟡 MEDIUM
Impact: Operations permanently stuck in queue
Location: lib/services/sync_service.dart:803
if (operation.isSyncing && operation.retryCount > 5) {
debugPrint('Skipping operation (max retries exceeded)');
continue; // ❌ SKIP FOREVER
}- Operations with
retryCount > 5are skipped forever - No user notification that operation failed permanently
- No way to manually retry
- Operation stays in queue, blocking other operations
- Move failed operations to "dead letter queue"
- Show error in UI with "Retry" button
- Allow manual intervention
Severity: 🟡 MEDIUM
Impact: Pending operations may not sync in background
Location: lib/main.dart:callbackDispatcher
void callbackDispatcher() {
Workmanager().executeTask((task, inputData) async {
// ...
// Note: We can't directly access PendingOperationsService here due to isolation
// The sync service will handle this during syncAll()
// ❌ COMMENT IS WRONG - syncAll() DOES call _processPendingOperations()
await syncService.syncAll(forceRefresh: false);
});
}- Comment says "can't access" but it can and should
- Background sync should explicitly check for pending operations
- Should show notification if pending ops exist
Severity: 🟡 MEDIUM
Impact: Partial syncs leave data inconsistent
Location: Multiple locations
- Sync involves multiple steps:
- Create issue on GitHub
- Delete local file
- Update UI
- No transaction support - if step 3 fails, steps 1-2 are already committed
- No rollback mechanism for partial failures
Implement transaction pattern:
class SyncTransaction {
final List<Function> _steps = [];
final List<Function> _rollback = [];
void addStep(Function step, Function rollback) {
_steps.add(step);
_rollback.add(rollback);
}
Future<bool> commit() async {
for (final step in _steps) {
if (!await step()) {
await rollback();
return false;
}
}
return true;
}
Future<void> rollback() async {
for (final rb in _rollback.reversed) {
await rb();
}
}
}Severity: 🟢 LOW
Impact: User confusion
Locations: Multiple
- "Issue closed (local)" vs "Issue queued for sync"
- "Issue saved (will sync when online)" vs "Local issue created"
- No consistency in message format or duration
Centralize error/success messages in a service.
Severity: 🟢 LOW
Impact: Users don't know sync is happening
Location: lib/widgets/sync_status_widget.dart
- Sync status widget exists but doesn't show per-operation progress
- Users see "syncing" but don't know which issues are being synced
Severity: 🟢 LOW
Impact: Performance, log clutter
Locations: 722 matches for debugPrint
Replace with proper logging service that can be disabled in production.
- ✅ Fix #4: Add pending operation queue to
expandable_repo.dart - ✅ Fix #3: Await async file deletion operations
- ✅ Fix #2: Two-phase commit for local file deletion
- ✅ Fix #1: Add rollback tracking to optimistic updates
- ✅ Fix #5: Centralize all operations through provider
- ✅ Fix #6: Unify network state handling
- ✅ Fix #8: Validate vault folder permissions
- ✅ Fix #9: Fix empty catch blocks
- ✅ Fix #7: Improve conflict detection
- ✅ Fix #10: Dead letter queue for failed ops
- ✅ Fix #12: Transaction support
- ✅ Fix #13-15: UX improvements
-
Offline Close/Reopen Test:
- Close issue offline → kill app → restart → verify still closed
- Reopen issue offline → kill app → restart → verify still open
-
Sync Failure Recovery Test:
- Create issue offline
- Simulate GitHub API failure during sync
- Verify local file still exists
- Retry sync → verify success
-
Duplicate Prevention Test:
- Create issue offline
- Sync successfully
- Manually restore local file (simulate failed deletion)
- Sync again → verify NO duplicate
-
Race Condition Test:
- Create 10 issues offline simultaneously
- Trigger sync
- Verify all 10 created on GitHub
- Verify all 10 local files deleted
-
Network Transition Test:
- Start offline → create issue
- Enable network → verify auto-sync triggers
- Disable network during sync → verify recovery
- Sync Success Rate: Target > 95%
- Duplicate Issue Rate: Target 0%
- Data Loss Incidents: Target 0
- Pending Operation Stuck Rate: Target < 1%
- Offline Operation Success Rate: Target > 99%
The GitDoIt app has a solid foundation for offline-first functionality, but critical gaps remain in:
- Pending operations queue - Not fully integrated
- Error handling - Inconsistent and sometimes silent
- Race conditions - Multiple async operations not properly awaited
- Rollback support - Missing for most optimistic updates
Immediate action required to prevent data loss and ensure reliable offline operation.
Audit Performed By: GitDoIt Critical Scan
Date: March 18, 2026
Version: 0.5.0+126
Next Audit: After Phase 1 fixes