billsplit: build a real APK by dropping file_picker for file_selector

billsplit had never produced an Android APK. file_picker 11.0.3 — the latest
release on pub.dev — is wedged under AGP 9 in both directions: 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`), and
with built-in Kotlin on its buildscript leaks KGP onto the global classpath
via rootProject.allprojects, which AGP then rejects for every module.

The app's Gradle config was never at fault: android/app/build.gradle.kts and
settings.gradle.kts are byte-identical to habit_stack, home_inventory and
diet_guard_app, which all build. Only the dependency differed, so the fix is
the dependency — the toolchain stays on the house-standard AGP 9.

Ported both call sites to the official file_selector. Its save flow returns a
destination and leaves the write to the caller, where file_picker wrote the
bytes inside the plugin; that write now goes through an injectable seam
because a real dart:io future never completes inside testWidgets' fake-async
zone, so a widget test could otherwise never observe the resulting snackbar.
The test double moves from a MethodChannel mock to a FileSelectorPlatform fake
(file_selector speaks Pigeon, so a channel mock would not intercept it), and a
plain non-widget test covers the real on-disk writer to hold 100% coverage
without an ignore.

The launcher icon is now verified from the built artifact: the ic_launcher
assets unzipped from the APK render as the charcoal + gold perforated receipt,
not the stock Flutter beach. Peak build footprint is 1.86 GiB, inside budget.

Also rewrites ci_mirror.sh to run its gates in a disposable worktree at HEAD.
`pre-commit run --all-files` stashes the live tree, and in a shared checkout
that made an unrelated agent's uncommitted work collide with hook auto-fixes,
failing the push while blaming an innocent hook — and putting that work inside
a stash window, which memories/git-rules.md forbids. A clean worktree needs no
stash and is a truer mirror of CI, which never sees a dirty file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-08-03 14:15:40 +02:00
parent 287512f011
commit 4d9f486176
14 changed files with 382 additions and 113 deletions

View File

@ -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<AppState>();
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);

View File

@ -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<void> Function(
String path,
Uint8List bytes,
String mimeType,
);
/// The active export writer. Replaced in tests via [debugSetExportWriter].
ExportWriter writeExport = _writeExportToDisk;
Future<void> _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<ReceiptScreen> {
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) {

View File

@ -6,6 +6,10 @@
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
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);
}

View File

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@ -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"))
}

View File

@ -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"

View File

@ -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:

View File

@ -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<XFile?> openFile({
List<XTypeGroup>? acceptedTypeGroups,
String? initialDirectory,
String? confirmButtonText,
}) async {
if (pickResult == null) return null;
return XFile.fromData(pickResult!, name: pickName ?? 'picked.json');
}
@override
Future<FileSaveLocation?> getSaveLocation({
List<XTypeGroup>? 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 <Map<dynamic, dynamic>>[
{
'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<String, Uint8List> exportedFiles = <String, Uint8List>{};
/// 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.

View File

@ -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);
});
}

View File

@ -6,6 +6,9 @@
#include "generated_plugin_registrant.h"
#include <file_selector_windows/file_selector_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
}

View File

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@ -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/<pid>/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"
}

View File

@ -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/<pid>/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."
]
}

View File

@ -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 "$@"