diff --git a/billsplit/lib/ui/home_screen.dart b/billsplit/lib/ui/home_screen.dart index dcdec10f..04c4e81d 100644 --- a/billsplit/lib/ui/home_screen.dart +++ b/billsplit/lib/ui/home_screen.dart @@ -6,7 +6,7 @@ import 'package:billsplit/domain/models.dart'; import 'package:billsplit/state/app_state.dart'; import 'package:billsplit/ui/people_screen.dart'; import 'package:billsplit/ui/receipt_screen.dart'; -import 'package:file_picker/file_picker.dart'; +import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -122,13 +122,17 @@ class HomeScreen extends StatelessWidget { final state = context.read(); final messenger = ScaffoldMessenger.of(context); final nav = Navigator.of(context); - final picked = await FilePicker.pickFiles( - type: FileType.custom, - allowedExtensions: ['json'], - withData: true, + const jsonGroup = XTypeGroup( + label: 'e-paragon', + extensions: ['json'], + // Android matches on MIME rather than extension; without this the + // picker greys out every file. + mimeTypes: ['application/json'], + uniformTypeIdentifiers: ['public.json'], ); - final data = picked?.files.firstOrNull?.bytes; - if (data == null) return; + final picked = await openFile(acceptedTypeGroups: [jsonGroup]); + if (picked == null) return; + final data = await picked.readAsBytes(); try { final result = parseEParagonDetailed(utf8.decode(data)); state.addReceipt(result.receipt); diff --git a/billsplit/lib/ui/receipt_screen.dart b/billsplit/lib/ui/receipt_screen.dart index 0f881eb5..c307d210 100644 --- a/billsplit/lib/ui/receipt_screen.dart +++ b/billsplit/lib/ui/receipt_screen.dart @@ -4,11 +4,40 @@ import 'package:billsplit/domain/xlsx_export.dart'; import 'package:billsplit/state/app_state.dart'; import 'package:billsplit/ui/assignment_sheet.dart'; import 'package:billsplit/ui/item_edit.dart'; -import 'package:file_picker/file_picker.dart'; +import 'package:file_selector/file_selector.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +/// Writes exported [bytes] to [path]; the seam the export flow writes through. +/// +/// Kept injectable because `file_selector` only returns a destination — the +/// app performs the write itself, where `file_picker` did it inside the +/// plugin. Real `dart:io` futures never complete inside `testWidgets`' +/// fake-async zone, so a widget test that exercised the real write could never +/// observe the resulting snackbar. Tests swap this for an in-memory stub. +typedef ExportWriter = Future Function( + String path, + Uint8List bytes, + String mimeType, +); + +/// The active export writer. Replaced in tests via [debugSetExportWriter]. +ExportWriter writeExport = _writeExportToDisk; + +Future _writeExportToDisk( + String path, + Uint8List bytes, + String mimeType, +) => + XFile.fromData(bytes, mimeType: mimeType).saveTo(path); + +/// Overrides [writeExport]; pass null to restore the real disk writer. +@visibleForTesting +void debugSetExportWriter(ExportWriter? writer) { + writeExport = writer ?? _writeExportToDisk; +} + /// One receipt: item list with assignments, totals, participants, export. class ReceiptScreen extends StatefulWidget { /// Creates the screen for the receipt with [receiptId]. @@ -154,16 +183,23 @@ class _ReceiptScreenState extends State { RegExp(r'[^\w\d ąćęłńóśżźĄĆĘŁŃÓŚŻŹ-]'), '_', ); - final path = await FilePicker.saveFile( - dialogTitle: 'Save split as .xlsx', - fileName: '$safe split.xlsx', - type: FileType.custom, - allowedExtensions: ['xlsx'], - bytes: bytes, + const xlsxGroup = XTypeGroup( + label: 'Excel workbook', + extensions: ['xlsx'], + mimeTypes: [ + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ], ); - if (path != null) { + final location = await getSaveLocation( + suggestedName: '$safe split.xlsx', + acceptedTypeGroups: [xlsxGroup], + ); + if (location != null) { + // file_selector hands back a destination and leaves the writing to the + // caller, unlike file_picker which wrote the bytes inside the plugin. + await writeExport(location.path, bytes, xlsxGroup.mimeTypes!.first); messenger.showSnackBar( - SnackBar(content: Text('Saved: ${path.split('/').last}')), + SnackBar(content: Text('Saved: ${location.path.split('/').last}')), ); } } on Exception catch (e) { diff --git a/billsplit/linux/flutter/generated_plugin_registrant.cc b/billsplit/linux/flutter/generated_plugin_registrant.cc index e71a16d2..64a0ecea 100644 --- a/billsplit/linux/flutter/generated_plugin_registrant.cc +++ b/billsplit/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); } diff --git a/billsplit/linux/flutter/generated_plugins.cmake b/billsplit/linux/flutter/generated_plugins.cmake index be1ee3e5..a630fa93 100644 --- a/billsplit/linux/flutter/generated_plugins.cmake +++ b/billsplit/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/billsplit/macos/Flutter/GeneratedPluginRegistrant.swift b/billsplit/macos/Flutter/GeneratedPluginRegistrant.swift index 774a6b8b..14b5f7c7 100644 --- a/billsplit/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/billsplit/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,8 +5,8 @@ import FlutterMacOS import Foundation -import file_picker +import file_selector_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) } diff --git a/billsplit/pubspec.lock b/billsplit/pubspec.lock index 141cfb71..95935915 100644 --- a/billsplit/pubspec.lock +++ b/billsplit/pubspec.lock @@ -97,14 +97,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" - dbus: - dependency: transitive - description: - name: dbus - sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" - url: "https://pub.dev" - source: hosted - version: "0.7.14" equatable: dependency: transitive description: @@ -137,14 +129,70 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" - file_picker: + file_selector: dependency: "direct main" description: - name: file_picker - sha256: "29cc1fdb20613876cc7afc529738c1c0f11a9ca159b010edad0c566ac330847e" + name: file_selector + sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a url: "https://pub.dev" source: hosted - version: "11.0.3" + version: "1.1.0" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: "1d45e9910f68c16eb0c74f0b10097ad81aed516ea28054c027137e8f7d75e840" + url: "https://pub.dev" + source: hosted + version: "0.5.2+9" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca + url: "https://pub.dev" + source: hosted + version: "0.5.3+5" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: "direct dev" + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" flutter: dependency: "direct main" description: flutter @@ -166,14 +214,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" - flutter_plugin_android_lifecycle: - dependency: transitive - description: - name: flutter_plugin_android_lifecycle - sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" - url: "https://pub.dev" - source: hosted - version: "2.0.35" flutter_test: dependency: "direct dev" description: flutter @@ -192,6 +232,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" image: dependency: transitive description: @@ -517,14 +573,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - win32: - dependency: transitive - description: - name: win32 - sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e - url: "https://pub.dev" - source: hosted - version: "5.15.0" xdg_directories: dependency: transitive description: @@ -551,4 +599,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.12.0 <4.0.0" - flutter: ">=3.38.4" + flutter: ">=3.44.0" diff --git a/billsplit/pubspec.yaml b/billsplit/pubspec.yaml index c9402893..869f870c 100644 --- a/billsplit/pubspec.yaml +++ b/billsplit/pubspec.yaml @@ -8,13 +8,14 @@ environment: dependencies: excel: ^4.0.6 - file_picker: ^11.0.0 + file_selector: ^1.1.0 flutter: sdk: flutter path_provider: ^2.1.4 provider: ^6.1.2 dev_dependencies: + file_selector_platform_interface: ^2.7.0 flutter_launcher_icons: ^0.14.4 flutter_lints: ^6.0.0 flutter_test: diff --git a/billsplit/test/helpers.dart b/billsplit/test/helpers.dart index 597fc2a5..d341bb1c 100644 --- a/billsplit/test/helpers.dart +++ b/billsplit/test/helpers.dart @@ -1,19 +1,61 @@ import 'dart:io'; import 'package:billsplit/state/app_state.dart'; +import 'package:billsplit/ui/receipt_screen.dart'; +import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; -const _pickerChannel = MethodChannel('miguelruivo.flutter.plugins.filepicker'); const _pathChannel = MethodChannel('plugins.flutter.io/path_provider'); -/// Mocks the file_picker method channel. +/// Fake [FileSelectorPlatform] driving open/save dialogs in tests. /// -/// `pickResult`: bytes returned by pickFiles (null → user cancelled). -/// `saveResult`: path returned by saveFile (null → user cancelled). -/// `saveThrows`: saveFile raises a PlatformException. +/// file_selector is swapped at the platform-interface level rather than at the +/// method channel: the real implementation talks Pigeon, so a MethodChannel +/// mock would not intercept it. +class _FakeFileSelector extends FileSelectorPlatform { + _FakeFileSelector({ + this.pickResult, + this.pickName, + this.saveResult, + this.saveThrows = false, + }); + + final Uint8List? pickResult; + final String? pickName; + final String? saveResult; + final bool saveThrows; + + @override + Future openFile({ + List? acceptedTypeGroups, + String? initialDirectory, + String? confirmButtonText, + }) async { + if (pickResult == null) return null; + return XFile.fromData(pickResult!, name: pickName ?? 'picked.json'); + } + + @override + Future getSaveLocation({ + List? acceptedTypeGroups, + SaveDialogOptions options = const SaveDialogOptions(), + }) async { + if (saveThrows) { + throw PlatformException(code: 'boom', message: 'disk full'); + } + if (saveResult == null) return null; + return FileSaveLocation(saveResult!); + } +} + +/// Installs a fake file_selector platform for the current test. +/// +/// `pickResult`: bytes returned by openFile (null → user cancelled). +/// `saveResult`: path returned by getSaveLocation (null → user cancelled). +/// `saveThrows`: getSaveLocation raises a PlatformException. void mockFilePicker({ Uint8List? pickResult, String? pickName, @@ -21,27 +63,24 @@ void mockFilePicker({ bool saveThrows = false, }) { TestWidgetsFlutterBinding.ensureInitialized(); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(_pickerChannel, (call) async { - if (call.method == 'save') { - if (saveThrows) { - throw PlatformException(code: 'boom', message: 'disk full'); - } - return saveResult; - } - // pickFiles ('custom'/'any'/...) - if (pickResult == null) return null; - return >[ - { - 'path': '/tmp/${pickName ?? 'picked.json'}', - 'name': pickName ?? 'picked.json', - 'size': pickResult.length, - 'bytes': pickResult, - }, - ]; + FileSelectorPlatform.instance = _FakeFileSelector( + pickResult: pickResult, + pickName: pickName, + saveResult: saveResult, + saveThrows: saveThrows, + ); + // Record the write instead of touching the disk: a real dart:io future never + // completes inside testWidgets' fake-async zone, so the export flow would + // stall before showing its snackbar. + exportedFiles.clear(); + debugSetExportWriter((path, bytes, mimeType) async { + exportedFiles[path] = bytes; }); } +/// Paths and payloads captured by the stub export writer. +final Map exportedFiles = {}; + /// Points path_provider's documents directory at [dir]. void mockPathProvider(Directory dir) { TestWidgetsFlutterBinding.ensureInitialized(); @@ -49,11 +88,12 @@ void mockPathProvider(Directory dir) { .setMockMethodCallHandler(_pathChannel, (call) async => dir.path); } -/// Clears both channel mocks. +/// Clears the path_provider channel mock and the file_selector fake. void clearChannelMocks() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - ..setMockMethodCallHandler(_pickerChannel, null) - ..setMockMethodCallHandler(_pathChannel, null); + .setMockMethodCallHandler(_pathChannel, null); + FileSelectorPlatform.instance = _FakeFileSelector(); + debugSetExportWriter(null); } /// Creates an [AppState] persisted under a fresh temp directory. diff --git a/billsplit/test/xlsx_extra_test.dart b/billsplit/test/xlsx_extra_test.dart index 9d5516f5..fcf912d9 100644 --- a/billsplit/test/xlsx_extra_test.dart +++ b/billsplit/test/xlsx_extra_test.dart @@ -1,5 +1,9 @@ +import 'dart:io'; +import 'dart:typed_data'; + import 'package:billsplit/domain/models.dart'; import 'package:billsplit/domain/xlsx_export.dart'; +import 'package:billsplit/ui/receipt_screen.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -33,4 +37,20 @@ void main() { expect(bytes.length, greaterThan(1000)); expect(bytes[0], 0x50); }); + + // Exercises the REAL disk writer, deliberately as a plain `test` rather than + // a `testWidgets`: a real dart:io future never completes inside the + // fake-async zone, which is the whole reason the export flow writes through + // an injectable seam. + test('the real export writer puts the bytes on disk', () async { + final dir = Directory.systemTemp.createTempSync('billsplit_export'); + addTearDown(() => dir.deleteSync(recursive: true)); + final target = '${dir.path}/split.xlsx'; + final payload = Uint8List.fromList([0x50, 0x4B, 0x03, 0x04, 0x99]); + + debugSetExportWriter(null); // restore the real writer + await writeExport(target, payload, 'application/vnd.ms-excel'); + + expect(File(target).readAsBytesSync(), payload); + }); } diff --git a/billsplit/windows/flutter/generated_plugin_registrant.cc b/billsplit/windows/flutter/generated_plugin_registrant.cc index 8b6d4680..77ab7a09 100644 --- a/billsplit/windows/flutter/generated_plugin_registrant.cc +++ b/billsplit/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,9 @@ #include "generated_plugin_registrant.h" +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); } diff --git a/billsplit/windows/flutter/generated_plugins.cmake b/billsplit/windows/flutter/generated_plugins.cmake index 3ad69c61..277a56f8 100644 --- a/billsplit/windows/flutter/generated_plugins.cmake +++ b/billsplit/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/docs/superpowers/contracts/billsplit-apk-buildable-2026-08-03.json b/docs/superpowers/contracts/billsplit-apk-buildable-2026-08-03.json new file mode 100644 index 00000000..44dd88fb --- /dev/null +++ b/docs/superpowers/contracts/billsplit-apk-buildable-2026-08-03.json @@ -0,0 +1,19 @@ +{ + "title": "Make billsplit's APK buildable and stop the CI mirror fighting a dirty tree", + "objective": "billsplit had never produced an Android APK: file_picker 11.0.3, the latest release, cannot compile under AGP 9 in either built-in-Kotlin mode. Replace it with the official file_selector so the app builds, verify the launcher icon from the built artifact rather than from a preview, keep the build inside this machine's 2 GB memory budget, and make the pre-push CI mirror stop failing whenever the shared checkout has uncommitted work.", + "acceptance_criteria": [ + "flutter build apk --debug succeeds", + "The launcher icon extracted from the APK is the charcoal + gold perforated-receipt glyph and does not match the stock Flutter beach icon", + "Peak PSS across the build's JVM and Dart processes stays under 2 GiB, measured from /proc//smaps_rollup", + "flutter analyze is clean, all tests pass, and billsplit holds 100% line coverage with no coverage ignores", + "meta/scripts/ci_mirror.sh passes while the working tree is dirty, leaves every uncommitted file byte-identical, and removes its temporary worktree", + "billsplit's Gradle and AGP versions stay identical to the sibling Flutter apps" + ], + "out_of_scope": [ + "Pinning AGP 8 or otherwise diverging billsplit's Android toolchain from habit_stack, home_inventory, todo and diet_guard_app", + "Installing or running the APK on the phone; the icon is verified from the artifact, not from a device launcher", + "Any change to the split engine, xlsx export format, or e-paragon parsing", + "Upgrading the other dependencies pub reports as outdated" + ], + "verifier": "flutter analyze, flutter test --no-pub --coverage and python3 -m python_pkg.billsplit_coverage inside billsplit; flutter build apk --debug followed by unzipping the APK and rendering its ic_launcher assets; a PSS sampler over the build's processes; bash meta/scripts/ci_mirror.sh run deliberately against a dirty tree with before/after md5 of an uncommitted file; shellcheck on the rewritten script; and pre-commit run --files on the staged set inside a 2G cgroup" +} diff --git a/docs/superpowers/evidence/billsplit-apk-buildable-2026-08-03.json b/docs/superpowers/evidence/billsplit-apk-buildable-2026-08-03.json new file mode 100644 index 00000000..7138bc32 --- /dev/null +++ b/docs/superpowers/evidence/billsplit-apk-buildable-2026-08-03.json @@ -0,0 +1,63 @@ +{ + "intent": "Make billsplit actually build an Android APK so its launcher icon can be verified from the built artifact, bring that build inside the machine's 2 GB memory budget, and stop the pre-push CI mirror from failing whenever the shared checkout is dirty.", + "scope": [ + "billsplit/pubspec.yaml — file_picker replaced by file_selector", + "billsplit/lib/ui/home_screen.dart and receipt_screen.dart — the two picker call sites", + "billsplit/test/helpers.dart — platform fake replaces the method-channel mock", + "billsplit/test/xlsx_extra_test.dart — covers the real on-disk export writer", + "meta/scripts/ci_mirror.sh — gates now run in a throwaway worktree", + "Non-goal: changing billsplit's Gradle/AGP versions, which are byte-identical to the four sibling apps and were never the fault", + "Non-goal: any change to split/export logic or the xlsx format" + ], + "changes": [ + "Replaced file_picker with the official file_selector. file_picker 11.0.3 is the LATEST on pub.dev and is unfixably wedged under AGP 9: it skips applying the Kotlin Gradle Plugin when AGP >= 9, so with built-in Kotlin off its own Kotlin never compiles (`cannot find symbol: FilePickerPlugin`), while with built-in Kotlin on its buildscript leaks KGP onto the global classpath via rootProject.allprojects and AGP rejects every module. Diagnosis rested on billsplit's android/app/build.gradle.kts and settings.gradle.kts being byte-identical to habit_stack, home_inventory and diet_guard_app, which all build — the dependency was the only difference.", + "Ported the two call sites: FilePicker.pickFiles -> openFile plus XFile.readAsBytes, and FilePicker.saveFile -> getSaveLocation plus an explicit write, since file_selector returns a destination and leaves writing to the caller.", + "Introduced an injectable ExportWriter seam (writeExport / debugSetExportWriter) for that write. Necessary, not decorative: file_picker performed the write inside the plugin so widget tests never touched the disk, whereas a real dart:io future never completes inside testWidgets' fake-async zone. Verified directly with a throwaway probe — a saveTo future was still pending after both pumpAndSettle and runAsync.", + "Swapped the test double from a MethodChannel mock to a FileSelectorPlatform fake, because file_selector speaks Pigeon and a channel mock would not intercept it.", + "Added a plain (non-widget) test exercising the real disk writer, restoring 100% line coverage without an ignore.", + "Rewrote ci_mirror.sh to run pre-commit --all-files and the pytest gate inside a disposable `git worktree add --detach HEAD`, removing the stash of the live tree entirely." + ], + "verification": [ + { + "command": "flutter build apk --debug", + "result": "pass", + "evidence": "'Built build/app/outputs/flutter-apk/app-debug.apk' (143M). The first APK this app has ever produced; previously it failed at :app:compileDebugJavaWithJavac." + }, + { + "command": "unzip the APK and render res/mipmap-xxxhdpi-v4/ic_launcher.png and res/drawable-xxxhdpi-v4/ic_launcher_foreground.png", + "result": "pass", + "evidence": "Both show the charcoal field with the gold perforated-receipt glyph. Neither matches the stock Flutter beach icon (md5 6270344430679711b81476e29878caa7). The icon is now verified from the built artifact rather than from a preview sheet." + }, + { + "command": "PSS summed from /proc//smaps_rollup across the build's JVM and Dart processes", + "result": "pass", + "evidence": "Peak 1.86 GiB, inside the 2 GB budget. Earlier attempts measured 2.03 and 3.01 GiB; removing file_picker also removed the flutter_plugin_android_lifecycle module and the built-in-Kotlin thrash." + }, + { + "command": "flutter analyze && flutter test --no-pub --coverage && python3 -m python_pkg.billsplit_coverage", + "result": "pass", + "evidence": "'No issues found!', 60 tests passed, '100% line coverage across 11 lib files.'" + }, + { + "command": "bash meta/scripts/ci_mirror.sh with the working tree deliberately dirty", + "result": "pass", + "evidence": "'all CI gates passed locally — safe to push' with eight modified files present, the exact condition that previously aborted the push. main.sh md5 unchanged at 262c91612bf9 across the run, and no worktree or /tmp/ci-mirror-* directory left behind." + }, + { + "command": "shellcheck meta/scripts/ci_mirror.sh", + "result": "pass", + "evidence": "No findings." + } + ], + "risks": [ + "The export write moved from the plugin into the app, so a failure now surfaces through the app's own catch as 'Export failed' rather than as a plugin exception. Behaviour on a real device is unverified — the APK was built but never installed or run.", + "writeExport is process-global mutable state. clearChannelMocks restores it, but a test that swaps it without going through the helper could leak the stub into later tests.", + "file_selector's save dialog is a different native flow from file_picker's; the Android and Linux pickers should be exercised by hand before trusting the export path in daily use.", + "ci_mirror.sh now gates HEAD rather than the working tree. That is the correct mirror of CI, but it means uncommitted work is no longer covered by the pre-push gate — it is checked at commit time instead." + ], + "rollback": [ + "git revert the commit, then `flutter pub get` inside billsplit to restore file_picker. The APK will stop building again, which is the pre-change state, not a new fault.", + "Reverting ci_mirror.sh reinstates the stash-based gate and with it the spurious push failures on a dirty shared checkout; prefer keeping that hunk.", + "After any rollback, re-run flutter test: helpers.dart, receipt_screen.dart and xlsx_extra_test.dart must move together, since the platform fake and the ExportWriter seam are coupled." + ] +} diff --git a/meta/scripts/ci_mirror.sh b/meta/scripts/ci_mirror.sh index 6fbd1a20..32ab2734 100755 --- a/meta/scripts/ci_mirror.sh +++ b/meta/scripts/ci_mirror.sh @@ -35,61 +35,90 @@ readonly REQ_PATH="$ROOT/$REQUIREMENTS_FILE" log() { printf 'ci-mirror: %s\n' "$1" >&2; } fail() { - log "FAILED — $1" - log "CI would be red. Fix the above, or 'git push --no-verify' to override." - exit 1 + log "FAILED — $1" + log "CI would be red. Fix the above, or 'git push --no-verify' to override." + exit 1 } require_file() { - if [[ ! -f "$REQ_PATH" ]]; then - fail "requirements file not found: $REQ_PATH" - fi + if [[ ! -f "$REQ_PATH" ]]; then + fail "requirements file not found: $REQ_PATH" + fi } # Rebuild the venv only when requirements.txt changed since the last build. ensure_venv() { - local current stored - current="$(sha256sum "$REQ_PATH" | cut -d' ' -f1)" - stored="" - if [[ -f "$HASH_FILE" ]]; then - stored="$(cat "$HASH_FILE")" - fi + local current stored + current="$(sha256sum "$REQ_PATH" | cut -d' ' -f1)" + stored="" + if [[ -f "$HASH_FILE" ]]; then + stored="$(cat "$HASH_FILE")" + fi - if [[ -x "$VENV_DIR/bin/python" && "$current" == "$stored" ]]; then - log "venv up to date (requirements.txt unchanged)" - return - fi + if [[ -x "$VENV_DIR/bin/python" && "$current" == "$stored" ]]; then + log "venv up to date (requirements.txt unchanged)" + return + fi - log "requirements.txt changed — rebuilding clean venv (mirrors CI install)" - rm -rf "$VENV_DIR" - python3 -m venv "$VENV_DIR" - "$VENV_DIR/bin/python" -m pip install --quiet --upgrade pip \ - || fail "pip self-upgrade in the clean venv" - "$VENV_DIR/bin/python" -m pip install --quiet -r "$REQ_PATH" \ - || fail "pip install -r $REQUIREMENTS_FILE (a dep may be undeclared)" - printf '%s' "$current" > "$HASH_FILE" - log "clean venv ready" + log "requirements.txt changed — rebuilding clean venv (mirrors CI install)" + rm -rf "$VENV_DIR" + python3 -m venv "$VENV_DIR" + "$VENV_DIR/bin/python" -m pip install --quiet --upgrade pip || + fail "pip self-upgrade in the clean venv" + "$VENV_DIR/bin/python" -m pip install --quiet -r "$REQ_PATH" || + fail "pip install -r $REQUIREMENTS_FILE (a dep may be undeclared)" + printf '%s' "$current" >"$HASH_FILE" + log "clean venv ready" } -run_precommit_all_files() { - # Skip pytest-coverage here: it is a pre-commit-stage hook that would run - # in the system env (not our clean venv) and duplicate run_pytest below. - log "pre-commit run --all-files (mirrors the pre-commit workflow)" - SKIP=pytest-coverage pre-commit run --all-files || fail "pre-commit --all-files" +# Disposable clean checkout of HEAD; see run_gates_in_clean_worktree. +CHECKOUT="" + +cleanup_checkout() { + if [[ -n "$CHECKOUT" && -d "$CHECKOUT" ]]; then + git -C "$ROOT" worktree remove --force "$CHECKOUT" >/dev/null 2>&1 || true + rm -rf "$CHECKOUT" + git -C "$ROOT" worktree prune >/dev/null 2>&1 || true + fi } -run_pytest_clean_venv() { - log "changed-packages pytest in the clean venv (OOM-safe runner)" - "$VENV_DIR/bin/python" meta/scripts/pytest_changed_packages.py \ - || fail "pytest_changed_packages (clean requirements.txt venv)" +trap cleanup_checkout EXIT + +# Run the gates against a throwaway worktree at HEAD rather than the live tree. +# +# `pre-commit run --all-files` stashes unstaged changes, runs the hooks, then +# restores. In a shared checkout that is actively hostile: when another agent +# (or the user) has uncommitted work that collides with a hook's auto-fix, the +# restore conflicts, pre-commit rolls the fixes back, and the push fails with a +# diagnostic that points at the innocent hook rather than at the collision. +# Worse, the stash window puts someone else's uncommitted work at risk, which +# is exactly what memories/git-rules.md forbids. +# +# A clean worktree removes the stash entirely and is a truer mirror of CI, +# which always builds from a pristine checkout and never sees a dirty file. +run_gates_in_clean_worktree() { + CHECKOUT="$(mktemp -d -t ci-mirror-XXXXXX)" + rm -rf "$CHECKOUT" + log "checking out HEAD into a clean worktree (no stash of your tree)" + git -C "$ROOT" worktree add --detach --quiet "$CHECKOUT" HEAD || + fail "could not create the clean worktree" + + # Skip pytest-coverage here: it is a pre-commit-stage hook that would run + # in the system env (not our clean venv) and duplicate the pytest run below. + log "pre-commit run --all-files (mirrors the pre-commit workflow)" + (cd "$CHECKOUT" && SKIP=pytest-coverage pre-commit run --all-files) || + fail "pre-commit --all-files" + + log "changed-packages pytest in the clean venv (OOM-safe runner)" + (cd "$CHECKOUT" && "$VENV_DIR/bin/python" meta/scripts/pytest_changed_packages.py) || + fail "pytest_changed_packages (clean requirements.txt venv)" } main() { - require_file - ensure_venv - run_precommit_all_files - run_pytest_clean_venv - log "all CI gates passed locally — safe to push" + require_file + ensure_venv + run_gates_in_clean_worktree + log "all CI gates passed locally — safe to push" } main "$@"