diff --git a/.gitignore b/.gitignore index ba317d23..ad2f713c 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ app/test/sim/frames/ # Local QA artifacts (screenshots, issue logs) from tool/shoot-ports.sh .qa/ +.piano/ + +# Local pnpm store, created when a store-dir is set for this checkout +.pnpm-store/ diff --git a/app/ASSET_ATTRIBUTION.md b/app/ASSET_ATTRIBUTION.md index 1415da8c..c534fab5 100644 --- a/app/ASSET_ATTRIBUTION.md +++ b/app/ASSET_ATTRIBUTION.md @@ -20,4 +20,26 @@ uses a neutral Phosphor glyph for those rather than a fabricated mark. | File(s) | Role | Source | License | |---------|------|--------|---------| | `repo-push.png`, `repo-pull.png` | Composer PR actions | [VS Code codicons](https://github.com/microsoft/vscode-codicons) | CC BY 4.0 | -| `git-pull-request-closed-{thin,light,regular,bold,fill}.svg` | Closed-PR state marker | Original, drawn to match [Phosphor](https://phosphoricons.com) metrics (five weights: light is default) | MIT (this repo) | +| `git-pull-request-closed-{thin,light,regular,bold,fill}.svg` | Closed-PR state marker | Original — [`phosphor_extras`](../../phosphor_extras) | MIT | +| `forgejo-light.svg` | Forgejo forge marker | Original reduction of Forgejo's mark to [Phosphor](https://phosphoricons.com) metrics — [`phosphor_extras`](../../phosphor_extras) | MIT (drawing); Forgejo's mark belongs to the Forgejo project | +| `gitea-light.svg` | Gitea forge marker | Original reduction of Gitea's mark to Phosphor metrics — [`phosphor_extras`](../../phosphor_extras) | MIT (drawing); Gitea's mark belongs to the Gitea project | + +Glyphs marked *Original* are authored in the **`phosphor_extras`** repo, which is +the source of truth for their geometry and holds the generator and the invariant +checks. `scripts/sync-icons.sh` vendors the built SVGs here (rather than adding a +dependency) so a fresh clone builds without network access to another repo; +`scripts/sync-icons.sh --check` fails if a vendored copy has drifted. + +The Forgejo and Gitea glyphs identify those projects' software in the UI — +nominative use. The MIT grant covers our drawings, not the underlying marks. + +## Agent logos in [`assets/agents/`](assets/agents/) + +Used to identify which coding agent backs a session. Each is the property of its +project and is included for identification only. + +| File | Agent | Owner | +|------|-------|-------| +| `claude.svg` | Claude Code | Anthropic | +| `codex.svg` | Codex | OpenAI | +| `pi.svg` | pi | Earendil Works | diff --git a/app/assets/icons/forgejo-light.svg b/app/assets/icons/forgejo-light.svg new file mode 100644 index 00000000..ce69da8e --- /dev/null +++ b/app/assets/icons/forgejo-light.svg @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/app/assets/icons/gitea-light.svg b/app/assets/icons/gitea-light.svg new file mode 100644 index 00000000..6f0629d1 --- /dev/null +++ b/app/assets/icons/gitea-light.svg @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/app/integration_test/desktop/settings_repo_test.dart b/app/integration_test/desktop/settings_repo_test.dart new file mode 100644 index 00000000..a80860e4 --- /dev/null +++ b/app/integration_test/desktop/settings_repo_test.dart @@ -0,0 +1,258 @@ +// SPEC-48 T6.2 — the per-repo Settings path, mounted for real. +// +// What this proves that the unit and widget tests cannot: the WHOLE path from the +// live repo snapshot through the dynamic registry to a rendered row, inside the +// real `SettingsWindow`, on a real macOS build. +// +// reposProvider → sectionsFor() → the nav pane → tap → RepositorySettingsPage +// → repoSettingsViewFor() → RepositorySettingsSection → the rows +// +// Every hop above is covered by a unit or widget test in isolation. None of them +// covers the composition, and this repo has already been bitten there once: the +// desktop shell mounts Settings *outside* a GoRouter, so a section that navigated +// with `context.go` threw at runtime while every test stayed green. A test that +// mounts the section directly cannot catch that class of fault; this one can. +// +// Deliberately NOT extended onto the daemon control socket (see the plan's T6.2): +// the daemon-side behaviour is proven by the server tests, and routing this through +// the socket would add infrastructure for no extra coverage. The repo snapshot is +// stubbed at `reposProvider`, which is exactly the seam `SettingsWindow` reads. +// +// Run: app/tool/e2e-desktop-settings.sh +// +// ignore_for_file: depend_on_referenced_packages +import 'dart:io' show Platform; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:makit/desktop/daemon/daemon_lifecycle.dart'; +import 'package:makit/desktop/desktop_app.dart' show desktopControllerProvider; +import 'package:makit/desktop/desktop_controller.dart'; +import 'package:makit/desktop/screens/fake_control_client.dart'; +import 'package:makit/desktop/settings/sections/repository_section.dart'; +import 'package:makit/desktop/settings/server_config.dart'; +import 'package:makit/desktop/settings/settings_nav_pane.dart'; +import 'package:makit/desktop/settings/settings_window.dart'; +import 'package:makit/store/connection.dart'; +import 'package:makit/store/models.dart'; +import 'package:makit/store/store.dart'; +import 'package:makit/ui/home/repo_monogram.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Two pinned repos and one unpinned, so the pinned filter is exercised by the +/// same fixture that exercises the rows. +/// +/// `Diana` carries a worktree-root override and a chosen hue; `makit` inherits +/// everything. The difference is the point: the section has to report each repo's +/// own state, and a bug that reads the wrong repo's settings passes any fixture +/// where both repos look the same. +/// The real home, because the section abbreviates paths against `HOME` at runtime. +/// Hardcoding `/Users/le` made the `~/trees/diana` and `~/.worktrees` assertions hold +/// on exactly one machine. +final String _home = Platform.environment['HOME'] ?? '/root'; + +final _repos = [ + RepoInfo.fromJson({ + 'id': 'p-diana', + 'name': 'Diana', + 'path': '$_home/Work/XDent/Diana', + 'pinned': true, + 'isGitRepo': true, + 'defaultBranch': 'main', + 'currentBranch': 'main', + 'worktrees': const >[], + 'settings': { + 'worktreeRoot': {'value': '$_home/trees/diana', 'source': 'override'}, + 'provider': {'value': 'forgejo', 'source': 'override'}, + 'defaultBranch': {'value': 'trunk', 'source': 'override'}, + 'logoHue': 2, + 'hasRemote': true, + 'forge': { + 'software': 'forgejo', + 'host': 'forgejo.internal.test', + 'authed': true, + }, + }, + })!, + RepoInfo.fromJson({ + 'id': 'p-makit', + 'name': 'makit', + 'path': '$_home/Work/makit', + 'pinned': true, + 'isGitRepo': true, + 'defaultBranch': 'main', + 'currentBranch': 'main', + 'worktrees': const >[], + 'settings': { + 'worktreeRoot': {'value': '$_home/.worktrees', 'source': 'default'}, + 'provider': {'value': 'auto', 'source': 'default'}, + 'hasRemote': true, + }, + })!, + RepoInfo.fromJson({ + 'id': 'p-noticed', + 'name': 'noticed', + 'path': '/tmp/noticed', + 'pinned': false, + 'isGitRepo': true, + 'worktrees': const >[], + })!, +]; + +late SharedPreferences _prefs; + +Widget _app() => ProviderScope( + overrides: [ + reposProvider.overrideWithValue(ReposState(_repos)), + serverConfigProvider.overrideWith( + (ref) => ServerConfigController(_prefs, const ServerConfig()), + ), + desktopControllerProvider.overrideWithValue( + DesktopController( + client: FakeControlClient(), + lifecycle: DaemonLifecycle( + resolver: MakitCliResolver(shellLookup: () async => null), + ), + ), + ), + connectionProvider.overrideWithValue(MakitConnState()), + ], + child: MaterialApp(home: SettingsWindow(onClose: () {})), +); + +Future _openRepo(WidgetTester tester, String name) async { + final row = find.descendant( + of: find.byType(SettingsNavPane), + matching: find.text(name), + ); + await tester.ensureVisible(row); + await tester.pumpAndSettle(); + await tester.tap(row); + await tester.pumpAndSettle(); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + _prefs = await SharedPreferences.getInstance(); + }); + + testWidgets('a pinned repo gets a reachable section in the real window', ( + tester, + ) async { + await tester.pumpWidget(_app()); + await tester.pumpAndSettle(); + + // The sidebar lists the pinned repos and not the merely-noticed one. + expect(find.text('Diana'), findsWidgets); + expect(find.text('makit'), findsWidgets); + expect(find.text('noticed'), findsNothing); + + await _openRepo(tester, 'Diana'); + + // The section rendered — not an empty pane, and not the fallback section. + // Group headers are upper-cased by `SettingsSectionHeader`, so these assert the + // rendered string rather than the source one. + expect(find.byType(RepositorySettingsSection), findsOneWidget); + expect(find.text('IDENTITY'), findsOneWidget); + expect(find.text('WORKTREES'), findsOneWidget); + // And the rows themselves, so "the section mounted" is not mistaken for "the + // section rendered its contents". + expect(find.text('Logo'), findsOneWidget); + expect(find.text('Git provider'), findsOneWidget); + expect(find.text('Worktree root'), findsOneWidget); + }); + + testWidgets('the rows carry THIS repo\'s values, resolved from the snapshot', ( + tester, + ) async { + await tester.pumpWidget(_app()); + await tester.pumpAndSettle(); + await _openRepo(tester, 'Diana'); + + // The override, home-abbreviated, and the badge that distinguishes it from an + // inherited root — the one row where that distinction is the whole feature. + expect(find.text('~/trees/diana'), findsOneWidget); + expect(find.text('overridden'), findsOneWidget); + // The provider override relabels the row rather than reporting detection. + expect(find.textContaining('Set to Forgejo'), findsOneWidget); + // The default-branch override wins over the DTO's git-derived `main`. + expect(find.text('trunk'), findsOneWidget); + }); + + testWidgets('switching repos re-renders from the newly selected repo', ( + tester, + ) async { + // The bug this guards: a section built once and cached would keep showing the + // first repo's values under the second repo's title, which reads as correct. + await tester.pumpWidget(_app()); + await tester.pumpAndSettle(); + + await _openRepo(tester, 'Diana'); + expect(find.text('overridden'), findsOneWidget); + + await _openRepo(tester, 'makit'); + expect( + find.text('~/trees/diana'), + findsNothing, + reason: "Diana's root leaked into makit", + ); + expect( + find.text('overridden'), + findsNothing, + reason: 'makit inherits, so nothing is overridden', + ); + expect(find.text('~/.worktrees'), findsOneWidget); + }); + + testWidgets('the sidebar draws each repo its own mark, with the chosen hue', ( + tester, + ) async { + await tester.pumpWidget(_app()); + await tester.pumpAndSettle(); + + final marks = find.descendant( + of: find.byType(SettingsNavPane), + matching: find.byType(RepoMonogram), + ); + expect(marks, findsNWidgets(2), reason: 'one mark per pinned repo'); + final diana = tester + .widgetList(marks) + .firstWhere((m) => m.name == 'Diana'); + expect(diana.hue, 2, reason: 'the stored hue must reach the sidebar'); + }); + + testWidgets('search reaches a repo row and lands on THAT repo', ( + tester, + ) async { + // The nav pane searches the DYNAMIC sections; on the static list "worktree root" + // found nothing and the result would have been titled `repo:`. + // + // Landing on the RIGHT repo is asserted, not just landing on a repo section: with + // two pinned repos the query matches both, so tapping `.first` and checking only + // that some repo section rendered would pass even if the search sent the user to + // the other repo. The result's subtitle is the repo name, so the tap targets + // Diana specifically and the assertion is on a value only Diana has. + await tester.pumpWidget(_app()); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField).first, 'worktree root'); + await tester.pumpAndSettle(); + + final results = find.widgetWithText(ListTile, 'Diana'); + expect(results, findsOneWidget, reason: 'one matching row for Diana'); + await tester.tap(results); + await tester.pumpAndSettle(); + + expect(find.byType(RepositorySettingsSection), findsOneWidget); + expect( + find.text('~/trees/diana'), + findsOneWidget, + reason: 'landed on Diana, not makit', + ); + }); +} diff --git a/app/lib/desktop/settings/registry/settings_registry.dart b/app/lib/desktop/settings/registry/settings_registry.dart index caa7cb3d..4f61a08a 100644 --- a/app/lib/desktop/settings/registry/settings_registry.dart +++ b/app/lib/desktop/settings/registry/settings_registry.dart @@ -17,6 +17,9 @@ import '../sections/general_section.dart'; import '../sections/notifications_section.dart'; import '../sections/server_devices_section.dart'; import '../sections/shortcuts_section.dart'; +import '../../../store/models.dart'; +import '../../../ui/home/repo_monogram.dart'; +import '../repository_settings_page.dart'; import 'settings_item.dart'; import 'settings_section.dart'; @@ -302,6 +305,63 @@ class SettingsSearchResult { final SettingsItem item; } +/// The section list for a given set of repositories: the fixed app sections, then +/// one per repository under a REPOSITORIES group. +/// +/// Only **pinned** repos get a section. That is what bounds the sidebar: a pinned +/// project is one the user added (`manager.ts:215`), where an unpinned one is +/// merely something makit noticed (`:287`). Without the filter the settings +/// sidebar would grow into a file browser. +/// +/// Section ids are `repo:` — keyed off the persisted id, never the +/// path, so a repo that moves keeps its section and its deep links. +List sectionsFor(List repos) { + final pinned = repos.where((r) => r.pinned).toList(); + return [ + ...kSettingsSections, + for (final repo in pinned) + SettingsSection( + id: repoSectionId(repo.id), + title: repo.name, + icon: PhosphorIconsLight.folder, + // The repo's own mark, so the sidebar rows are distinguishable — which is + // the place the "two repos look identical" problem actually bites, and the + // place the user looks to confirm a chosen colour took effect (D15/D14′). + // `icon` stays as the fallback for any renderer that ignores `leading`. + leading: RepoMonogram( + name: repo.name, + hue: repo.settings?.logoHue, + size: 20, + ), + builder: (_) => RepositorySettingsPage(repoId: repo.id), + // Generated per repo so the existing search field reaches these rows too; + // without them "worktree root" would find nothing. + items: [ + SettingsItem( + id: '${repoSectionId(repo.id)}.identity', + title: 'Identity', + help: + 'Logo, root path, git provider and default branch for ${repo.name}.', + keywords: const ['logo', 'path', 'provider', 'forge', 'branch'], + ), + SettingsItem( + id: '${repoSectionId(repo.id)}.worktrees', + title: 'Worktrees', + help: 'Where new worktrees for ${repo.name} are created.', + keywords: const ['worktree', 'worktree root', 'directory'], + ), + ], + ), + ]; +} + +/// The section id for a repo. One function, so the window, the nav pane and any +/// deep link cannot disagree about the format. +String repoSectionId(String projectId) => 'repo:$projectId'; + +/// True when [sectionId] addresses a repository section. +bool isRepoSection(String sectionId) => sectionId.startsWith('repo:'); + /// A reusable search over an arbitrary list of sections. Defaults to the /// app-wide [kSettingsSections]. Returns items whose title, keywords, or help /// contain [query] (case-insensitive); an empty/whitespace query returns no diff --git a/app/lib/desktop/settings/registry/settings_section.dart b/app/lib/desktop/settings/registry/settings_section.dart index 68b73db9..20a1febb 100644 --- a/app/lib/desktop/settings/registry/settings_section.dart +++ b/app/lib/desktop/settings/registry/settings_section.dart @@ -15,6 +15,7 @@ class SettingsSection { required this.icon, required this.builder, this.items = const [], + this.leading, }); /// Stable identifier; also the value stored in `settings.lastSection`. @@ -26,6 +27,15 @@ class SettingsSection { /// Leading icon (a `PhosphorIcons.*` glyph from `phosphoricons_flutter`). final IconData icon; + /// A leading widget that replaces [icon] when the section has a mark of its own + /// — a repository's monogram (SPEC-48 D15). + /// + /// Optional and additive rather than a widened `icon`: every app section is + /// correctly described by a glyph, and only the generated per-repo sections need + /// to be told apart from each other. [icon] stays required so a section can never + /// end up with nothing to draw. + final Widget? leading; + /// Builds the section body widget. final WidgetBuilder builder; diff --git a/app/lib/desktop/settings/repository_settings_page.dart b/app/lib/desktop/settings/repository_settings_page.dart new file mode 100644 index 00000000..4091271a --- /dev/null +++ b/app/lib/desktop/settings/repository_settings_page.dart @@ -0,0 +1,338 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../status/status_event.dart'; +import '../../status/status_providers.dart'; +import '../../store/models.dart'; +import '../../store/store.dart'; +import '../../ui/home/repo_monogram.dart'; +import 'sections/repository_section.dart'; + +/// One repository's Settings page, connected to the live repo snapshot. +/// +/// Thin on purpose: it resolves the repo by **id** (never by index or path, so a +/// reordered or moved repo keeps working), maps it to a view, and hands the writes +/// to the server. All the rendering lives in [RepositorySettingsSection] and all +/// the mapping in `repoSettingsViewFor`, so this file has nothing to get wrong +/// except the wiring. +class RepositorySettingsPage extends ConsumerWidget { + const RepositorySettingsPage({required this.repoId, super.key}); + + final String repoId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final repos = ref.watch(reposProvider).repos; + final repo = repos.where((r) => r.id == repoId).firstOrNull; + + // The repo went away while its section was open — removed, or the snapshot has + // not arrived yet. Say so rather than rendering an empty shell. + if (repo == null) { + return _Notice( + text: repos.isEmpty + ? 'Waiting for the repository list…' + : 'This repository is no longer open in makit.', + ); + } + + final view = repoSettingsViewFor(repo); + // An older server sends no settings. Rendering fabricated defaults would be + // worse than saying nothing, because every value shown would be a guess. + if (view == null) { + return const _Notice( + text: 'This server does not report per-repository settings yet.', + ); + } + + return RepositorySettingsSection( + view: view, + onChooseProvider: (choice) => _write(ref, { + // `auto` clears the override; absent means "believe detection". + 'provider': choice == ForgeChoice.auto ? null : choice.name, + }), + onResetWorktreeRoot: () => _write(ref, {'worktreeRoot': null}), + onEditWorktreeRoot: () => _promptWorktreeRoot(context, ref, view), + onChooseDefaultBranch: () => _pickBranch(context, ref, view), + onEditLogo: () => _pickHue(context, ref, repo), + onChangeRootPath: () => _promptRootPath(context, ref, view), + ); + } + + /// Write a settings patch and REPORT a refusal. + /// + /// The server refuses a non-loopback client, an invalid worktree root and an invalid + /// branch name with an explicit error, and each message is written to be read by the + /// user. Dropping it left the row unchanged and silent — the exact "appears to save + /// and does not" failure the server handler documents as unacceptable. + /// + /// Posted to the StatusCenter, not a snackbar (SPEC-48 D8): the message lands on the + /// Activity record, so it can be copied into a bug report rather than vanishing after + /// four seconds. `ref.status` is hoisted BEFORE the await, because `ref` dies with its + /// widget and this pane can close mid-flight — reaching for it afterwards would crash + /// exactly when there is bad news to deliver. + Future _write(WidgetRef ref, Map patch) async { + final status = ref.status; + try { + await ref + .read(storeControllerProvider.notifier) + .setRepoSettings(repoId, patch); + } catch (e) { + status.failure( + 'Could not change repository settings', + detail: _reasonFrom(e), + error: e, + source: StatusSources.settings, + ); + } + } + + Future _promptWorktreeRoot( + BuildContext context, + WidgetRef ref, + RepoSettingsView view, + ) async { + final controller = TextEditingController(text: view.worktreeRoot); + try { + final value = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Worktree root'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration( + hintText: '/Users/you/.worktrees', + ), + ), + const SizedBox(height: 10), + Text( + 'Must be an absolute path inside your home directory. ' + 'It does not have to exist yet.', + style: Theme.of(ctx).textTheme.bodySmall, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, controller.text.trim()), + child: const Text('Set'), + ), + ], + ), + ); + if (value == null || value.isEmpty) return; + // Not validated here: the server owns the rules (absolute, no `..`, inside + // $HOME, canonicalised) and re-implementing them in Dart would give two + // answers that could disagree. The refusal is shown by `_write`. + if (!context.mounted) return; + await _write(ref, {'worktreeRoot': value}); + } finally { + controller.dispose(); + } + } + + Future _pickBranch( + BuildContext context, + WidgetRef ref, + RepoSettingsView view, + ) async { + // Not `branches.isEmpty` alone: the "Use the branch git reports" option clears a + // stored override and is valid whatever the branch list holds. Returning early on + // an empty list left an override stuck on any repo whose branches cannot be + // enumerated — an empty repo, or one whose branch went away. + if (view.branches.isEmpty && !view.defaultBranchOverridden) return; + final picked = await showDialog( + context: context, + builder: (ctx) => SimpleDialog( + title: const Text('Default branch'), + children: [ + for (final b in view.branches) + SimpleDialogOption( + onPressed: () => Navigator.pop(ctx, b), + child: Text(b), + ), + SimpleDialogOption( + onPressed: () => Navigator.pop(ctx, ''), + child: const Text('Use the branch git reports'), + ), + ], + ), + ); + if (picked == null) return; + if (!context.mounted) return; + await _write(ref, {'defaultBranch': picked.isEmpty ? null : picked}); + } + + Future _pickHue( + BuildContext context, + WidgetRef ref, + RepoInfo repo, + ) async { + final picked = await showDialog( + context: context, + builder: (ctx) => SimpleDialog( + title: const Text('Logo colour'), + children: [ + for (var i = 0; i < RepoMonogram.paletteLength; i++) + SimpleDialogOption( + onPressed: () => Navigator.pop(ctx, i), + child: Row( + children: [ + Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: RepoMonogram.paletteAt(i), + borderRadius: BorderRadius.circular(5), + ), + ), + const SizedBox(width: 10), + Text('Colour ${i + 1}'), + ], + ), + ), + SimpleDialogOption( + onPressed: () => Navigator.pop(ctx, -1), + child: const Text('Derive from the name'), + ), + ], + ), + ); + if (picked == null) return; + if (!context.mounted) return; + await _write(ref, {'logoHue': picked < 0 ? null : picked}); + } + + /// Re-point the repository (SPEC-48 D4\u2032). + /// + /// Distinct from the settings writes in two ways, both deliberate: + /// + /// - it **says what it will do** before doing it. The other rows change a + /// preference; this one changes which directory every session in this project + /// runs its git commands in, and the daemon re-runs forge detection as a + /// result. A confirmation is proportionate to that. + /// - it **surfaces the refusal**. "Not a git repository" and "already open as X" + /// are the whole point of the server-side check, and a fire-and-forget write + /// would leave the path unchanged with no explanation. + Future _promptRootPath( + BuildContext context, + WidgetRef ref, + RepoSettingsView view, + ) async { + // Hoisted to the top of the method, not merely before the write: `ref` dies with + // its widget, and the dialog is itself an asynchronous gap during which this pane + // can close. Pinned by test/status/status_lifetime_test.dart -- whose scanner is + // textual, so the word it looks for is kept out of this comment too. + final status = ref.status; + final controller = TextEditingController(text: view.path); + try { + final value = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Repository path'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration( + hintText: '/Users/you/Work/project', + ), + ), + const SizedBox(height: 10), + Text( + 'Use this when the repository has moved on disk. It keeps this ' + 'project\u2019s settings and session history, which removing and ' + 're-adding it would not.\n\n' + 'It must be an existing git repository. Sessions already bound to a ' + 'worktree keep the path they were created with.', + style: Theme.of(ctx).textTheme.bodySmall, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel'), + ), + // Disabled until the value is both non-empty and actually different. The + // guard below still returns early, but a button that closes the dialog and + // does nothing is the failure this feature already refuses elsewhere: a + // control that appears to act and does not is worse than one that says it + // cannot. + ValueListenableBuilder( + valueListenable: controller, + builder: (ctx, field, _) { + final next = field.text.trim(); + return TextButton( + onPressed: next.isEmpty || next == view.path + ? null + : () => Navigator.pop(ctx, next), + child: const Text('Re-point'), + ); + }, + ), + ], + ), + ); + // Kept as a guard even though the button is disabled: dismissing the dialog and + // the disabled state are two different mechanisms, and only one of them is + // enforced by the widget tree. + if (value == null || value.isEmpty || value == view.path) return; + // Not validated here: the server owns the rules (absolute, no `..`, exists, is + // a git repo, not already open) and re-implementing them in Dart would give two + // answers that could disagree. + // + // `ref` is only safe while the widget is alive, and the dialog above was an + // asynchronous gap: this pane can be closed while it was open. The hoisted + // `status` survives that, but `ref.read` does not. + if (!context.mounted) return; + try { + await ref + .read(storeControllerProvider.notifier) + .setRepoPath(repoId, value); + } catch (e) { + status.failure( + 'Could not re-point the repository', + detail: _reasonFrom(e), + error: e, + source: StatusSources.repo, + ); + } + } finally { + controller.dispose(); + } + } + + /// The server's own wording where there is one, so an actionable refusal is not + /// replaced by a generic failure. + static String _reasonFrom(Object error) { + final text = error.toString(); + final marker = text.indexOf(': '); + return marker >= 0 && marker + 2 < text.length + ? text.substring(marker + 2) + : text; + } +} + +class _Notice extends StatelessWidget { + const _Notice({required this.text}); + final String text; + @override + Widget build(BuildContext context) => Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text(text, style: Theme.of(context).textTheme.bodyMedium), + ), + ); +} diff --git a/app/lib/desktop/settings/sections/repository_section.dart b/app/lib/desktop/settings/sections/repository_section.dart new file mode 100644 index 00000000..79d69d64 --- /dev/null +++ b/app/lib/desktop/settings/sections/repository_section.dart @@ -0,0 +1,542 @@ +import 'dart:io' show Platform; + +import 'package:flutter/material.dart'; +import 'package:phosphoricons_flutter/phosphoricons_flutter.dart'; + +import '../../../app/theme.dart'; +import '../../../store/models.dart'; +import '../../../ui/home/repo_chips.dart'; +import '../../../ui/home/repo_monogram.dart'; +import '../../../ui/widgets/forge_glyph.dart'; +import 'section_header.dart'; +import 'settings_group.dart'; +import 'settings_reset_button.dart'; + +/// Everything one repository's Settings section renders — nothing more. +/// +/// A view model rather than a `RepoInfo`, for two reasons. The section shows facts +/// the repo DTO does not carry yet (the detected forge, the *effective* worktree +/// root and whether it was overridden), and keeping those as inputs means the +/// widget is complete and testable before the server plumbing that will supply +/// them exists. It also keeps the section pure presentation: it is *told* facts +/// and never derives them, which is the rule that stopped the app re-deriving the +/// forge from a PR URL. +@immutable +class RepoSettingsView { + const RepoSettingsView({ + required this.name, + required this.path, + required this.worktreeRoot, + this.defaultBranch, + this.forge, + this.forgeHost, + this.forgeAuthed = false, + this.worktreeRootOverridden = false, + this.defaultBranchOverridden = false, + this.editable = true, + this.providerChoice = ForgeChoice.auto, + this.branches = const [], + this.hasRemote = true, + this.logoHue, + }); + + final String name; + final String path; + + /// The **effective** worktree root — never blank. An empty field that silently + /// means `~/.worktrees` is how worktrees end up somewhere unexpected. + final String worktreeRoot; + + /// From `origin/HEAD`; null when it could not be read. + final String? defaultBranch; + + /// What detection reported. **Null means "not measured yet"**, and the provider + /// row is then omitted entirely rather than rendering a guess: routing only + /// happens when a PR operation runs, so a quiet repo may genuinely not know. + final ForgeKind? forge; + + /// The instance host, shown as the provider row's subtitle. + final String? forgeHost; + + /// Whether a credential is configured **for that host**. Never the token. + final bool forgeAuthed; + + /// True when a repo-level value replaces the inherited one — the only state + /// that earns a reset button. + final bool worktreeRootOverridden; + + /// Whether [defaultBranch] came from a stored override rather than from git. + /// + /// Needed so the row stays reachable when there is nothing to PICK but something to + /// CLEAR: a control that can set a value must be able to unset it, or a repo whose + /// branches cannot be enumerated keeps an override forever. + final bool defaultBranchOverridden; + + /// What the user picked for the provider. [ForgeChoice.auto] means "believe + /// detection", and is the default — an override exists for the case detection + /// cannot solve (a private instance with no token, or a proxy hiding + /// `/api/forgejo/v1/version`), not as a preference. + final ForgeChoice providerChoice; + + /// Whether the repo has an `origin` remote at all. False means no forge is + /// possible — which is a **different statement** from "not identified yet", and + /// rendering them the same implies a probe is still pending when none can help. + final bool hasRemote; + + /// Branches offered when picking a default. Empty = nothing to pick from, so the + /// row stays read-only rather than opening an empty picker. + final List branches; + + /// False on a client that may read but not write: per-repo writes are accepted + /// only from a loopback connection (SPEC-48 D16), so a paired phone shows the + /// same values read-only rather than offering a control that would be refused. + final bool editable; + + /// The palette index the user chose for this repo's mark, or null to derive it + /// from the name. + /// + /// Null rather than a default index, because index 0 is a real palette entry: a + /// numeric default would silently repaint every repo that never chose one. + final int? logoHue; +} + +/// Build the section's view from a real [RepoInfo]. +/// +/// One place, so the section stays pure presentation and the mapping from wire +/// facts to rendered facts is testable on its own. Returns null when the server +/// sent no settings — an older server, where rendering fabricated defaults would +/// be worse than rendering nothing. +RepoSettingsView? repoSettingsViewFor(RepoInfo repo, {bool editable = true}) { + final st = repo.settings; + if (st == null) return null; + final forge = st.forge; + return RepoSettingsView( + name: repo.name, + path: repo.path, + worktreeRoot: st.worktreeRoot.value, + worktreeRootOverridden: st.worktreeRoot.isOverride, + defaultBranchOverridden: st.defaultBranch != null, + // The override wins; otherwise git's own answer, which the DTO already carries + // rather than duplicating into settings. + defaultBranch: st.defaultBranch?.value ?? repo.defaultBranch, + forge: forge == null ? null : _forgeKindFor(forge.software), + forgeHost: forge?.host, + forgeAuthed: forge?.authed ?? false, + providerChoice: _choiceFor(st.provider.value), + hasRemote: st.hasRemote, + // Offered from what the repo actually has, so a pick cannot be a typo. + branches: { + for (final w in repo.worktrees) + if (w.branch != null && w.branch!.isNotEmpty) w.branch!, + if (repo.defaultBranch != null) repo.defaultBranch!, + }.toList()..sort(), + editable: editable, + logoHue: st.logoHue, + ); +} + +/// `gitlab` and `unknown` map to null: the app has no glyph for a forge it cannot +/// talk to, and the row's subtitle carries the name instead. +ForgeKind? _forgeKindFor(String software) => switch (software) { + 'github' => ForgeKind.github, + 'forgejo' => ForgeKind.forgejo, + 'gitea' => ForgeKind.gitea, + _ => null, +}; + +ForgeChoice _choiceFor(String wire) => switch (wire) { + 'none' => ForgeChoice.none, + 'forgejo' => ForgeChoice.forgejo, + 'gitea' => ForgeChoice.gitea, + 'github' => ForgeChoice.github, + _ => ForgeChoice.auto, +}; + +/// The provider the user chose, or [auto] to believe detection. +enum ForgeChoice { + auto, + + /// No forge for this repository: makit does not talk to one, and does not poll + /// pull requests. Distinct from [auto] failing to identify something — this is + /// an instruction, not an outcome. Two reasons it earns a place beside the three + /// forges: a purely local repo has no remote and never will, and a repo whose + /// forge you simply do not care about (a mirror, a vendored copy) should be able + /// to stop generating PR chatter. + none, + + forgejo, + gitea, + github; + + String get label => switch (this) { + ForgeChoice.auto => 'Auto', + ForgeChoice.none => 'None', + ForgeChoice.forgejo => 'Forgejo', + ForgeChoice.gitea => 'Gitea', + ForgeChoice.github => 'GitHub', + }; +} + +/// One repository's Settings section. +/// +/// **A badge appears only where nothing else in the row says it.** The provenance +/// family (`from name`, `detected`, `from remote`) was cut: the monogram *is* the +/// logo, `main` *is* the branch, and the provider row's subtitle already reads +/// `Auto: GitHub · …` or `Set to Gitea · …`. Restating that in a chip beside it is +/// the same sentence twice, and once every row became editable the provenance +/// stopped being actionable. What survives is `inherited` / `overridden` on +/// Worktree root, which has no subtitle and where the distinction is the whole +/// point — paired with the reset button, which is an action rather than a label. +/// +/// Built from the shipped settings atoms — [SettingsSectionHeader], +/// [SettingsGroup], [SettingsResetButton] — so it inherits the window's spacing, +/// the green uppercase headers and, crucially, the reset button's fixed-width +/// collapse, which keeps rows with and without an override on the same grid. +class RepositorySettingsSection extends StatelessWidget { + const RepositorySettingsSection({ + required this.view, + this.onEditWorktreeRoot, + this.onResetWorktreeRoot, + this.onEditLogo, + this.onChangeRootPath, + this.onChooseProvider, + this.onChooseDefaultBranch, + super.key, + }); + + final RepoSettingsView view; + final VoidCallback? onEditWorktreeRoot; + final VoidCallback? onResetWorktreeRoot; + final VoidCallback? onEditLogo; + final VoidCallback? onChangeRootPath; + final ValueChanged? onChooseProvider; + final VoidCallback? onChooseDefaultBranch; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return ListView( + children: [ + // The repo name is the page title, exactly as `Server & Devices` heads its + // own section before its subsections. + SettingsSectionHeader(title: view.name), + + const SettingsSectionHeader(title: 'Identity'), + SettingsGroup( + children: [ + _SettingsValueRow( + leading: RepoMonogram(name: view.name, hue: view.logoHue), + title: 'Logo', + enabled: view.editable, + onTap: onEditLogo, + // A chevron, not a text field: the choice is a colour and a glyph + // from a fixed set. Two repos that hash to the same hue are + // indistinguishable in the sidebar, which is the one thing the + // monogram exists to prevent — so it must be overridable. + action: _Chevron(enabled: view.editable), + ), + _SettingsValueRow( + leading: Icon( + PhosphorIconsLight.folder, + size: 20, + color: cs.outline, + ), + title: 'Root path', + enabled: view.editable, + onTap: onChangeRootPath, + value: _tilde(view.path), + mono: true, + // Editable, reversing an earlier "identity is immutable" position: + // when a repo MOVES on disk, remove-and-re-add loses its persisted + // id and everything keyed to it (settings, session history). + // Re-pointing keeps the id, which is the whole reason the id exists. + action: _Chevron(enabled: view.editable), + ), + // Row + segmented control below, exactly as `Endpoint` does it: the + // subtitle says what `Auto` resolved to, so the default is legible + // rather than mysterious. An override is here for the case detection + // cannot solve — a private instance answering 401, or a proxy hiding + // `/api/forgejo/v1/version` — where the repo is otherwise unusable + // with no recourse. + _ProviderRow( + view: view, + onChoose: view.editable ? onChooseProvider : null, + ), + // Rendered even when nothing is resolved. Gating on + // `defaultBranch != null` hid the row in exactly the state it exists for: + // git reports no `origin/HEAD` after a `--single-branch` clone or a + // default-branch rename, and no override has been set yet -- so the user + // could not reach the picker precisely when they needed it. + _SettingsValueRow( + leading: Icon( + PhosphorIconsLight.gitBranch, + size: 20, + color: cs.outline, + ), + title: 'Default branch', + // Pickable from the repo's own branches, never free text: a typo + // here silently breaks diff-vs-default and the PR base. + // Reachable when there is something to pick OR something to clear. + enabled: + view.editable && + (view.branches.isNotEmpty || view.defaultBranchOverridden), + onTap: onChooseDefaultBranch, + // A stated absence, not a blank cell: "Not detected" says the probe + // finished and found nothing, which is what makes the row worth tapping. + value: view.defaultBranch ?? 'Not detected', + mono: view.defaultBranch != null, + action: _Chevron( + enabled: + view.editable && + (view.branches.isNotEmpty || view.defaultBranchOverridden), + ), + ), + ], + ), + + const SettingsSectionHeader(title: 'Worktrees'), + SettingsGroup( + children: [ + _SettingsValueRow( + enabled: view.editable, + onTap: onEditWorktreeRoot, + title: 'Worktree root', + value: _tilde(view.worktreeRoot), + mono: true, + badge: _Badge( + label: view.worktreeRootOverridden ? 'overridden' : 'inherited', + color: view.worktreeRootOverridden ? cs.primary : cs.outline, + ), + // Only an override can be reset, and reset means inherit again — not + // "copy today's inherited value" — so a later change still propagates. + resetVisible: view.worktreeRootOverridden && view.editable, + onReset: onResetWorktreeRoot, + ), + ], + ), + + if (!view.editable) + Padding( + padding: const EdgeInsets.fromLTRB(kSpace24, 0, kSpace24, kSpace24), + child: Text( + 'These settings are editable on the machine running makit.', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: cs.outline), + ), + ), + ], + ); + } + + /// `/Users/le/x` -> `~/x`. A settings row is not the place to spend 40pt of the + /// value column on a home directory the reader already knows. + /// `$HOME/x` as `~/x`, leaving anything else alone. + /// + /// The boundary check is load-bearing: a bare `startsWith` matched + /// `/Users/leduck/x` when `HOME` was `/Users/le` and rendered `~duck/x` -- not a + /// valid path, and a different repository from the one on screen. + static String _tilde(String path) { + final home = Platform.environment['HOME']; + if (home == null || home.isEmpty) return path; + final trimmed = home.endsWith('/') + ? home.substring(0, home.length - 1) + : home; + if (path == trimmed) return '~'; + if (!path.startsWith('$trimmed/')) return path; + return '~/${path.substring(trimmed.length + 1)}'; + } + + /// Visible for [_ProviderRow]. + static String forgeSubtitle(RepoSettingsView v) { + if (v.providerChoice == ForgeChoice.none) { + return 'No forge · pull requests are not checked for this repository'; + } + if (v.providerChoice != ForgeChoice.auto) { + return 'Set to ${v.providerChoice.label}' + '${v.forgeHost == null ? '' : ' · ${v.forgeHost}'}'; + } + // "No remote" is a conclusion; "not identified yet" is a pending probe. Saying + // the second when the first is true sends the reader looking for a fix that + // does not exist. + if (!v.hasRemote) return 'Auto: no remote, so no forge'; + if (v.forge == null) return 'Auto: not identified yet'; + final parts = [ + 'Auto: ${forgeNameFor(v.forge!)}', + if (v.forgeHost != null) v.forgeHost!, + v.forgeAuthed ? 'token set' : 'no token', + ]; + return parts.join(' · '); + } +} + +/// One settings row: title on the left, its **value right-aligned** in a column +/// down the section, then the provenance badge and the reset slot. +/// +/// The value column is the mockup's idea and it earns its place — a column of +/// right-aligned values scans in one vertical sweep, where values buried in +/// subtitles have to be read line by line. It does mean this row style differs +/// from `CLI`/`Fingerprint` in Server & Devices, which put their value in the +/// subtitle; that is a deliberate, recorded divergence rather than an oversight. +/// +/// No subtitle slot: a description under a row labelled "Logo" is words about +/// words. The one row with a genuine second fact — the forge's host — builds its +/// own ListTile in [_ProviderRow]. +class _SettingsValueRow extends StatelessWidget { + const _SettingsValueRow({ + required this.title, + this.leading, + this.value, + this.mono = false, + this.badge, + this.action, + this.resetVisible = false, + this.onReset, + this.enabled = true, + this.onTap, + }); + + final String title; + final Widget? leading; + final String? value; + + /// Render [value] monospaced — paths and refs, where character shape matters. + final bool mono; + final Widget? badge; + + /// A trailing control that occupies the reset slot instead (the copy button). + final Widget? action; + final bool resetVisible; + final VoidCallback? onReset; + final bool enabled; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final t = Theme.of(context); + final cs = t.colorScheme; + final valueStyle = t.textTheme.bodySmall?.copyWith( + color: cs.onSurfaceVariant, + ); + return ListTile( + enabled: enabled, + onTap: enabled ? onTap : null, + leading: leading, + title: Text(title), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (value != null) + ConstrainedBox( + // Bounded so a long path elides instead of shoving the badge off the + // row — the failure the mockup hit until its subtitle was shortened. + constraints: const BoxConstraints(maxWidth: 260), + child: Text( + value!, + textAlign: TextAlign.right, + overflow: TextOverflow.ellipsis, + style: mono ? valueStyle?.mono : valueStyle, + ), + ), + if (badge != null) ...[const SizedBox(width: kSpace8), badge!], + // Exactly one thing occupies the trailing slot, so every row shares one + // right edge whether or not it can be reset. + if (action != null) + action! + else + SettingsResetButton( + visible: resetVisible, + onPressed: onReset ?? () {}, + ), + ], + ), + ); + } +} + +/// The Git provider row and its selector. +/// +/// Row then segmented control below, exactly as `Endpoint` does it, so the +/// subtitle can say what `Auto` resolved to and the default is legible rather than +/// mysterious. +class _ProviderRow extends StatelessWidget { + const _ProviderRow({required this.view, this.onChoose}); + final RepoSettingsView view; + final ValueChanged? onChoose; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final detected = view.forge; + final overridden = view.providerChoice != ForgeChoice.auto; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + leading: view.providerChoice == ForgeChoice.none || !view.hasRemote + // Not a question mark: nothing is being asked. This state is settled. + ? Icon(PhosphorIconsLight.prohibit, size: 20, color: cs.outline) + : detected == null + ? Icon(PhosphorIconsLight.question, size: 20, color: cs.outline) + : SizedBox( + width: 20, + height: 20, + child: forgeGlyphFor( + detected, + ).build(size: 20, color: cs.primary), + ), + title: const Text('Git provider'), + subtitle: Text(RepositorySettingsSection.forgeSubtitle(view)), + trailing: SettingsResetButton( + visible: overridden && view.editable, + onPressed: () => onChoose?.call(ForgeChoice.auto), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(kSpace24, 0, kSpace24, kSpace8), + child: SegmentedButton( + segments: [ + for (final c in ForgeChoice.values) + ButtonSegment(value: c, label: Text(c.label)), + ], + selected: {view.providerChoice}, + showSelectedIcon: false, + onSelectionChanged: onChoose == null + ? null + : (s) => onChoose!(s.first), + ), + ), + ], + ); + } +} + +/// The "opens something" affordance, sized to the reset slot so every row keeps +/// one right edge whether it navigates, copies or resets. +class _Chevron extends StatelessWidget { + const _Chevron({required this.enabled}); + final bool enabled; + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return SizedBox( + width: 40, + child: Icon( + PhosphorIconsLight.caretRight, + size: 16, + color: enabled ? cs.outline : cs.outline.withValues(alpha: 0.35), + ), + ); + } +} + +/// A provenance/resolution badge. Reuses [TagChip] so it cannot drift from the +/// chips the repo list already shows. +class _Badge extends StatelessWidget { + const _Badge({required this.label, required this.color}); + final String label; + final Color color; + @override + Widget build(BuildContext context) => TagChip(label: label, color: color); +} diff --git a/app/lib/desktop/settings/settings_nav_pane.dart b/app/lib/desktop/settings/settings_nav_pane.dart index 2fb6f1a5..69cadeeb 100644 --- a/app/lib/desktop/settings/settings_nav_pane.dart +++ b/app/lib/desktop/settings/settings_nav_pane.dart @@ -75,7 +75,11 @@ class SettingsNavPane extends StatelessWidget { ), Expanded( child: searching - ? _SearchResults(query: query, onSelectResult: onSelectResult) + ? _SearchResults( + query: query, + sections: sections, + onSelectResult: onSelectResult, + ) : _SectionList( sections: sections, selectedId: selectedId, @@ -134,7 +138,9 @@ class _SectionList extends StatelessWidget { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(kRadius8), ), - leading: Icon(section.icon), + // A section with a mark of its own draws it; everything else keeps its + // glyph. See SettingsSection.leading (SPEC-48 D15). + leading: section.leading ?? Icon(section.icon), title: Text(section.title), onTap: () => onSelect(section.id), ), @@ -144,21 +150,32 @@ class _SectionList extends StatelessWidget { } class _SearchResults extends StatelessWidget { - const _SearchResults({required this.query, required this.onSelectResult}); + const _SearchResults({ + required this.query, + required this.sections, + required this.onSelectResult, + }); final String query; + + /// The same list the pane renders, so repo rows are searchable and their result + /// titles read as the repo name rather than `repo:`. + final List sections; final void Function(String sectionId, String itemId) onSelectResult; @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - final results = searchSettings(query); + // Over the sections this pane was GIVEN, not the static list: otherwise repo + // rows are unsearchable, which is the whole point of generating their items. + final results = searchSettings(query, sections: sections); if (results.isEmpty) { return Padding( padding: const EdgeInsets.all(kSpace16), child: Text('No matches', style: TextStyle(color: cs.outline)), ); } - final sectionTitles = {for (final s in kSettingsSections) s.id: s.title}; + // Titles from the same list, or a repo result renders as its raw `repo:`. + final sectionTitles = {for (final s in sections) s.id: s.title}; return ListView( padding: const EdgeInsets.symmetric(horizontal: kSpace8), children: [ diff --git a/app/lib/desktop/settings/settings_window.dart b/app/lib/desktop/settings/settings_window.dart index 16e0d4a5..9c01bbf3 100644 --- a/app/lib/desktop/settings/settings_window.dart +++ b/app/lib/desktop/settings/settings_window.dart @@ -11,6 +11,7 @@ import '../window_overlays.dart'; // forgotten by a call site; re-exported so existing importers of // `settingsOpenProvider` are unaffected. export '../window_overlays.dart' show settingsOpenProvider; +import '../../store/store.dart'; import 'registry/settings_registry.dart'; import 'settings_detail_pane.dart'; import 'settings_item_anchor.dart'; @@ -115,7 +116,12 @@ class _SettingsWindowState extends ConsumerState { final stored = ref .read(preferencesControllerProvider.notifier) .get(lastSectionPreference); + // Resolved against the STATIC list here, because the repo snapshot may not + // have arrived yet. A stored `repo:` is honoured in build(), once the + // sections that could contain it exist. _selectedId = kSettingsSections.any((s) => s.id == stored) + ? stored + : isRepoSection(stored) ? stored : kSettingsSections.first.id; } @@ -142,7 +148,19 @@ class _SettingsWindowState extends ConsumerState { @override Widget build(BuildContext context) { - final section = kSettingsSections.firstWhere((s) => s.id == _selectedId); + // A function of the live repo list: one section per pinned repo (SPEC-48 D1). + final sections = sectionsFor(ref.watch(reposProvider).repos); + // Falls back rather than throwing when the selected repo disappears — removed + // while its section was open, or a stored id whose repo is gone. + final section = sections.firstWhere( + (s) => s.id == _selectedId, + orElse: () => sections.first, + ); + // The nav pane is told which section is ACTUALLY shown, not what was requested. + // When a stored `repo:` is no longer available the detail pane falls back to + // the first section while `_selectedId` still held the missing id, so the sidebar + // highlighted nothing and the window looked like it had lost its place. + final effectiveSelectedId = section.id; // A modal focus scope: traps tab traversal inside Settings, binds Escape to // close, and marks the subtree as a route for assistive tech. return FocusScope( @@ -163,8 +181,8 @@ class _SettingsWindowState extends ConsumerState { SizedBox( width: _navWidth, child: SettingsNavPane( - sections: kSettingsSections, - selectedId: _selectedId, + sections: sections, + selectedId: effectiveSelectedId, query: _query, controller: _searchController, onQueryChanged: (q) => setState(() => _query = q), diff --git a/app/lib/store/models.dart b/app/lib/store/models.dart index 76eed409..b2eb7a9b 100644 --- a/app/lib/store/models.dart +++ b/app/lib/store/models.dart @@ -907,6 +907,109 @@ class Worktree { /// A repo on the home screen: a [Project] enriched with git intelligence — /// its default/current branch and live worktrees. +/// Where an effective per-repo value came from. Drives the badge; the app is told +/// this rather than deriving it, so one rule lives on the server. +enum SettingSource { override, environment, defaultValue } + +SettingSource _sourceFrom(Object? raw) => switch (raw) { + 'override' => SettingSource.override, + 'environment' => SettingSource.environment, + // Anything unrecognised reads as the default rather than throwing: a newer + // server adding a source must not crash an older app. + _ => SettingSource.defaultValue, +}; + +/// An effective value and its source. +class Resolved { + const Resolved(this.value, this.source); + final T value; + final SettingSource source; + + /// True when a repo-level value replaces the inherited one — the only state that + /// earns a reset affordance. + bool get isOverride => source == SettingSource.override; + + @override + bool operator ==(Object other) => + other is Resolved && other.value == value && other.source == source; + @override + int get hashCode => Object.hash(value, source); +} + +/// What detection concluded about a repo's forge. +class RepoForge { + const RepoForge({required this.software, required this.host, this.authed}); + final String software; + final String host; + + /// Whether a credential is configured for that host. Absent for GitHub, where + /// `gh`'s budget is not host-specific authentication. + final bool? authed; + + static RepoForge? fromJson(Object? raw) { + if (raw is! Map) return null; + final j = Map.from(raw); + final software = j['software']; + final host = j['host']; + if (software is! String || host is! String) return null; + return RepoForge( + software: software, + host: host, + authed: j['authed'] is bool ? j['authed'] as bool : null, + ); + } +} + +/// Per-repo settings as the server resolved them. +class RepoSettings { + const RepoSettings({ + required this.worktreeRoot, + required this.provider, + required this.hasRemote, + this.defaultBranch, + this.logoHue, + this.forge, + }); + + final Resolved worktreeRoot; + final Resolved provider; + + /// False = no `origin`, so no forge is possible. A different statement from + /// "not identified yet", which is [forge] being null. + final bool hasRemote; + + /// Present ONLY when overridden; otherwise read `RepoInfo.defaultBranch`. + final Resolved? defaultBranch; + final int? logoHue; + + /// Null means detection has not run for this repo yet, never "no forge". + final RepoForge? forge; + + static RepoSettings? fromJson(Object? raw) { + if (raw is! Map) return null; + final j = Map.from(raw); + final root = _resolvedString(j['worktreeRoot']); + if (root == null) return null; + return RepoSettings( + worktreeRoot: root, + provider: + _resolvedString(j['provider']) ?? + const Resolved('auto', SettingSource.defaultValue), + hasRemote: j['hasRemote'] == true, + defaultBranch: _resolvedString(j['defaultBranch']), + logoHue: j['logoHue'] is num ? (j['logoHue'] as num).toInt() : null, + forge: RepoForge.fromJson(j['forge']), + ); + } + + static Resolved? _resolvedString(Object? raw) { + if (raw is! Map) return null; + final v = raw['value']; + if (v is! String) return null; + return Resolved(v, _sourceFrom(raw['source'])); + } +} + class RepoInfo { const RepoInfo({ required this.id, @@ -918,6 +1021,7 @@ class RepoInfo { required this.defaultBranch, required this.currentBranch, required this.worktrees, + this.settings, }); final String id; @@ -930,6 +1034,11 @@ class RepoInfo { final String? currentBranch; final List worktrees; + /// Per-repo settings, or null when the server did not send any — an older + /// server, in which case the settings section renders nothing rather than + /// fabricating defaults. + final RepoSettings? settings; + /// Total added/removed lines across every worktree. int get totalInsertions => worktrees.fold(0, (a, w) => a + w.insertions); int get totalDeletions => worktrees.fold(0, (a, w) => a + w.deletions); @@ -960,6 +1069,7 @@ class RepoInfo { currentBranch: j['currentBranch'] is String ? j['currentBranch'] as String : null, + settings: RepoSettings.fromJson(j['settings']), worktrees: ((j['worktrees'] as List?) ?? const []) .whereType>() .map((m) => Worktree.fromJson(Map.from(m))) diff --git a/app/lib/store/store.dart b/app/lib/store/store.dart index b202be3b..6941e8d1 100644 --- a/app/lib/store/store.dart +++ b/app/lib/store/store.dart @@ -801,6 +801,50 @@ class StoreController extends StateNotifier { ); } + /// Write per-repo settings (SPEC-48). + /// + /// No optimistic local state on purpose — the server persists, re-broadcasts the + /// repos snapshot, and the page re-renders from that, so what is on screen is always + /// what the daemon actually stored. + /// + /// AWAITED, so a refusal is not silent. The server refuses a non-loopback client, an + /// invalid `worktreeRoot` and an invalid `defaultBranch` with an explicit `err` + /// frame, and its handler documents why: "A refusal is an explicit error, never a + /// silent no-op — a settings row that appears to save and does not is worse than one + /// that says it cannot." Sending fire-and-forget threw that frame away and produced + /// exactly the silent no-op the server took care to avoid. The caller shows the + /// message. + Future setRepoSettings( + String projectId, + Map settings, + ) async { + await _ref.read(connectionControllerProvider.notifier).request( + MsgType.cmd, + { + 'kind': 'repo.settings.set', + 'projectId': projectId, + 'settings': settings, + }, + ); + } + + /// Re-point a repository at a new root path (SPEC-48 D4'). + /// + /// A separate verb from [setRepoSettings] because it is not a setting: the server + /// re-validates that the target is a git repository and re-runs forge detection, + /// and it keeps the project's id so its settings and session history survive the + /// move. + /// + /// Awaited, unlike the settings writes: the refusals here are actionable ("not a + /// git repository", "already open as X") and the caller shows them, where a + /// fire-and-forget write would drop the only part the user can act on. + Future setRepoPath(String projectId, String path) async { + await _ref.read(connectionControllerProvider.notifier).request( + MsgType.cmd, + {'kind': 'repo.path.set', 'projectId': projectId, 'path': path}, + ); + } + /// Spawn a fresh agent session in the given project, in the worktree the /// caller already resolved (creating it first when the user asked for a new /// branch or a PR). Resolves with the new session id once the server acks. diff --git a/app/lib/ui/home/repo_monogram.dart b/app/lib/ui/home/repo_monogram.dart new file mode 100644 index 00000000..dfb503d9 --- /dev/null +++ b/app/lib/ui/home/repo_monogram.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; + +import '../../app/theme.dart'; + +/// A repository's stand-in mark: its initial on a hue derived from its name. +/// +/// Deterministic, so the same repo shows the same colour on every device with no +/// state to sync and nothing to configure — which is the point. A custom image is +/// a byte-transfer path with size and type validation, not a settings row, so it +/// is deliberately not offered here (SPEC-48 D14). +/// +/// Shared with the repo list rather than reimplemented there: two independently +/// drawn marks for the same repo drift in colour and letter, and the Settings +/// sidebar plus the repo-centric home are exactly two such places (SPEC-48 D15). +class RepoMonogram extends StatelessWidget { + const RepoMonogram({required this.name, this.size = 22, this.hue, super.key}); + + /// The repository name. Only its first character is drawn. + final String name; + + /// Edge length of the (square, rounded) mark. + final double size; + + /// Explicit palette index, when the user has chosen one. Null derives it from + /// the name — which is the default precisely because two repos sharing a hue is + /// the only reason to choose. + final int? hue; + + /// Hue for [name], stable across runs and devices. + /// + /// A plain character-sum rather than a cryptographic hash: the requirement is + /// determinism and spread across a small palette, not collision resistance. Two + /// repos sharing a hue is a cosmetic coincidence, and pretending otherwise + /// would mean testing a hash for a property that does not matter. + static Color hueFor(String name) { + if (name.isEmpty) return _palette.first; + var sum = 0; + for (final unit in name.codeUnits) { + sum = (sum + unit) % 0xFFFF; + } + return _palette[sum % _palette.length]; + } + + /// The glyph drawn for [name] — its first character, or `?` when there is none. + /// + /// Uses `characters`-free logic deliberately kept simple: a name beginning with + /// an emoji or a combining mark renders that first UTF-16 unit, which may be a + /// replacement glyph but never throws and never renders empty. + static String glyphFor(String name) { + final trimmed = name.trim(); + if (trimmed.isEmpty) return '?'; + final first = trimmed.substring(0, 1); + // Upper-case a multi-word name's initial ("Diana"), leave a lone lowercase + // project name as it is ("makit") — matching how the names actually read. + return trimmed.contains(RegExp(r'[A-Z]')) ? first.toUpperCase() : first; + } + + /// Palette entry [i], wrapped so a picker can show the choices without the + /// palette becoming public mutable state. + static Color paletteAt(int i) => _palette[i % _palette.length]; + + /// How many hues a picker may offer. + static int get paletteLength => _palette.length; + + static const List _palette = [ + Color(0xFF4ADE80), + Color(0xFFA371F7), + Color(0xFFE0A72E), + Color(0xFF38BDF8), + Color(0xFFE07B39), + Color(0xFFF472B6), + ]; + + @override + Widget build(BuildContext context) { + final tint = hue == null ? hueFor(name) : paletteAt(hue!); + return Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + color: tint, + borderRadius: BorderRadius.circular(kRadius6), + ), + child: Text( + glyphFor(name), + style: TextStyle( + // Dark ink on a saturated tile: the palette is light enough that white + // text on it fails contrast, which the mockup's dark-on-green shows. + color: const Color(0xFF0E0E0E), + fontSize: size * 0.5, + fontWeight: FontWeight.w700, + height: 1, + ), + ), + ); + } +} diff --git a/app/lib/ui/widgets/forge_glyph.dart b/app/lib/ui/widgets/forge_glyph.dart new file mode 100644 index 00000000..5950fddb --- /dev/null +++ b/app/lib/ui/widgets/forge_glyph.dart @@ -0,0 +1,87 @@ +import 'package:phosphoricons_flutter/phosphoricons_flutter.dart'; + +import 'icon_glyph.dart'; + +/// The Forgejo mark. Phosphor ships no forge logos, so this is an in-house SVG +/// drawn on Phosphor's 256 grid with the weight carried by stroke width — the +/// same construction as [kClosedPrAsset]. Light, because every glyph the app +/// renders is `PhosphorIconsLight`; a heavier sibling would read bolder than its +/// neighbours. +/// +/// Source of truth is the `phosphor_extras` repo; `scripts/sync-icons.sh` vendors +/// the built SVG here and `--check` fails if the copy drifts. +const kForgejoAsset = 'assets/icons/forgejo-light.svg'; + +/// The Gitea mark. Same construction and provenance as [kForgejoAsset]. +const kGiteaAsset = 'assets/icons/gitea-light.svg'; + +/// Which forge hosts a pull request. +enum ForgeKind { github, forgejo, gitea } + +/// Classify the forge for a pull-request URL, or null when nothing proves one. +/// +/// [detected] is the server's own answer (`RepoDTO.settings.forge.software`), and it +/// WINS when supplied: the daemon asks the instance what software it runs, which is +/// the only way to know. Pass it wherever the repo is in scope. +/// +/// Without it, only a host that is decisive on its own is claimed — `github.com`, +/// `gitea.com` and `codeberg.org` (Forgejo's own flagship instance). A self-hosted +/// host is left unnamed rather than guessed. +/// +/// This used to report every other host as Forgejo. That agreed with the router while +/// the router also guessed by hostname, but the router now probes the instance, so the +/// guess could contradict the provider that actually served the data — labelling a +/// self-hosted Gitea, or a GitLab remote, with Forgejo's name and mark. Null is the +/// same null-versus-zero rule the PR lookup follows on the server, and it is what this +/// widget's own contract already asked for: naming the wrong forge is worse than +/// naming none. +ForgeKind? forgeKindForUrl(String? url, {String? detected}) { + final fromServer = _kindFromSoftware(detected); + if (fromServer != null) return fromServer; + if (url == null || url.isEmpty) return null; + final uri = Uri.tryParse(url); + final host = uri?.host.toLowerCase(); + if (host == null || host.isEmpty) return null; + if (host == 'github.com' || host.endsWith('.github.com')) { + return ForgeKind.github; + } + if (host == 'gitea.com' || host.endsWith('.gitea.com')) { + return ForgeKind.gitea; + } + // Hosts that PROVE a forge on their own, the same way `github.com` does. Codeberg + // runs Forgejo (it is the project's own flagship instance), so naming it is a fact + // rather than the hostname guess this function used to make for every host. + if (host == 'codeberg.org' || host.endsWith('.codeberg.org')) { + return ForgeKind.forgejo; + } + return null; +} + +/// The server's `forge.software` string as a [ForgeKind]; null for anything the app +/// has no mark for (`gitlab`, `unknown`, or an absent value). +ForgeKind? _kindFromSoftware(String? software) => switch (software) { + 'github' => ForgeKind.github, + 'forgejo' => ForgeKind.forgejo, + 'gitea' => ForgeKind.gitea, + _ => null, +}; + +/// The glyph for [kind]. GitHub's mark ships in Phosphor; the other two do not. +IconGlyph forgeGlyphFor(ForgeKind kind) => switch (kind) { + ForgeKind.github => const IconGlyph.font(PhosphorIconsLight.githubLogo), + ForgeKind.forgejo => const IconGlyph.svg(kForgejoAsset), + ForgeKind.gitea => const IconGlyph.svg(kGiteaAsset), +}; + +/// How each project spells its own name, for user-facing copy. +String forgeNameFor(ForgeKind kind) => switch (kind) { + ForgeKind.github => 'GitHub', + ForgeKind.forgejo => 'Forgejo', + ForgeKind.gitea => 'Gitea', +}; + +/// The glyph for the forge hosting [url], or null when it cannot be identified. +IconGlyph? forgeGlyphForUrl(String? url) { + final kind = forgeKindForUrl(url); + return kind == null ? null : forgeGlyphFor(kind); +} diff --git a/app/lib/ui/widgets/pr_detail.dart b/app/lib/ui/widgets/pr_detail.dart index e55cb59f..0ce7645f 100644 --- a/app/lib/ui/widgets/pr_detail.dart +++ b/app/lib/ui/widgets/pr_detail.dart @@ -12,6 +12,7 @@ library; import 'package:flutter/material.dart'; +import 'forge_glyph.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:phosphoricons_flutter/phosphoricons_flutter.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -274,13 +275,10 @@ class PrDetailBody extends StatelessWidget { const SizedBox(height: kSpace8), Align( alignment: Alignment.centerLeft, - child: TextButton.icon( + child: _OpenOnForgeButton( + identity: status.identity, + url: pr!.url, onPressed: () => _open(context), - icon: const Icon( - PhosphorIconsLight.arrowSquareOut, - size: 16, - ), - label: Text('Open ${status.identity} on GitHub'), ), ), ], @@ -299,6 +297,40 @@ class PrDetailBody extends StatelessWidget { } } +/// "Open #42 on Forgejo" — the forge is named and badged from the PR's own URL. +/// +/// Before Forgejo support this said "on GitHub" unconditionally, which is now +/// simply wrong for a self-hosted PR. When the URL cannot be parsed the forge is +/// left unnamed rather than guessed, and the button falls back to the generic +/// external-link mark: naming the wrong forge is worse than naming none. +class _OpenOnForgeButton extends StatelessWidget { + const _OpenOnForgeButton({ + required this.identity, + required this.url, + required this.onPressed, + }); + + final String identity; + final String url; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final kind = forgeKindForUrl(url); + return TextButton.icon( + onPressed: onPressed, + icon: kind == null + ? const Icon(PhosphorIconsLight.arrowSquareOut, size: 16) + : forgeGlyphFor(kind).build(size: 16), + label: Text( + kind == null + ? 'Open $identity' + : 'Open $identity on ${forgeNameFor(kind)}', + ), + ); + } +} + /// Open [url] externally, reporting failure rather than doing nothing. Lives /// here because both the detail body and the desktop bar need it and neither /// should depend on the other. diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 44f59970..15e4cf61 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -110,7 +110,8 @@ flutter: # VS Code codicon glyphs (assets/fonts/codicon.ttf, CC-BY-4.0 — see # assets/fonts/codicon-LICENSE.txt). Only the comment-discussion glyph is # used (see lib/ui/widgets/codicons.dart); Push/Pull ship as PNGs under - # assets/icons/, alongside the closed-PR SVG (see ASSET_ATTRIBUTION.md). + # assets/icons/, alongside the in-house closed-PR / Forgejo / Gitea SVGs + # vendored by scripts/sync-icons.sh (see ASSET_ATTRIBUTION.md). - family: codicon fonts: - asset: assets/fonts/codicon.ttf diff --git a/app/test/desktop/settings/repo_settings_view_test.dart b/app/test/desktop/settings/repo_settings_view_test.dart new file mode 100644 index 00000000..933f156f --- /dev/null +++ b/app/test/desktop/settings/repo_settings_view_test.dart @@ -0,0 +1,232 @@ +// Mapping wire facts to rendered facts. Kept apart from the widget tests because +// this is where "the app is told, never derives" is actually enforced. +import 'package:flutter_test/flutter_test.dart'; +import 'package:makit/desktop/settings/sections/repository_section.dart'; +import 'package:makit/store/models.dart'; +import 'package:makit/ui/widgets/forge_glyph.dart'; + +RepoInfo _repo( + Map settings, { + List> worktrees = const [], +}) => RepoInfo.fromJson({ + 'id': 'p1', + 'name': 'Diana', + 'path': '/Users/le/Work/XDent/Diana', + 'pinned': true, + 'isGitRepo': true, + 'defaultBranch': 'main', + 'currentBranch': 'main', + 'worktrees': worktrees, + 'settings': settings, +})!; + +const _root = {'value': '/Users/le/.worktrees', 'source': 'default'}; + +void main() { + test( + 'a server that sends no settings yields no view, not fabricated defaults', + () { + final repo = RepoInfo.fromJson({ + 'id': 'p1', + 'name': 'Diana', + 'path': '/p', + 'pinned': true, + 'isGitRepo': true, + 'worktrees': const >[], + })!; + expect(repoSettingsViewFor(repo), isNull); + }, + ); + + group('the chosen logo hue (SPEC-48 D14\u2032)', () { + test( + 'a stored hue reaches the view, so the choice is not dropped in mapping', + () { + // Without this the whole row is ornamental: the write reaches the server, the + // server stores it, the DTO returns it \u2014 and the mapping throws it away, so the + // mark renders name-derived and identical to before the user chose. + final v = repoSettingsViewFor( + _repo({ + 'worktreeRoot': _root, + 'provider': {'value': 'auto', 'source': 'default'}, + 'hasRemote': true, + 'logoHue': 3, + }), + )!; + expect(v.logoHue, 3); + }, + ); + + test('no stored hue leaves it null, so the name still derives it', () { + // Null rather than 0: index 0 is a real palette entry, so defaulting to it would + // silently repaint every repo that has never chosen. + final v = repoSettingsViewFor( + _repo({ + 'worktreeRoot': _root, + 'provider': {'value': 'auto', 'source': 'default'}, + 'hasRemote': true, + }), + )!; + expect(v.logoHue, isNull); + }); + }); + + test('the effective worktree root and its source come across', () { + final v = repoSettingsViewFor( + _repo({ + 'worktreeRoot': {'value': '/Users/le/trees', 'source': 'override'}, + 'provider': {'value': 'auto', 'source': 'default'}, + 'hasRemote': true, + }), + )!; + expect(v.worktreeRoot, '/Users/le/trees'); + expect(v.worktreeRootOverridden, isTrue); + }); + + test('environment-sourced values are NOT shown as overrides', () { + // MAKIT_WORKTREE_DIR is inherited, so it must not offer a reset the app cannot + // honour — it cannot change the daemon's environment. + final v = repoSettingsViewFor( + _repo({ + 'worktreeRoot': {'value': '/env/trees', 'source': 'environment'}, + 'provider': {'value': 'auto', 'source': 'default'}, + 'hasRemote': true, + }), + )!; + expect(v.worktreeRoot, '/env/trees'); + expect(v.worktreeRootOverridden, isFalse); + }); + + test('a forge we cannot talk to maps to no glyph, not to a wrong one', () { + for (final software in ['gitlab', 'unknown', 'bitbucket']) { + final v = repoSettingsViewFor( + _repo({ + 'worktreeRoot': _root, + 'provider': {'value': 'auto', 'source': 'default'}, + 'hasRemote': true, + 'forge': {'software': software, 'host': 'h'}, + }), + )!; + expect(v.forge, isNull, reason: software); + expect( + v.forgeHost, + 'h', + reason: 'the host still shows, so the row can name it', + ); + } + }); + + test('each known forge maps to its glyph', () { + for (final pair in const [ + ['github', ForgeKind.github], + ['forgejo', ForgeKind.forgejo], + ['gitea', ForgeKind.gitea], + ]) { + final v = repoSettingsViewFor( + _repo({ + 'worktreeRoot': _root, + 'provider': {'value': 'auto', 'source': 'default'}, + 'hasRemote': true, + 'forge': {'software': pair[0], 'host': 'h', 'authed': true}, + }), + )!; + expect(v.forge, pair[1]); + expect(v.forgeAuthed, isTrue); + } + }); + + test('no remote and no detection are DIFFERENT states', () { + final noRemote = repoSettingsViewFor( + _repo({ + 'worktreeRoot': _root, + 'provider': {'value': 'auto', 'source': 'default'}, + 'hasRemote': false, + }), + )!; + final pending = repoSettingsViewFor( + _repo({ + 'worktreeRoot': _root, + 'provider': {'value': 'auto', 'source': 'default'}, + 'hasRemote': true, + }), + )!; + expect(noRemote.hasRemote, isFalse); + expect(pending.hasRemote, isTrue); + expect(noRemote.forge, isNull); + expect(pending.forge, isNull); + }); + + test( + 'the provider choice round-trips, unknown wire values falling back to auto', + () { + ForgeChoice choiceFor(String wire) => repoSettingsViewFor( + _repo({ + 'worktreeRoot': _root, + 'provider': {'value': wire, 'source': 'override'}, + 'hasRemote': true, + }), + )!.providerChoice; + expect(choiceFor('none'), ForgeChoice.none); + expect(choiceFor('forgejo'), ForgeChoice.forgejo); + expect(choiceFor('gitea'), ForgeChoice.gitea); + expect(choiceFor('github'), ForgeChoice.github); + // A newer server sending a provider this build has never heard of must not + // crash the settings page. + expect(choiceFor('mercurial-hub'), ForgeChoice.auto); + }, + ); + + test('an override wins over git for the default branch', () { + final v = repoSettingsViewFor( + _repo({ + 'worktreeRoot': _root, + 'provider': {'value': 'auto', 'source': 'default'}, + 'hasRemote': true, + 'defaultBranch': {'value': 'develop', 'source': 'override'}, + }), + )!; + expect(v.defaultBranch, 'develop'); + }); + + test('with no override the branch comes from git, not from settings', () { + final v = repoSettingsViewFor( + _repo({ + 'worktreeRoot': _root, + 'provider': {'value': 'auto', 'source': 'default'}, + 'hasRemote': true, + }), + )!; + expect(v.defaultBranch, 'main'); + }); + + test('branches are offered from the repo itself, deduped and sorted', () { + final v = repoSettingsViewFor( + _repo( + { + 'worktreeRoot': _root, + 'provider': {'value': 'auto', 'source': 'default'}, + 'hasRemote': true, + }, + worktrees: [ + {'id': 'w1', 'path': '/a', 'branch': 'feat/z'}, + {'id': 'w2', 'path': '/b', 'branch': 'main'}, + {'id': 'w3', 'path': '/c', 'branch': 'feat/z'}, + {'id': 'w4', 'path': '/d'}, + ], + ), + )!; + expect(v.branches, ['feat/z', 'main']); + }); + + test('a malformed settings object yields no view rather than throwing', () { + for (final bad in [ + {}, + {'worktreeRoot': 7}, + { + 'worktreeRoot': {'value': 1}, + }, + ]) { + expect(repoSettingsViewFor(_repo(bad)), isNull, reason: bad.toString()); + } + }); +} diff --git a/app/test/desktop/settings/repository_section_test.dart b/app/test/desktop/settings/repository_section_test.dart new file mode 100644 index 00000000..cdbf273e --- /dev/null +++ b/app/test/desktop/settings/repository_section_test.dart @@ -0,0 +1,507 @@ +// The per-repo Settings section: what each DTO state renders, and that the +// controls are wired rather than decorative. +// +// Interaction is asserted HERE rather than on the real app on purpose. Driving the +// built macOS app with cua-driver's synthesized clicks did not work — both a +// sidebar row and a 107x28pt segmented-button segment reported +// `"effect":"unverifiable"` and left the UI unchanged — so the real-app pass covers +// appearance only and these tests cover behaviour. That split is a stated coverage +// gap, not an accident. +import 'dart:io' show Platform; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:makit/app/theme.dart'; +import 'package:makit/desktop/settings/sections/repository_section.dart'; +import 'package:makit/ui/home/repo_monogram.dart'; +import 'package:makit/ui/widgets/forge_glyph.dart'; + +/// The real home directory, because `_tilde` reads `HOME` at runtime. Hardcoding +/// `/Users/le` made every abbreviation assertion pass only on one machine -- and this +/// suite runs on the Linux VM, where `HOME` is never that. +final String _home = Platform.environment['HOME'] ?? '/root'; + +final _base = RepoSettingsView( + name: 'Diana', + path: '$_home/Work/XDent/Diana', + worktreeRoot: '$_home/.worktrees', + defaultBranch: 'main', + forge: ForgeKind.forgejo, + forgeHost: 'forgejo.internal.xdent.ai', + forgeAuthed: true, + branches: const ['main', 'develop'], +); + +RepoSettingsView _view({ + ForgeKind? forge = ForgeKind.forgejo, + String? forgeHost = 'forgejo.internal.xdent.ai', + bool forgeAuthed = true, + bool worktreeRootOverridden = false, + bool editable = true, + ForgeChoice providerChoice = ForgeChoice.auto, + List branches = const ['main', 'develop'], + String? defaultBranch = 'main', + bool hasRemote = true, + int? logoHue, + String? path, + bool defaultBranchOverridden = false, +}) => RepoSettingsView( + name: _base.name, + path: path ?? _base.path, + worktreeRoot: _base.worktreeRoot, + defaultBranch: defaultBranch, + forge: forge, + forgeHost: forgeHost, + forgeAuthed: forgeAuthed, + worktreeRootOverridden: worktreeRootOverridden, + editable: editable, + providerChoice: providerChoice, + branches: branches, + hasRemote: hasRemote, + logoHue: logoHue, + defaultBranchOverridden: defaultBranchOverridden, +); + +Future _pump( + WidgetTester tester, + RepoSettingsView view, { + ValueChanged? onChoose, + VoidCallback? onReset, + VoidCallback? onBranch, + VoidCallback? onLogo, + VoidCallback? onRootPath, +}) async { + await tester.pumpWidget( + MaterialApp( + theme: makitDarkTheme, + home: Scaffold( + body: RepositorySettingsSection( + view: view, + onChooseProvider: onChoose, + onResetWorktreeRoot: onReset, + onChooseDefaultBranch: onBranch, + onEditLogo: onLogo, + onChangeRootPath: onRootPath, + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +/// The colour the monogram actually paints, read off its `BoxDecoration` — the +/// only thing that proves what the user sees. +Color? _paintedHue(WidgetTester t) { + final container = t.widget( + find + .descendant( + of: find.byType(RepoMonogram), + matching: find.byType(Container), + ) + .first, + ); + return (container.decoration as BoxDecoration?)?.color; +} + +void main() { + group('what it renders', () { + testWidgets('the repo name is the page title, above the group headers', ( + t, + ) async { + await _pump(t, _view()); + expect(find.text('DIANA'), findsOneWidget); + expect(find.text('IDENTITY'), findsOneWidget); + expect(find.text('WORKTREES'), findsOneWidget); + }); + + testWidgets( + 'paths are home-abbreviated, so the column is not spent on /Users/le', + (t) async { + await _pump(t, _view()); + expect(find.text('~/Work/XDent/Diana'), findsOneWidget); + expect(find.text('$_home/Work/XDent/Diana'), findsNothing); + }, + ); + + testWidgets( + 'an unprobed repo omits the provider name but still offers the choice', + (t) async { + // `forge == null` means "not measured yet", never "no forge" — routing only + // happens when a PR operation runs, so a quiet repo genuinely may not know. + await _pump(t, _view(forge: null, forgeHost: null)); + expect(find.text('Auto: not identified yet'), findsOneWidget); + expect( + find.text('Forgejo'), + findsOneWidget, + reason: 'the segment, not a value', + ); + expect(find.byType(SegmentedButton), findsOneWidget); + }, + ); + + testWidgets( + 'Auto names what it resolved to, so the default is not mysterious', + (t) async { + await _pump(t, _view()); + expect( + find.text('Auto: Forgejo · forgejo.internal.xdent.ai · token set'), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'an unauthenticated instance says so rather than implying a token', + (t) async { + await _pump(t, _view(forgeAuthed: false)); + expect(find.textContaining('no token'), findsOneWidget); + }, + ); + + testWidgets('provenance badges are absent — the row already says it', ( + t, + ) async { + // Pinned as a negative: `from name` beside a monogram, `from remote` beside + // `main`, and `detected` beside a subtitle reading "Auto: Forgejo …" are all + // the same sentence twice. Only resolution state earns a badge. + await _pump(t, _view()); + expect(find.text('from name'), findsNothing); + expect(find.text('from remote'), findsNothing); + expect(find.text('detected'), findsNothing); + expect(find.byTooltip('Copy path'), findsNothing); + // …and the one badge that carries state is still there. + expect(find.text('inherited'), findsOneWidget); + }); + }); + + group('no forge at all', () { + testWidgets('None is offered beside the three forges', (t) async { + await _pump(t, _view()); + expect(find.text('None'), findsOneWidget); + }); + + testWidgets('choosing None reports it', (t) async { + final chosen = []; + await _pump(t, _view(), onChoose: chosen.add); + await t.tap(find.text('None')); + await t.pumpAndSettle(); + expect(chosen, [ForgeChoice.none]); + }); + + testWidgets( + 'None says what it means for polling, not just that it is set', + (t) async { + await _pump(t, _view(providerChoice: ForgeChoice.none)); + expect( + find.text( + 'No forge · pull requests are not checked for this repository', + ), + findsOneWidget, + ); + }, + ); + + testWidgets('a repo with no remote is a conclusion, not a pending probe', ( + t, + ) async { + // These two states must NOT read the same: one has an answer, the other is + // waiting for one, and only the second is worth looking into. + await _pump(t, _view(forge: null, forgeHost: null, hasRemote: false)); + expect(find.text('Auto: no remote, so no forge'), findsOneWidget); + expect(find.text('Auto: not identified yet'), findsNothing); + }); + + testWidgets( + 'an unprobed repo WITH a remote still says a probe is pending', + (t) async { + await _pump(t, _view(forge: null, forgeHost: null)); + expect(find.text('Auto: not identified yet'), findsOneWidget); + }, + ); + + testWidgets('None is an override, so it offers a way back to Auto', ( + t, + ) async { + final chosen = []; + await _pump( + t, + _view(providerChoice: ForgeChoice.none), + onChoose: chosen.add, + ); + await t.tap(find.byTooltip('Reset to default').first); + await t.pumpAndSettle(); + expect(chosen, [ForgeChoice.auto]); + }); + }); + + group('inheritance', () { + testWidgets('inherited shows no reset button', (t) async { + await _pump(t, _view()); + expect(find.text('inherited'), findsOneWidget); + expect(find.byTooltip('Reset to default'), findsNothing); + }); + + testWidgets('overridden shows the badge AND the reset button', (t) async { + await _pump(t, _view(worktreeRootOverridden: true)); + expect(find.text('overridden'), findsOneWidget); + expect(find.byTooltip('Reset to default'), findsOneWidget); + }); + + testWidgets('only the row with no subtitle badges its override', (t) async { + await _pump( + t, + _view(worktreeRootOverridden: true, providerChoice: ForgeChoice.gitea), + ); + // Two things are overridden, but only Worktree root wears the chip; the + // provider says it in prose. Two resets, one badge. + expect(find.text('overridden'), findsOneWidget); + expect(find.byTooltip('Reset to default'), findsNWidgets(2)); + }); + + testWidgets('reset calls back — it does not silently do nothing', ( + t, + ) async { + var reset = 0; + await _pump( + t, + _view(worktreeRootOverridden: true), + onReset: () => reset++, + ); + await t.tap(find.byTooltip('Reset to default').first); + await t.pumpAndSettle(); + expect(reset, 1); + }); + }); + + group('the provider selector is wired', () { + testWidgets('choosing a forge reports that choice', (t) async { + final chosen = []; + await _pump(t, _view(), onChoose: chosen.add); + await t.tap(find.text('Gitea')); + await t.pumpAndSettle(); + expect(chosen, [ForgeChoice.gitea]); + }); + + testWidgets('an override relabels the row and offers a way back to Auto', ( + t, + ) async { + await _pump(t, _view(providerChoice: ForgeChoice.gitea)); + expect(find.textContaining('Set to Gitea'), findsOneWidget); + // No badge: the subtitle already says it. The reset button is the only + // trailing element, because it is an action rather than a restatement — and + // it returns to Auto rather than freezing today's detected value. + expect(find.byTooltip('Reset to default'), findsOneWidget); + }); + + testWidgets( + 'resetting the provider asks for Auto, not for the detected forge', + (t) async { + final chosen = []; + await _pump( + t, + _view(providerChoice: ForgeChoice.gitea), + onChoose: chosen.add, + ); + await t.tap(find.byTooltip('Reset to default').first); + await t.pumpAndSettle(); + expect(chosen, [ForgeChoice.auto]); + }, + ); + }); + + group('read-only clients (D16: only loopback may write)', () { + testWidgets('a non-editable view offers no selector and no reset', ( + t, + ) async { + final chosen = []; + await _pump( + t, + _view( + editable: false, + worktreeRootOverridden: true, + providerChoice: ForgeChoice.gitea, + ), + onChoose: chosen.add, + ); + expect(find.byTooltip('Reset to default'), findsNothing); + // The control is present but inert: tapping must not report a choice. + await t.tap(find.text('Forgejo')); + await t.pumpAndSettle(); + expect(chosen, isEmpty); + expect( + find.textContaining('editable on the machine running makit'), + findsOneWidget, + ); + }); + }); + + group('the chosen logo is actually drawn (SPEC-48 D14\u2032)', () { + // The reason the row exists: two repos whose names hash to the same hue are + // indistinguishable, which defeats the one job the monogram has. A choice that + // does not change the mark leaves that defeat in place AND adds a control that + // lies about having fixed it. + testWidgets('a chosen hue overrides the name-derived one', (t) async { + // The index is DERIVED, not hardcoded: 'Diana' happens to hash to 3, so a + // hardcoded 3 asserted nothing. Picking the first index that differs keeps the + // test honest if either the palette or the hash changes. + final derived = RepoMonogram.hueFor(_base.name); + final chosen = List.generate(RepoMonogram.paletteLength, (i) => i) + .firstWhere( + (i) => RepoMonogram.paletteAt(i) != derived, + // Not reachable with the current palette, but a cryptic "Bad state: No + // element" would send the next reader hunting in the wrong file. + orElse: () => + fail('every palette entry equals the derived hue $derived'), + ); + await _pump(t, _view(logoHue: chosen)); + expect(_paintedHue(t), RepoMonogram.paletteAt(chosen)); + expect(_paintedHue(t), isNot(derived)); + }); + + testWidgets('with no choice the mark PAINTS the name-derived hue', ( + t, + ) async { + // Asserting `hue == null` alone proved nothing: it holds whatever the widget + // then paints, so a hardcoded fallback colour inside build() would pass. The + // assertion has to be on the pixel decision, not on the input that informs it. + await _pump(t, _view()); + expect(t.widget(find.byType(RepoMonogram)).hue, isNull); + expect(_paintedHue(t), RepoMonogram.hueFor(_base.name)); + }); + }); + + group('the default-branch row survives the case it exists for', () { + testWidgets('an unresolved default branch still offers the picker', ( + t, + ) async { + // The row was rendered only when `defaultBranch != null`, but that is exactly + // null when git reported no `origin/HEAD` and no override has been set -- the + // `--single-branch` clone and the renamed-default case the row was built for. + // The user could not reach the picker precisely when they needed it. + await _pump(t, _view(defaultBranch: null)); + expect(find.text('Default branch'), findsOneWidget); + }); + + testWidgets('...and says so rather than showing a blank value', (t) async { + await _pump(t, _view(defaultBranch: null)); + expect(find.text('Not detected'), findsOneWidget); + }); + + testWidgets('it is tappable in that state, since branches are known', ( + t, + ) async { + var taps = 0; + await _pump(t, _view(defaultBranch: null), onBranch: () => taps++); + await t.tap(find.text('Default branch')); + await t.pumpAndSettle(); + expect(taps, 1); + }); + + testWidgets( + 'with neither a branch nor anything to pick, it stays read-only', + (t) async { + var taps = 0; + await _pump( + t, + _view(defaultBranch: null, branches: const []), + onBranch: () => taps++, + ); + await t.tap(find.text('Default branch')); + await t.pumpAndSettle(); + expect(taps, 0); + }, + ); + }); + + group('home abbreviation', () { + testWidgets('a sibling directory sharing the home prefix is NOT abbreviated', ( + t, + ) async { + // `path.startsWith(home)` matched `/Users/leduck/x` when HOME was `/Users/le`, + // rendering `~duck/x` -- not a valid path, and the wrong repository. + final sibling = '${_home}x/Work/Thing'; + await _pump(t, _view(path: sibling)); + expect(find.text(sibling), findsOneWidget, reason: 'shown in full'); + // The specific corruption, not any tilde: the worktree-root row legitimately + // shows `~/.worktrees`, so a blanket "no tilde anywhere" assertion was wrong. + expect(find.text('~x/Work/Thing'), findsNothing); + }); + + testWidgets('the home directory itself abbreviates to ~', (t) async { + await _pump(t, _view(path: _home)); + expect(find.text('~'), findsOneWidget); + }); + }); + + group('editability of the identity rows', () { + testWidgets('the logo row is tappable', (t) async { + var taps = 0; + await _pump(t, _view(), onLogo: () => taps++); + await t.tap(find.text('Logo')); + await t.pumpAndSettle(); + expect(taps, 1); + }); + + testWidgets('the root path row opens the re-point flow (SPEC-48 D4\u2032)', ( + t, + ) async { + // It used to show a "not supported yet" notice. The row is only honest once it + // actually re-points, because the alternative it recommended — remove and + // re-add — loses the project id and everything keyed to it. + var taps = 0; + await _pump(t, _view(), onRootPath: () => taps++); + await t.tap(find.text('Root path')); + await t.pumpAndSettle(); + expect(taps, 1); + }); + + testWidgets('a read-only client cannot re-point the repository', (t) async { + // D16 governs every write, and this is the most consequential one: it names the + // directory every session's git commands run in. + var taps = 0; + await _pump(t, _view(editable: false), onRootPath: () => taps++); + await t.tap(find.text('Root path')); + await t.pumpAndSettle(); + expect(taps, 0); + }); + + testWidgets('default branch is pickable when there are branches to pick', ( + t, + ) async { + var taps = 0; + await _pump(t, _view(), onBranch: () => taps++); + await t.tap(find.text('Default branch')); + await t.pumpAndSettle(); + expect(taps, 1); + }); + + testWidgets( + 'with nothing to pick and nothing to clear, it stays read-only', + (t) async { + var taps = 0; + await _pump(t, _view(branches: const []), onBranch: () => taps++); + await t.tap(find.text('Default branch')); + await t.pumpAndSettle(); + expect(taps, 0); + }, + ); + + testWidgets('an override with no branches listed can still be CLEARED', ( + t, + ) async { + // Otherwise the override is permanent: the row was disabled AND the picker + // returned early, so a repo whose branches cannot be enumerated -- an empty + // repo, or one whose branch went away -- kept a stored override with no way + // back. A control that can set a value must be able to unset it. + var taps = 0; + await _pump( + t, + _view(branches: const [], defaultBranchOverridden: true), + onBranch: () => taps++, + ); + await t.tap(find.text('Default branch')); + await t.pumpAndSettle(); + expect(taps, 1); + }); + }); +} diff --git a/app/test/desktop/settings/settings_nav_repo_search_test.dart b/app/test/desktop/settings/settings_nav_repo_search_test.dart new file mode 100644 index 00000000..b6b5ad5d --- /dev/null +++ b/app/test/desktop/settings/settings_nav_repo_search_test.dart @@ -0,0 +1,63 @@ +// The nav pane must search the sections it was GIVEN, not the static list. +// Without this the generated repo items exist but are unreachable, which looks +// identical to not generating them at all. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:makit/app/theme.dart'; +import 'package:makit/desktop/settings/registry/settings_registry.dart'; +import 'package:makit/desktop/settings/settings_nav_pane.dart'; +import 'package:makit/store/models.dart'; + +void main() { + testWidgets('searching a repo-only term shows the result under the repo name', ( + t, + ) async { + final repo = RepoInfo.fromJson({ + 'id': 'a', + 'name': 'Diana', + 'path': '/p/Diana', + 'pinned': true, + 'isGitRepo': true, + 'worktrees': const >[], + })!; + final sections = sectionsFor([repo]); + // Owned by the test, so the test disposes it: an undisposed ChangeNotifier is + // what Flutter's leak tracking reports. + final controller = TextEditingController(text: 'worktree root'); + addTearDown(controller.dispose); + + await t.pumpWidget( + MaterialApp( + theme: makitDarkTheme, + home: Scaffold( + body: SizedBox( + width: 300, + height: 700, + child: SettingsNavPane( + sections: sections, + selectedId: sections.first.id, + // A term only a repo section knows: nothing in the static list + // mentions a worktree root. + query: 'worktree root', + controller: controller, + onQueryChanged: (_) {}, + onSelect: (_, {String? targetItemId}) {}, + onSelectResult: (_, _) {}, + onClose: () {}, + ), + ), + ), + ), + ); + await t.pumpAndSettle(); + + expect( + find.text('No matches'), + findsNothing, + reason: 'the repo item must be found', + ); + // Rendered under the repo's NAME, not its raw `repo:` section id. + expect(find.text('Diana'), findsWidgets); + expect(find.textContaining('repo:a'), findsNothing); + }); +} diff --git a/app/test/desktop/settings/settings_registry_repos_test.dart b/app/test/desktop/settings/settings_registry_repos_test.dart new file mode 100644 index 00000000..7583d541 --- /dev/null +++ b/app/test/desktop/settings/settings_registry_repos_test.dart @@ -0,0 +1,113 @@ +// The settings taxonomy becomes a function of the repo list (SPEC-48 D1/D21). +// Before this it was a static `final List`, so a per-repo section was impossible. +import 'package:flutter_test/flutter_test.dart'; +import 'package:makit/desktop/settings/registry/settings_registry.dart'; +import 'package:makit/store/models.dart'; +import 'package:makit/ui/home/repo_monogram.dart'; + +RepoInfo _repo(String id, String name, {bool pinned = true, int? logoHue}) => + RepoInfo.fromJson({ + 'id': id, + 'name': name, + 'path': '/p/$name', + 'pinned': pinned, + 'isGitRepo': true, + 'worktrees': const >[], + if (logoHue != null) + 'settings': { + 'worktreeRoot': {'value': '/w', 'source': 'default'}, + 'provider': {'value': 'auto', 'source': 'default'}, + 'hasRemote': true, + 'logoHue': logoHue, + }, + })!; + +void main() { + test('the fixed sections are unchanged and come first', () { + final s = sectionsFor([_repo('a', 'Diana')]); + expect( + s.take(kSettingsSections.length).map((x) => x.id), + kSettingsSections.map((x) => x.id), + ); + }); + + test('one section per PINNED repo; unpinned ones stay out of the sidebar', () { + // This is what bounds the sidebar. Without the filter it grows into a file + // browser as makit notices repos. + final s = sectionsFor([ + _repo('a', 'Diana'), + _repo('b', 'noticed', pinned: false), + _repo('c', 'makit'), + ]); + final ids = s.map((x) => x.id).where(isRepoSection).toList(); + expect(ids, ['repo:a', 'repo:c']); + }); + + test('no repos means no repo sections, and no empty group', () { + final s = sectionsFor(const []); + expect(s.where((x) => isRepoSection(x.id)), isEmpty); + expect(s.length, kSettingsSections.length); + }); + + test('the section id is keyed off the persisted id, not the name or path', () { + // So a repo that is renamed or moved keeps its section and its deep links. + final s = sectionsFor([_repo('abc-123', 'Diana')]); + expect(s.last.id, 'repo:abc-123'); + expect(s.last.title, 'Diana'); + }); + + test('repo rows are searchable, and their titles are not raw ids', () { + final sections = sectionsFor([_repo('a', 'Diana')]); + final hits = searchSettings('worktree root', sections: sections); + expect(hits.any((h) => h.sectionId == 'repo:a'), isTrue); + // The title the nav pane shows for the owning section. + final titles = {for (final s in sections) s.id: s.title}; + expect(titles['repo:a'], 'Diana'); + }); + + test('searching something only a repo section knows finds it', () { + final sections = sectionsFor([_repo('a', 'Diana')]); + expect(searchSettings('forge', sections: sections), isNotEmpty); + // …and the static list alone does not, which is why threading matters. + expect( + searchSettings('worktree root', sections: kSettingsSections), + isEmpty, + ); + }); + + group('the sidebar tells the repos apart (SPEC-48 D15)', () { + test( + 'a repo section leads with its own mark, not a folder every repo shares', + () { + // A folder glyph on every row makes the sections indistinguishable in exactly + // the place the monogram exists to disambiguate \u2014 and it is where the user + // looks after choosing a colour, so the choice appears to have done nothing. + final section = sectionsFor([ + _repo('a', 'Diana'), + ]).firstWhere((x) => x.id == 'repo:a'); + expect(section.leading, isA()); + expect((section.leading! as RepoMonogram).name, 'Diana'); + }, + ); + + test( + 'the mark carries the chosen hue, so the sidebar shows the choice', + () { + final section = sectionsFor([ + _repo('a', 'Diana', logoHue: 2), + ]).firstWhere((x) => x.id == 'repo:a'); + expect((section.leading! as RepoMonogram).hue, 2); + }, + ); + + test('an app section has no mark, so the icon still renders', () { + expect(sectionsFor(const []).first.leading, isNull); + }); + }); + + test('isRepoSection distinguishes repo ids from app ones', () { + expect(isRepoSection('repo:a'), isTrue); + expect(isRepoSection('general'), isFalse); + expect(repoSectionId('x'), 'repo:x'); + }); +} diff --git a/app/test/desktop/settings/settings_window_test.dart b/app/test/desktop/settings/settings_window_test.dart index 11ef27a7..f3b0f970 100644 --- a/app/test/desktop/settings/settings_window_test.dart +++ b/app/test/desktop/settings/settings_window_test.dart @@ -11,6 +11,8 @@ import 'package:makit/store/prefs/preferences_providers.dart'; import 'package:makit/desktop/settings/sections/appearance_section.dart'; import 'package:makit/desktop/settings/server_config.dart'; import 'package:makit/desktop/settings/settings_item_anchor.dart'; +import 'package:makit/desktop/settings/registry/settings_registry.dart'; +import 'package:makit/desktop/settings/settings_nav_pane.dart'; import 'package:makit/desktop/settings/settings_window.dart'; import 'package:makit/store/connection.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -113,6 +115,28 @@ void main() { expect(find.byType(AppearanceSection), findsOneWidget); }); + testWidgets('a stored repo section that is gone leaves the sidebar in step', ( + tester, + ) async { + // A repo section is only offered while its repo is pinned and present. When the + // stored `repo:` is unavailable the detail pane falls back to the first + // section, but `_selectedId` still held the missing id, so `SettingsNavPane` + // matched no row and the window looked like it had lost its place. + final controller = PreferencesController.ephemeral(); + await controller.set(lastSectionPreference, 'repo:gone'); + await tester.pumpWidget( + _wrap(SettingsWindow(onClose: () {}), controller: controller), + ); + + final nav = tester.widget(find.byType(SettingsNavPane)); + expect(nav.selectedId, isNot('repo:gone')); + expect( + nav.selectedId, + kSettingsSections.first.id, + reason: 'the highlighted row is the section actually rendered', + ); + }); + testWidgets('selecting a section persists settings.lastSection', ( tester, ) async { diff --git a/app/test/ui/session/session_pr_test.dart b/app/test/ui/session/session_pr_test.dart index e667badc..f1aecce3 100644 --- a/app/test/ui/session/session_pr_test.dart +++ b/app/test/ui/session/session_pr_test.dart @@ -6,6 +6,7 @@ // how the sheet is ordered, and that a picked prompt reaches the composer unsent. import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:url_launcher_platform_interface/link.dart'; import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; @@ -234,6 +235,33 @@ void main() { expect(icon.color, fg, reason: 'the icon must match the label'); }); + testWidgets('names and badges the forge from the PR url, not GitHub always', ( + tester, + ) async { + // Before Forgejo support this button said "on GitHub" for every PR, which + // is wrong for a self-hosted one. The forge is read off the PR's own URL. + await _pumpSheet( + tester, + pr: _pr(url: 'https://codeberg.org/o/r/pulls/42'), + ); + + expect(find.text('Open #42 on Forgejo'), findsOneWidget); + expect(find.text('Open #42 on GitHub'), findsNothing); + // Phosphor ships no Forgejo mark, so it renders as our vendored SVG. + expect( + find.byWidgetPredicate( + (w) => w is SvgPicture, + description: 'the Forgejo SVG glyph', + ), + findsWidgets, + ); + }); + + testWidgets('a GitHub PR still says GitHub', (tester) async { + await _pumpSheet(tester, pr: _pr()); + expect(find.text('Open #42 on GitHub'), findsOneWidget); + }); + testWidgets('reports a PR url the platform declines to open', ( tester, ) async { @@ -267,7 +295,10 @@ void main() { status: center, ); - await tester.tap(find.text('Open #42 on GitHub')); + // No forge name: an unparseable URL identifies no forge, and the button + // says "Open #42" rather than claiming a forge it could not read. The + // GitHub-URL cases above still read "on GitHub". + await tester.tap(find.text('Open #42')); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); diff --git a/app/test/ui/widgets/forge_glyph_test.dart b/app/test/ui/widgets/forge_glyph_test.dart new file mode 100644 index 00000000..c0fc0645 --- /dev/null +++ b/app/test/ui/widgets/forge_glyph_test.dart @@ -0,0 +1,153 @@ +// Which forge hosts a pull request, derived from its URL. Pure Dart — no +// widgets — so the classification rules are pinned here and the widget layer +// only has to prove the glyph is rendered. +import 'package:flutter_test/flutter_test.dart'; +import 'package:makit/ui/widgets/forge_glyph.dart'; + +void main() { + group('forgeKindForUrl', () { + test('recognises github.com and its subdomains', () { + expect( + forgeKindForUrl('https://github.com/acme/app/pull/42'), + ForgeKind.github, + ); + expect( + forgeKindForUrl('https://www.github.com/acme/app/pull/42'), + ForgeKind.github, + ); + }); + + test('is not fooled by a lookalike host', () { + // Mirrors the server router's isGitHubHost: a suffix test alone would + // classify an attacker-controlled host as GitHub. It is now unnamed rather + // than named as some other forge. + expect( + forgeKindForUrl('https://github.com.evil.test/a/b/pull/1'), + isNull, + ); + expect(forgeKindForUrl('https://notgithub.com/a/b/pull/1'), isNull); + }); + + test('recognises gitea.com explicitly', () { + expect( + forgeKindForUrl('https://gitea.com/gitea/tea/pulls/1'), + ForgeKind.gitea, + ); + }); + + test('codeberg.org proves Forgejo, like gitea.com proves Gitea', () { + // A decisive host, not a guess: Codeberg is the Forgejo project's own instance. + expect( + forgeKindForUrl('https://codeberg.org/forgejo/forgejo/pulls/1'), + ForgeKind.forgejo, + ); + }); + + test('an unidentifiable host is left UNNAMED, not guessed', () { + // It used to report every non-GitHub host as Forgejo, which agreed with the + // router while the router also guessed by hostname. The router now probes the + // instance, so the guess could contradict the provider that served the data -- + // putting Forgejo's name and mark on a self-hosted Gitea or a GitLab remote. + expect( + forgeKindForUrl('https://git.example.com:3000/a/b/pulls/1'), + isNull, + ); + }); + + test("the server's detected software wins over the URL", () { + // The daemon asks the instance what it runs, which is the only way to know. + expect( + forgeKindForUrl( + 'https://git.example.com/a/b/pulls/1', + detected: 'forgejo', + ), + ForgeKind.forgejo, + ); + expect( + forgeKindForUrl( + 'https://git.example.com/a/b/pulls/1', + detected: 'gitea', + ), + ForgeKind.gitea, + ); + }); + + test('a forge the app has no mark for stays unnamed', () { + expect( + forgeKindForUrl('https://gl.example.com/a/b/-/1', detected: 'gitlab'), + isNull, + ); + expect( + forgeKindForUrl('https://x.example.com/a/b/1', detected: 'unknown'), + isNull, + ); + }); + + test('returns null when there is no URL to classify', () { + // Null, not a default glyph: claiming a forge we did not measure would put + // a wrong logo beside a PR. + expect(forgeKindForUrl(null), isNull); + expect(forgeKindForUrl(''), isNull); + expect(forgeKindForUrl('not a url'), isNull); + expect(forgeKindForUrl('/relative/path'), isNull); + }); + }); + + group('forgeGlyphFor', () { + test('every kind has a glyph', () { + for (final kind in ForgeKind.values) { + expect(forgeGlyphFor(kind), isNotNull, reason: '$kind has no glyph'); + } + }); + + test('GitHub uses the Phosphor font glyph, the others use our SVGs', () { + // GitHub's mark ships in Phosphor; Forgejo's and Gitea's do not, hence the + // in-house SVGs on the same 256 grid. + expect( + forgeGlyphFor(ForgeKind.forgejo), + forgeGlyphFor(ForgeKind.forgejo), + ); + expect( + forgeGlyphFor(ForgeKind.forgejo) == forgeGlyphFor(ForgeKind.gitea), + isFalse, + ); + expect( + forgeGlyphFor(ForgeKind.github) == forgeGlyphFor(ForgeKind.forgejo), + isFalse, + ); + }); + }); + + group('forgeNameFor', () { + test('names each forge as that project spells it', () { + expect(forgeNameFor(ForgeKind.github), 'GitHub'); + expect(forgeNameFor(ForgeKind.forgejo), 'Forgejo'); + expect(forgeNameFor(ForgeKind.gitea), 'Gitea'); + }); + }); + + group('forgeGlyphForUrl', () { + test('composes classification and glyph lookup', () { + expect( + forgeGlyphForUrl('https://gitea.com/a/b/pulls/1'), + forgeGlyphFor(ForgeKind.gitea), + ); + expect(forgeGlyphForUrl(null), isNull); + // An unidentifiable host composes to no glyph, rather than to Forgejo's. + expect(forgeGlyphForUrl('https://git.example.com/a/b/pulls/1'), isNull); + expect( + forgeGlyphForUrl('https://codeberg.org/a/b/pulls/1'), + forgeGlyphFor(ForgeKind.forgejo), + ); + }); + }); + + group('asset paths', () { + test('point at glyphs that are actually declared in pubspec assets', () { + // assets/icons/ is bundled wholesale, so a typo here fails only at runtime + // as an invisible icon. Pin the exact names the sync script vendors. + expect(kForgejoAsset, 'assets/icons/forgejo-light.svg'); + expect(kGiteaAsset, 'assets/icons/gitea-light.svg'); + }); + }); +} diff --git a/app/tool/e2e-desktop-settings.sh b/app/tool/e2e-desktop-settings.sh new file mode 100755 index 00000000..b2c7c2a1 --- /dev/null +++ b/app/tool/e2e-desktop-settings.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Per-repo Settings e2e on the real macOS app (SPEC-48 T6.2). +# +# Sibling of e2e-desktop.sh, and deliberately much smaller: that harness boots a +# real daemon control socket because it is testing the control plane. This one +# needs no daemon at all. What it proves is the composition inside the app -- +# +# reposProvider -> sectionsFor() -> nav pane -> RepositorySettingsPage -> rows +# +# -- and the daemon-side behaviour it would otherwise duplicate is already covered +# by the server tests (repo_settings*, router, git). Routing it through the socket +# would add infrastructure for no extra coverage, so the repo snapshot is stubbed +# at `reposProvider`, which is exactly the seam `SettingsWindow` reads. +# +# macOS-only: this is the desktop shell, so the harness targets the `macos` +# device rather than a simulator. + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +APP_DIR="$ROOT/app" + +FLUTTER_BIN="${MAKIT_FLUTTER_BIN:-$(command -v flutter || true)}" +if [[ -z "$FLUTTER_BIN" ]]; then + echo "flutter not found — set MAKIT_FLUTTER_BIN or add flutter to PATH" >&2 + exit 1 +fi +FLUTTER_BIN_DIR="$(cd "$(dirname "$FLUTTER_BIN")" && pwd)" + +cd "$APP_DIR" +PATH="$FLUTTER_BIN_DIR:$PATH" "$FLUTTER_BIN" test \ + -d macos \ + integration_test/desktop/settings_repo_test.dart \ + "$@" diff --git a/app/tool/repo_settings_demo.dart b/app/tool/repo_settings_demo.dart new file mode 100644 index 00000000..2836914f --- /dev/null +++ b/app/tool/repo_settings_demo.dart @@ -0,0 +1,268 @@ +// Visual harness for SPEC-48's per-repo Settings section, with seeded data and no +// server. Run on the desktop you are reading this from: +// +// cd app && rm -rf .dart_tool/flutter_build build/macos/Build/Products/Profile +// flutter run -d macos --profile -t tool/repo_settings_demo.dart +// +// The cache clear is not optional: `flutter run -t .dart` silently reuses a +// cached bundle and will render lib/main.dart instead, omitting every edit here. +// +// The section is the shipped `RepositorySettingsSection` inside the real settings +// chrome (a sidebar + the real `SettingsSectionHeader` / `SettingsGroup` / +// `SettingsResetButton`), so what you see is what the Settings window will draw. +// Pick a repo on the left to switch states. +// +// Not part of the app or the test suite: a harness, kept out of `lib/` so neither +// can import it. +import 'package:flutter/material.dart'; +import 'package:phosphoricons_flutter/phosphoricons_flutter.dart'; +import 'package:makit/app/theme.dart'; +import 'package:makit/desktop/settings/sections/repository_section.dart'; +import 'package:makit/ui/home/repo_monogram.dart'; +import 'package:makit/ui/widgets/forge_glyph.dart'; + +/// The states worth looking at, including the ones that must render *nothing*. +final _scenes = { + 'Diana — Forgejo, authed': const RepoSettingsView( + name: 'Diana', + path: '/Users/le/Work/XDent/Diana', + defaultBranch: 'main', + forge: ForgeKind.forgejo, + forgeHost: 'forgejo.internal.xdent.ai', + forgeAuthed: true, + worktreeRoot: '/Users/le/.worktrees', + branches: ['main', 'develop', 'release/16'], + ), + 'makit — GitHub, overridden root': const RepoSettingsView( + name: 'makit', + path: '/Users/le/Work/Vibe/makit', + defaultBranch: 'main', + forge: ForgeKind.github, + forgeHost: 'github.com', + forgeAuthed: true, + worktreeRoot: '/Users/le/.worktrees/makit', + worktreeRootOverridden: true, + branches: ['main', 'gh-pages'], + ), + 'piano — Gitea, no token': const RepoSettingsView( + name: 'piano', + path: '/Users/le/Work/Vibe/piano', + defaultBranch: 'master', + forge: ForgeKind.gitea, + forgeHost: 'gitea.example.org', + worktreeRoot: '/Users/le/.worktrees', + ), + 'unprobed — forge not measured': const RepoSettingsView( + name: 'quiet-repo', + path: '/Users/le/Work/Vibe/quiet-repo', + defaultBranch: 'main', + worktreeRoot: '/Users/le/.worktrees', + ), + 'local-only — no remote': const RepoSettingsView( + name: 'scratch', + path: '/Users/le/Work/scratch', + defaultBranch: 'main', + worktreeRoot: '/Users/le/.worktrees', + hasRemote: false, + branches: ['main'], + ), + 'set to None — polling off': const RepoSettingsView( + name: 'vendored-mirror', + path: '/Users/le/Work/Vibe/vendored-mirror', + defaultBranch: 'main', + forge: ForgeKind.github, + forgeHost: 'github.com', + forgeAuthed: true, + worktreeRoot: '/Users/le/.worktrees', + providerChoice: ForgeChoice.none, + branches: ['main'], + ), + 'read-only (paired phone)': const RepoSettingsView( + name: 'Diana', + path: '/Users/le/Work/XDent/Diana', + defaultBranch: 'main', + forge: ForgeKind.forgejo, + forgeHost: 'forgejo.internal.xdent.ai', + forgeAuthed: true, + worktreeRoot: '/Users/le/.worktrees', + editable: false, + ), +}; + +void main() => runApp(const _DemoApp()); + +class _DemoApp extends StatelessWidget { + const _DemoApp(); + @override + Widget build(BuildContext context) => MaterialApp( + debugShowCheckedModeBanner: false, + theme: makitDarkTheme, + home: const _Shell(), + ); +} + +class _Shell extends StatefulWidget { + const _Shell(); + @override + State<_Shell> createState() => _ShellState(); +} + +class _ShellState extends State<_Shell> { + // Keyed by NAME: `elementAt(5)` depended on the literal's insertion order, so + // adding or reordering a scene silently changed the default and contradicted this + // comment. Falls back to the first scene if the name is ever renamed. + static const _defaultScene = 'set to None — polling off'; + String _key = _scenes.containsKey(_defaultScene) + ? _defaultScene + : _scenes.keys.first; + final Map _choice = {}; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Scaffold( + body: Row( + children: [ + // Stand-in for the Settings sidebar: enough chrome to judge the section + // in context, deliberately not a copy of the real nav pane. + Container( + width: 220, + color: cs.surface, + child: ListView( + padding: const EdgeInsets.symmetric(vertical: kSpace12), + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + kSpace16, + kSpace4, + kSpace16, + kSpace12, + ), + child: Row( + children: [ + Icon(PhosphorIconsLight.x, size: 16, color: cs.outline), + const SizedBox(width: kSpace8), + Text( + 'Settings', + style: Theme.of(context).textTheme.titleSmall, + ), + ], + ), + ), + for (final label in const [ + 'General', + 'Appearance', + 'Agents & Chat', + 'Server & Devices', + 'Notifications', + 'Shortcuts', + 'Advanced', + 'About', + ]) + ListTile( + dense: true, + leading: Icon( + PhosphorIconsLight.circle, + size: 15, + color: cs.outline, + ), + title: Text( + label, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB( + kSpace16, + kSpace16, + kSpace16, + kSpace4, + ), + child: Text( + 'REPOSITORIES', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: cs.primary, + fontWeight: FontWeight.w700, + letterSpacing: 0.9, + ), + ), + ), + for (final entry in _scenes.entries) + ListTile( + dense: true, + selected: entry.key == _key, + selectedTileColor: cs.surfaceContainerHigh, + leading: RepoMonogram(name: entry.value.name, size: 16), + title: Text( + entry.key, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + ), + onTap: () => setState(() => _key = entry.key), + ), + ], + ), + ), + const VerticalDivider(width: 1), + Expanded( + child: _Section( + scene: _key, + base: _scenes[_key]!, + // Fall back to the SCENE's own choice, not to auto -- otherwise a + // scene that exists to show an override renders as if it had none. + choice: _choice[_key] ?? _scenes[_key]!.providerChoice, + onChoose: (c) => setState(() => _choice[_key] = c), + ), + ), + ], + ), + ); + } +} + +/// Re-projects the seeded scene with whatever the user has since selected, so the +/// controls visibly respond and a screenshot proves they are wired rather than +/// decorative. +class _Section extends StatelessWidget { + const _Section({ + required this.scene, + required this.base, + required this.choice, + required this.onChoose, + }); + + final String scene; + final RepoSettingsView base; + final ForgeChoice choice; + final ValueChanged onChoose; + + @override + Widget build(BuildContext context) => RepositorySettingsSection( + key: ValueKey(scene), + view: RepoSettingsView( + name: base.name, + path: base.path, + worktreeRoot: base.worktreeRoot, + defaultBranch: base.defaultBranch, + forge: base.forge, + forgeHost: base.forgeHost, + forgeAuthed: base.forgeAuthed, + worktreeRootOverridden: base.worktreeRootOverridden, + editable: base.editable, + providerChoice: choice, + branches: base.branches, + // Copied, not defaulted. `hasRemote` defaults to TRUE, so the "local-only" + // scene -- which exists to show `Auto: no remote, so no forge` -- rendered + // `Auto: not identified yet` instead, i.e. the harness demonstrated the exact + // wording it was built to check against. + hasRemote: base.hasRemote, + logoHue: base.logoHue, + ), + onChooseProvider: onChoose, + onEditLogo: () {}, + onChangeRootPath: () {}, + onChooseDefaultBranch: () {}, + onEditWorktreeRoot: () {}, + onResetWorktreeRoot: () {}, + ); +} diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index cc33168d..c5cb8003 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -153,6 +153,86 @@ pnpm build # tsc -p . → dist/ (then: node dist/src/index.js serve --- +## 1b. Forges other than GitHub (Forgejo / Gitea) + +The server picks a provider per repository by **asking the instance what it runs**, +once per host, cached: + +| Probe | Forgejo | Gitea | GitLab | +|-------|---------|-------|--------| +| `GET /api/forgejo/v1/version` | 200 | 404 | — | +| `GET /api/v1/version` | 200 | 200 | 302 → sign-in | +| `GET /api/v4/version` | — | — | 401 | + +`github.com` (and subdomains) is decided by hostname and never probed. Forgejo and +Gitea share one provider — the REST API is the same. GitLab, or anything +unidentifiable, routes to an **unsupported** provider that makes no requests and +says so on a button press, instead of being polled against an API that is not +there and failing as "unknown" (which looked identical to an outage). The host is +logged once, not once per poll. + +Caching, precisely: a decisive answer (`forgejo`, `gitea`, `gitlab`) is cached for +the process lifetime, keyed by the normalised base URL. An **`unknown`** result -- +whether the host is unidentifiable or the probe failed to connect -- is cached for +60 seconds and then re-probed, so an instance that was briefly down is not pinned as +unsupported until the server restarts. Routing treats `unknown` as undecided too, so +the repo is re-routed rather than left on the fallback provider. + +A repo's **provider setting** short-circuits all of this: `Forgejo`, `Gitea` or +`GitHub` picks the gateway with no probe at all, and `None` reaches no forge. That is +the recourse for an instance the probe cannot classify -- one that answers 401 to an +anonymous request, or sits behind a proxy that hides the version endpoints. See +`docs/specs/2026-08-10-SPEC-48-per-repo-settings.md`. + +No `gh`-style login is involved; the provider talks REST with a token. + +| Variable | Purpose | +|----------|---------| +| `FORGEJO_BASE_URL` (or `MAKIT_FORGEJO_BASE_URL`) | The instance URL. Only needed when `https://` is not right — a sub-path install, a non-standard port, or plain HTTP on a private network. | +| `FORGEJO_ACCESS_TOKEN` (or `MAKIT_FORGEJO_TOKEN`, `FORGEJO_TOKEN`, `GITEA_TOKEN`) | API token, checked in that order. Create it under *Settings → Applications*. | + +**Setting an instance URL scopes the token to that host.** This is a security +property: configuring one instance means exporting a single global token, and +without scoping it would be attached to every non-GitHub remote — so opening any +public Gitea/Forgejo repo would send your internal token to a third party. A +foreign host is still queried, just unauthenticated (correct for a public repo). +Host matching ignores scheme, port and path, because an scp-form remote +(`git@host:owner/repo`) cannot express the API's port. + +Token scopes: `read:repository` is enough for the PR pills. The PR *actions* +(mark ready, update branch, squash-merge) additionally need `write:repository`. +Creating repositories needs `write:user` / `write:organization`, which makit +never does. + +### Differences you will see versus GitHub + +- **No quota panel.** Forgejo exposes no request quota to read: no + `/api/v1/rate_limit` endpoint and no rate-limit response headers, and its + configuration has no instance-wide request limiter (the `quota` feature meters + *storage* — repo/LFS/package bytes — not requests). Rate limiting on a Forgejo + instance therefore comes from whatever sits in front of it, not from Forgejo + itself. So there is nothing to ration or display, and the budget + footer keeps showing GitHub's quota only. A Forgejo-only setup therefore polls + at the fast 5s rung rather than being throttled by GitHub's ladder. + In a **mixed** setup GitHub's ladder still governs the shared poll timer for + every repo; per-repo cadence would need reworking `pr_watcher`. +- **Throttling still happens, just not from Forgejo.** An instance behind nginx + `limit_req`, Cloudflare or an anti-scraper gate can answer `429`, and a slow + query can shed load with `503`. Those are honoured: the provider backs off for + `Retry-After` (capped at 5 minutes so a bad header cannot park polling for a + day), withholds background polls while waiting, still lets a button press + through, and reports `throttled` rather than "no PR". +- **"Mark ready" rewrites the title.** Forgejo derives `draft` from a + `WORK_IN_PROGRESS_PREFIXES` title prefix (default `WIP:`, `[WIP]`, + case-insensitive, configurable per instance) and its API has no `draft` field. + makit strips the prefix, and refuses rather than guessing if it does not + recognise one. +- **No merge-state detail.** GitHub's `BEHIND`/`BLOCKED`/`CLEAN` has no Forgejo + counterpart, so that fact is reported as unknown instead of guessed. +- **Unresolved review comments are not counted yet.** Forgejo exposes resolution + per review *comment* via a reviews→comments walk; until that is verified the + count is marked unmeasured rather than reported as zero. + ## 2. App (`app/`) ### Setup diff --git a/docs/specs/2026-08-10-SPEC-48-PLAN.md b/docs/specs/2026-08-10-SPEC-48-PLAN.md new file mode 100644 index 00000000..1bc9c9ed --- /dev/null +++ b/docs/specs/2026-08-10-SPEC-48-PLAN.md @@ -0,0 +1,224 @@ +# SPEC-48 — Implementation plan (rev 2) + +**Spec:** [`2026-08-10-SPEC-48-per-repo-settings.md`](./2026-08-10-SPEC-48-per-repo-settings.md) rev 2 +**Rev:** 4 — P2 implemented (see *P2 · what shipped*) · rev 3 — four editable rows (see the spec's *Rev 3*) · **Supersedes:** rev 1 (review failed — see the spec's *Review round 1*) +**Branch:** `feat/forgejo-git-provider` + +Rev 1's tasks are withdrawn. Its wire-contract red test was vacuous, two of its foundations did not +exist, and one task targeted the wrong shell. Rev 2 puts the foundations in P0 and makes every red test +assert a **value the production code must produce**, not a fixture's shape. + +Rule for every task below: the RED TEST must fail for the *stated reason* before production code, and +its bite is proven by reverting only the production line. A test that passes on a fixture edit alone is +not a red test. + +--- + +## Status + +| | Task | State | +| --- | --- | --- | +| P0 | F1 forge decision record | ✅ | +| P0 | F2 `ForgeInspector` port | ✅ | +| P0 | F3 lossless `settings` round-trip | ✅ | +| P0 | F4 `sectionsFor(repos)` | ✅ | +| P0 | F5 Settings open-target | ⏳ deferred — sections are reachable from the sidebar; a deep link with `repoId` is only needed once the repo-card entry point lands (desktop-only, D23) | +| P1 | T1 wire contract (typed, not fixture) | ✅ | +| P1 | T2 resolver + all three consumers | ✅ | +| P1 | T3 loopback-gated write + path validation | ✅ | +| P1 | T4 the section | ✅ | +| P1 | T5 entry point (desktop) | ⏳ reachable via the sidebar; the repo-card item is not built | +| P1 | T6 QA: widget, integration, real app | ✅ widget (68), **integration on a real macOS build (5)**, real app (appearance) | +| **P2** | **P1 the provider override routes (D3″)** | ✅ | +| **P2** | **P2 the default-branch override is consumed (3 sites)** | ✅ | +| **P2** | **P3 the logo hue is drawn (section + sidebar)** | ✅ | +| **P2** | **P4 root path re-pointing (D4′)** | ✅ | +| **P2** | **P5 "New worktree from PR" on Forgejo (checkout strategy)** | ✅ | +| P3 | Lifecycle scripts | ⏳ gated on D13(a)/(b) | +| P3 | Branch prefix | ⏳ no source (D9) | +| P3 | Mobile repo-card destination + `repo:` deep link | ⏳ nothing is unreachable without it | + +## P2 · what shipped + +Results, decisions and the live proof are in the spec's **P2 implemented** section. +In short: three of P1's five writes changed no behaviour, and the fourth row did not +exist. All four are now wired, with two bugs fixed on the way (`hasRemote` collapsing +three states into a boolean, which made the app's "not identified yet" wording +unreachable; and a re-point duplicate check that compared paths at different +canonicality, so two projects could occupy one path). + +--- + +## P0 · Foundations the review exposed + +### F1 — The router records its per-repo forge decision + +`chosen` is `Map>` (`router.ts:165`) and retains nothing else. Add a +parallel record of `{ software, host, authed }` written at the same point the gateway is chosen. + +- **RED TEST** — `forge/router.test.ts`: after routing a repo whose detector reports `gitea`, + `router.forgeFor('/repo')` is `{software:'gitea', host:'git.example', authed:true}`; a repo never + routed is `undefined`. Fails to compile today (no such method), then fails on value. +- **Bite** — delete the record write; the value assertion fails while the `undefined` case still passes. +- **VERIFY** — `cd server && pnpm exec tsc -p . --noEmit && node --import tsx --test "src/forge/*.test.ts"` + +### F2 — A narrow `ForgeInspector` port, so `repo_service` can ask (D19) + +```ts +export interface ForgeInspector { forgeFor(repoPath: string): RepoForge | undefined; } +``` +`listRepos` gains it as a dependency. The router satisfies it structurally; nothing widens +`GithubGateway`. + +- **RED TEST** — `repo_service.test.ts`: `listRepos` given an inspector that reports `forgejo` for repo A + and nothing for repo B yields `dto.forge.software === 'forgejo'` on A and `forge === undefined` on B + (**not** a guessed value — this is D18's pending case). +- **VERIFY** — as F1, plus the repo-service test file. + +### F3 — `settings` survives load → save → load, and reaches `ProjectDTO` (~~D11~~) + +Three places, all confirmed to drop it today: `project-store.ts:81` (load), `:99` (save), +`manager.ts:204` (DTO copy). + +- **RED TEST** — `project-store.test.ts`: write a record with `settings:{worktreeRoot:'/tmp/a'}` **and** + an unknown key `settings:{futureThing:1}`; `loadProjects` → `saveProjects` → `loadProjects` returns + both unchanged. Separately: a `settings` value of the wrong *type* (a string, an array) degrades that + record to no settings rather than throwing — the file must never stop the daemon starting. +- **Bite** — restore the `{id, path}`-only save; the round-trip assertion fails. +- **VERIFY** — as F1, plus `test/**`. + +### F4 — `sectionsFor(repos)` replaces the static list (D21) + +`kSettingsSections` is a `final List` (`settings_registry.dart:24`) also carrying `SettingsItem` search +entries; `settings_window.dart` resolves selection against it statically. + +- **RED TEST** — `sectionsFor([pinnedA, unpinnedB])` contains a section with id `repo:` and none + for B; it emits a search entry per repo section; and given a selected id of `repo:` the resolver + returns `general` rather than throwing or rendering an empty pane. **Plus the search path:** + `searchSettings('worktree', sections)` returns A's row, and the result's displayed title is the repo + **name**, not `repo:` — `SettingsNavPane` currently derives both from the static list, so it must + take the dynamic sections too (round-2 finding). +- **Bite** — drop the pinned filter → the "no section for B" assertion fails. Leave the nav pane on the + static list → the search-title assertion fails. +- **VERIFY** — `cd app && flutter test --no-pub test/desktop/settings/settings_registry_test.dart` + +### F5 — Settings open state carries a target (D22) + +Replace the bare `bool` (`window_overlays.dart:28`); `_openSettings()` (`desktop_app.dart:315`) takes an +optional section id. + +- **RED TEST** — requesting Settings with `repo:` opens with that section selected; requesting with + no target opens on the previously selected section. +- **VERIFY** — as F4. + +## P1 · The page, with one live control + +### T1 — Wire contract, asserted on a typed payload (fixes rev 1's vacuous test) + +`contract.test.ts` cannot catch this: it loads snapshots as `Record[]` and only asserts +codec round-trip, and `decodeFrame` (`codec.ts:104`) validates `v`/`t`/`id` then casts. + +- **RED TEST** — build a `repos.snapshot` **through `listRepos`** and assert the payload typed as + `RepoDTO[]`: `forge` and `worktreeRoot` present with exact expected values. A fixture-only edit + cannot make this pass. +- **Bite** — remove either field from the DTO construction; the assertion fails (and `tsc` fails, which + is the point of a typed assertion). +- **Committed alone**, before P1's other tasks. + +### T2 — One resolver, three consumers (fixes rev 1's R5) + +`resolveWorktreeRoot(override, env)` — three levels only (D8′). Then route **all three** operational +consumers through it: `addWorktree` (`git.ts:468`), `addWorktreeForPr` (`git.ts:585`), +`uniqueWorktreeDir` (`manager.ts:1006`). + +- **RED TEST** — with repo A overridden to `/tmp/rootA` and repo B unset, `addWorktree` for A creates + under `/tmp/rootA` while B creates under the env/default root; and `uniqueWorktreeDir` for A collides + against **A's** root, not the global one. Also: `MAKIT_WORKTREE_DIR=''` resolves to the built-in + default, not an empty path. +- **Bite** — revert one of the three call sites; the corresponding assertion fails. This is the test + that proves rev 1's "only consumer" claim was wrong. + +### T3 — `repo.settings.set` is loopback-gated and path-validated (D16, D17) + +- **RED TEST** — a client with `isLocal:false` calling `repo.settings.set{worktreeRoot}` gets an explicit + error (not a silent no-op) and the store is unchanged; the same call from `isLocal:true` succeeds. + Path cases, each chosen to reach a *different* rule: + - a **relative** path (`work/trees`) is rejected by the absolute check; + - an **absolute** path containing `..` (`/Users/x/work/../../etc`) is rejected by the + **pre-canonicalisation `..` segment check** — rejected on sight, not collapsed, because collapsing + would silently yield a valid path and hide what the user actually typed. Note `~/x/../../etc` would + **not** reach this rule: it is not absolute, so the earlier check rejects it first (round-2 finding: + rev 2 asserted that unreachable case; round-3 finding: "after collapsing" was also wrong); + - a **real symlink** into a sibling directory whose target escapes is rejected — a `..` string cannot + exercise this, which is the over-defence trap already recorded in this repo's skills; + - a valid absolute path **that does not exist yet** is accepted and stored canonicalised via its + nearest existing ancestor (D17) — this is the common case and a naive `realpath` fails it; + - a valid absolute path that does exist is stored canonicalised. +- **Bite** — remove the `isLocal` check → the refusal test fails. Remove ancestor-canonicalisation → + the not-yet-existing-path test fails. Remove symlink resolution → the symlink test fails. + +### T4 — `RepositorySettingsSection` + +`IDENTITY` (Logo, Root path, Git provider *when known*, Default branch) + `WORKTREES` (Worktree root, +editable). No scripts group (cut). Subtitles only where they distinguish state (cut the blanket rule). + +- **RED TEST** — a DTO **matrix**, not a widget-tree restatement: `forge` absent ⇒ no provider row; + `forge.software=gitlab` ⇒ the unsupported presentation; `worktreeRoot` inherited ⇒ no reset button; + overridden ⇒ reset button present and tapping it clears the override (asserted on a recorded call, + since in Dart a `Map` is never `==` to another `Map`); a non-loopback client ⇒ the row is read-only. +- **Golden test** for the group's geometry (header spacing, row height, trailing slot alignment) so the + visual contract is reproducible, replacing "pixel-perfect" as a gate. + +### T5 — Desktop entry point only (D23) + +Open Settings at `repo:` from the desktop shell. `RepoCard` is mobile (`home_screen.dart:53`) and is +**not** touched in P1. + +- **RED TEST** — the action opens Settings with that repo's section selected, and still resolves after a + refreshed repo snapshot reorders the list. + +### T6 — QA + +1. **Widget** — the T4 matrix and the golden. +2. **Integration** — **decided (round-2 finding): mount `SettingsWindow` with a stubbed repo snapshot** + in a new `app/integration_test/desktop/settings_repo_test.dart`, following how + `control_e2e_test.dart` pumps `ServerDevicesSection` directly. *Not* an extension of the control + socket: what this test must prove is the window → dynamic registry → section → row path (F4, F5, T4), + and the daemon-side behaviour it would otherwise duplicate is already proven by T2/T3 server tests. + Extending the socket would add infrastructure for no additional coverage. +3. **Real app** — build and drive the macOS app to the section via `cua-driver`, screenshot, read the + image. This is the only check that catches the desktop-shell routing hazard already recorded in the + project skill (`desktop_sidebar.dart` calls `context.go` under a shell with no GoRouter — throws at + runtime while tests pass). +4. **Visual audit** against `mockups/repo-settings.html` — evidence, listed deviation by deviation. + +Run only ONE `flutter test` at a time: concurrent runs collide over `.dart_tool` and the second dies +with `Test directory "test" not found`, silently measuring nothing. + +--- + +## Risks + +| Risk | Mitigation | +| --- | --- | +| F1/F2 touch the forge router, which just shipped | Both are additive; the existing 29 router tests must stay green untouched | +| T2 changes worktree *creation* — the destructive-adjacent path | Three explicit assertions, one per consumer, plus the empty-env case; no change to prune in P1 | +| The integration harness needs a stubbed repo snapshot `SettingsWindow` does not supply today | **Decided** in T6.2: pump `SettingsWindow` under a `ProviderScope` with an overridden repo snapshot, as `settings_window_test.dart:109` already does and `control_e2e_test.dart:89` does for its section. The only new input is the snapshot override | +| Scope creep from P1 into P2 | `git diff --stat` must show no branch-prefix row and no script code | +| app/ flake baseline | Judge by non-loading failures and by files failing in *all* runs | + +## Deviations log + +| # | Departure from the plan | Why | +| --- | --- | --- | +| 1 | T4 shipped before P0's foundations, fed by a `RepoSettingsView` instead of `RepoDTO` | The requested order was UI-first. The view model keeps the widget complete and testable without the server plumbing, and keeps the section pure presentation. | +| 2 | Rows use a right-aligned value column, diverging from `CLI`/`Fingerprint` which put the value in the subtitle | Chosen deliberately after seeing both on the real app: a value column scans in one vertical sweep. Recorded in the widget's own doc comment so it is not "fixed" later. | +| 3 | The mockup was corrected to match the build, not the reverse | Three of four "pixel-perfect" gaps were treatments the mockup invented and the shipped Settings window does not use (outlined badges, descriptive subtitles, value-in-subtitle). | +| 4 | Provider/root-path/branch/logo became editable mid-P1 | Requested; reversals justified per failure case in the spec's *Rev 3*. Consequence: P2 owns four writes, not one. | +| 5 | Interaction verified by widget test rather than on the real app | `cua-driver` synthesized clicks did not land in this Flutter app on two builds (`"effect":"unverifiable"`). Stated as a coverage gap; appearance still verified on the real app. | + +## Review findings applied + +Rev 2 applies review round 1 in full: see the spec's *Review round 1* for the eleven corrected claims, +the accepted YAGNI cuts, and the two items rejected with reasons (keeping a descoped monogram; keeping +the visual audit as evidence rather than as a gate). diff --git a/docs/specs/2026-08-10-SPEC-48-per-repo-settings.md b/docs/specs/2026-08-10-SPEC-48-per-repo-settings.md new file mode 100644 index 00000000..c60f0ad5 --- /dev/null +++ b/docs/specs/2026-08-10-SPEC-48-per-repo-settings.md @@ -0,0 +1,540 @@ +# SPEC-48 — Per-repo settings: one Settings section per repository + +**Status:** P2 Implemented (rev 3.2) · **Priority:** P2 · **Branch:** `feat/forgejo-git-provider` +**Depends on:** SPEC-11 (repo-centric home — `RepoDTO`, `repos.snapshot`, the repo card and its +`dotsThree` menu), SPEC-19 (`SettingsResetButton` as the one shared "reset to default" widget, and +`SettingsGroup` as the grouped-list idiom), and the forge-detection work already on this branch +(`server/src/forge/detect.ts`, `router.ts`, commits `53be5e26`/`164eda8e`/`87a0883c`). +**Design board:** [`mockups/repo-settings.html`](../../mockups/repo-settings.html) (the section, the +inheritance model, the script trust decisions) and +[`mockups/forge-provider-per-repo.html`](../../mockups/forge-provider-per-repo.html) (where the forge +surfaces outside Settings). Both are kept as separate boards on purpose. + +--- + +## Goal + +A repository in makit currently has **no configurable state at all**. Everything that varies is either +global (`MAKIT_WORKTREE_DIR` at `git.ts:33`), environmental (`FORGEJO_ACCESS_TOKEN`), or derived +(`defaultBranch` from `origin/HEAD`). The moment a second repo wants a different worktree root, or a +different post-create step, there is nowhere to put it. + +This spec adds that place: **one Settings section per repository**, and — more importantly — the +*inheritance model* those sections render. The model is the feature; the rows are its first four +tenants. + +| Question a user has | Answered by | +| --- | --- | +| "which forge is this on, and am I authenticated?" | the Git provider row, **detected**, never asked (D3) | +| "where will a new worktree land?" | Worktree root, showing the **effective** value and its source (D5) | +| "why is this repo different from my others?" | any row badged `overridden` (D6) | +| "run `pnpm install` after every worktree create" | a lifecycle script — **P3, and gated** (D12, D13) | + +## What already exists, and is reused rather than rebuilt + +Half of this is committed. Naming it here so the plan does not re-invent it. + +| Need | Already in the repo | Consequence | +| --- | --- | --- | +| Per-project persistence | `server/src/project-store.ts` → `$MAKIT_HOME/projects.json`, documented to degrade to `[]` on a corrupt file so the daemon always starts | P2 extends `PersistedProject`; no new store, no new file | +| Stable per-repo key | `PersistedProject.id` (random handle, survives restart, leaks nothing about the filesystem) | settings key off `id`, never off the path | +| Grouped rows + reset | `SettingsGroup`, `SettingsSectionHeader`, `SettingsResetButton` — the last already collapses to a fixed-width box so rows with and without an override stay aligned | the inheritance affordance is a relabel, not a new widget | +| Forge identity | `forge/detect.ts` (probes `/api/forgejo/v1/version`, `/api/v1/version`, `/api/v4/version`; cached per host; verified against four live forges) | the provider row is a read-out | +| Which repos are "mine" | `pinned:true` for projects restored from `projects.json` (`manager.ts:215`) vs `pinned:false` for ad-hoc `addProject` (`manager.ts:287`) | bounds the sidebar (D2) | +| Command plumbing | `server/src/ws/commands/*` + `ws/commands/deps.ts` | P2 adds one command file | +| Effective worktree root | `git.ts:33` — `process.env.MAKIT_WORKTREE_DIR ?? join(homedir(), ".worktrees")`, the **only** consumer | P1 reports it; P2 routes it through the resolver | + +## Why the UI ships first, and why that is not a fake + +The requested order is UI-first. That is achievable **without stub data**, because every row in the +mockup already has a real source: + +| Row | P1 source | Real? | +| --- | --- | --- | +| Logo | monogram derived from `RepoDTO.name` — pure, client-side | yes | +| Root path | `RepoDTO.path` | yes | +| Git provider | new `RepoDTO.forge` (one field, fed by `detect.ts`) | yes | +| Default branch | `RepoDTO.defaultBranch` | yes | +| Worktree root | new `RepoDTO.worktreeRoot` — the **effective global** value | yes | +| Branch prefix | *not rendered in P1* — it has no source until P2 (D9) | — | +| Lifecycle scripts | rendered as a disabled group reading `Not set` (D12) | yes (it is genuinely not set) | + +So P1 is pixel-complete against the mockup minus one row, every value is measured, and every badge is +true: `detected`, `from remote`, `from name`, `inherited`. Nothing says `overridden` because in P1 +nothing *can* be overridden — which is the honest rendering, not a placeholder. + +## Decisions (locked before implementation) + +| # | Decision | Why | +| --- | --- | --- | +| **D1** | **One sidebar section per repository**, under a `REPOSITORIES` group header, after `About`. Not a single "Repositories" page with a list→detail drill. | Fixed app sections are a closed taxonomy; repos are data. Grouped, this is the Mail/Finder idiom, and it removes a navigation level — the section title *is* the repo name, so no breadcrumb. | +| **D2** | Only `pinned:true` projects get a section. | Bounds the sidebar by what the user added (3 on a real install) rather than by what makit noticed. Prevents the settings sidebar becoming a file browser. | +| **D3** | The Git provider row is a **read-out with no P1 control**. Detection is authoritative; the override is P2 and lives behind the same segmented control the Endpoint row uses (`Auto | Forgejo | Gitea | GitHub`), with `Auto` selected and its resolved value described beneath. | Detection is correct for every forge tested. Asking the user to answer what the server already knows is friction on the majority path. The segmented-plus-description idiom is already in Settings (Endpoint: `Auto: Tailscale if available, else loopback`). | +| **D4** | Root path is **displayed, not editable**, with a copy affordance. | The path is the repo's identity in `projects.json`; changing it is remove-and-re-add, which the repo card menu already offers. | +| **D5** | Every inheritable row renders the **effective value** plus a badge naming its source. Never an empty field that silently means a default. | A blank "Worktree root" that means `~/.worktrees` is how worktrees end up somewhere unexpected. | +| **D6** | Badge vocabulary is closed: `detected`, `from remote`, `from name`, `inherited`, `from environment`, `overridden`. `overridden` is the **only** state with a visible `SettingsResetButton`. | One vocabulary, one undo target. | +| **D7** | Reset means **"inherit again"**, not "copy today's global". | So a later change to the global still propagates. Storing the resolved value at reset time is a silent fork. | +| **D8** | Resolution order, per setting, first hit wins: `repo override → global setting → env var → built-in default`. Env vars are a **source in the chain**, rendered `from environment` and read-only. | `MAKIT_WORKTREE_DIR` must keep working; the app cannot change the daemon's environment, so it must not pretend to. | +| **D9** | P1 renders **only rows with a P1 source**. Branch prefix is deferred to P2 rather than shown disabled. | A row that exists but can never do anything is worse than an absent one; it invites a bug report. | +| **D10** | Per-repo settings are **server-owned** (`projects.json`), never `SharedPreferences`. | The daemon consumes them (worktree root, hooks), and app-side prefs are per-device — a value set on a phone would never reach the daemon that creates the worktree, and two paired devices would disagree. | +| **D11** | Unknown keys in a persisted `settings` object are **preserved on save**. | An older daemon paired with a newer app must not silently drop a field it does not understand. | +| **D12** | **P1 and P2 ship no script execution.** The group renders, disabled, reading `Not set`; on iOS it reads `Editable on the host only`. | The rows communicate the shape without creating the surface. | +| **D13** | **P3 is gated on two security decisions, recorded here before any code:** (a) script text lives in makit's own config and is **never read from the repository working tree**; (b) **only the host may set one** — a paired device may read but not write. | (a) Otherwise `git clone` + add-to-makit is arbitrary code execution, and reviewing a stranger's PR branch runs their script. (b) makit pairs with phones; if any paired device can write a script the daemon executes, pairing stops meaning "chat with an agent" and starts meaning "remote shell on my laptop". Neither is recoverable by a later patch. | +| **D14** | Logo is a **deterministic monogram** from the repo name (stable hue), with a custom image deferred to P4. | Zero state, identical on every device, and it makes the sidebar group scannable. A custom image is a byte-transfer path with size/type validation, not a settings row. | +| **D15** | The sidebar is the **second** place listing repos. Both it and the repo-centric home render from the same `RepoDTO` and the same monogram widget. | Two independent lists drift in order, name and logo. The mitigation is a shared source, not a convention. | + +## What P1 does not do + +- No writes. Nothing on the P1 page is editable; there is no `repo.settings.set`. +- No `settings` object in `projects.json` yet — P1 adds **no persistence at all**. +- No branch prefix row (D9), no custom logo image (D14), no script execution (D12). +- No GitLab provider. Detection *names* GitLab; supporting it is a separate implementation + (`merge_requests`, different auth) and explicitly out of scope for every phase here. +- No overflow rule for many repos. Past ~10, keep pinned inline and add one `All repositories…` + entry — cheap later precisely because `pinned` exists, and 3 sections is not a scrolling problem. + +## Phasing + +| Phase | Contents | Gate to start | +| --- | --- | --- | +| **P1** | `RepoDTO.forge` + `RepoDTO.worktreeRoot`; the Settings section, read-only, pixel-audited against the mockup; monogram widget; `Settings…` entry in the repo card menu; widget + integration tests; real-app verification | spec+plan reviewed | +| **P2** | `PersistedProject.settings`, the resolver, `repo.settings.get/set`, editable rows (worktree root, branch prefix, provider override), `git.ts:33` routed through the resolver | P1 shipped and verified | +| **P3** | Lifecycle scripts: runner with allowlisted env, cwd, timeout, pre-prune veto, host-only writes | **D13 (a) and (b) explicitly confirmed** | +| **P4** | Custom logo image upload; `All repositories…` overflow | demand | + +## Verification + +P1 is not "the tests pass". It is, in order: + +1. `cd server && pnpm exec tsc -p . --noEmit` clean; `node --import tsx --test` all-green with the + pre-existing count preserved (1380 at spec time). +2. `cd app && flutter analyze --no-pub` reports no issues; `flutter test --no-pub` green **judged + against the known flake baseline** — `loading [E]` failures are random and present at HEAD + (~15–23 per run, every file passes alone). Judge by non-loading failures and by whether a file + fails in *all* runs. +3. Every new test's bite proven by reverting only the production line and watching it fail. +4. **Pixel audit against `mockups/repo-settings.html`**: the built section screenshotted from the real + macOS app and compared to the mockup card, row by row — green uppercase group headers, two-line + rows, badge placement, trailing reset slot alignment. +5. **The real app, opened and driven** (not a widget test): Settings → the repo section, on macOS. +6. An integration test in `app/integration_test/` that reaches the section and asserts the rows. + +## Non-goals + +Global settings redesign. Repo add/remove flows. Anything that writes to the repository working tree. +A settings-sync mechanism between paired devices. Per-worktree (as opposed to per-repo) settings. + + +--- + +## Review round 1 — both reviewers returned NOT READY + +Two parallel `codex exec` reviews (technical correctness; engineering practice). Every finding below +was **re-verified against the code by hand** before being accepted — the reviewers' verdicts are not +taken on trust. + +### Confirmed wrong in rev 1 + +| # | Claim in rev 1 | Reality | +| --- | --- | --- | +| R1 | T1.1's fixture edit is a RED test | **Vacuous.** `contract.test.ts:24` loads snapshots as `Record[]` and only asserts codec round-trip; `decodeFrame` (`codec.ts:104`) validates `v`/`t`/`id` and then casts. A new `RepoDTO` field can never fail it. | +| R2 | `createForgeRouter` already records per-repo decisions | **False.** `router.ts:165` caches `Map>` only — no software, host or auth is retained. `softwareFor` needs a new decision record, not an accessor. | +| R3 | A previous `softwareFor` was reverted for not being on the declared type | **Unsubstantiated.** It happened in an uncommitted edit, so `git log -S softwareFor` finds nothing. Claim removed. | +| R4 | `repo_service.ts` can reach the router | **False.** `listRepos` takes `GithubGateway` (`repo_service.ts:62`) and `manager._gateway` is typed the same (`manager.ts:187`); a router-only accessor is invisible across that boundary. | +| R5 | `git.ts:33` is the only consumer of the worktree root | **Only of the env read.** The resolved root is consumed by `addWorktree` (`git.ts:468`), `addWorktreeForPr` (`git.ts:585`) and `uniqueWorktreeDir` (`manager.ts:1006`). P2 must route all three or collision checks and creation disagree. | +| R6 | The Settings sidebar can take a new group | **Foundational work missing.** `kSettingsSections` is a static `final List` (`settings_registry.dart:24`) carrying `SettingsItem` search entries, resolved statically by `settings_window.dart`. Dynamic repo sections need `sectionsFor(repos)`, stable ids, generated search entries, and defined behaviour when a selected repo disappears. | +| R7 | T4.1 adds the entry point | **Wrong surface.** `RepoCard` is used only by the *mobile* `home_screen.dart:53`; desktop Settings is a separate window whose open state is a bare `bool` (`window_overlays.dart:28`) and whose `_openSettings()` takes no repo id. There is no deep-link path to carry a `repoId`. | +| R8 | T2.3 is red today | **Cannot compile.** `PrDetailBody` receives `PrStatus`/`PullRequest`, not a repo (`pr_detail.dart:77`), so there is no server-forge input to disagree with the URL. | +| R9 | T5.1 uses the right harness | **Wrong one.** `app/tool/e2e-desktop.sh` runs `control_e2e_test.dart`, which pumps `ServerDevicesSection` directly and serves no `repos.snapshot`. | +| R10 | D11 "unknown keys preserved" | **Not true today and not free.** `project-store.ts:81`/`:99` reconstruct and emit only `{id, path}`, and `manager.ts:204` copies only those into `ProjectDTO`. Lossless `settings` needs work in all three places. | +| R11 | `forge` will be populated for every repo | **May never be.** Routing happens only when a gateway PR op calls `pick`; a repo with no eligible worktree is never routed. `forge` must be modelled as genuinely *pending*, or detection must be driven proactively for the snapshot. | + +### The finding that changes the product, not the plan + +**Write authorization was specified for scripts (D13) and for nothing else.** A paired device that can +set an arbitrary `worktreeRoot` directs host filesystem operations at a path of its choosing. That is a +security surface of the same kind as D13(b), and rev 1 left it undecided. Rev 2 must state who may write +each setting, and what path validation applies (absolute? canonicalised? symlink-checked? denied +outside `$HOME`?). + +### Accepted YAGNI cuts + +Disabled Lifecycle Scripts group in P1; the six-value badge vocabulary as one abstraction (provenance +and resolution are different things); the generic four-level resolver (there is no global settings +store — the real chain is `repo override → env → default`); provider and default-branch overrides; +the copy button; two-line rows as a blanket rule; the monogram's hue-determinism as a *tested* +requirement; P4; and "pixel-perfect" as an acceptance gate rather than design evidence. + +### Rejected + +- **"Cut the monogram entirely."** A per-repo logo was an explicit product request and the sidebar + group leans on it for scanability. Descoped instead: keep the glyph, drop the collision/grapheme + over-specification and test fixed name→output fixtures rather than "two names differ". +- **"Drop the visual audit."** Kept as *evidence*, not as the acceptance criterion. + +### Open decisions blocking rev 2 + +1. **Phasing.** The practice reviewer holds that a read-only P1 is ornamental and that the smallest + shippable P1 is the worktree-root override end-to-end. The request was explicitly UI-first. These + conflict; rev 2 needs one of them chosen. +2. **Write authorization** for non-script settings (above). + + +--- + +# Rev 2 — supersedes the rev 1 Decisions and Phasing tables + +Both blocking decisions are answered, and the eleven confirmed errors are corrected. Where rev 1 and +rev 2 disagree, **rev 2 wins**; the rev 1 tables are kept only as the record of what was reviewed. + +## The two answers + +**Phasing — UI first, plus one editable row.** P1 ships the Settings section pixel-audited *and* makes +Worktree root editable end-to-end. This keeps the requested sequencing while removing the reviewer's +"ornamental" objection: the page can do exactly one real thing on day one, and that one thing is the +setting the feature exists for. + +**Write authorization — host only, enforced at the transport.** And the mechanism already exists, which +is what makes this decidable rather than aspirational: `WsClient.isLocal` (`ws/client.ts:46`) is set +from the real socket address in `server.ts:733` (`127.0.0.1`, `::1`, `::ffff:127.0.0.1`), and it already +gates a privileged input — the app's reported pid in `hello` (SPEC-37 decision 6): *"a non-loopback +client must connect normally but may not ask us to sample an arbitrary pid."* Per-repo writes take the +same shape: **any paired device may read; only a loopback client may write.** This answers the +reviewer's objection that "host" needs an enforceable role rather than a UI assertion. + +## Decisions (rev 2) + +Rev 1's D1, D2, D4, D5, D7, D8 (as amended), D10, D12, D13, D14 (as amended) stand. Changed, added and +withdrawn below. + +| # | Decision | Why | +| --- | --- | --- | +| **D3′** | The Git provider row is a **read-out with no override in any phase of this spec**. | Detection is authoritative and verified against four live forges. An override creates a second truth that would have to be threaded through routing, auth lookup *and* PR rendering — the reviewer's point, accepted. | +| **D6′** | Two badge families, not one vocabulary. **Provenance** (`detected`, `from remote`, `from name`) is a fact about where a value was read. **Resolution** (`inherited`, `from environment`, `overridden`) is a fact about configuration precedence, and only `overridden` carries a `SettingsResetButton`. | They are different things; forcing one "closed vocabulary" was uniformity for its own sake. | +| **D8′** | Resolution is **three levels**, not four: `repo override → env var → built-in default`. | There is no global settings store to inherit from. A four-level chain was a framework for a level that does not exist. | +| **D16** | **Only a loopback client may write per-repo settings.** A non-loopback client receives an explicit refusal, not a silent no-op. Reads are unrestricted. | `worktreeRoot` is a path the daemon creates directories under and, via prune, removes. A remote device that can set it directs host filesystem operations. Precedent: SPEC-37 D6 / `WsClient.isLocal`. | +| **D17** | Every path-valued setting is **canonicalised** before use and rejected if it is not absolute. Validation happens **server-side on write**, and again on read-back before use. **A worktree root that does not exist yet is normal and must be accepted:** canonicalise the nearest *existing* ancestor with `realpath`, then require the remaining segments to contain no `..` and no symlink. Reject only if the resolved ancestor itself escapes **the allowed area, which is `$HOME`** — the boundary `validateWorktreeRoot` enforces, chosen because prune *removes* directories under this root. **If no existing ancestor can be `realpath`ed, reject** (round-3 finding). Re-validate on read-back before use.

**What read-back validation does and does not close:** it defends against a `projects.json` edited by hand between write and use. It does **not** close the filesystem TOCTOU window — a local attacker who can write inside `$HOME` could replace a validated component with a symlink between the check and the `git worktree add`. That residual risk is accepted and recorded rather than mitigated: the attacker already needs write access to the user's home directory, where they could act directly. Race-resistant creation (`openat`/`O_NOFOLLOW` walks) is not available through the `git` CLI this code drives. | Validating only on write trusts a file the user can edit by hand — `projects.json` is plain JSON in `$MAKIT_HOME`. And `realpath` on a path that does not exist fails, so a naive rule would reject `~/work/worktrees` before the user has created it, which is the *common* case (round-2 finding). | +| **D18** | `RepoDTO.forge` is **genuinely pending-able**. Routing only happens when a gateway PR operation calls `pick`, so a repo with no eligible worktree may never be routed. Absent `forge` renders **no row**, and the row appears when detection lands — it is never guessed. | Rev 1 assumed every repo would be routed. Confirmed false. | +| **D19** | `repo_service` receives a **narrow `ForgeInspector`** — `forgeFor(repoPath)` plus `hasRemoteFor(repoPath)`, the name the plan and the code both use (an earlier draft of this table said `softwareFor`; there is no such method) — not the router. | `listRepos` takes `GithubGateway` (`repo_service.ts:62`) and `manager._gateway` is typed the same, so a router-only accessor is invisible across that boundary. Widening the gateway contract to carry inspection would put two responsibilities on one interface; a separate narrow port is the SOLID answer. | +| **D20** | `authed` means **"a credential is configured for this host"** — for Forgejo, `forgejoRefFromRemote(...).token !== undefined`; for GitHub, **omitted entirely**. | `gh`'s budget snapshot is not host-specific authentication. Reporting it as `authed` would be a guess dressed as a fact. | +| **D21** | The Settings taxonomy becomes a **function of the repo list**: `sectionsFor(repos)` — and the *search* path with it. `SettingsNavPane` calls `searchSettings(query)` and builds result titles from the static list, so both must take the dynamic sections or repo rows will be unsearchable and render as `repo:` (round-2 finding). Section id is `repo:` (the persisted id, not the path). If the selected repo disappears from the snapshot, selection falls back to `General`. Search entries are generated per repo section. | `kSettingsSections` is a static `final List` (`settings_registry.dart:24`) resolved statically by `settings_window.dart`. This is foundational, not incidental. | +| **D22** | Settings open state carries an **optional target section id**, replacing the bare `bool` (`window_overlays.dart:28`). | There is otherwise no way to open Settings *at* a repo. | +| **D23** | The P1 entry point is **desktop only**. `RepoCard` is the *mobile* home card (`home_screen.dart:53`); mobile Settings is a separate `/repos/settings` route. A mobile destination is out of scope for P1. | Rev 1's T4.1 targeted the wrong shell. Stating the scope is honest; pretending one task covers both is not. | +| **~~D11~~** | **Withdrawn as specified.** "Unknown keys preserved" is not free: `project-store.ts:81`/`:99` reconstruct and emit only `{id, path}`, and `manager.ts:204` copies only those into `ProjectDTO`. Rev 2 requires a *lossless round-trip for the `settings` object*, with an explicit test, in all three places — not an aspiration in a table. | | + +## Cut from the spec entirely (accepted YAGNI) + +The disabled Lifecycle Scripts group in P1 (advertises a dangerous feature before its trust model +exists); provider and default-branch overrides (D3′); the copy button; two-line rows as a blanket +mandate — subtitles only where they distinguish state; the monogram's hue determinism as a *tested* +requirement (keep the glyph, test fixed name→output fixtures); P4; and "pixel-perfect" as an +**acceptance gate** — the visual audit remains as *evidence*, alongside golden tests for geometry. + +## Phasing (rev 2) + +| Phase | Contents | Gate | +| --- | --- | --- | +| **P0 · foundations** | Router keeps a per-repo forge decision record; `ForgeInspector` port (D19); `sectionsFor(repos)` + `repo:` ids + fallback (D21); Settings open-target (D22); lossless `settings` round-trip through store *and* manager (~~D11~~) | rev 2 reviewed | +| **P1 · the page, with one live control** | The section rendered from real data; Worktree root editable end-to-end — persisted, canonicalised (D17), loopback-gated (D16), and routed through `addWorktree`, `addWorktreeForPr` **and** `uniqueWorktreeDir`; desktop entry point (D23); widget + integration tests; visual audit; real-app run | P0 | +| **P2 · the rest of the rows** | Branch prefix; mobile destination; whatever the P1 page proves is missing | P1 shipped | +| **P3 · lifecycle scripts** | Unchanged, and still gated on D13(a)/(b) — plus the reviewer's additions: no script text sent to paired devices, config file ownership/permissions, interpreter, child-process-tree termination, output redaction, pre-prune veto timeout and override | D13 confirmed | + +## What rev 2 explicitly does not do + +No provider override, ever (D3′). No global settings store, so no four-level chain (D8′). No mobile +repo-settings destination in P1 (D23). No script execution (P3). No GitLab provider. No custom logo +image. No `All repositories…` overflow. + + +--- + +# Rev 3 — the four identity rows become editable + +Requested after seeing the built section: **Logo, Root path, Git provider and Default branch must be +editable/selectable.** That reverses D4 and D14 as written, and re-opens two cuts that review round 1 +made — so each reversal is justified by the *concrete failure case* the reviewer said was missing, +rather than by the request alone. + +| # | Reversal | The failure case that justifies it | +| --- | --- | --- | +| **D3″** (was D3′, "read-out, no override, ever") | The provider is **selectable**: `Auto \| Forgejo \| Gitea \| GitHub`, `Auto` selected by default and its subtitle naming what it resolved to. | Round 1 cut this for having "no concrete failure case". There are two. Detection returns `unknown` for a private instance that answers 401 to an anonymous probe, and for an instance behind a proxy that hides `/api/forgejo/v1/version` — I verified both endpoints are the only discriminators. In either case the repo is routed to the *unsupported* provider and is unusable, **with no recourse anywhere in the product**. An override is the recourse. The reviewer's real objection stands and is accepted: it must control routing, auth lookup and PR rendering — not be a display preference. That is P2 work, and D3″ is not satisfied by the control alone. | +| **D4′** (was "displayed, not editable") | Root path is **changeable**. | "Remove and re-add" is not equivalent: it mints a new `PersistedProject.id`, and everything keyed to that id — per-repo settings, session history — is lost. A repo that simply **moved on disk** should keep its identity. Re-pointing preserves the id, which is the entire reason the id exists (`project-store.ts:28`). Constraint: it must re-validate that the target is a git repo and re-run detection, because the forge and default branch may both change. | +| **D14′** (monogram only) | The logo is **selectable** (colour + glyph from fixed sets). A custom *image* stays deferred. | Two repos whose names hash to the same hue are indistinguishable in the sidebar — which defeats the one thing the monogram exists for. Choosing from a palette needs no byte transfer, so it does not drag in the upload path that made a custom image P4. | +| **Default branch** (round 1: "no demonstrated consumer") | **Pickable from the repo's own branches.** | The consumer is concrete: `origin/HEAD` is genuinely absent after a `--single-branch` clone or a default-branch rename, and makit then shows the wrong base — so diff-vs-default and the PR base are both wrong. Picked from known branches, never free text: a typo silently breaks both. | + +## What rev 3 does not change + +D16 still governs: **only a loopback client may write any of these.** A non-loopback client sees the +same values, the selector inert, and one line saying where they are editable. Asserted by test. + +And the controls are affordances only until P2 supplies persistence and, for the provider, the routing +change D3″ demands. A selector that reports a choice nothing acts on is exactly the "ornamental" +failure round 1 named; it is acceptable *only* because P1 was explicitly sequenced UI-first, and the +plan's P2 now owns four writes rather than one. + +## Verification (rev 3) + +Interaction is proven by **widget test, not on the real app** — a stated coverage gap. Driving the built +macOS app with `cua-driver` did not work: a sidebar row *and* a 107×28pt segmented-button segment both +returned `"effect":"unverifiable"` and left the UI unchanged, on two separate builds. The real-app pass +therefore covers appearance only. 15 widget tests cover behaviour, two proven to bite by reverting the +read-only gate and by making reset freeze the detected forge instead of asking for `Auto`. + + +## Rev 3.1 — the provenance badges and the copy button are cut + +**D6″ supersedes D6′.** Rev 2 split badges into two families and kept both. Only the *resolution* +family survives, and only where nothing else in the row already says it: + +| Row | Rev 2 | Rev 3.1 | Why | +| --- | --- | --- | --- | +| Logo | `from name` | — | The monogram **is** the value. A chip explaining it is a caption on a caption. | +| Root path | copy button | — (chevron) | Copying a path is not a configuration task — round 1 said so and was overruled at the time. The row is editable now, so the slot belongs to the chevron like its neighbours. | +| Git provider | `detected` / `overridden` | — | The subtitle already reads `Auto: GitHub · …` or `Set to Gitea · …`. The badge was the same sentence twice. The reset button stays: it is an action, not a label. | +| Default branch | `from remote` | — | `main` is the fact. Once the row is editable, where it came from stops being actionable. | +| Worktree root | `inherited` / `overridden` | **kept** | The only row with no subtitle, and the distinction is the whole point of the feature. | + +The rule, stated once: **a badge appears only where nothing else in the row says it.** This is what +round 1 was reaching for — *"they are not one abstraction"* — and rev 2's compromise of keeping both +families kept the chrome round 1 objected to. Rev 3.1 finishes the cut. + +Pinned by a negative test (`provenance badges are absent`) so they cannot creep back, and by one +asserting that with **two** things overridden there are two reset buttons but exactly **one** badge. + + +## Rev 3.2 — the provider can be None + +`Auto | None | Forgejo | Gitea | GitHub`. **None** means makit talks to no forge for this repository and +stops checking pull requests. + +It earns a segment because it answers two things nothing else could: + +| Case | Before | After | +| --- | --- | --- | +| A purely local repo, no `origin` at all | `Auto: not identified yet` — implies a probe is pending when none can ever help. And the router sends it to the `gh` gateway, which fails on every poll. | `Auto: no remote, so no forge`, with a prohibit glyph. A conclusion, not a wait. | +| A mirror or vendored copy whose forge you do not care about | No way to stop the PR chatter | `None`, selected explicitly — an instruction, not an outcome | + +The distinction is the point: **"we could not tell" and "there is nothing to tell" must not read the +same**, because only the first is worth investigating. Pinned by a test asserting the two subtitles are +different strings and that the pending one is absent when there is no remote. + +This adds one fact to the DTO — `hasRemote` — which the server already computes (`git remote get-url +origin` runs in the routing path today) and currently throws away. And it makes P2's write set five: +worktree root, logo, root path, default branch, provider choice. + + +--- + +## P1 implemented — results + +| Piece | Where | Proof | +| --- | --- | --- | +| Settings model, resolution, validation | `server/src/repo_settings.ts` | 21 tests | +| Lossless persistence, unknown keys included | `project-store.ts`, `manager.ts` | 3 round-trip tests | +| Per-repo worktree root, **all three consumers** | `manager.worktreeRootFor` → `addWorktree`, `addWorktreeForPr`, `uniqueWorktreeDir` | 5 tests, incl. a collision that only exists under the override | +| Host-only writes | `ws/commands/repo_settings.ts`, gated on `WsClient.isLocal` | 14 tests | +| Forge decision record + inspector port | `forge/router.ts` | 6 tests | +| Settings on the wire | `protocol.ts` `RepoSettingsDTO`, `repo_service.ts`, `manager.settingsDtoFor` | typed DTO | +| Wire → view mapping | `repoSettingsViewFor` | 11 tests | +| Dynamic sidebar sections | `sectionsFor(repos)`, `settings_window`, `settings_nav_pane` | 8 tests | +| The section itself | `repository_section.dart`, `repository_settings_page.dart` | 23 tests | + +**Server 1429/1429, typecheck clean. `flutter analyze` clean.** Every group above had at +least one bite proven by reverting the production line: the loopback gate, the override lookup, the +collision-check root, the pinned filter, the nav-pane search threading, the read-only gate, and reset's +return to `Auto`. + +### Two bugs the tests found that review did not + +- **The `..` rejection was dead code.** It split a *normalised* copy of the path, and `normalize()` + collapses `..`. The test could not catch it either, because `path.join` collapses too — the case only + became reachable once the test built the string by concatenation. A traversal would have been rejected + by the containment rule instead, with a misleading message. +- **`realpath` fails on a path that does not exist**, so canonicalisation had to walk to the nearest + existing ancestor. Without that, naming a worktree root before creating it — the common case — would + have been refused. + +### What P1 does not do, and is honest about on screen + +`Root path` shows a notice rather than an edit: re-pointing a project needs the daemon to re-check it is +a git repo and re-run detection, and getting that wrong silently detaches a project from its sessions. +The provider choice is **stored and served but does not yet re-route** — `D3″` demands it drive routing, +auth lookup and PR rendering, which is P2. Lifecycle scripts remain P3 behind `D13`. + + +### The live proof the tests could not give + +A throwaway probe over the real machine — two real `git init` repos, a real +`projects.json`, a real `SessionManager`, and a real `git worktree add`: + +``` +settings survived the round trip PASS +an unknown key survived too PASS +the untouched repo stays two-key PASS +repo A uses its override /Users/le/.makit-e2e-trees-alpha +repo B inherits /Users/le/.worktrees +A reports source=override, B reports source=default PASS +no token is ever on the wire PASS +A's unknown key was not clobbered by B's write PASS +worktree created under the override + /Users/le/.makit-e2e-trees-alpha/makit-alpha-DAfwAT/feat-e2e +...and not under the inherited root PASS +``` + +The last two are the point: the setting **moves where `git worktree add` writes**, which no unit test +can establish. The probe was deleted; its temp repos and roots were removed. + +**Limitation it exposed:** the probe reloaded settings from the file rather than restarting the process. +Reload is what a restart does, but a true restart would also re-run detection, so the `forge` field's +behaviour across a restart is still unproven. + + +--- + +# P2 implemented — the settings stop being ornamental + +P1 shipped five writes; **three of them changed no behaviour anywhere**, which is +precisely the failure round 1 named and rev 3 accepted as a risk. P2 closes that, +plus the one row that was missing entirely. + +| Setting | After P1 | After P2 | +| --- | --- | --- | +| Worktree root | ✅ three consumers | unchanged | +| **Git provider** | stored, served, displayed — router ignored it | **routes**: picks the gateway, skips the probe, `None` reaches no forge | +| **Default branch** | stored, served, displayed — nothing read it | **resolved**: diff base, worktree base, wrap-up sync | +| **Logo hue** | stored, served, parsed — dropped in mapping | **drawn**, in the section and the sidebar | +| **Root path** | a notice: "not supported yet" | **re-pointable**, id and settings preserved | + +## What the provider override does now (D3″ satisfied) + +`forgejo`/`gitea` route to the REST gateway **without probing**, `github` to the +`gh` gateway (the only way to reach `gh` for a GitHub Enterprise host), and `none` +to a new gateway that reads no remote and makes no request. + +Two decisions worth recording: + +**The probe is skipped, not merely ignored.** The cases the override exists for are +exactly the ones where the probe cannot answer — a private instance that 401s an +anonymous request, one behind a proxy hiding `/api/forgejo/v1/version`. Spending it +anyway would delay every poll to learn nothing. + +**`none` is not `unsupported`.** Unsupported means *we cannot talk to this forge* +and answers `unknown`; `None` means *do not talk to any forge here* and answers +`none`. `unknown` would make the app hold a stale PR pill and keep retrying +(SPEC-32 §6.5) — the exact chatter `None` exists to stop. Pinned by a test +asserting the two gateways do not report the same thing. + +**The routing cache keys on the choice.** Without that the setting would apply only +after a daemon restart, which is indistinguishable from a broken feature. An +unchanged choice still shares one `git remote` read, so the fan-out stays cheap. + +## Two bugs found while wiring it + +- **`hasRemote` was derived from `forge !== undefined`.** Those two facts have three + states between them — *not measured*, *no remote*, *a forge* — and one boolean + cannot hold three. Every un-polled repo claimed to have no origin, which made the + app's `Auto: not identified yet` branch **unreachable** and sent the reader + hunting for a remote that was never missing. rev 3.2 pinned that these must read + differently; the server could not produce the distinction. The router now records + the remote as its own fact. +- **The re-point duplicate check compared paths at different canonicality.** On + macOS `/tmp/x` and `/private/tmp/x` are one directory, so two projects could + occupy one path — where settings and the forge decision, both keyed BY PATH, would + answer for each other. + +## Two tests that were vacuous until they were fixed + +Recorded because both passed while proving nothing, which is worse than failing: + +- a worktree-base test whose fixture branched from `main` with no commit of its own, + so `merge-base` could not tell which branch was forked; +- a logo test asserting a chosen hue of `3` differed from the derived one — `Diana` + hashes to 3. + +## Verification + +**Server 1496/1496, typecheck clean. `flutter analyze` clean.** Every group has a +bite proven by reverting one production line: the cache re-check, the override +branch, each new default-branch consumer, the loopback gate on `repo.path.set`, the +canonical duplicate check, and `sectionsFor`. + +`app/integration_test/desktop/settings_repo_test.dart` (T6.2, the last undone P1 +task) mounts the real `SettingsWindow` on a **real macOS build** and drives +`reposProvider → sectionsFor → nav pane → page → rows`. All five tests fail when +`sectionsFor` ignores the repo list. + +### The live proof, over a real daemon + +A throwaway probe — real git repos, a real `SessionManager`, the real command +router, a real `projects.json`, persistence wired exactly as `serve.ts` wires it — +**21/21**: + +``` +detection cannot identify the instance -> unsupported PASS +the override RE-ROUTES with no restart served=forgejo +and spends no probe -- the probe is what failed PASS +repo B still routes on its own host served=github +None reaches no provider at all served= +None answers 'none', not 'unknown' PASS +the snapshot's default branch follows the override got=trunk +an un-routed repo reports hasRemote true PASS +a paired phone CANNOT re-point PASS +a non-git directory is refused with a reason PASS +re-pointing onto another project's path is refused PASS +the project KEEPS its id ...and its settings PASS +the moved path survives the reload PASS +no token appears in the repos snapshot PASS +``` + +The first four are the point: the setting **changes which provider serves the +repo**, which no unit test can establish. The probe was deleted. + +## "New worktree from PR" on both providers + +Found by asking the obvious follow-up question: if the provider setting routes, does +the PR picker follow it? Listing did — `listOpenPrs` goes through the gateway. The +**checkout** did not: it ran `gh pr checkout` unconditionally, so the flow broke +exactly halfway. The user saw their Forgejo PRs, picked one, and the worktree never +appeared. + +| Provider | List | Checkout | +| --- | --- | --- | +| GitHub | gateway → `gh` | `gh pr checkout` (unchanged) | +| Forgejo / Gitea | gateway → REST | **`refs/pull//head`**, plain git | + +`gh` is kept for GitHub rather than replaced by the generic path: it already handles +fork PRs and sets up push tracking, and swapping a working path for a hand-rolled +equivalent is a regression risk taken for tidiness. `refs/pull//head` is used for +Forgejo instead of the head branch name because the forge creates it for **every** PR +including forks', whose branch is not on `origin` at all. + +Upstream tracking is set only for a same-repo PR, so a push updates the PR. For a +fork it is left unset on purpose: pointing it anywhere would aim a push at a branch +that is not the PR's. + +The strategy resolves from the same two sources, in the same order, that the router +uses to pick a gateway — override first, then detection — so the checkout cannot +disagree with the provider that served the list. + +Proven end to end over the real commands (`pr.list`, `worktree.createFromPr`) against +a real bare repo publishing a real `refs/pull/7/head`, **12/12**: listed by the +Forgejo provider on a host detection could not identify, strategy `pull-ref`, worktree +at the PR's commit under the repo's own worktree root. The GitHub path, which had no +test at all before this, is now pinned argv-and-all through a PATH shim. + +## What P2 does not do + +- **Lifecycle scripts** remain P3, still gated on D13(a)/(b). +- **Branch prefix** has no source and is still not rendered (D9). +- **A repo-card entry point on mobile** — repo sections are reachable from the + desktop sidebar; the mobile destination and the `repo:` deep link (F5/T5) are + not built. Nothing is unreachable as a result. +- **Sessions already bound to a worktree keep their recorded paths** across a + re-point. For the case D4′ exists for, worktrees live under the worktree root and + are unaffected; a session whose worktree was the repo directory itself still + points at the old location. Stated in the code, not only here. +- **Interaction on the real app is still verified by test, not by clicking.** + `cua-driver`'s synthesized clicks do not land in this Flutter app + (`"effect":"unverifiable"` on three builds), so the macOS integration test is the + substitute — it is a real build, driven by the Flutter harness rather than by the + window server. diff --git a/mockups/forge-detection-display.html b/mockups/forge-detection-display.html new file mode 100644 index 00000000..07683d28 --- /dev/null +++ b/mockups/forge-detection-display.html @@ -0,0 +1,263 @@ + + + + + +makit — Forge detection display + + + + +
+

Forge detection in makit

+ +
+
What we're shipping
+
+ No UI needed yet. The app automatically detects the forge provider by probing the git remote host. Most users have one provider (GitHub, Forgejo, or Gitea). Multi-provider repos get GitHub's full feature set; other providers get the essential PR features (state, branch, merge). +
+
+ +

Detection logic

+ +
+ +
+
🔍 GitHub repo (most common)
+

Remote: github.com/user/repo

+
+
🐙
+
+
GitHub
+
github.com
+
+
+

+ ✓ Full feature set: PR checks, rate-limit tracking, merge state, thread resolution.
+ ✓ Budget panel shows quota health (core, graphql buckets). +

+
+ + +
+
🔍 Forgejo repo (your instance)
+

Remote: forgejo.internal.xdent.ai/le/repo

+
+
+
+
Forgejo
+
forgejo.internal.xdent.ai
+
+
+

+ ✓ Essential PR features: state, branch, merge.
+ ✗ No quota (no rate-limit endpoint). Poll at fast cadence (5s).
+ ✗ Merge state: no "behind" detection (use commit comparison).
+ ✗ Unresolved threads: requires N+1 queries. +

+
+ + +
+
🔍 Gitea repo (public)
+

Remote: gitea.com/user/repo

+
+
🍵
+
+
Gitea
+
gitea.com
+
+
+

+ ✓ Same surface as Forgejo (both share the Gitea API).
+ ✗ No quota. Needs GITEA_TOKEN env var if the repo is private. +

+
+ + +
+
❓ Unknown or unsupported forge
+

Remote: git.company.com/team/repo (probes return 404/403/timeout)

+
+
?
+
+
Unknown
+
git.company.com
+
+
+

+ ⚠ No PR support. The PR widget is hidden. Multi-repo features work (sessions, chat), just not PR automation. +

+
+
+ +

How it works: detection probes

+ +
+
+
1. probe /api/forgejo/v1/version
+
+ 200 OKForgejo +
404 Not Found → try next +
+
+ +
+
2. probe /api/v1/version
+
+ 200 OKGitea (or Forgejo on older versions) +
404 Not Found → try next +
+
+ +
+
3. probe /api/v4/version
+
+ 401 UnauthorizedGitLab +
404 Not FoundUnknown +
+
+
+ +
+ Probes run in SEQUENCE, each with its own timeout. classify tries them in order and returns on the first decisive answer, so an instance that identifies itself on the first probe costs one request. If none is decisive the forge is Unknown and PR features are hidden — and because unknown is cached only briefly, a host that was merely unreachable is re-probed rather than written off. (This page described parallel probes with a single 10s budget; the shipped detector in server/src/forge/detect.ts does not.) +
+ +

UI affordances (future)

+ +
+ Superseded: there is a settings UI now. This page recorded the design before per-repo settings landed. Each repository has a Git provider row (Auto | None | Forgejo | Gitea | GitHub) in its own Settings section, and a non-Auto choice picks the provider without probing — which is the recourse for exactly the case named here, a repo whose host does not advertise what it runs. MAKIT_FORGEJO_BASE_URL still supplies the instance URL and scopes the token to it. See docs/specs/2026-08-10-SPEC-48-per-repo-settings.md. +
+ +

Performance

+ +
+
+
Local instance
+
~50–80ms
+
+
+
Cloud instance
+
~600–800ms
+
+
+
Caching
+
Per host; unknown re-probed
+
+
+ +
+ Why cache? A decisive answer does not change mid-session, and it is keyed by the instance's normalised base URL, so every repo on one host shares a single probe. An unknown answer is cached for only 60s and then re-probed, so a briefly unreachable instance is not written off until restart — and re-pointing a project discards its routing decision outright. +
+ +
+ + + diff --git a/mockups/forge-provider-per-repo.html b/mockups/forge-provider-per-repo.html new file mode 100644 index 00000000..f572bd1f --- /dev/null +++ b/mockups/forge-provider-per-repo.html @@ -0,0 +1,511 @@ + + + + + +makit — Which forge is this repo on? + + + + + + +

makit — Which forge is this repo on?

+

+ The server can now identify a forge instead of guessing: it probes + /api/forgejo/v1/version, /api/v1/version and /api/v4/version, + once per host, cached. Verified live against Forgejo (self-hosted + Codeberg), Gitea, GitLab and an + unrelated host. Nothing in the UI surfaces any of it. These are the three places it + should, and one shape the config should not take. +

+
+ Status: this page is a PROPOSAL, and part of it has since shipped. Read it as the + argument that led to the design, not as a description of current behaviour. What is built now: each + repository has its own Settings section with a Git provider row + (Auto | None | Forgejo | Gitea | GitHub) that drives routing, plus worktree root, + default branch and logo. Tokens remain per instance, exactly as argued below. The current + behaviour is documented in docs/specs/2026-08-10-SPEC-48-per-repo-settings.md and + docs/DEVELOPMENT.md §1b. +
+
+ The premise needs one correction. The ask was “set up a git provider per repo”. + But an API token authenticates an instance, not a repository — the server already enforces that + (forgejoRefFromRemote withholds a token from any host other than the configured one, so an + internal token cannot leak to codeberg.org). So credentials belong to the instance, and the + only genuinely per-repo thing is an override for when detection is wrong. Designing this as + per-repo credentials would mean asking for the same token once per repository. +
+ + +
+
+

1 · The forge is a fact, not a setting

RECOMMENDED +

A chip in the row that already carries branch / diff / PR chips. No setup, no prompt.

+
+
+
+
+

iOS — repo card

+
+
+
9:41􀙇 􀛨
+ +
+
+
Diana
+
+ Forgejo + main + +248 −31 + 2 PRs +
+
+
makit
+
+ GitHub + main + 1 PR +
+
+
legacy-tools
+
+ GitLab — unsupported + master +
+
+
+
+
+ +
+

macOS — sidebar + repo pane

+
+
+ +
+
+
+
Diana
+
makit
+
legacy-tools
+
+
+
Diana + forgejo.internal.xdent.aisigned in
+
+ Forgejo 16.0.0 + main + 2 PRs +
+
+
feat/attachments — #142
Open on Forgejo
+
+
fix/lfs-message — #139
Open on Forgejo
+
+
main
no PR
+
+
+
+
+ +
+

why

+
    +
  • Zero-setup is the default. Detection is right for every forge tested, so the + common path must not involve a form. The chip reports; it does not ask.
  • +
  • It joins an existing family. repo_chips.dart already has + BranchChip, DiffChip, PrStatusChip, TagChip at + kPillIconSize = 11. A ForgeChip costs a row of that file, not a screen.
  • +
  • The unsupported case finally speaks. Today a GitLab remote polls a Forgejo API + that is not there and reports unknown — pixel-identical to “instance down”. The chip + says GitLab — unsupported, and the server logs the host once.
  • +
  • The glyph must come from the server. forge_glyph.dart currently + re-derives the forge from the PR URL in Dart. That is a third copy of a guess the server now + answers properly; it should read the DTO and delete its own rule.
  • +
+

shown only when it earns the space

+

GitHub is the overwhelming majority for most users, so a “GitHub” chip on every card is noise. + Render the chip when the forge is not GitHub, or when it is unsupported. A one-forge user + sees no new chrome at all.

+
+
+
+
+ + +
+
+

2 · Credentials belong to the instance

RECOMMENDED + +

One row per instance, not per repo. Replaces today's env-only FORGEJO_ACCESS_TOKEN.

+
+
+
+
+

macOS — Settings ▸ Forges (new section)

+
+
+ +
+
+
+
General
+
Agents & chat
+
Forges
+
Server & devices
+
+
+
Forges
+
+
detected instances
+
+
github.com
gh signed in
+
+
forgejo.internal.xdent.ai
Forgejo 16.0.0 · 3 repos
+ token set
+
+
gitea.example.org
Gitea 1.27 · 1 repo
+ no token
+
+
git.legacy.example
GitLab — no provider yet
+ read-only
+
+
Tokens are stored per instance and never sent to another host.
+
+
+
+
+ +
+

iOS — instance detail

+
+
+
9:41􀙇 􀛨
+ +
+
+
forgejo.internal.xdent.ai
+
Software
Forgejo 16.0.0
+
Repositories
3
+
+
access token
+
••••••••••••••••••••••••••••••••••••c0e1
+
Settings ▸ Applications on your instance. Needs + read:repository; PR actions also need write:repository.
+
+
+
Test connection
+
+
+
+
+ +
+

why the instance, not the repo

+
    +
  • It is what the token actually is. The server withholds a token from any host + other than the one it was configured for — that is a deliberate security property, added after a + bug that would have sent an internal token to codeberg.org. Per-repo credentials + would fight it.
  • +
  • It de-duplicates. Three repos on one Forgejo means one token, entered once.
  • +
  • Detection fills the list. Rows appear because a repo was added, not because + someone registered a server. The only editable field is the token.
  • +
+

what this replaces

+

Today the only way to authenticate is exporting FORGEJO_ACCESS_TOKEN before the + daemon starts — invisible to the app and impossible from a phone. Env vars should keep working and + show as from environment, read-only.

+
+
+
+
+ + +
+
+

3 · Per-repo override — the escape hatch

RECOMMENDED +

In the repo overflow menu that already exists (dotsThree, repo_card.dart:188).

+
+
+
+
+

iOS — repo menu

+
+
+
9:41􀙇 􀛨
+ +
+
Diana
+
+
+
New session
+
Resume session
+
+
Forge…
Forgejo
+
+
Remove from makit
+
+
+
+
+
+ +
+

iOS — the override sheet

+
+
+
9:41􀙇 􀛨
+ +
+
Diana · forgejo.internal.xdent.ai
+
+
Detected automatically
Forgejo 16.0.0
+
+
or choose manually
+
Forgejo
+
Gitea
+
GitHub
+
+
GitLab
no provider yet
+
+
Only change this if detection is wrong — a proxy hiding + /api/forgejo, for example.
+
+
+
+
+ +
+

why a disclosure, not a required step

+
    +
  • “Detected automatically” is pre-selected and named. The user sees what was + found, so the override is a correction rather than a question.
  • +
  • The unsupported option is visible but disabled. Listing GitLab greyed out + answers “can I use GitLab?” without a support round trip. Hiding it invites the question.
  • +
  • It reuses the existing menu. repo_card.dart already has + themedMenuItem entries for New session / Resume session / Remove from makit; this is + a fourth entry before the divider, plus one sheet.
  • +
  • An override must survive a restart, so it is server-side state keyed by repo + path — which is the part that needs a protocol addition, not a widget.
  • +
+
+
+
+
+ + +
+

4 · Every state the server can produce

+

Vocabulary is ForgeSoftwareName in server/src/forge/types.ts. No invented states.

+
+
+ + + + + + + + + + + +
server valueprovider usedchipPR buttonmutations
githubgh gatewaynone (majority case)Open #42 on GitHuball
forgejoforgejo RESTForgejoOpen #42 on Forgejoall; “ready” rewrites the title
giteaforgejo RESTGiteaOpen #42 on Giteaall; same API
gitlabunsupportedGitLab — unsupportedOpen #42 (no forge named)refused, with the reason
unknownunsupportedUnrecognised forgeOpen #42refused
unknownretried in 60sChecking…Open #42refused meanwhile
no readable remotegh gatewaynonehidden (no PR)n/a
+

+ Two unknown rows on purpose: a failed probe is cached for 60s, not forever, so an instance + that was briefly down is not pinned as unsupported until the daemon restarts. The UI should distinguish + “we could not tell yet” from “we asked and it is not supported”. +

+
+
+ + +
+

5 · Shapes rejected

REJECTED
+
+ + + + + + + + + + + + + + +
shapewhy not
A required “choose your provider” step when adding a repoAsks the user to answer what the server already knows, and gets it wrong more often than + detection does. Setup friction on the majority path to serve a rare one.
Per-repo token fieldsA token authenticates an instance. Three repos on one Forgejo would mean pasting the same + secret three times and three places to rotate it — and it contradicts the host-scoping the + server enforces.
A free-text “API base URL” per repoDerivable from the remote in every case tested. Keep it as an instance-level override for + sub-path installs (today's FORGEJO_BASE_URL), not a per-repo field.
Silently treating unknown forges as Forgejo AS BUILT (before)What shipped until detection landed: a GitLab remote polled a nonexistent API and read as + “unknown”, indistinguishable from an outage.
A “GitHub” chip on every repo cardChrome for the majority case to serve the minority. Show the chip only when the forge is not + GitHub, or is unsupported.
+
+
+ + +
+

6 · Deltas, ordered by value per line changed

+

Nothing here is built yet. Detection (server) is.

+
+
+ + + + + + + + + + + +
#changefilesize
1Carry the detected forge on the repo DTO: forge?: {software, host, authed, source}. Optional, so an older app renders no chip rather than a fabricated one.server/src/protocol.ts~14
2Expose softwareFor(repoPath) from the router and populate the DTO in the repo snapshot.server/src/forge/router.ts
server/src/repo_service.ts
~25
3ForgeChip beside the existing chips; render only for non-GitHub or unsupported.app/lib/ui/home/repo_chips.dart~45
4Delete the Dart-side guess: forgeKindForUrl reads the DTO instead of re-deriving the forge from the PR URL. Removes the third copy of one rule.app/lib/ui/widgets/forge_glyph.dart−25
5Instance list + token field, with env-provided values shown read-only.app/lib/desktop/settings/sections/
forges_section.dart (new)
~190
6Persist and apply a per-repo override; a forge.setOverride command plus storage keyed by repo path.server/src/ws/commands/forge.ts (new)
server/src/forge/router.ts
~120
7“Forge…” entry in the repo overflow menu + the override sheet.app/lib/ui/home/repo_card.dart
app/lib/ui/home/forge_sheet.dart (new)
~150
+

not changed, and why

+
    +
  • No GitLab provider. That is a third implementation (merge_requests, + different auth), not a config entry. Rows 1–7 make makit honest about GitLab; they do not + support it.
  • +
  • The budget footer stays GitHub-only. Forgejo has no rate limiting to display — + no /rate_limit, no headers, no config knob.
  • +
  • Detection stays server-side. The app is told; it never probes. One answer, one + place, no drift.
  • +
+
+
+ + + diff --git a/mockups/repo-settings.html b/mockups/repo-settings.html new file mode 100644 index 00000000..416c41e6 --- /dev/null +++ b/mockups/repo-settings.html @@ -0,0 +1,495 @@ + + + + + +makit — Per-repo settings (matched to the built app) + + + + + + +

makit — Per-repo settings

+

+ A repository needs more than a forge: a logo, a default branch, a worktree root that may or may not follow + the global one, and lifecycle scripts. That is a settings surface, not a field — so the question is + the pattern it grows into, not the first row. +

+
+ Card 1 tracks the built section, screenshotted from the real macOS app. All four + identity rows are editable, the provider is a segmented control in the Endpoint idiom, and + the provenance badges are gone: from name beside a monogram, + from remote beside main, and detected beside a subtitle already + reading “Auto: Forgejo · …” are each the same sentence twice — and once every row became editable, where + a value came from stopped being actionable. The copy button went with them: copying a path is not a + configuration task. What survives is inherited / overridden on Worktree root, + the one row with no subtitle, where the distinction is the whole point. +

+ The provider selector also offers None: makit talks to no forge and stops checking + pull requests. That is an instruction, distinct from Auto failing to identify one — and it + gives the two states that previously read identically their own words: “Auto: no remote, so no forge” + is a conclusion, “Auto: not identified yet” is a probe still pending. +

+ Provider auto-detection is already built and verified (server/src/forge/detect.ts). + It probes /api/forgejo/v1/version, /api/v1/version and /api/v4/version, + once per host, cached — correctly identifying Forgejo (self-hosted + Codeberg), Gitea, GitLab and an unrelated + host against live servers. So the forge row below is a read-out with an override, never a required + choice. Nothing else on this page is built. +
+
+ Lifecycle scripts are the one genuinely dangerous item here, and they change what makit is. + They run on the host as the daemon user, which holds your gh token, your + FORGEJO_ACCESS_TOKEN and your SSH keys. Two decisions have to be made before any of this + is built — see card 4. Both are easy to get wrong in a way that is not recoverable by a later patch. +
+ + +
+

1 · One Settings section per repository

RECOMMENDED +

Fixed app sections, then a REPOSITORIES group — the Mail/Finder idiom. Same destination from the repo card's dotsThree menu.

+
+
+
+

macOS — one sidebar section per repository

+
+
+
+
+
Settings
+ + + + + + + + + + + + + +
+
+
Diana
+
identity
+
+
+
Logo
+
+
+
Root path
+ ~/Work/XDent/Diana +
+
+
Git provider
Auto: Forgejo · forgejo.internal.xdent.ai · token set
+
+
+ AutoNoneForgejoGiteaGitHub +
+
+
Default branch
+ main +
+
+ +
worktrees
+
+
Worktree root
+ ~/.worktreesinherited +
+
Worktree root (overridden)
+ ~/.worktrees/makitoverridden +
+
+ +
+
+
+
+ +
+

iOS — same page, one column

+
+
+
9:41􀙇 􀛨
+ +
+
identity
+
+
Logo
+
Git provider
Auto: Forgejo
+
Default branch
main
+
+
worktrees
+
+
Worktree root
~/.worktrees inherited
+
+
Worktree root is editable on the machine running makit; a paired phone shows + the same values read-only (D16). Lifecycle scripts are P3 and deliberately not advertised here.
+
+
+
+
+ +
+

why a section per repo, not one “Repositories” page

+
    +
  • It is bounded by what you added. Sections come from projects — three on this + install (makit, Diana, piano). Repos makit merely noticed are + pinned:false (manager.ts:287 vs :215) and stay out of the + sidebar, so it cannot turn into a file browser.
  • +
  • Grouped, so the taxonomy stays honest. Fixed app sections are a closed set; + repos are data. Interleaving them in one flat list is a smell — under a + REPOSITORIES header it is just the Mail/Finder sidebar idiom, and the monogram makes + the group scannable at a glance.
  • +
  • One click, and the page gets simpler. No list-then-detail drill, and the green + page title becomes the repo name — the breadcrumb an “All repositories” page would have needed + disappears entirely.
  • +
  • Search still reaches everything. The existing “Search settings” field is what + makes a longer sidebar safe: “worktree root” finds the row whichever section owns it.
  • +
+

the risk worth naming

+

The sidebar becomes a second place that lists repositories, alongside the repo-centric home. + Two lists drift — different order, different names, different logos. Mitigation is not a rule but a + shared source: both render from the same RepoDTO and the same monogram widget, which is + row 6 of the deltas below.

+

if it ever grows

+

Past roughly ten repos, add a single All repositories… entry at the end of the group and + keep only pinned ones inline. Not worth building now — three sections is not a scrolling problem, and + the overflow rule is cheap to add later precisely because pinned already exists.

+
+
+
+
+ + +
+

2 · Inheritance: show the effective value and where it came from

RECOMMENDED
+
+ + + + + + + + + + + + + + + + + + + +
settingresolution order (first hit wins)badge shown
Worktree rootrepo override → global setting → MAKIT_WORKTREE_DIR~/.worktreesinherited / overridden
Git providerrepo override → detection (detect.ts) → unsupportednone — the subtitle says it
Default branchrepo override → origin/HEADmainnone — main is the fact
Logorepo override (logoHue, a palette index) → deterministic monogram from the namenone — the monogram is the value
Access tokenper instance, never per repo — see forge-provider-per-repo.html
+
+

three rules that keep this honest

+
    +
  • Always render the effective value, never an empty field. A blank “Worktree root” + box that silently means ~/.worktrees is how people end up with worktrees somewhere they + did not expect. Show ~/.worktrees with inherited beside it.
  • +
  • The badge names the source, and it is the undo target. + overridden is the only state with a visible + — reverting means “inherit again”, + not “set to the current global value”, so a later change to the global still propagates.
  • +
  • Env vars are a source, not a competitor. MAKIT_WORKTREE_DIR already + exists and must keep working; it slots into the chain above the built-in default and renders as + from environment, read-only, because the app cannot change the daemon's env.
  • +
+
+
+
+ + +
+

3 · The logo, without a file-picker problem

RECOMMENDED
+
+
+
+

default: deterministic monogram

+
+
+
Diana
+
makit
+
legacy-tools
+
infra-prod
custom image — not built
+
+
Hue is derived from the repo name, so it is stable across devices without syncing anything. What persists is logoHue, an index into a fixed six-colour palette (RepoSettings in server/src/repo_settings.ts) — there is no image field, and the “custom image” state above is not built.
+
+
+
+

why a monogram first

+
    +
  • Nothing to configure, and it already distinguishes. The list needs to be scannable + more than it needs to be branded. Deriving hue from the name means every device shows the same colour + with zero state.
  • +
  • A custom image is a file that must reach the daemon. Picking one on a phone means + uploading bytes to the host — makit has a media route, so it is possible, but it is a real transfer + path with size and type validation, not a settings row. Ship the monogram, add image upload later.
  • +
  • Do not auto-scrape the repo. Reading a favicon or the first README image is a + surprise: the logo would change when someone edits a file. An explicit override is predictable.
  • +
+
+
+
+
+ + +
+

4 · Lifecycle scripts — two decisions before any code

SECURITY +

They run as the daemon user, next to your forge tokens and SSH keys.

+
+ + + + + + + + + + + + + + + + + + + +
decisionrecommendedwhat goes wrong otherwise
Where does the script text live?RECOMMENDED In makit's own config (projects.json), authored + on the host. Never read from the repository working tree.If makit runs .makit/hooks/post-worktree-create.sh from the repo, then + cloning a repository and adding it to makit is arbitrary code execution. Reviewing a + stranger's PR branch would run their script. This is the direnv / workspace-trust trap.
Who may set one?RECOMMENDED The host only. A paired phone can read the row but not edit + it; the iOS page shows “Editable on the host only”.makit pairs with phones. If any paired device can write a script the daemon executes, pairing + stops meaning “chat with an agent” and starts meaning “remote shell on my laptop”. A lost phone + becomes a host compromise.
If repo-provided hooks are ever wantedExplicit per-repo trust, granted on the host, with the script's full text shown before the first + run and re-prompted whenever it changes.Silent execution of file contents that change under version control.
+ +

and the boring parts that still matter

+
+
+

macOS — editing a hook (host only)

+
+
+
+
+
Diana
+
Lifecycle scripts + After worktree create
+
#!/bin/sh +pnpm install --frozen-lockfile +cp "$MAKIT_REPO_ROOT/.env.local" .
+
+
Working directory
the new worktree
+
Environment
MAKIT_* only allowlist
+
Timeout
120s
+
On failure
keep the worktree, show a notice
+
+
Output is captured to the session log, so a failing hook is diagnosable rather than a worktree that “just did not work”.
+
+
+
+
+
+
    +
  • Do not hand the hook the daemon's environment. It holds + FORGEJO_ACCESS_TOKEN and whatever else launched the daemon. Pass a documented + allowlist (MAKIT_REPO_ROOT, MAKIT_WORKTREE, MAKIT_BRANCH) + so a hook that leaks its env leaks nothing that matters.
  • +
  • Failure must not destroy work. A failed post-create hook leaves the + worktree and reports; a failed pre-prune hook cancels the prune. Prune is the destructive + verb, so its hook is the one with veto power.
  • +
  • Timeout, always. A hook that waits on stdin would otherwise hang worktree + creation forever — the same non-tty hazard the server already guards for + gh pr merge.
  • +
+
+
+
+
+ + +
+

5 · Storage: extend what already persists

RECOMMENDED
+
+
+

server/src/project-store.ts already persists one record per project to + $MAKIT_HOME/projects.json, server-side, with a documented rule that a corrupt or missing + file degrades to an empty list so the daemon always starts. That is the right home: the daemon itself + needs these values (worktree root, hooks), and it is keyed by a stable id that survives restarts.

+

Not SharedPreferences. Today's settings live in app-side prefs, which are + per-device: a worktree root set on the phone would not reach the daemon that creates the worktree, and + two paired devices would disagree. Per-repo settings must be server-owned, with the app as an editor.

+
+ + + + + + + + + + + +
#changefilesize
1PersistedProject.settings?: RepoSettings — every field optional, so absent means “inherit”. Unknown keys preserved on save so an older daemon does not silently drop a newer app's field.server/src/project-store.ts~60
2Resolver: effective value + its source, one pure function per setting. This is what the badges render, and it is the whole inheritance model.server/src/repo_settings.ts (new)~120
3Wire the worktree root through it — replaces the direct MAKIT_WORKTREE_DIR read at git.ts:33, which is the only consumer today.server/src/git.ts~15
4repo.settings.get / repo.settings.set; set rejects hook fields unless the caller is the host.server/src/ws/commands/repo_settings.ts (new)~110
5Settings ▸ Repositories: list + detail, built from SettingsGroup / SettingsSectionHeader / SettingsResetButton.app/lib/desktop/settings/sections/
repositories_section.dart (new)
~260
6Monogram logo widget + “Settings…” entry in the repo card menu.app/lib/ui/home/repo_logo.dart (new)
app/lib/ui/home/repo_card.dart
~70
7Hook runner: allowlisted env, cwd, timeout, output to the session log, pre-prune veto.server/src/worktree_hooks.ts (new)~180
+

order I would build it

+

+ Rows 1–3 first and alone: they make the inheritance model real and immediately fix a live wart + (worktree root is global-only today), with no new UI and no security surface. Row 5 next, read-only, so + the page exists and shows detected values. Rows 4 and 6 make it editable. Row 7 last, and only + after card 4's two decisions are settled — it is the only irreversible one. +

+
+
+ + + diff --git a/scripts/forge b/scripts/forge new file mode 100755 index 00000000..9dcfca54 --- /dev/null +++ b/scripts/forge @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# +# forge — a provider-neutral shim over `gh` (GitHub) and `tea` (Forgejo/Gitea). +# +# WHY THIS EXISTS +# The agent-facing prompts in app/lib/ui/widgets/pr_actions.dart tell the model +# which command to run. Without this shim every prompt needs a per-provider +# variant. With it they name one verb and the shim picks the tool from the +# repo's own remote. +# +# WHAT THIS IS NOT +# Not a data source. It never parses JSON and the makit daemon never calls it: +# the daemon talks to each forge over its own typed provider +# (server/src/forge/**). This file only translates verbs and passes output +# through verbatim for a human or a model to read. Keep it that way — the moment +# it starts extracting fields, the logic belongs in TypeScript where the test +# suite can reach it. +# +set -euo pipefail + +usage() { + cat <<'EOF' +forge — provider-neutral PR commands (gh for GitHub, tea for Forgejo/Gitea). + + forge pr create [args...] create a pull request + forge pr list [args...] list pull requests + forge pr view [N] [args...] show a PR with its comments + forge pr checks [N] show a PR's CI status + forge provider print the detected provider + +The provider is read from the `origin` remote: github.com means gh, anything +else is treated as a Forgejo/Gitea instance. +EOF +} + +die() { + printf 'forge: %s\n' "$1" >&2 + exit 1 +} + +need() { + command -v "$1" >/dev/null 2>&1 || die "$1 is not installed (try: brew install $1)" +} + +# The forge hosting `origin`. Anything that is not github.com is treated as +# Forgejo/Gitea, because those are self-hosted on arbitrary hostnames — there is +# no domain to match against. +detect_provider() { + local url host + url="$(git remote get-url origin 2>/dev/null || true)" + [ -n "$url" ] || die "no 'origin' remote here; run this inside a repo checkout" + # Strip scheme then userinfo, then cut at the first ':' or '/' — handles both + # the scp-like (git@host:owner/repo) and URL (https://host/owner/repo) forms. + host="${url#*://}" + host="${host#*@}" + host="${host%%[:/]*}" + case "$host" in + github.com | *.github.com) printf 'github\n' ;; + '') die "could not read a host out of origin ($url)" ;; + *) printf 'forgejo\n' ;; + esac +} + +# `tea pr ` shows PR detail, including the CI status list; with no index it +# lists. Callers below always pass --comments explicitly because tea PROMPTS for +# it when the flag is absent and stdin is a tty, which would hang an agent's turn +# — the same reason the server passes `--squash` to `gh pr merge` rather than +# letting it open a prompt. +tea_detail() { + local want_comments="$1" + shift + local index="" + local rest=() + local arg + for arg in "$@"; do + case "$arg" in + # We set this ourselves; drop any caller-supplied copy so it cannot conflict. + --comments | --comments=*) ;; + -*) rest+=("$arg") ;; + *) + if [ -z "$index" ]; then index="$arg"; else rest+=("$arg"); fi + ;; + esac + done + if [ -z "$index" ]; then + exec tea pr list ${rest+"${rest[@]}"} + fi + exec tea pr "$index" "--comments=${want_comments}" ${rest+"${rest[@]}"} +} + +main() { + case "${1:-}" in + -h | --help | help | '') + usage + exit 0 + ;; + provider) + detect_provider + exit 0 + ;; + pr) ;; + *) die "unknown command '${1}' (try: forge --help)" ;; + esac + shift + + local verb="${1:-}" + [ -n "$verb" ] || die "missing verb after 'pr' (try: forge --help)" + shift + + local provider + provider="$(detect_provider)" + + if [ "$provider" = github ]; then + need gh + exec gh pr "$verb" "$@" + fi + + need tea + case "$verb" in + create) exec tea pr create "$@" ;; + list) exec tea pr list "$@" ;; + # Comments are the point of `gh pr view --comments`, so they are always on. + view) tea_detail true "$@" ;; + # `gh pr checks` is asking for CI state, which the detail view carries. + # Comments are suppressed to keep that output focused. + checks) tea_detail false "$@" ;; + ready) + die "Forgejo has no 'pr ready'. Draft state is derived from a WIP: title prefix, so +leaving draft means rewriting the title: + tea pr edit --title '' +The makit server does this for you via the Forgejo provider — prefer the app's action." + ;; + *) + die "'$verb' has no tea equivalent. Reach the API directly, e.g.: + tea api repos/<owner>/<repo>/pulls/<N>" + ;; + esac +} + +main "$@" diff --git a/scripts/sync-icons.sh b/scripts/sync-icons.sh new file mode 100755 index 00000000..6ee71c11 --- /dev/null +++ b/scripts/sync-icons.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# +# sync-icons.sh — vendor the glyphs from the phosphor_extras source repo. +# +# WHY VENDOR RATHER THAN DEPEND +# phosphor_extras is the source of truth: geometry is generated there and its +# invariants are checked there. It is not yet a git dependency because it has no +# published remote, and a `path:` dependency pointing outside this repo would +# break CI and the cloud VM, which both run `flutter pub get` on a fresh clone. +# So the built SVGs are committed here and this script keeps them honest. +# Once the source repo is pushed, this becomes a `git:` dependency in +# app/pubspec.yaml and this script goes away. +# +# USAGE +# scripts/sync-icons.sh # copy glyphs in +# scripts/sync-icons.sh --check # fail if the vendored copies have drifted +# +# PHOSPHOR_EXTRAS_DIR=/path/to/repo scripts/sync-icons.sh +# +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SRC="${PHOSPHOR_EXTRAS_DIR:-$ROOT/../phosphor_extras}" +DEST="$ROOT/app/assets/icons" + +# Only the glyphs this app actually renders. Pulling the whole set would ship +# weights nothing references, and a Flutter asset directory is bundled wholesale. +GLYPHS=( + git-pull-request-closed-thin + git-pull-request-closed-light + git-pull-request-closed-regular + git-pull-request-closed-bold + git-pull-request-closed-fill + forgejo-light + gitea-light +) + +die() { + printf 'sync-icons: %s\n' "$1" >&2 + exit 1 +} + +# Arguments are validated before anything is copied: `[ "$1" = --check ]` alone left a +# misspelled `--chek` as check_only=false, so a command meant to VERIFY vendored assets +# silently overwrote them instead. +check_only=false +case "${1:-}" in + --check) check_only=true ;; + "") ;; + *) die "unknown argument: $1 (usage: sync-icons.sh [--check])" ;; +esac +[ "$#" -le 1 ] || die "too many arguments (usage: sync-icons.sh [--check])" + +[ -d "$SRC/icons" ] || die "no glyphs at $SRC/icons — set PHOSPHOR_EXTRAS_DIR to the phosphor_extras checkout" + + +drifted=0 +for name in "${GLYPHS[@]}"; do + from="$SRC/icons/$name.svg" + to="$DEST/$name.svg" + [ -f "$from" ] || die "missing source glyph: $from" + if [ ! -f "$to" ] || ! cmp -s "$from" "$to"; then + if $check_only; then + printf ' drifted: %s\n' "$name.svg" + drifted=$((drifted + 1)) + else + cp "$from" "$to" + printf ' updated: %s\n' "$name.svg" + fi + fi +done + +if $check_only; then + [ "$drifted" -eq 0 ] || die "$drifted vendored glyph(s) differ from $SRC/icons — run scripts/sync-icons.sh" + printf 'sync-icons: %d glyphs match the source repo\n' "${#GLYPHS[@]}" +else + printf 'sync-icons: %d glyphs in sync\n' "${#GLYPHS[@]}" +fi diff --git a/server/src/forge/cadence.test.ts b/server/src/forge/cadence.test.ts new file mode 100644 index 00000000..b2d7bf98 --- /dev/null +++ b/server/src/forge/cadence.test.ts @@ -0,0 +1,49 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { forgePollIntervalMs } from "./cadence.js"; +import { POLL_FAST_MS } from "../github/policy.js"; +import type { GithubGateway } from "../github/gateway.js"; + +/** A gateway stub exposing just what the cadence helper reads. */ +function stub(opts: { level?: string; providers?: string[] }): GithubGateway { + const g: Record<string, unknown> = { + // The real BudgetLike shape: hourly buckets plus a level and retry window. + budget: () => ({ + level: opts.level ?? "unknown", + retryAfterMs: null, + buckets: { core: { remaining: 5000 }, graphql: { remaining: 5000 } }, + }), + }; + if (opts.providers !== undefined) g.providersInUse = () => new Set(opts.providers); + return g as unknown as GithubGateway; +} + +test("a Forgejo-only setup polls at the fast rung, ignoring GitHub's ladder", () => { + // The bug this fixes: with no GitHub repo, the rate_limit read never succeeds, + // the level stays `unknown`, and the ladder treated that as the warm rung -- + // throttling Forgejo polling to 30s against a quota that does not apply. + assert.equal(forgePollIntervalMs(stub({ level: "unknown", providers: ["forgejo"] })), POLL_FAST_MS); + assert.equal(forgePollIntervalMs(stub({ level: "critical", providers: ["forgejo"] })), POLL_FAST_MS); +}); + +test("any GitHub repo in play hands the cadence back to the GitHub ladder", () => { + const mixed = forgePollIntervalMs(stub({ level: "unknown", providers: ["forgejo", "github"] })); + assert.ok(mixed > POLL_FAST_MS, `mixed setups must stay conservative, got ${mixed}`); +}); + +test("before anything is routed the cadence stays conservative", () => { + // An empty mix is "not known yet", not "no GitHub" -- guessing fast here would + // burn GitHub quota for the first few ticks after startup. + const idle = forgePollIntervalMs(stub({ level: "unknown", providers: [] })); + assert.ok(idle > POLL_FAST_MS); +}); + +test("a gateway that cannot report a mix falls back to the ladder", () => { + const legacy = forgePollIntervalMs(stub({ level: "unknown" })); + assert.ok(legacy > POLL_FAST_MS); +}); + +test("a healthy GitHub budget still yields the fast rung", () => { + assert.equal(forgePollIntervalMs(stub({ level: "healthy", providers: ["github"] })), POLL_FAST_MS); +}); diff --git a/server/src/forge/cadence.ts b/server/src/forge/cadence.ts new file mode 100644 index 00000000..e3b6db8b --- /dev/null +++ b/server/src/forge/cadence.ts @@ -0,0 +1,39 @@ +/** + * cadence.ts — how often to re-poll pull requests, across providers. + * + * GitHub's degradation ladder (`github/policy.ts`) exists to ration a quota. + * Forgejo has no quota: no `/api/v1/rate_limit` endpoint, no rate-limit response + * headers, and no request limiter anywhere in its configuration. So a + * Forgejo-only setup must not be governed by that ladder. + * + * It previously was, and the failure was silent: with no GitHub repo the + * `rate_limit` read never succeeds, the level stays `unknown`, and the ladder + * treats unknown as the warm rung — 30s polling with unresolved counts shed. + * Forgejo repos were therefore polled 6x slower than needed against a quota that + * provably does not exist. + * + * KNOWN LIMITATION: `pr_watcher` runs one global timer, so a MIXED setup (GitHub + * and Forgejo repos together) still takes the GitHub cadence for everything. + * Fixing that properly means per-repo cadence in the watcher; conflating it here + * would be worse, because taking the fast rung in a mixed setup would burn the + * GitHub quota the ladder is protecting. + */ + +import type { GithubGateway } from "../github/gateway.js"; +import { POLL_FAST_MS, decide } from "../github/policy.js"; +import { hasProviderMix } from "./types.js"; + +/** + * The poll interval to use now. + * + * Takes the unthrottled rung only when the providers in play are KNOWN and + * exclude GitHub. An empty mix means "nothing routed yet", not "no GitHub" — + * guessing fast there would spend GitHub quota for the first ticks after startup. + */ +export function forgePollIntervalMs(gateway: GithubGateway): number { + if (hasProviderMix(gateway)) { + const inUse = gateway.providersInUse(); + if (inUse.size > 0 && !inUse.has("github")) return POLL_FAST_MS; + } + return decide(gateway.budget()).pollIntervalMs; +} diff --git a/server/src/forge/detect.test.ts b/server/src/forge/detect.test.ts new file mode 100644 index 00000000..03e86672 --- /dev/null +++ b/server/src/forge/detect.test.ts @@ -0,0 +1,233 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + createForgeDetector, + forgejoProbeUrl, + giteaProbeUrl, + gitlabProbeUrl, + isGiteaFamilyVersion, + isGitHubHost, + NEGATIVE_TTL_MS, + type ForgeSoftware, +} from "./detect.js"; +import type { Http, HttpRequest } from "./forgejo/gateway.js"; + +/** + * Scripted HTTP, matched by URL substring. Records every request so the tests can + * assert on probe COUNT — detection runs once per host, and a detector that + * re-probes on every lookup would add a round trip to the hot path. + */ +function harness(routes: Array<[string, { status: number; body?: string }]>) { + const calls: HttpRequest[] = []; + let nowMs = 1_000; + const http: Http = async (req) => { + calls.push(req); + for (const [needle, res] of routes) { + if (req.url.includes(needle)) return { status: res.status, body: res.body ?? "", headers: {} }; + } + return { status: 404, body: "not found", headers: {} }; + }; + const detector = createForgeDetector({ http, now: () => nowMs }); + return { detector, calls, tick: (ms: number) => (nowMs += ms) }; +} + +const VERSION = (v: string) => JSON.stringify({ version: v }); +/** Real payloads, copied from live instances. */ +const FORGEJO_V = VERSION("16.0.0+gitea-1.22.0"); +const GITEA_V = VERSION("1.27.0+dev-652-g0571722545"); + +// --------------------------------------------------------------------------- +// Host classification (GitHub needs no probe) +// --------------------------------------------------------------------------- + +test("isGitHubHost accepts github.com and subdomains, and no lookalikes", () => { + assert.equal(isGitHubHost("github.com"), true); + assert.equal(isGitHubHost("WWW.GitHub.com"), true); + assert.equal(isGitHubHost("github.com.evil.test"), false); + assert.equal(isGitHubHost("notgithub.com"), false); + assert.equal(isGitHubHost("codeberg.org"), false); +}); + +// --------------------------------------------------------------------------- +// Probe URLs +// --------------------------------------------------------------------------- + +test("probe URLs are built off the instance base, trailing slash tolerated", () => { + assert.equal(forgejoProbeUrl("https://x.test/"), "https://x.test/api/forgejo/v1/version"); + assert.equal(giteaProbeUrl("https://x.test"), "https://x.test/api/v1/version"); + assert.equal(gitlabProbeUrl("https://x.test"), "https://x.test/api/v4/version"); +}); + +test("probe URLs survive a sub-path install", () => { + assert.equal(forgejoProbeUrl("https://x.test/forge"), "https://x.test/forge/api/forgejo/v1/version"); +}); + +// --------------------------------------------------------------------------- +// Payload classification +// --------------------------------------------------------------------------- + +test("isGiteaFamilyVersion accepts a version payload and rejects anything else", () => { + assert.equal(isGiteaFamilyVersion(FORGEJO_V), true); + assert.equal(isGiteaFamilyVersion(GITEA_V), true); + assert.equal(isGiteaFamilyVersion('{"version":""}'), false); + assert.equal(isGiteaFamilyVersion("{}"), false); + // GitLab answers /api/v1/version with an HTML redirect to its sign-in page. + assert.equal(isGiteaFamilyVersion("<html><body>redirected</body></html>"), false); + assert.equal(isGiteaFamilyVersion(""), false); +}); + +// --------------------------------------------------------------------------- +// Detection, against the responses real servers actually give +// --------------------------------------------------------------------------- + +const detect = async ( + routes: Array<[string, { status: number; body?: string }]>, +): Promise<{ got: ForgeSoftware; probes: number }> => { + const { detector, calls } = harness(routes); + const got = await detector.detect("https://git.test"); + return { got, probes: calls.length }; +}; + +test("Forgejo is identified by its own API namespace, in one probe", async () => { + const { got, probes } = await detect([["/api/forgejo/v1/version", { status: 200, body: FORGEJO_V }]]); + assert.equal(got, "forgejo"); + assert.equal(probes, 1, "the Forgejo namespace is decisive; do not probe further"); +}); + +test("Gitea is identified by serving /api/v1/version WITHOUT the Forgejo namespace", async () => { + const { got } = await detect([ + ["/api/forgejo/v1/version", { status: 404 }], + ["/api/v1/version", { status: 200, body: GITEA_V }], + ]); + assert.equal(got, "gitea"); +}); + +test("a Forgejo version string is not mistaken for Gitea when the namespace 404s", async () => { + // Belt and braces: if a proxy hides /api/forgejo, the `+gitea-` suffix still + // marks it as Forgejo rather than Gitea. + const { got } = await detect([ + ["/api/forgejo/v1/version", { status: 404 }], + ["/api/v1/version", { status: 200, body: FORGEJO_V }], + ]); + assert.equal(got, "forgejo"); +}); + +test("GitLab is identified by /api/v4/version answering at all", async () => { + // 401 unauthenticated is what gitlab.com actually returns, and it is still + // proof the server is GitLab. + const { got } = await detect([ + ["/api/forgejo/v1/version", { status: 404 }], + ["/api/v1/version", { status: 302, body: "<html>redirected</html>" }], + ["/api/v4/version", { status: 401, body: '{"message":"401 Unauthorized"}' }], + ]); + assert.equal(got, "gitlab"); +}); + +test("a server that answers nothing recognisable is `unknown`, not guessed", async () => { + const { got } = await detect([]); + assert.equal(got, "unknown"); +}); + +test("an unreachable host is `unknown` rather than throwing", async () => { + const { got } = await detect([ + ["/api", { status: 0 }], + ]); + assert.equal(got, "unknown"); +}); + +// --------------------------------------------------------------------------- +// Caching. Detection is on the hot path's critical section, so it must happen +// once per host -- but a transient failure must NOT pin a host as unsupported. +// --------------------------------------------------------------------------- + +test("a positive detection is cached and never re-probed", async () => { + const { detector, calls } = harness([["/api/forgejo/v1/version", { status: 200, body: FORGEJO_V }]]); + assert.equal(await detector.detect("https://git.test"), "forgejo"); + const n = calls.length; + assert.equal(await detector.detect("https://git.test"), "forgejo"); + assert.equal(await detector.detect("https://git.test"), "forgejo"); + assert.equal(calls.length, n); +}); + +test("a failed detection is retried after a short TTL", async () => { + // The hazard: an instance down during the first probe would otherwise be pinned + // as unsupported until the server restarts. + const routes: Array<[string, { status: number; body?: string }]> = [["/api", { status: 0 }]]; + const { detector, calls, tick } = harness(routes); + assert.equal(await detector.detect("https://git.test"), "unknown"); + const n = calls.length; + await detector.detect("https://git.test"); + assert.equal(calls.length, n, "not immediately -- that would hammer a down host"); + tick(NEGATIVE_TTL_MS + 1); + routes[0] = ["/api/forgejo/v1/version", { status: 200, body: FORGEJO_V }]; + assert.equal(await detector.detect("https://git.test"), "forgejo", "recovery must be possible"); +}); + +test("concurrent first detections for one host share a single probe", async () => { + const { detector, calls } = harness([["/api/forgejo/v1/version", { status: 200, body: FORGEJO_V }]]); + const [a, b, c] = await Promise.all([ + detector.detect("https://git.test"), + detector.detect("https://git.test"), + detector.detect("https://git.test"), + ]); + assert.deepEqual([a, b, c], ["forgejo", "forgejo", "forgejo"]); + assert.equal(calls.length, 1, "an in-flight probe must be shared"); +}); + +test("detection is keyed per instance, not shared across hosts", async () => { + const { detector } = harness([ + ["a.test/api/forgejo/v1/version", { status: 200, body: FORGEJO_V }], + ["b.test/api/forgejo/v1/version", { status: 404 }], + ["b.test/api/v1/version", { status: 200, body: GITEA_V }], + ]); + assert.equal(await detector.detect("https://a.test"), "forgejo"); + assert.equal(await detector.detect("https://b.test"), "gitea"); +}); + +test("a token is sent with the probe, since a private instance 401s without one", async () => { + const { detector, calls } = harness([["/api/forgejo/v1/version", { status: 200, body: FORGEJO_V }]]); + await detector.detect("https://git.test", "t0k"); + assert.equal(calls[0].headers.Authorization, "token t0k"); +}); + +test("no Authorization header is sent when there is no token", async () => { + const { detector, calls } = harness([["/api/forgejo/v1/version", { status: 200, body: FORGEJO_V }]]); + await detector.detect("https://git.test"); + assert.equal("Authorization" in calls[0].headers, false); +}); + +test("a gate that 401s every path is NOT reported as GitLab", async () => { + // Review finding: step 3 treated 401/403 on the GitLab path as proof of GitLab, + // because "only GitLab serves that path". That holds for the status only if the + // earlier probes were answered by the APPLICATION. An instance behind SSO or an + // authenticating reverse proxy answers 401 on every path, including both Forgejo + // probes — so a perfectly ordinary Forgejo instance behind a gate was classified + // `gitlab`, routed to the unsupported provider, and the log told the user it + // "looks like gitlab". When every probe returns the same auth status the responses + // carry no information about the software, so the honest answer is `unknown` — + // which is also re-probed later and can be overridden per repo. + const d = createForgeDetector({ + http: async () => ({ status: 401, body: "", headers: {} }), + }); + assert.equal(await d.detect("https://gated.example"), "unknown"); +}); + +test("403 on every path is likewise unknown, not GitLab", async () => { + const d = createForgeDetector({ + http: async () => ({ status: 403, body: "", headers: {} }), + }); + assert.equal(await d.detect("https://gated.example"), "unknown"); +}); + +test("401 on the GitLab path alone is still GitLab", async () => { + // The real gitlab.com case: the Forgejo/Gitea probes are ANSWERED (404), so the + // 401 on /api/v4/version carries information. + const d = createForgeDetector({ + http: async (req) => { + if (req.url.includes("/api/v4/version")) return { status: 401, body: "", headers: {} }; + return { status: 404, body: "", headers: {} }; + }, + }); + assert.equal(await d.detect("https://gitlab.example"), "gitlab"); +}); diff --git a/server/src/forge/detect.ts b/server/src/forge/detect.ts new file mode 100644 index 00000000..b489eb13 --- /dev/null +++ b/server/src/forge/detect.ts @@ -0,0 +1,206 @@ +/** + * detect.ts — identify which forge software an instance runs. + * + * Replaces a guess. Routing previously keyed off the hostname alone — github.com + * meant GitHub, everything else was ASSUMED to be Forgejo — which sent GitLab and + * Bitbucket remotes to the Forgejo provider, where they failed as `unknown`, + * indistinguishable from "your instance is down". A hostname cannot tell you what + * software a server runs; asking the server can. + * + * The discriminators are endpoints, not version-string sniffing, and each was + * verified against a live instance: + * + * GET /api/forgejo/v1/version 200 on Forgejo (codeberg.org, and a self-hosted + * 16.0.0), 404 on Gitea (gitea.com) + * GET /api/v1/version 200 + {"version":...} on both Forgejo and Gitea; + * GitLab answers a 302 to its sign-in page + * GET /api/v4/version 401 unauthenticated on gitlab.com — which is + * still proof it is GitLab + * + * Forgejo is probed first because it is decisive in ONE call, and it is the case + * we care about; Gitea costs two, and anything else three. Results are cached per + * instance, so this never touches the PR hot path more than once. + */ + +import type { Http } from "./forgejo/gateway.js"; +import type { ForgeSoftwareName } from "./types.js"; + +/** Which software an instance runs. `unknown` means we could not tell. */ +/** + * Re-exported from `types.ts` rather than declared again. + * + * Two identical unions in two files drift: this one and `ForgeSoftwareName` were + * already the same list in two places, and nothing would have failed if one had + * gained a member. + */ +export type ForgeSoftware = ForgeSoftwareName; + +/** Probe timeout. A version endpoint is trivial; a slow answer is a bad sign. */ +const PROBE_TIMEOUT_MS = 8_000; + +/** + * How long a FAILED detection is remembered. + * + * Short on purpose. Caching a failure forever would pin an instance that happened + * to be down during the first probe as unsupported until the server restarts — + * the user would see "unsupported forge" on a perfectly good Forgejo. Short + * enough to recover quickly, long enough not to re-probe a down host every tick. + */ +export const NEGATIVE_TTL_MS = 60_000; + +const trim = (base: string): string => base.replace(/\/+$/, ""); + +/** Forgejo's own API namespace — absent on Gitea. */ +export function forgejoProbeUrl(baseUrl: string): string { + return `${trim(baseUrl)}/api/forgejo/v1/version`; +} + +/** The Gitea-compatible version endpoint, served by both Forgejo and Gitea. */ +export function giteaProbeUrl(baseUrl: string): string { + return `${trim(baseUrl)}/api/v1/version`; +} + +/** GitLab's version endpoint. */ +export function gitlabProbeUrl(baseUrl: string): string { + return `${trim(baseUrl)}/api/v4/version`; +} + +/** + * Whether a host is GitHub. Matches the apex and its subdomains and nothing else: + * a bare suffix test would classify `github.com.evil.test` as GitHub and hand it + * whatever credentials that path carries. + */ +export function isGitHubHost(host: string): boolean { + const h = host.toLowerCase().split(":")[0]; + return h === "github.com" || h.endsWith(".github.com"); +} + +/** Whether a body is a Gitea-family `{"version": "..."}` payload. */ +export function isGiteaFamilyVersion(body: string): boolean { + try { + const parsed = JSON.parse(body) as { version?: unknown }; + return typeof parsed.version === "string" && parsed.version.length > 0; + } catch { + return false; + } +} + +/** + * Whether a Gitea-family version string is actually Forgejo. Forgejo reports its + * own version with a `+gitea-x.y.z` API-compatibility suffix (`16.0.0+gitea-1.22.0`) + * where Gitea reports a bare `1.27.0`. Only used as a fallback for an instance + * whose `/api/forgejo` namespace is hidden by a proxy. + */ +function looksLikeForgejoVersion(body: string): boolean { + try { + const v = (JSON.parse(body) as { version?: unknown }).version; + return typeof v === "string" && /\+gitea-/i.test(v); + } catch { + return false; + } +} + +export interface ForgeDetectorDeps { + http: Http; + now?: () => number; +} + +export interface ForgeDetector { + /** + * Identify the software at `baseUrl`. `token` is used for the probe because a + * private instance (`REQUIRE_SIGNIN_VIEW`) answers 401 to anonymous callers, + * which would otherwise read as "not a forge". + */ + detect(baseUrl: string, token?: string): Promise<ForgeSoftware>; + /** Forget everything learned (tests, and gateway close). */ + clear(): void; +} + +interface CachedDetection { + value: ForgeSoftware; + /** null = never expires (a positive result); a number = epoch ms. */ + expiresAt: number | null; +} + +export function createForgeDetector(deps: ForgeDetectorDeps): ForgeDetector { + const now = deps.now ?? (() => Date.now()); + const settled = new Map<string, CachedDetection>(); + /** In-flight probes, so a fan-out across worktrees shares one round trip. */ + const inFlight = new Map<string, Promise<ForgeSoftware>>(); + + async function probe(url: string, token: string | undefined) { + const headers: Record<string, string> = { Accept: "application/json" }; + if (token !== undefined && token.length > 0) headers.Authorization = `token ${token}`; + try { + return await deps.http({ url, method: "GET", headers, timeoutMs: PROBE_TIMEOUT_MS }); + } catch { + return { status: 0, body: "" }; + } + } + + const ok = (status: number): boolean => status >= 200 && status < 300; + + /** A status that a gate in front of the app could have produced for any path. */ + const isAuthStatus = (status: number): boolean => status === 401 || status === 403; + + async function classify(baseUrl: string, token: string | undefined): Promise<ForgeSoftware> { + // 1. Forgejo's own namespace: decisive, and one call. + const fj = await probe(forgejoProbeUrl(baseUrl), token); + if (ok(fj.status) && isGiteaFamilyVersion(fj.body)) return "forgejo"; + + // 2. Gitea-compatible version endpoint: Forgejo and Gitea both serve it. + const gt = await probe(giteaProbeUrl(baseUrl), token); + if (ok(gt.status) && isGiteaFamilyVersion(gt.body)) { + return looksLikeForgejoVersion(gt.body) ? "forgejo" : "gitea"; + } + + // 3. GitLab. `401`/`403` counts, because that is what gitlab.com returns + // unauthenticated and only GitLab serves that path -- but ONLY when an earlier + // probe was answered by the application rather than by a gate. + // + // An instance behind SSO or an authenticating reverse proxy answers the same auth + // status on every path, including both probes above. Treating that as proof of + // GitLab classified an ordinary gated Forgejo instance as unsupported and told the + // user it "looks like gitlab". When every probe returns the same auth status the + // responses carry no information about the software, so the honest answer is + // `unknown` -- which is re-probed later, and which the per-repo provider setting + // can override. + const gl = await probe(gitlabProbeUrl(baseUrl), token); + if (ok(gl.status)) return "gitlab"; + if (gl.status === 401 || gl.status === 403) { + const gatedEarlier = isAuthStatus(fj.status) && isAuthStatus(gt.status); + if (!gatedEarlier) return "gitlab"; + } + + return "unknown"; + } + + return { + async detect(baseUrl: string, token?: string): Promise<ForgeSoftware> { + const key = trim(baseUrl).toLowerCase(); + const hit = settled.get(key); + if (hit !== undefined && (hit.expiresAt === null || hit.expiresAt > now())) return hit.value; + + const running = inFlight.get(key); + if (running !== undefined) return running; + + const p = classify(baseUrl, token) + .then((value) => { + settled.set(key, { + value, + // Only a failure expires; a server does not change software often + // enough to be worth re-probing, and a wrong positive is loud. + expiresAt: value === "unknown" ? now() + NEGATIVE_TTL_MS : null, + }); + return value; + }) + .finally(() => inFlight.delete(key)); + inFlight.set(key, p); + return p; + }, + clear(): void { + settled.clear(); + inFlight.clear(); + }, + }; +} diff --git a/server/src/forge/forgejo/gateway.test.ts b/server/src/forge/forgejo/gateway.test.ts new file mode 100644 index 00000000..20d75ae9 --- /dev/null +++ b/server/src/forge/forgejo/gateway.test.ts @@ -0,0 +1,564 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { createForgejoGateway, type Http, type HttpRequest, type ForgejoRepoRef } from "./gateway.js"; + +const REF: ForgejoRepoRef = { + baseUrl: "https://git.example.com", + owner: "acme", + repo: "app", + token: "t0ken", +}; + +interface Call { + url: string; + method: string; + headers: Record<string, string>; + body?: string; + timeoutMs: number; +} + +/** Build a gateway over a scripted HTTP seam. Routes are matched by substring. */ +function harness( + routes: Array<[string, { status?: number; json?: unknown; body?: string; headers?: Record<string, string> }]>, + ref = REF, +) { + const calls: Call[] = []; + const http: Http = async (req: HttpRequest) => { + calls.push({ url: req.url, method: req.method, headers: req.headers, body: req.body, timeoutMs: req.timeoutMs }); + for (const [needle, res] of routes) { + if (req.url.includes(needle)) { + return { + status: res.status ?? 200, + body: res.body ?? JSON.stringify(res.json ?? null), + headers: res.headers ?? {}, + }; + } + } + return { status: 404, body: '{"message":"not found"}', headers: {} }; + }; + let nowMs = 1_000; + const gateway = createForgejoGateway({ + http, + resolveRepo: async () => ref, + now: () => nowMs, + }); + return { gateway, calls, tick: (ms: number) => (nowMs += ms) }; +} + +const openPrRow = { + number: 42, + state: "open", + merged: false, + title: "feat: thing", + draft: false, + mergeable: true, + html_url: "https://git.example.com/acme/app/pulls/42", + base: { ref: "main" }, + head: { ref: "feat/x", sha: "cafebabe" }, +}; + +// --------------------------------------------------------------------------- +// prForBranch +// --------------------------------------------------------------------------- + +test("prForBranch returns the PR with checks composed from the combined status", async () => { + const { gateway, calls } = harness([ + ["/pulls?", { json: [openPrRow] }], + ["/commits/cafebabe/status", { json: { state: "failure", statuses: [{ context: "test", status: "failure" }] } }], + ]); + const got = await gateway.prForBranch("/repo", "feat/x"); + assert.equal(got.kind, "pr"); + if (got.kind !== "pr") return; + assert.equal(got.pr.number, 42); + assert.equal(got.pr.state, "OPEN"); + assert.equal(got.pr.mergeable, "MERGEABLE"); + assert.equal(got.pr.mergeStateStatus, null); + assert.equal(got.pr.checkRollup, "fail"); + assert.deepEqual( + got.pr.checks.map((c) => c.name), + ["test"], + ); + // The head filter must be the bare branch name. + assert.ok(calls[0].url.includes("head=feat%2Fx")); + assert.ok(!calls[0].url.includes("acme%3Afeat")); +}); + +test("prForBranch sends the token as a Forgejo `token` credential", async () => { + const { gateway, calls } = harness([["/pulls?", { json: [] }]]); + await gateway.prForBranch("/repo", "b"); + assert.equal(calls[0].headers.Authorization, "token t0ken"); +}); + +test("prForBranch omits Authorization entirely when there is no token", async () => { + const { gateway, calls } = harness([["/pulls?", { json: [] }]], { ...REF, token: undefined }); + await gateway.prForBranch("/repo", "b"); + assert.equal("Authorization" in calls[0].headers, false); +}); + +test("prForBranch returns none only for a genuinely empty result", async () => { + const { gateway } = harness([["/pulls?", { json: [] }]]); + assert.deepEqual(await gateway.prForBranch("/repo", "b"), { kind: "none" }); +}); + +test("prForBranch reports unknown -- never none -- on a transport failure", async () => { + const { gateway } = harness([["/pulls?", { status: 0, body: "" }]]); + assert.deepEqual(await gateway.prForBranch("/repo", "b"), { kind: "unknown", reason: "error" }); +}); + +test("prForBranch reports unknown on a 5xx and on unparseable JSON", async () => { + const a = harness([["/pulls?", { status: 500, body: "boom" }]]); + assert.deepEqual(await a.gateway.prForBranch("/repo", "b"), { kind: "unknown", reason: "error" }); + const b = harness([["/pulls?", { body: "<html>not json</html>" }]]); + assert.deepEqual(await b.gateway.prForBranch("/repo", "b"), { kind: "unknown", reason: "error" }); +}); + +test("prForBranch reports unknown when the repo has no Forgejo remote", async () => { + const http: Http = async () => ({ status: 200, body: "[]" }); + const gateway = createForgejoGateway({ http, resolveRepo: async () => null }); + assert.deepEqual(await gateway.prForBranch("/repo", "b"), { kind: "unknown", reason: "error" }); +}); + +test("prForBranch picks the highest-numbered PR, not the first row", async () => { + const { gateway } = harness([ + [ + "/pulls?", + { + json: [ + { ...openPrRow, number: 7, state: "closed", merged: false, head: { ref: "b", sha: "aa" } }, + { ...openPrRow, number: 99, state: "open", head: { ref: "b", sha: "bb" } }, + ], + }, + ], + ["/commits/bb/status", { json: { statuses: [] } }], + ]); + const got = await gateway.prForBranch("/repo", "b"); + assert.equal(got.kind === "pr" && got.pr.number, 99); + assert.equal(got.kind === "pr" && got.pr.state, "OPEN"); +}); + +test("prForBranch still returns the PR when the status call fails, with checks unmeasured", async () => { + const { gateway } = harness([ + ["/pulls?", { json: [openPrRow] }], + ["/commits/cafebabe/status", { status: 500, body: "nope" }], + ]); + const got = await gateway.prForBranch("/repo", "b"); + assert.equal(got.kind, "pr"); + if (got.kind !== "pr") return; + // A PR we found must not vanish because CI could not be read. + assert.deepEqual(got.pr.checks, []); + assert.equal(got.pr.checkRollup, "none"); +}); + +test("prForBranch marks unresolved comments as unmeasured rather than reporting zero", async () => { + const { gateway } = harness([ + ["/pulls?", { json: [openPrRow] }], + ["/commits/cafebabe/status", { json: { statuses: [] } }], + ]); + const got = await gateway.prForBranch("/repo", "b"); + assert.equal(got.kind === "pr" && got.pr.unresolvedUnknown, true); + assert.equal(got.kind === "pr" && got.pr.unresolvedComments, 0); +}); + +test("prForBranch skips the status call when the PR reports no head sha", async () => { + const { gateway, calls } = harness([["/pulls?", { json: [{ ...openPrRow, head: { ref: "b" } }] }]]); + const got = await gateway.prForBranch("/repo", "b"); + assert.equal(got.kind, "pr"); + assert.equal(calls.length, 1); +}); + +// Forgejo's `head` filter is an unindexed scan: measured against codeberg.org +// (~9.5k PRs) `state=all&head=X` is bimodal at 1.5s or 20-30s, while the combined +// status read stays sub-second. A single timeout for both would either abandon +// good lookups or hold a fast call open far too long -- and abandoning a lookup +// reports `unknown`, which flickers the pill. +test("the branch lookup gets a longer timeout than the sub-second status read", async () => { + const { gateway, calls } = harness([ + ["/pulls?", { json: [openPrRow] }], + ["/commits/cafebabe/status", { json: { statuses: [] } }], + ]); + await gateway.prForBranch("/repo", "b"); + const list = calls.find((c) => c.url.includes("/pulls?")); + const status = calls.find((c) => c.url.includes("/status")); + assert.ok(list && status); + assert.ok( + list.timeoutMs >= 15_000, + `branch lookup timeout ${list.timeoutMs}ms is below the observed p90 of the unindexed scan`, + ); + assert.ok(status.timeoutMs < list.timeoutMs); +}); + +// --------------------------------------------------------------------------- +// Caching +// --------------------------------------------------------------------------- + +test("prForBranch serves a repeat poll from cache and counts the hit", async () => { + const { gateway, calls } = harness([ + ["/pulls?", { json: [openPrRow] }], + ["/commits/cafebabe/status", { json: { statuses: [] } }], + ]); + await gateway.prForBranch("/repo", "b"); + const n = calls.length; + await gateway.prForBranch("/repo", "b"); + assert.equal(calls.length, n, "second poll should not hit the network"); + assert.equal(gateway.stats().cacheHits, 1); +}); + +test("an interactive call bypasses the cache", async () => { + const { gateway, calls } = harness([ + ["/pulls?", { json: [openPrRow] }], + ["/commits/cafebabe/status", { json: { statuses: [] } }], + ]); + await gateway.prForBranch("/repo", "b"); + const n = calls.length; + await gateway.prForBranch("/repo", "b", { interactive: true }); + assert.ok(calls.length > n); +}); + +test("a failed lookup is not cached, so the next poll retries", async () => { + const { gateway, calls } = harness([["/pulls?", { status: 500, body: "x" }]]); + await gateway.prForBranch("/repo", "b"); + await gateway.prForBranch("/repo", "b"); + assert.equal(calls.length, 2); +}); + +test("the cache expires", async () => { + const { gateway, calls, tick } = harness([ + ["/pulls?", { json: [openPrRow] }], + ["/commits/cafebabe/status", { json: { statuses: [] } }], + ]); + await gateway.prForBranch("/repo", "b"); + const n = calls.length; + tick(120_000); + await gateway.prForBranch("/repo", "b"); + assert.ok(calls.length > n); +}); + +// --------------------------------------------------------------------------- +// openPrs +// --------------------------------------------------------------------------- + +test("openPrs maps rows and orders them newest-first regardless of server order", async () => { + const { gateway } = harness([ + [ + "/pulls?", + { + json: [ + { number: 3, title: "c", draft: false, html_url: "u3", head: { ref: "b3" } }, + { number: 51, title: "a", draft: true, html_url: "u51", head: { ref: "b51" } }, + { number: 12, title: "b", draft: false, html_url: "u12", head: { ref: "b12" } }, + ], + }, + ], + ]); + const prs = await gateway.openPrs("/repo", 30); + assert.deepEqual( + prs.map((p) => p.number), + [51, 12, 3], + ); + assert.deepEqual(prs[0], { number: 51, title: "a", headRefName: "b51", isDraft: true, url: "u51" }); +}); + +test("openPrs returns an empty list on failure rather than throwing", async () => { + const { gateway } = harness([["/pulls?", { status: 500, body: "x" }]]); + assert.deepEqual(await gateway.openPrs("/repo", 30), []); +}); + +// --------------------------------------------------------------------------- +// mutatePr — `ready` is a title rewrite on Forgejo, not a flag flip. +// --------------------------------------------------------------------------- + +test("mutatePr ready re-reads the title and PATCHes it with the prefix stripped", async () => { + const { gateway, calls } = harness([ + ["/pulls/42", { json: { number: 42, title: "WIP: feat: thing", state: "open" } }], + ]); + const res = await gateway.mutatePr("/repo", "b", 42, "ready"); + assert.equal(res.ok, true); + const patch = calls.find((c) => c.method === "PATCH"); + assert.ok(patch, "expected a PATCH"); + assert.deepEqual(JSON.parse(patch.body ?? "{}"), { title: "feat: thing" }); +}); + +test("mutatePr ready refuses when the title carries no known draft prefix", async () => { + const { gateway, calls } = harness([["/pulls/42", { json: { number: 42, title: "feat: thing" } }]]); + const res = await gateway.mutatePr("/repo", "b", 42, "ready"); + assert.equal(res.ok, false); + assert.match(res.error ?? "", /draft/i); + assert.equal( + calls.some((c) => c.method === "PATCH"), + false, + "must not rewrite a title it did not recognise", + ); +}); + +test("mutatePr ready honours a server's configured WIP prefixes", async () => { + const http: Http = async (req) => { + if (req.method === "GET") return { status: 200, body: JSON.stringify({ number: 1, title: "Draft: x" }) }; + return { status: 200, body: "{}" }; + }; + const gateway = createForgejoGateway({ + http, + resolveRepo: async () => REF, + wipPrefixes: ["Draft:"], + }); + assert.equal((await gateway.mutatePr("/repo", "b", 1, "ready")).ok, true); +}); + +test("mutatePr update-branch POSTs to /update with an explicit style", async () => { + const { gateway, calls } = harness([["/update", { json: {} }]]); + const res = await gateway.mutatePr("/repo", "b", 42, "update-branch"); + assert.equal(res.ok, true); + assert.equal(calls[0].method, "POST"); + assert.ok(calls[0].url.includes("/pulls/42/update")); + assert.ok(calls[0].url.includes("style=merge")); +}); + +test("mutatePr merge-squash POSTs the PascalCase Do field Forgejo requires", async () => { + const { gateway, calls } = harness([["/merge", { json: {} }]]); + const res = await gateway.mutatePr("/repo", "b", 42, "merge-squash"); + assert.equal(res.ok, true); + assert.deepEqual(JSON.parse(calls[0].body ?? "{}"), { Do: "squash" }); +}); + +test("mutatePr surfaces the server's own error message", async () => { + const { gateway } = harness([["/update", { status: 409, json: { message: "merge conflict" } }]]); + const res = await gateway.mutatePr("/repo", "b", 42, "update-branch"); + assert.equal(res.ok, false); + assert.match(res.error ?? "", /merge conflict/); +}); + +test("a successful mutation invalidates the cached lookup for that branch", async () => { + const { gateway, calls } = harness([ + ["/pulls?", { json: [openPrRow] }], + ["/commits/cafebabe/status", { json: { statuses: [] } }], + ["/update", { json: {} }], + ]); + await gateway.prForBranch("/repo", "b"); + await gateway.mutatePr("/repo", "b", 42, "update-branch"); + const n = calls.length; + await gateway.prForBranch("/repo", "b"); + assert.ok(calls.length > n, "post-mutation poll must refetch, not serve stale state"); +}); + +test("a failed mutation leaves the cache intact", async () => { + const { gateway, calls } = harness([ + ["/pulls?", { json: [openPrRow] }], + ["/commits/cafebabe/status", { json: { statuses: [] } }], + ["/update", { status: 500, body: "x" }], + ]); + await gateway.prForBranch("/repo", "b"); + await gateway.mutatePr("/repo", "b", 42, "update-branch"); + const n = calls.length; + await gateway.prForBranch("/repo", "b"); + assert.equal(calls.length, n); +}); + +// --------------------------------------------------------------------------- +// Contract: no budget facet, because Forgejo has no quota to report. +// --------------------------------------------------------------------------- + +test("the Forgejo gateway does not pretend to report a budget", async () => { + const { gateway } = harness([]); + const { hasBudgetReporting } = await import("../types.js"); + assert.equal(hasBudgetReporting(gateway), false); +}); + +test("stats counts network calls and close() is safe to call twice", async () => { + const { gateway } = harness([ + ["/pulls?", { json: [openPrRow] }], + ["/commits/cafebabe/status", { json: { statuses: [] } }], + ]); + await gateway.prForBranch("/repo", "b"); + assert.equal(gateway.stats().execs, 2); + gateway.close(); + gateway.close(); +}); + +// --------------------------------------------------------------------------- +// Throttling. Forgejo itself has no rate limiter -- no /rate_limit endpoint, no +// rate-limit headers, no config knob -- but an instance behind nginx `limit_req`, +// Cloudflare or an anti-scraper gate certainly does, and a slow query can shed +// load with a 503. Answering those by polling at the same cadence leans on a +// server that just asked us to stop. +// --------------------------------------------------------------------------- + +test("a 429 puts background lookups into backoff without further requests", async () => { + const { gateway, calls } = harness([["/pulls?", { status: 429, body: "slow down" }]]); + assert.deepEqual(await gateway.prForBranch("/repo", "b"), { kind: "unknown", reason: "throttled" }); + const n = calls.length; + // A second background poll must not spend a request while told to wait. + assert.deepEqual(await gateway.prForBranch("/repo", "b"), { kind: "unknown", reason: "throttled" }); + assert.equal(calls.length, n, "must not re-request while in backoff"); +}); + +test("the backoff is reported as throttled, never as `none`", async () => { + // `none` would erase the PR pill on a server that merely asked us to wait. + const { gateway } = harness([["/pulls?", { status: 503, body: "" }]]); + const first = await gateway.prForBranch("/repo", "b"); + assert.equal(first.kind, "unknown"); + assert.equal(first.kind === "unknown" && first.reason, "throttled"); +}); + +test("Retry-After in seconds is honoured, and the window then expires", async () => { + const { gateway, calls, tick } = harness([ + ["/pulls?", { status: 429, headers: { "retry-after": "30" } }], + ]); + await gateway.prForBranch("/repo", "b"); + const n = calls.length; + tick(29_000); + await gateway.prForBranch("/repo", "b"); + assert.equal(calls.length, n, "still inside the Retry-After window"); + tick(2_000); + await gateway.prForBranch("/repo", "b"); + assert.ok(calls.length > n, "the window must expire"); +}); + +test("an absurd Retry-After is capped rather than parking the poller for a day", async () => { + const { gateway, calls, tick } = harness([ + ["/pulls?", { status: 429, headers: { "retry-after": "86400" } }], + ]); + await gateway.prForBranch("/repo", "b"); + const n = calls.length; + tick(10 * 60_000); + await gateway.prForBranch("/repo", "b"); + assert.ok(calls.length > n, "a hostile or buggy header must not disable polling"); +}); + +test("a garbage Retry-After falls back to the default backoff", async () => { + const { gateway, calls, tick } = harness([ + ["/pulls?", { status: 429, headers: { "retry-after": "next tuesday" } }], + ]); + await gateway.prForBranch("/repo", "b"); + const n = calls.length; + tick(1_000); + await gateway.prForBranch("/repo", "b"); + assert.equal(calls.length, n, "a default backoff still applies"); +}); + +test("an interactive call is still attempted during backoff", async () => { + // A button press must reach the server and surface its real answer; silently + // returning a cached refusal would read as a dead button. + const { gateway, calls } = harness([["/pulls?", { status: 429, body: "" }]]); + await gateway.prForBranch("/repo", "b"); + const n = calls.length; + await gateway.prForBranch("/repo", "b", { interactive: true }); + assert.ok(calls.length > n); +}); + +test("a successful response clears the backoff", async () => { + // Review finding: this test used to advance the clock 31s past a 30s Retry-After, so + // the window had expired by time ALONE and the later poll reached the network whether + // or not `call` reset `backoffUntil`. It passed with the reset deleted. + // + // Now the clock stays INSIDE the window, and the success is forced through an + // interactive call (which is exempt from the backoff). A background poll afterwards + // can only reach the network because the reset ran. + const routes: Array<[string, { status?: number; json?: unknown; headers?: Record<string, string> }]> = [ + ["/pulls?", { status: 429, headers: { "retry-after": "30" } }], + ]; + const { gateway, calls, tick } = harness(routes); + await gateway.prForBranch("/repo", "b"); + tick(5_000); // still well within the 30s window + routes[0] = ["/pulls?", { json: [] }]; + await gateway.prForBranch("/repo", "b", { interactive: true }); + const n = calls.length; + // A BACKGROUND poll, on a branch with no cache entry: only the reset lets it out. + await gateway.prForBranch("/repo", "x"); + assert.ok(calls.length > n, "no residual backoff after a success"); +}); + +test("a short-circuited poll is not counted as a network call", async () => { + const { gateway } = harness([["/pulls?", { status: 429, body: "" }]]); + await gateway.prForBranch("/repo", "b"); + const after = gateway.stats().execs; + await gateway.prForBranch("/repo", "b"); + assert.equal(gateway.stats().execs, after, "backoff must not inflate the exec count"); +}); + +test("openPrs also respects the backoff and returns an empty list", async () => { + const { gateway, calls } = harness([["/pulls?", { status: 429, body: "" }]]); + await gateway.openPrs("/repo", 30); + const n = calls.length; + assert.deepEqual(await gateway.openPrs("/repo", 30), []); + assert.equal(calls.length, n); +}); + +// --------------------------------------------------------------------------- +// Review findings on the Forgejo gateway. +// --------------------------------------------------------------------------- + +/** A gateway over a scripted `http`, so concurrency and call counts are visible. */ +function counting(handler: (url: string) => { status?: number; body?: string }) { + const urls: string[] = []; + let inflight = 0; + let peak = 0; + const http: Http = async (req: HttpRequest) => { + urls.push(req.url); + inflight += 1; + peak = Math.max(peak, inflight); + await new Promise((r) => setTimeout(r, 5)); + inflight -= 1; + const res = handler(req.url); + return { status: res.status ?? 200, body: res.body ?? "[]", headers: {} }; + }; + const gateway = createForgejoGateway({ http, resolveRepo: async () => REF, now: () => 1_000 }); + return { gateway, urls, peak: () => peak, lists: () => urls.filter((u) => u.includes("/pulls?")).length }; +} + +test("a successful mutation drops the cached open-PR list too", async () => { + // Review finding: only `prKey` was invalidated, so `open:<repo>:<limit>` survived its + // full TTL. That list backs the "New worktree from PR" picker, so a squash-merged PR + // stayed listed and the checkout that followed failed, and a PR just marked ready + // still read as a draft. The GitHub gateway drops both, and both feed one picker. + const h = counting(() => ({ body: "[]" })); + await h.gateway.openPrs("/r", 30); + assert.equal(h.lists(), 1); + await h.gateway.openPrs("/r", 30); + assert.equal(h.lists(), 1, "served from cache"); + await h.gateway.mutatePr("/r", "b", 7, "merge-squash"); + await h.gateway.openPrs("/r", 30); + assert.equal(h.lists(), 2, "re-fetched after the mutation"); +}); + +test("every cached limit for the repo is dropped, not only one", async () => { + // The key carries the limit, and the picker and the home screen ask for different + // ones, so a single delete leaves the other stale. + const h = counting(() => ({ body: "[]" })); + await h.gateway.openPrs("/r", 30); + await h.gateway.openPrs("/r", 5); + assert.equal(h.lists(), 2); + // The precondition is asserted, not assumed: invalidation only runs on SUCCESS, so a + // verb that failed in the stub would make this test pass for the wrong reason. + const r = await h.gateway.mutatePr("/r", "b", 7, "merge-squash"); + assert.equal(r.ok, true, "the mutation must succeed for invalidation to be in play"); + await h.gateway.openPrs("/r", 30); + await h.gateway.openPrs("/r", 5); + assert.equal(h.lists(), 4); +}); + +test("concurrent lookups for one branch share a single in-flight request", async () => { + // Review finding: results were cached but in-flight requests were not shared, so on + // a cold cache N worktrees of one repo each issued their own copy of a query this + // module measures at 1.5-30s against a real instance. The GitHub gateway dedupes for + // exactly this reason. + const h = counting(() => ({ body: "[]" })); + await Promise.all([ + h.gateway.prForBranch("/r", "same"), + h.gateway.prForBranch("/r", "same"), + h.gateway.prForBranch("/r", "same"), + ]); + assert.equal(h.peak(), 1, "one request served all three callers"); +}); + +test("different branches are NOT collapsed into one request", async () => { + // The key must include the branch, or one worktree's question gets another's answer. + const h = counting(() => ({ body: "[]" })); + await Promise.all([h.gateway.prForBranch("/r", "a"), h.gateway.prForBranch("/r", "b")]); + assert.equal(h.urls.length, 2); +}); + +test("concurrent openPrs for one repo and limit share a request too", async () => { + const h = counting(() => ({ body: "[]" })); + await Promise.all([h.gateway.openPrs("/r", 30), h.gateway.openPrs("/r", 30)]); + assert.equal(h.peak(), 1); +}); diff --git a/server/src/forge/forgejo/gateway.ts b/server/src/forge/forgejo/gateway.ts new file mode 100644 index 00000000..7bca7050 --- /dev/null +++ b/server/src/forge/forgejo/gateway.ts @@ -0,0 +1,531 @@ +/** + * gateway.ts — the Forgejo implementation of {@link ForgeGateway}, over REST. + * + * No subprocess. Forgejo's API is plain REST with a token, so a request is a + * `fetch`, which removes three whole classes of problem the `gh`-backed GitHub + * gateway has to manage: process fan-out (the reason `concurrency.ts` exists), + * CLI discovery and version skew, and stdout parsing. + * + * It also implements NO budget facet on purpose. Forgejo exposes no `rate_limit` + * endpoint and sends no rate-limit response headers, so there is no quota to + * ration — the GitHub gateway's router/policy/budget machinery has no counterpart + * here, and faking one would put a number on screen that means nothing. See + * `../types.ts`. + * + * What remains is a cache (to keep the home-screen fan-out cheap) and strict + * discipline about the difference between "no PR" and "could not tell", which is + * SPEC-32 §6.5 and the reason every failure path below returns `unknown`. + */ + +import type { OpenPr, PullRequestInfo } from "../../git.js"; +import { rollupChecks } from "../../git.js"; +import type { PrCheckDTO } from "../../protocol.js"; +import type { ForgeGateway, GatewayStats, PrLookup, PrMutation } from "../types.js"; +import { + DEFAULT_WIP_PREFIXES, + combinedStatusUrl, + forgejoChecks, + mapForgejoPr, + mergeUrl, + openPrsUrl, + pickLatestPr, + prDetailUrl, + prForBranchUrl, + readyTitle, + updateBranchUrl, +} from "./map.js"; + +/** One HTTP request. `timeoutMs` is advisory to the adapter. */ +export interface HttpRequest { + url: string; + method: string; + headers: Record<string, string>; + body?: string; + timeoutMs: number; +} + +/** + * An HTTP response. `status: 0` means the request never completed (DNS, TLS, + * timeout, connection refused). + */ +export interface HttpResponse { + status: number; + body: string; + /** + * Response headers, keys lower-cased. Only `retry-after` is read today, but a + * throttled response is useless without it: guessing a backoff either ignores + * the server's instruction or parks the poller far longer than it asked for. + */ + headers?: Record<string, string>; +} + +/** + * The injectable HTTP seam. Mirrors {@link import("../../github/gateway.js").Exec} + * in one important respect: it MUST NOT reject. A transport failure is data + * (`status: 0`), not an exception, so a single unreachable instance can never + * take down the poller that fans out across every worktree. + */ +export type Http = (req: HttpRequest) => Promise<HttpResponse>; + +/** Where a local repo path lives on a Forgejo instance. */ +export interface ForgejoRepoRef { + /** Instance origin, e.g. `https://git.example.com` (no trailing slash needed). */ + baseUrl: string; + owner: string; + repo: string; + /** API token. Absent means unauthenticated — fine for public reads. */ + token?: string; +} + +/** Resolve a local repo path to its Forgejo coordinates, or null if it isn't one. */ +export type ResolveRepo = (repoPath: string) => Promise<ForgejoRepoRef | null>; + +export interface ForgejoGatewayDeps { + http: Http; + resolveRepo: ResolveRepo; + /** Clock, injectable so cache expiry is testable without real time. */ + now?: () => number; + /** + * The instance's `WORK_IN_PROGRESS_PREFIXES`. Defaults to Forgejo's own + * defaults; pass the server's real value when it can be read, because "mark + * ready for review" strips one of these from the title. + */ + wipPrefixes?: readonly string[]; +} + +/** Read timeout for cheap, indexed reads (combined status, PR detail). */ +const READ_TIMEOUT_MS = 5_000; +/** + * Timeout for the branch->PR lookup specifically. + * + * Far above {@link READ_TIMEOUT_MS} because Forgejo's `head` filter is an + * unindexed scan: measured against codeberg.org (Forgejo 16, ~9.5k PRs) the same + * `state=all&head=X&limit=5` query returned in 1.5s on some attempts and 20-30s + * on others. A tight cap turns that variance into a stream of `unknown` results, + * which flickers the PR pill to "unmeasured" on a repo that is merely busy. + * + * A self-hosted instance with a normal repo is expected to be far quicker; this + * cap exists for the pathological end. If a real instance proves slow enough for + * this to hurt, the known optimisation is the dedicated + * `/pulls/{base}/{head}` endpoint (~1.9s, single object) once the base ref is + * known -- deliberately not built yet, since it trades a guess about `base` for + * speed we may not need. + */ +const BRANCH_LOOKUP_TIMEOUT_MS = 20_000; +/** The picker's list is larger, so it gets the same slack `gh` got. */ +const OPEN_PRS_TIMEOUT_MS = 8_000; +/** + * Write timeout — deliberately far above the read timeout. Abandoning a read + * costs a stale pill; abandoning a write costs correctness, because the server + * may apply it anyway while the caller, told it failed, skips its cache + * invalidation and reports pre-mutation state until the TTL runs out. + */ +const MUTATION_TIMEOUT_MS = 60_000; + +const TTL_PR_MS = 20_000; +const TTL_OPEN_PRS_MS = 60_000; + +/** + * Statuses that mean "stop asking": 429 from a rate limiter, 503 from a server + * shedding load. Forgejo core has no rate limiter, but instances routinely sit + * behind nginx `limit_req`, Cloudflare or an anti-scraper gate. + */ +const THROTTLE_STATUSES = new Set([429, 503]); +/** Backoff when the server throttles us without saying for how long. */ +const DEFAULT_BACKOFF_MS = 60_000; +/** + * Ceiling on an honoured `Retry-After`. A misconfigured proxy (or a hostile one) + * can answer `86400`, and obeying that literally would silently disable PR + * polling for a day with no way for the user to tell why. + */ +const MAX_BACKOFF_MS = 5 * 60_000; + +/** Parse `Retry-After`: delta-seconds or an HTTP date. Null when unusable. */ +function parseRetryAfter(value: string | undefined, now: number): number | null { + if (value === undefined) return null; + const trimmed = value.trim(); + if (/^\d+$/.test(trimmed)) return Number(trimmed) * 1000; + const at = Date.parse(trimmed); + return Number.isFinite(at) ? Math.max(0, at - now) : null; +} + +interface CacheEntry { + value: unknown; + expiresAt: number; +} + +function isOk(res: HttpResponse): boolean { + return res.status >= 200 && res.status < 300; +} + +/** Parse a JSON body, returning `undefined` rather than throwing. */ +function parseJson(body: string): unknown { + try { + return JSON.parse(body) as unknown; + } catch { + return undefined; + } +} + +/** + * The most useful error text available: Forgejo's own `message` when it sent + * one, else the raw body, else the status. Surfacing the server's wording keeps + * the diagnosis accurate (a branch-protection refusal reads as such). + */ +function errorText(res: HttpResponse): string { + const parsed = parseJson(res.body); + if (typeof parsed === "object" && parsed !== null) { + const msg = (parsed as { message?: unknown }).message; + if (typeof msg === "string" && msg.trim().length > 0) return msg.trim(); + } + const raw = res.body.trim(); + if (raw.length > 0 && raw.length < 300) return raw; + return res.status === 0 ? "request failed" : `HTTP ${res.status}`; +} + +export function createForgejoGateway(deps: ForgejoGatewayDeps): ForgeGateway { + const now = deps.now ?? (() => Date.now()); + const wipPrefixes = deps.wipPrefixes ?? DEFAULT_WIP_PREFIXES; + const cache = new Map<string, CacheEntry>(); + const stats: GatewayStats = { execs: 0, exemptExecs: 0, cacheHits: 0 }; + /** Epoch ms until which background requests are withheld. 0 = not throttled. */ + let backoffUntil = 0; + + /** True while a server-requested pause is in force. */ + const throttled = (): boolean => backoffUntil > now(); + + /** + * Record a throttling response. Interactive callers are still allowed through + * (a button press must reach the server), so this only gates polling. + */ + function noteThrottle(res: HttpResponse): void { + const asked = parseRetryAfter(res.headers?.["retry-after"], now()); + const wait = Math.min(asked ?? DEFAULT_BACKOFF_MS, MAX_BACKOFF_MS); + backoffUntil = now() + Math.max(wait, 1); + } + + function cacheGet<T>(key: string): T | undefined { + const hit = cache.get(key); + if (hit === undefined) return undefined; + if (hit.expiresAt <= now()) { + cache.delete(key); + return undefined; + } + return hit.value as T; + } + + function cacheSet(key: string, value: unknown, ttlMs: number): void { + cache.set(key, { value, expiresAt: now() + ttlMs }); + } + + /** + * In-flight requests, so N callers asking the same question issue ONE request. + * + * The cache alone is not enough: it is only populated once a response arrives, so on + * a cold cache the home-screen fan-out -- every worktree of a repo at once -- issued + * one copy per worktree of a query this module measures at 1.5-30s against a real + * instance (see BRANCH_LOOKUP_TIMEOUT_MS). The GitHub gateway shares in-flight work + * for the same reason. + * + * Keyed exactly like the cache entry it will produce, so a branch never receives + * another branch's answer. + */ + const inflight = new Map<string, Promise<unknown>>(); + + function share<T>(key: string, run: () => Promise<T>): Promise<T> { + const hit = inflight.get(key) as Promise<T> | undefined; + if (hit !== undefined) return hit; + // `finally` rather than `then`: a rejection must also release the slot, or one + // failure would wedge that key for the process lifetime. + const p = run().finally(() => { + if (inflight.get(key) === p) inflight.delete(key); + }); + inflight.set(key, p); + return p; + } + + /** Drop every cached open-PR list for a repo, whatever limit it was asked with. */ + function dropOpenPrLists(repoPath: string): void { + const prefix = `open:${repoPath}:`; + for (const key of cache.keys()) if (key.startsWith(prefix)) cache.delete(key); + } + + function headers(ref: ForgejoRepoRef, withBody: boolean): Record<string, string> { + const h: Record<string, string> = { Accept: "application/json" }; + // Forgejo/Gitea's own credential form. Omitted entirely when absent, so an + // unauthenticated read of a public repo is not sent a bogus header. + if (ref.token !== undefined && ref.token.length > 0) h.Authorization = `token ${ref.token}`; + if (withBody) h["Content-Type"] = "application/json"; + return h; + } + + /** + * Issue one request. Defensive against an adapter that rejects despite the + * {@link Http} contract — a throwing adapter must degrade to `unknown`, not + * take down the caller. + */ + async function call( + ref: ForgejoRepoRef, + url: string, + opts: { method?: string; body?: unknown; timeoutMs?: number } = {}, + ): Promise<HttpResponse> { + const method = opts.method ?? "GET"; + const body = opts.body === undefined ? undefined : JSON.stringify(opts.body); + stats.execs += 1; + let res: HttpResponse; + try { + res = await deps.http({ + url, + method, + headers: headers(ref, body !== undefined), + body, + timeoutMs: opts.timeoutMs ?? READ_TIMEOUT_MS, + }); + } catch { + res = { status: 0, body: "" }; + } + if (THROTTLE_STATUSES.has(res.status)) noteThrottle(res); + // Any completed, non-throttled answer means the server is talking to us + // again -- holding the backoff after that would throttle us on our own. + else if (res.status !== 0) backoffUntil = 0; + return res; + } + + const prKey = (repoPath: string, branch: string) => `pr:${repoPath}:${branch}`; + + async function prForBranch( + repoPath: string, + branch: string, + opts?: { interactive?: boolean }, + ): Promise<PrLookup> { + const ref = await deps.resolveRepo(repoPath); + // Not a Forgejo repo (or the remote could not be read): we never queried, so + // the answer is unmeasured. Returning `none` here would erase the pill and + // read as "this branch has no PR" -- a fact we do not have. + if (ref === null) return { kind: "unknown", reason: "error" }; + + const key = prKey(repoPath, branch); + if (opts?.interactive !== true) { + const hit = cacheGet<PrLookup>(key); + if (hit !== undefined) { + stats.cacheHits += 1; + return hit; + } + // The server asked us to wait. `throttled`, not `none`: a pause is not + // evidence that the branch has no PR (SPEC-32 §6.5). + if (throttled()) return { kind: "unknown", reason: "throttled" }; + } + + // Shared, so the fan-out across a repo's worktrees issues one request per + // (repo, branch) rather than one per caller. + const listed = await share(`req:${key}`, () => + call(ref, prForBranchUrl(ref.baseUrl, ref.owner, ref.repo, branch), { + timeoutMs: BRANCH_LOOKUP_TIMEOUT_MS, + }), + ); + if (!isOk(listed)) { + return { + kind: "unknown", + reason: THROTTLE_STATUSES.has(listed.status) ? "throttled" : "error", + }; + } + const rows = parseJson(listed.body); + // A non-array body is a malformed or error response, not an empty repo. + if (!Array.isArray(rows)) return { kind: "unknown", reason: "error" }; + + const raw = pickLatestPr(rows as Array<Record<string, unknown> | null>); + if (raw === null) { + const miss: PrLookup = { kind: "none" }; + cacheSet(key, miss, TTL_PR_MS); + return miss; + } + const core = mapForgejoPr(raw); + // We found a row but could not read it — again unmeasured, not absent. + if (core === null) return { kind: "unknown", reason: "error" }; + + let checks: PrCheckDTO[] = []; + if (core.headSha !== null) { + const status = await call(ref, combinedStatusUrl(ref.baseUrl, ref.owner, ref.repo, core.headSha)); + // A PR we already found must not disappear because CI could not be read; + // an empty check list renders as "no checks", which is the honest fallback. + if (isOk(status)) checks = forgejoChecks(parseJson(status.body)); + } + + const pr: PullRequestInfo = { + number: core.number, + url: core.url, + state: core.state, + title: core.title, + isDraft: core.isDraft, + mergeable: core.mergeable, + mergeStateStatus: core.mergeStateStatus, + baseRefName: core.baseRefName, + checks, + checkRollup: rollupChecks(checks), + // Forgejo exposes resolution per review COMMENT (`resolver`), not per + // thread, and only via a reviews -> comments walk. Until that walk is + // verified against an instance with real review threads, the count is + // declared unmeasured rather than reported as 0 -- a plain 0 would render + // as "no unresolved comments" and be believed (SPEC-32 §6.5). + unresolvedComments: 0, + unresolvedUnknown: true, + }; + const found: PrLookup = { kind: "pr", pr }; + cacheSet(key, found, TTL_PR_MS); + return found; + } + + async function openPrs(repoPath: string, limit: number, opts?: { interactive?: boolean }): Promise<OpenPr[]> { + const ref = await deps.resolveRepo(repoPath); + if (ref === null) return []; + + const key = `open:${repoPath}:${limit}`; + if (opts?.interactive !== true) { + const hit = cacheGet<OpenPr[]>(key); + if (hit !== undefined) { + stats.cacheHits += 1; + return hit; + } + if (throttled()) return []; + } + + const res = await share(`req:${key}`, () => + call(ref, openPrsUrl(ref.baseUrl, ref.owner, ref.repo, limit), { + timeoutMs: OPEN_PRS_TIMEOUT_MS, + }), + ); + if (!isOk(res)) return []; + const rows = parseJson(res.body); + if (!Array.isArray(rows)) return []; + + const out: OpenPr[] = []; + for (const raw of rows) { + if (typeof raw !== "object" || raw === null) continue; + const r = raw as Record<string, unknown>; + if (typeof r.number !== "number") continue; + const head = r.head as { ref?: unknown } | undefined; + out.push({ + number: r.number, + title: typeof r.title === "string" ? r.title : "", + headRefName: typeof head?.ref === "string" ? head.ref : "", + isDraft: r.draft === true, + url: typeof r.html_url === "string" ? r.html_url : "", + }); + } + // Newest first. Sorted here rather than requested from the server because + // Forgejo's `sort` enum has no created-desc member: the default order is + // newest-first in practice but is not part of the contract, and the picker + // relies on it. + out.sort((a, b) => b.number - a.number); + cacheSet(key, out, TTL_OPEN_PRS_MS); + return out; + } + + /** + * Take a PR out of draft. On Forgejo this is a TITLE REWRITE, not a flag flip: + * `draft` is a read-only projection of the title's WIP prefix and + * `EditPullRequestOption` has no `draft` field. + * + * The title is re-read immediately before the write instead of being taken from + * the cached lookup, because a PATCH sends the whole title: acting on a stale + * copy would silently revert an edit made in the web UI since the last poll. + */ + async function markReady(ref: ForgejoRepoRef, number: number): Promise<{ ok: boolean; error?: string }> { + const url = prDetailUrl(ref.baseUrl, ref.owner, ref.repo, number); + const res = await call(ref, url); + if (!isOk(res)) return { ok: false, error: errorText(res) }; + const parsed = parseJson(res.body); + const title = typeof parsed === "object" && parsed !== null ? (parsed as { title?: unknown }).title : undefined; + if (typeof title !== "string") return { ok: false, error: "could not read the pull request title" }; + + const next = readyTitle(title, wipPrefixes); + if (next === null) { + return { + ok: false, + error: `#${number} is not a draft: its title carries none of the instance's work-in-progress prefixes (${wipPrefixes.join(", ")})`, + }; + } + const patched = await call(ref, url, { method: "PATCH", body: { title: next }, timeoutMs: MUTATION_TIMEOUT_MS }); + return isOk(patched) ? { ok: true } : { ok: false, error: errorText(patched) }; + } + + async function mutatePr( + repoPath: string, + branch: string, + number: number, + verb: PrMutation, + ): Promise<{ ok: boolean; error?: string }> { + const ref = await deps.resolveRepo(repoPath); + if (ref === null) return { ok: false, error: "not a Forgejo repository" }; + + let result: { ok: boolean; error?: string }; + if (verb === "ready") { + result = await markReady(ref, number); + } else if (verb === "update-branch") { + const res = await call(ref, updateBranchUrl(ref.baseUrl, ref.owner, ref.repo, number), { + method: "POST", + timeoutMs: MUTATION_TIMEOUT_MS, + }); + result = isOk(res) ? { ok: true } : { ok: false, error: errorText(res) }; + } else { + // `Do` is PascalCase and required by MergePullRequestOption. Naming the + // strategy explicitly also avoids inheriting the instance's configurable + // default, which would make the same button squash on one server and + // rebase on another. + const res = await call(ref, mergeUrl(ref.baseUrl, ref.owner, ref.repo, number), { + method: "POST", + body: { Do: "squash" }, + timeoutMs: MUTATION_TIMEOUT_MS, + }); + result = isOk(res) ? { ok: true } : { ok: false, error: errorText(res) }; + } + + // Only a success invalidates: dropping the entry after a failed mutation + // would spend a fresh round trip to re-learn the state we already hold. + // + // BOTH the branch lookup and every open-PR list go: that list backs the "New + // worktree from PR" picker, so a squash-merged PR left in it leads to a checkout + // that fails, and a PR just marked ready still reads as a draft. The key carries + // the limit, and the picker and the home screen ask with different ones, so one + // delete is not enough. + if (result.ok) { + cache.delete(prKey(repoPath, branch)); + dropOpenPrLists(repoPath); + } + return result; + } + + return { + prForBranch, + openPrs, + mutatePr, + stats: () => ({ ...stats }), + close: () => { + cache.clear(); + inflight.clear(); + }, + }; +} + +/** + * Production {@link Http} over global `fetch`, upholding the never-reject + * contract: every failure mode becomes `status: 0`. + */ +export function createFetchHttp(): Http { + return async (req: HttpRequest): Promise<HttpResponse> => { + try { + const res = await fetch(req.url, { + method: req.method, + headers: req.headers, + body: req.body, + signal: AbortSignal.timeout(req.timeoutMs), + }); + const headers: Record<string, string> = {}; + const retryAfter = res.headers.get("retry-after"); + if (retryAfter !== null) headers["retry-after"] = retryAfter; + return { status: res.status, body: await res.text(), headers }; + } catch { + return { status: 0, body: "" }; + } + }; +} diff --git a/server/src/forge/forgejo/map.test.ts b/server/src/forge/forgejo/map.test.ts new file mode 100644 index 00000000..5603639d --- /dev/null +++ b/server/src/forge/forgejo/map.test.ts @@ -0,0 +1,268 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + DEFAULT_WIP_PREFIXES, + PR_PAGE_SIZE, + forgejoChecks, + isEpochTimestamp, + mapForgejoPr, + parseForgejoRemote, + pickLatestPr, + prForBranchUrl, + openPrsUrl, + readyTitle, + updateBranchUrl, +} from "./map.js"; + +// --------------------------------------------------------------------------- +// URL building. Forgejo's `head` filter takes a BARE branch name -- passing +// GitHub's `owner:branch` returns an empty list, which the gateway would map to +// `none` and erase the pill (the very defect SPEC-32 §6.5 exists to prevent). +// --------------------------------------------------------------------------- + +test("prForBranchUrl filters by bare branch name, never owner:branch", () => { + const u = new URL(prForBranchUrl("https://git.example.com", "acme", "app", "feat/x")); + assert.equal(u.pathname, "/api/v1/repos/acme/app/pulls"); + assert.equal(u.searchParams.get("head"), "feat/x"); + assert.equal(u.searchParams.get("state"), "all"); +}); + +test("prForBranchUrl percent-encodes refs that would otherwise inject query params", () => { + const u = new URL(prForBranchUrl("https://git.example.com", "acme", "app", "a&state=closed")); + assert.equal(u.searchParams.get("head"), "a&state=closed"); + assert.equal(u.searchParams.get("state"), "all"); +}); + +test("prForBranchUrl requests a page, not limit=1, because order is not guaranteed", () => { + const u = new URL(prForBranchUrl("https://git.example.com", "acme", "app", "b")); + assert.ok(Number(u.searchParams.get("limit")) > 1); + // Forgejo's sort enum has no created-desc, so we must not pretend to ask for one. + assert.equal(u.searchParams.get("sort"), null); + assert.equal(u.searchParams.get("direction"), null); +}); + +// Measured against codeberg.org (Forgejo 16, ~9.5k PRs): `state=all` combined with +// a `head` filter costs ~1.3s at limit=5 but 17-19s at limit=30, and 504s outright +// often enough to matter. The scan is unindexed, so this page size is a latency +// cliff on the hot path, not a tuning preference. +test("prForBranchUrl keeps the page small -- the head-filtered scan is unindexed", () => { + assert.ok(PR_PAGE_SIZE >= 3, "too small to survive imperfect default ordering"); + assert.ok(PR_PAGE_SIZE <= 10, `page size ${PR_PAGE_SIZE} risks a multi-second or 504 hot path`); + const u = new URL(prForBranchUrl("https://git.example.com", "acme", "app", "b")); + assert.equal(u.searchParams.get("limit"), String(PR_PAGE_SIZE)); +}); + +test("openPrsUrl asks only for open PRs and honours the caller's limit", () => { + const u = new URL(openPrsUrl("https://git.example.com", "acme", "app", 30)); + assert.equal(u.searchParams.get("state"), "open"); + assert.equal(u.searchParams.get("limit"), "30"); +}); + +test("updateBranchUrl targets the PR index with an explicit merge style", () => { + const u = new URL(updateBranchUrl("https://git.example.com", "acme", "app", 7)); + assert.equal(u.pathname, "/api/v1/repos/acme/app/pulls/7/update"); + assert.equal(u.searchParams.get("style"), "merge"); +}); + +// --------------------------------------------------------------------------- +// Hazard 3: no created-desc sort. `limit=1` is unsafe -- an older CLOSED PR +// could win the slot over a newer OPEN one and flip the glyph. +// --------------------------------------------------------------------------- + +test("pickLatestPr takes the highest number regardless of arrival order", () => { + const rows = [{ number: 3 }, { number: 91 }, { number: 12 }]; + assert.equal(pickLatestPr(rows)?.number, 91); +}); + +test("pickLatestPr returns null for an empty list", () => { + assert.equal(pickLatestPr([]), null); +}); + +test("pickLatestPr ignores rows without a usable number", () => { + const rows = [{ number: "nope" }, { number: 4 }, {}, null]; + assert.equal(pickLatestPr(rows)?.number, 4); +}); + +// --------------------------------------------------------------------------- +// PR mapping. Forgejo has no mergeStateStatus, and reports MERGED via a bool. +// --------------------------------------------------------------------------- + +test("mapForgejoPr distinguishes MERGED from CLOSED via the merged flag", () => { + const closed = mapForgejoPr({ number: 1, state: "closed", merged: false }); + const merged = mapForgejoPr({ number: 2, state: "closed", merged: true }); + assert.equal(closed?.state, "CLOSED"); + assert.equal(merged?.state, "MERGED"); +}); + +test("mapForgejoPr upper-cases the open state", () => { + assert.equal(mapForgejoPr({ number: 1, state: "open", merged: false })?.state, "OPEN"); +}); + +test("mapForgejoPr maps mergeable onto the GraphQL vocabulary the DTO speaks", () => { + assert.equal(mapForgejoPr({ number: 1, state: "open", mergeable: true })?.mergeable, "MERGEABLE"); + assert.equal(mapForgejoPr({ number: 1, state: "open", mergeable: false })?.mergeable, "CONFLICTING"); + assert.equal(mapForgejoPr({ number: 1, state: "open" })?.mergeable, "UNKNOWN"); +}); + +test("mapForgejoPr reports mergeStateStatus as null -- Forgejo has no such concept", () => { + const pr = mapForgejoPr({ number: 1, state: "open", mergeable: true }); + assert.equal(pr?.mergeStateStatus, null); +}); + +test("mapForgejoPr reads url from html_url, not the API url", () => { + const pr = mapForgejoPr({ + number: 5, + state: "open", + html_url: "https://git.example.com/acme/app/pulls/5", + url: "https://git.example.com/api/v1/repos/acme/app/pulls/5", + }); + assert.equal(pr?.url, "https://git.example.com/acme/app/pulls/5"); +}); + +test("mapForgejoPr carries draft, base ref and head sha", () => { + const pr = mapForgejoPr({ + number: 5, + state: "open", + draft: true, + base: { ref: "main" }, + head: { ref: "feat/x", sha: "deadbeef" }, + }); + assert.equal(pr?.isDraft, true); + assert.equal(pr?.baseRefName, "main"); + assert.equal(pr?.headSha, "deadbeef"); +}); + +test("mapForgejoPr rejects a row with no usable number", () => { + assert.equal(mapForgejoPr({ state: "open" }), null); + assert.equal(mapForgejoPr(null), null); +}); + +// --------------------------------------------------------------------------- +// Hazard 1: `draft` is derived from a title prefix, and the prefix list is +// server-configurable. "Mark ready" is a title rewrite, not a flag flip. +// --------------------------------------------------------------------------- + +test("readyTitle strips a configured WIP prefix", () => { + assert.equal(readyTitle("WIP: add thing", DEFAULT_WIP_PREFIXES), "add thing"); + assert.equal(readyTitle("[WIP] add thing", DEFAULT_WIP_PREFIXES), "add thing"); +}); + +test("readyTitle matches prefixes case-insensitively, as Forgejo does", () => { + assert.equal(readyTitle("wip: add thing", DEFAULT_WIP_PREFIXES), "add thing"); + assert.equal(readyTitle("Wip: add thing", DEFAULT_WIP_PREFIXES), "add thing"); +}); + +test("readyTitle returns null when no prefix matches, so we never rewrite blindly", () => { + assert.equal(readyTitle("add thing", DEFAULT_WIP_PREFIXES), null); + // "WIPE" is not the "WIP:" prefix -- a substring match would corrupt the title. + assert.equal(readyTitle("WIPE the cache", DEFAULT_WIP_PREFIXES), null); +}); + +test("readyTitle honours a server's custom prefix list", () => { + assert.equal(readyTitle("Draft: x", ["Draft:"]), "x"); + assert.equal(readyTitle("WIP: x", ["Draft:"]), null); +}); + +test("readyTitle never yields an empty title", () => { + assert.equal(readyTitle("WIP:", DEFAULT_WIP_PREFIXES), null); + assert.equal(readyTitle("WIP: ", DEFAULT_WIP_PREFIXES), null); +}); + +// --------------------------------------------------------------------------- +// Check rollup. Forgejo's per-status field is `status` (GitHub REST uses +// `state`), and its enum includes `skipped`, which GitHub's status vocabulary +// has no member for. +// --------------------------------------------------------------------------- + +test("forgejoChecks reads the `status` field and maps the full enum", () => { + const checks = forgejoChecks({ + statuses: [ + { context: "a", status: "success" }, + { context: "b", status: "failure" }, + { context: "c", status: "error" }, + { context: "d", status: "pending" }, + { context: "e", status: "skipped" }, + { context: "f", status: "warning" }, + ], + }); + assert.deepEqual( + checks.map((c) => [c.name, c.bucket]), + [ + ["a", "pass"], + ["b", "fail"], + ["c", "fail"], + ["d", "pending"], + ["e", "skipping"], + ["f", "skipping"], + ], + ); +}); + +test("forgejoChecks does not silently bucket an unknown state as passing", () => { + const [c] = forgejoChecks({ statuses: [{ context: "x", status: "something-new" }] }); + assert.equal(c.bucket, "pending"); +}); + +test("forgejoChecks carries target_url as detailsUrl and leaves workflowName null", () => { + const [c] = forgejoChecks({ + statuses: [{ context: "x", status: "success", target_url: "https://ci/1" }], + }); + assert.equal(c.detailsUrl, "https://ci/1"); + assert.equal(c.workflowName, null); +}); + +test("forgejoChecks tolerates a missing or malformed statuses array", () => { + assert.deepEqual(forgejoChecks({}), []); + assert.deepEqual(forgejoChecks(null), []); + assert.deepEqual(forgejoChecks({ statuses: "nope" }), []); +}); + +// --------------------------------------------------------------------------- +// Hazard: Forgejo returns epoch for unset timestamps instead of null. Feeding +// that into a duration renders as ~56 years (SPEC-47's timings). +// --------------------------------------------------------------------------- + +test("isEpochTimestamp recognises Forgejo's unset-time sentinel", () => { + assert.equal(isEpochTimestamp("1970-01-01T01:00:00+01:00"), true); + assert.equal(isEpochTimestamp("1970-01-01T00:00:00Z"), true); + assert.equal(isEpochTimestamp("2026-07-24T18:20:47+02:00"), false); + assert.equal(isEpochTimestamp(undefined), false); + assert.equal(isEpochTimestamp("not a date"), false); +}); + +// --------------------------------------------------------------------------- +// Remote parsing: any host, since a Forgejo instance is self-hosted. +// --------------------------------------------------------------------------- + +test("parseForgejoRemote handles ssh and https remotes on an arbitrary host", () => { + assert.deepEqual(parseForgejoRemote("git@git.example.com:acme/app.git"), { + host: "git.example.com", + owner: "acme", + repo: "app", + }); + assert.deepEqual(parseForgejoRemote("https://git.example.com/acme/app.git"), { + host: "git.example.com", + owner: "acme", + repo: "app", + }); + assert.deepEqual(parseForgejoRemote("https://git.example.com/acme/app"), { + host: "git.example.com", + owner: "acme", + repo: "app", + }); +}); + +test("parseForgejoRemote keeps an explicit port and strips ssh:// and userinfo", () => { + assert.deepEqual(parseForgejoRemote("ssh://git@git.example.com:2222/acme/app.git"), { + host: "git.example.com:2222", + owner: "acme", + repo: "app", + }); +}); + +test("parseForgejoRemote returns null for a remote it cannot read", () => { + assert.equal(parseForgejoRemote(""), null); + assert.equal(parseForgejoRemote("not-a-remote"), null); + assert.equal(parseForgejoRemote("https://git.example.com/acme"), null); +}); diff --git a/server/src/forge/forgejo/map.ts b/server/src/forge/forgejo/map.ts new file mode 100644 index 00000000..0eb415f0 --- /dev/null +++ b/server/src/forge/forgejo/map.ts @@ -0,0 +1,353 @@ +/** + * map.ts — pure Forgejo REST mapping: URL builders and payload adapters. + * + * Pure by design (no I/O, no clock), because every hazard in Forgejo's API that + * can silently corrupt a PR signal lives here and must be unit-testable: + * + * 1. `draft` is a READ-only projection of the title: Forgejo marks a PR draft + * when its title starts with a `WORK_IN_PROGRESS_PREFIXES` entry (default + * `WIP:,[WIP]`, matched case-insensitively, configurable per instance). + * `EditPullRequestOption` has no `draft` field, so "mark ready for review" + * is a TITLE REWRITE -- see {@link readyTitle}. + * 2. There is no `mergeStateStatus`. GitHub's BEHIND/BLOCKED/CLEAN vocabulary + * has no Forgejo counterpart, so it is reported as `null` (unknown) rather + * than guessed -- a wrong CLEAN would tell the user a blocked PR is ready. + * 3. The `sort` enum has no created-desc member and there is no `direction` + * param, so "the newest PR on this branch" is NOT expressible as a query. + * Default order is newest-first in practice but is not part of the contract, + * so we page and pick -- see {@link pickLatestPr}. + * 4. Each combined-status entry keys its state as `status` (GitHub REST uses + * `state`) and the enum includes `skipped`, which GitHub's status vocabulary + * cannot express -- see {@link forgejoChecks}. + * 5. Unset timestamps come back as the zero time (`1970-01-01T01:00:00+01:00`) + * rather than null -- see {@link isEpochTimestamp}. + * + * The `head` filter takes a BARE branch name. GitHub's `owner:branch` form + * returns an empty list here, which the gateway would map to `none` and erase the + * pill -- exactly the null-versus-zero defect SPEC-32 §6.5 exists to prevent. + */ + +import type { PrCheckBucket, PrCheckDTO } from "../../protocol.js"; + +/** + * Page size for a branch->PR lookup. + * + * Two forces set this. It must be >1 because hazard 3 means we cannot ask the + * server for "the newest" and must choose locally. It must be SMALL because the + * `head` filter is unindexed: measured against codeberg.org (Forgejo 16, ~9.5k + * PRs), `state=all` with a `head` filter costs ~1.3s at limit=5 but 17-19s at + * limit=30 -- and returns 504 often enough to matter. This is the hot path, + * polled per worktree, so a deep page would stall the whole home screen. + * + * 5 is enough to absorb the default ordering being merely "roughly newest-first" + * while staying an order of magnitude inside the read timeout. + */ +export const PR_PAGE_SIZE = 5; + +/** + * Forgejo's default `[repository.pull-request] WORK_IN_PROGRESS_PREFIXES`. + * + * A default, not a constant: an instance can redefine this list, so any caller + * that can read the server's config should pass its real value through rather + * than assume this one. + */ +export const DEFAULT_WIP_PREFIXES: readonly string[] = ["WIP:", "[WIP]"]; + +/** Forgejo's API root for an instance base URL (`https://git.example.com`). */ +function apiRoot(baseUrl: string): string { + return `${baseUrl.replace(/\/+$/, "")}/api/v1`; +} + +/** Percent-encode one path segment; owner/repo may contain URL-significant bytes. */ +function seg(value: string | number): string { + return encodeURIComponent(String(value)); +} + +/** `.../pulls` for a repo. */ +function pullsPath(baseUrl: string, owner: string, repo: string): string { + return `${apiRoot(baseUrl)}/repos/${seg(owner)}/${seg(repo)}/pulls`; +} + +/** + * The PRs whose head is `branch`, newest LAST-resort-sorted by us (hazard 3). + * + * `state=all` (not `open`) so a merged or closed PR keeps rendering with its own + * glyph instead of vanishing to the bare-branch icon. Note the absence of + * `sort`/`direction`: Forgejo offers no created-desc, and sending a bogus value + * would be a silent no-op that reads as if ordering were guaranteed. + * + * `state=all` is also what makes this query expensive -- see {@link PR_PAGE_SIZE}. + * `state=open` is ~15x faster, but would erase the pill on a merged PR, which is + * the regression this whole lookup exists to avoid. + */ +export function prForBranchUrl(baseUrl: string, owner: string, repo: string, branch: string): string { + const u = new URL(pullsPath(baseUrl, owner, repo)); + u.searchParams.set("state", "all"); + // Bare branch name -- NOT `owner:branch`. See the module note. + u.searchParams.set("head", branch); + u.searchParams.set("limit", String(PR_PAGE_SIZE)); + return u.toString(); +} + +/** All open PRs for the repo (the "New worktree from PR" picker). */ +export function openPrsUrl(baseUrl: string, owner: string, repo: string, limit: number): string { + const u = new URL(pullsPath(baseUrl, owner, repo)); + u.searchParams.set("state", "open"); + u.searchParams.set("limit", String(limit)); + return u.toString(); +} + +/** Combined commit status for a head sha — Forgejo's `statusCheckRollup`. */ +export function combinedStatusUrl(baseUrl: string, owner: string, repo: string, ref: string): string { + return `${apiRoot(baseUrl)}/repos/${seg(owner)}/${seg(repo)}/commits/${seg(ref)}/status`; +} + +/** A single PR, by index. Used to re-read a title before rewriting it. */ +export function prDetailUrl(baseUrl: string, owner: string, repo: string, index: number): string { + return `${pullsPath(baseUrl, owner, repo)}/${seg(index)}`; +} + +/** + * Merge the base branch into the PR head — Forgejo's `gh pr update-branch`. + * + * `style` is explicit: the instance default (`DEFAULT_UPDATE_STYLE`) is + * configurable, and silently inheriting it would make the same button rebase on + * one server and merge on another. + */ +export function updateBranchUrl( + baseUrl: string, + owner: string, + repo: string, + index: number, + style: "merge" | "rebase" = "merge", +): string { + const u = new URL(`${pullsPath(baseUrl, owner, repo)}/${seg(index)}/update`); + u.searchParams.set("style", style); + return u.toString(); +} + +/** Squash-merge a PR. */ +export function mergeUrl(baseUrl: string, owner: string, repo: string, index: number): string { + return `${pullsPath(baseUrl, owner, repo)}/${seg(index)}/merge`; +} + +/** + * The newest PR in a page, by index — hazard 3's mitigation. + * + * Highest `number` wins rather than first-returned, so an older CLOSED PR can + * never take the slot from a newer OPEN one on the same branch and flip the + * glyph. Rows without a numeric index are skipped rather than coerced. + */ +export function pickLatestPr<T extends { number?: unknown }>(rows: readonly (T | null)[]): T | null { + let best: T | null = null; + for (const row of rows) { + if (row === null || typeof row !== "object") continue; + if (typeof row.number !== "number" || !Number.isFinite(row.number)) continue; + if (best === null || row.number > (best.number as number)) best = row; + } + return best; +} + +/** Identity + mergeability of a Forgejo PR, in the vocabulary the DTO speaks. */ +export interface ForgejoPrCore { + number: number; + url: string; + /** OPEN | CLOSED | MERGED */ + state: string; + title: string; + isDraft: boolean; + /** MERGEABLE | CONFLICTING | UNKNOWN */ + mergeable: string | null; + /** Always null: Forgejo has no equivalent concept (hazard 2). */ + mergeStateStatus: null; + baseRefName: string | null; + /** Head commit, needed to fetch the check rollup. Null when unreported. */ + headSha: string | null; +} + +function str(v: unknown): string | null { + return typeof v === "string" && v.length > 0 ? v : null; +} + +/** + * Map one Forgejo PR row onto {@link ForgejoPrCore}. + * + * `mergeable` is taken verbatim from the API rather than being ANDed with the + * open state: conflating the two is a presentation choice, and the pill already + * derives its own tint from `state`. Absent means "not computed yet" — Forgejo + * resolves conflicts asynchronously — which is `UNKNOWN`, not "conflicting". + */ +export function mapForgejoPr(raw: unknown): ForgejoPrCore | null { + if (typeof raw !== "object" || raw === null) return null; + const r = raw as Record<string, unknown>; + if (typeof r.number !== "number" || !Number.isFinite(r.number)) return null; + + const merged = r.merged === true; + const rawState = typeof r.state === "string" ? r.state.toUpperCase() : ""; + // REST has no distinct "merged" state; the flag disambiguates it from a plain + // close, which is what keeps a merged PR from rendering with the closed glyph. + const state = merged ? "MERGED" : rawState === "OPEN" ? "OPEN" : "CLOSED"; + + let mergeable: string; + if (r.mergeable === true) mergeable = "MERGEABLE"; + else if (r.mergeable === false) mergeable = "CONFLICTING"; + else mergeable = "UNKNOWN"; + + const base = r.base as { ref?: unknown } | undefined; + const head = r.head as { ref?: unknown; sha?: unknown } | undefined; + + return { + number: r.number, + // `html_url` is the human page; `url` is the API resource. The UI links out. + url: str(r.html_url) ?? "", + state, + title: typeof r.title === "string" ? r.title : "", + isDraft: r.draft === true, + mergeable, + mergeStateStatus: null, + baseRefName: str(base?.ref), + headSha: str(head?.sha), + }; +} + +/** + * The title a PR must be given to leave draft — hazard 1's mitigation. + * + * Returns null when no configured prefix matches, so a caller can never blindly + * rewrite a title it did not recognise as a draft marker. Matching is anchored + * and case-insensitive (mirroring Forgejo), so `WIPE the cache` is untouched + * while `wip: x` is not missed. Also returns null when stripping would leave an + * empty title, which Forgejo would reject anyway. + */ +export function readyTitle(title: string, prefixes: readonly string[] = DEFAULT_WIP_PREFIXES): string | null { + const lower = title.toLowerCase(); + for (const prefix of prefixes) { + if (prefix.length === 0) continue; + if (!lower.startsWith(prefix.toLowerCase())) continue; + const stripped = title.slice(prefix.length).trim(); + return stripped.length > 0 ? stripped : null; + } + return null; +} + +/** + * Map Forgejo's `CommitStatusState` onto a {@link PrCheckBucket}. + * + * Forgejo's enum is `pending | success | error | failure | warning | skipped`. + * Two notes on the edges: + * - `skipped` exists here but not in GitHub's status vocabulary, which is why + * these entries are classified locally instead of being reshaped into + * GitHub's `statusCheckRollup` form and run through `normalizeChecks`. + * - An unrecognised value buckets as `pending`, never `pass`: a check we cannot + * classify must not be reported as green. + */ +function bucketForForgejoStatus(status: string): PrCheckBucket { + switch (status.toLowerCase()) { + case "success": + return "pass"; + case "failure": + case "error": + return "fail"; + case "pending": + return "pending"; + case "skipped": + // `warning` is Forgejo's advisory state — the closest honest bucket is the + // one already used for GitHub's NEUTRAL: present, but not a pass or a fail. + case "warning": + return "skipping"; + default: + return "pending"; + } +} + +/** + * Adapt a Forgejo combined-status payload into flat {@link PrCheckDTO}s. + * + * `workflowName` is left null: Forgejo's commit status carries only a `context` + * string, and splitting it on `/` to invent a workflow name would be a guess + * (`testing / test-unit` and `a/b` are indistinguishable). + */ +export function forgejoChecks(combined: unknown): PrCheckDTO[] { + if (typeof combined !== "object" || combined === null) return []; + const statuses = (combined as { statuses?: unknown }).statuses; + if (!Array.isArray(statuses)) return []; + const out: PrCheckDTO[] = []; + for (const raw of statuses) { + if (typeof raw !== "object" || raw === null) continue; + const s = raw as Record<string, unknown>; + out.push({ + // Forgejo keys the state as `status`; GitHub REST uses `state`. + name: str(s.context) ?? "check", + bucket: bucketForForgejoStatus(typeof s.status === "string" ? s.status : ""), + workflowName: null, + detailsUrl: str(s.target_url), + }); + } + return out; +} + +/** + * True when a Forgejo timestamp is the zero-time sentinel (hazard 5). + * + * Forgejo serialises an unset time as the zero `time.Time` in the server's local + * zone (`1970-01-01T01:00:00+01:00`), not as null. Fed into a duration that + * renders as ~56 years, so callers must treat these as "absent" rather than + * "epoch". `<= 0` rather than `=== 0` so a negative offset zone is caught too. + */ +export function isEpochTimestamp(value: unknown): boolean { + if (typeof value !== "string" || value.length === 0) return false; + const t = Date.parse(value); + return Number.isFinite(t) && t <= 0; +} + +/** Host + slug of a git remote, for any self-hosted instance. */ +export interface ForgejoRemote { + /** Host including a non-default port, e.g. `git.example.com:2222`. */ + host: string; + owner: string; + repo: string; +} + +/** + * Parse `owner/repo` and the host out of a git remote URL. + * + * Unlike the GitHub parser this cannot anchor on a known hostname — a Forgejo + * instance is self-hosted, so the host is data. Both git forms are accepted: the + * scp-like `git@host:owner/repo.git` and any explicit scheme. Exactly two path + * segments are required, so a group URL or a bare owner returns null rather than + * a half-parsed slug that would 404 on every call. + */ +export function parseForgejoRemote(url: string): ForgejoRemote | null { + const trimmed = url.trim(); + if (trimmed.length === 0) return null; + + let host: string; + let path: string; + + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) { + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return null; + } + // `host` keeps an explicit port; userinfo (`git@`) is dropped by the parser. + host = parsed.host; + path = parsed.pathname; + } else { + // scp-like syntax: [user@]host:path — the colon separates host from path, + // so a port cannot be expressed and any digits after it are part of the path. + const m = /^(?:[^@/]+@)?([^:/]+):(.+)$/.exec(trimmed); + if (!m) return null; + host = m[1]; + path = m[2]; + } + + if (host.length === 0) return null; + const parts = path + .replace(/\.git$/i, "") + .split("/") + .filter((p) => p.length > 0); + if (parts.length !== 2) return null; + return { host, owner: parts[0], repo: parts[1] }; +} diff --git a/server/src/forge/none.test.ts b/server/src/forge/none.test.ts new file mode 100644 index 00000000..f808c33c --- /dev/null +++ b/server/src/forge/none.test.ts @@ -0,0 +1,47 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { createNoForgeGateway } from "./none.js"; +import { createUnsupportedGateway } from "./unsupported.js"; +import { hasBudgetReporting } from "./types.js"; + +test("a lookup is none, not unknown — there is nothing to look for, by instruction", async () => { + // The distinction from `unsupported` is the reason this gateway exists. `unknown` + // makes the app hold a stale PR pill and keep retrying (SPEC-32 §6.5), which is + // precisely the chatter setting the provider to None is meant to stop. + const g = createNoForgeGateway(); + assert.deepEqual(await g.prForBranch("/r", "b"), { kind: "none" }); +}); + +test("None and unsupported do NOT report the same thing", async () => { + // Pinned as a comparison rather than two separate assertions: if these ever + // collapse into one value, a deliberate choice starts reading as a defect. + const chosen = await createNoForgeGateway().prForBranch("/r", "b"); + const cannot = await createUnsupportedGateway().prForBranch("/r", "b"); + assert.notDeepEqual(chosen, cannot); +}); + +test("it makes no requests at all, so a None repo costs nothing to poll", async () => { + const g = createNoForgeGateway(); + await g.prForBranch("/r", "b"); + await g.openPrs("/r", 30); + assert.deepEqual(g.stats(), { execs: 0, exemptExecs: 0, cacheHits: 0 }); +}); + +test("there are no open PRs to offer, so the picker is empty rather than wrong", async () => { + assert.deepEqual(await createNoForgeGateway().openPrs("/r", 30), []); +}); + +test("a mutation names the setting, so the fix is one hop away", async () => { + // "It failed" is useless here: the cause is a choice the user made, and the + // message has to say where to unmake it. + const r = await createNoForgeGateway().mutatePr("/r", "b", 1, "merge-squash"); + assert.equal(r.ok, false); + assert.match(r.error ?? "", /None/); + assert.match(r.error ?? "", /Settings/); + assert.match(r.error ?? "", /merge-squash/); +}); + +test("it claims no budget", () => { + assert.equal(hasBudgetReporting(createNoForgeGateway()), false); +}); diff --git a/server/src/forge/none.ts b/server/src/forge/none.ts new file mode 100644 index 00000000..9e00a2a1 --- /dev/null +++ b/server/src/forge/none.ts @@ -0,0 +1,45 @@ +/** + * none.ts — the provider for a repository the user told makit to leave alone. + * + * Distinct from `unsupported.ts`, and the distinction is the entire reason this + * file exists: unsupported means *we cannot talk to this forge*, which is a + * failure worth investigating; `none` means *do not talk to any forge for this + * repository*, which is an instruction and settled. Collapsing them would make + * a deliberate choice read as a defect in the UI, and would leave the user with + * no way to silence PR chatter on a mirror or a vendored copy (SPEC-48 rev 3.2). + * + * The observable difference is `prForBranch`: + * + * unsupported → `unknown` — we did not look, so we cannot claim there is no PR + * none → `none` — there is nothing to look for, by instruction + * + * `unknown` would be wrong here: it makes the app hold a stale PR pill and keep + * retrying (SPEC-32 §6.5), which is the chatter the user just asked to stop. + * + * Makes no requests, spawns no processes, and reads no remote. + */ + +import type { OpenPr } from "../git.js"; +import type { ForgeGateway, GatewayStats, PrLookup, PrMutation } from "./types.js"; + +export function createNoForgeGateway(): ForgeGateway { + return { + // `none`, not `unknown` — see the module note. This is a conclusion. + prForBranch: async (): Promise<PrLookup> => ({ kind: "none" }), + openPrs: async (): Promise<OpenPr[]> => [], + mutatePr: async ( + _repoPath: string, + _branch: string, + _number: number, + verb: PrMutation, + ): Promise<{ ok: boolean; error?: string }> => ({ + ok: false, + // Names the setting, so the fix is one hop away rather than a mystery. + error: `This repository's Git provider is set to None, so makit cannot run "${verb}" on it. Choose a provider in its Settings section first.`, + }), + // Always zero: nothing here spends quota or touches the network, and reporting + // otherwise would corrupt the call-reduction figure the stats feed. + stats: (): GatewayStats => ({ execs: 0, exemptExecs: 0, cacheHits: 0 }), + close: () => {}, + }; +} diff --git a/server/src/forge/router.test.ts b/server/src/forge/router.test.ts new file mode 100644 index 00000000..db8a2d3f --- /dev/null +++ b/server/src/forge/router.test.ts @@ -0,0 +1,838 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + createDefaultForgeGateway, + createForgeRouter, + forgejoRefFromRemote, + isGitHubHost, + ORIGIN_REMOTE_ARGV, +} from "./router.js"; +import { createUnsupportedGateway } from "./unsupported.js"; +import type { ForgeGateway, ForgeSoftwareName, GatewayStats, PrLookup } from "./types.js"; +import type { ProviderChoice } from "../repo_settings.js"; +import type { GithubGateway } from "../github/gateway.js"; + +/** A recording stand-in for either provider. */ +function fake(name: string, calls: string[]): ForgeGateway { + return { + prForBranch: async (repoPath, branch) => { + calls.push(`${name}.prForBranch(${repoPath},${branch})`); + return { kind: "none" } as PrLookup; + }, + openPrs: async (repoPath) => { + calls.push(`${name}.openPrs(${repoPath})`); + return []; + }, + mutatePr: async (repoPath, _branch, _number, verb) => { + calls.push(`${name}.mutatePr(${repoPath},${verb})`); + return { ok: true }; + }, + stats: () => ({ execs: name === "github" ? 3 : 5, exemptExecs: 1, cacheHits: 2 }) as GatewayStats, + close: () => calls.push(`${name}.close`), + }; +} + +function githubFake(calls: string[]): GithubGateway { + const base = fake("github", calls); + return { + ...base, + budget: () => { + calls.push("github.budget"); + return { level: "high" } as never; + }, + history: () => { + calls.push("github.history"); + return []; + }, + refresh: async () => { + calls.push("github.refresh"); + return { level: "high" } as never; + }, + setPaused: (p: boolean) => calls.push(`github.setPaused(${p})`), + onBudgetChange: (fn) => { + calls.push("github.onBudgetChange"); + void fn; + return () => {}; + }, + } as GithubGateway; +} + +/** + * [hosts] maps a repo path to its origin host (null = unreadable remote), and + * [software] maps a host to what detection reports for it. A host with no entry + * defaults to "forgejo", which keeps the routing tests focused on routing. + */ +function harness(hosts: Record<string, string | null>, software: Record<string, ForgeSoftwareName> = {}) { + const calls: string[] = []; + const lookups: string[] = []; + const probes: string[] = []; + /** + * Per-repo provider overrides, MUTABLE on purpose: the setting is changed while + * the daemon runs, so a test that could only set it before the first route + * would never catch the routing cache serving a stale decision. + */ + const choices = new Map<string, ProviderChoice>(); + const router = createForgeRouter({ + github: githubFake(calls), + forgejo: fake("forgejo", calls), + unsupported: fake("unsupported", calls), + none: fake("none", calls), + providerFor: (repoPath: string) => choices.get(repoPath) ?? "auto", + resolveInstance: async (repoPath: string) => { + lookups.push(repoPath); + const host = hosts[repoPath]; + if (host === undefined || host === null) return null; + return { host, baseUrl: `https://${host}`, token: "t" }; + }, + detect: async (baseUrl: string) => { + probes.push(baseUrl); + const host = baseUrl.replace("https://", ""); + return software[host] ?? "forgejo"; + }, + onUnsupported: (host, sw) => calls.push(`warn(${host},${sw})`), + }); + return { router, calls, lookups, probes, choices }; +} + +// --------------------------------------------------------------------------- +// Host classification +// --------------------------------------------------------------------------- + +test("isGitHubHost accepts github.com and its subdomains only", () => { + assert.equal(isGitHubHost("github.com"), true); + assert.equal(isGitHubHost("GitHub.com"), true); + assert.equal(isGitHubHost("www.github.com"), true); + assert.equal(isGitHubHost("git.example.com"), false); + assert.equal(isGitHubHost("codeberg.org"), false); + // Must not be fooled by a lookalike host. + assert.equal(isGitHubHost("github.com.evil.test"), false); + assert.equal(isGitHubHost("notgithub.com"), false); +}); + +// --------------------------------------------------------------------------- +// Routing +// --------------------------------------------------------------------------- + +test("a github.com repo goes to the gh-backed gateway", async () => { + const { router, calls } = harness({ "/gh": "github.com" }); + await router.prForBranch("/gh", "b"); + assert.deepEqual(calls, ["github.prForBranch(/gh,b)"]); +}); + +test("a self-hosted repo goes to the Forgejo gateway", async () => { + const { router, calls } = harness({ "/fj": "git.example.com" }); + await router.prForBranch("/fj", "b"); + assert.deepEqual(calls, ["forgejo.prForBranch(/fj,b)"]); +}); + +test("openPrs and mutatePr route the same way as prForBranch", async () => { + const { router, calls } = harness({ "/fj": "git.example.com", "/gh": "github.com" }); + await router.openPrs("/fj", 30); + await router.mutatePr("/fj", "b", 1, "ready"); + await router.openPrs("/gh", 30); + assert.deepEqual(calls, [ + "forgejo.openPrs(/fj)", + "forgejo.mutatePr(/fj,ready)", + "github.openPrs(/gh)", + ]); +}); + +test("an unreadable remote falls back to GitHub, preserving today's behaviour", async () => { + // Routing elsewhere would change the failure mode for every non-git directory; + // the gh gateway already degrades such a repo to `unknown`. + const { router, calls } = harness({ "/mystery": null }); + await router.prForBranch("/mystery", "b"); + assert.deepEqual(calls, ["github.prForBranch(/mystery,b)"]); +}); + +test("the host is resolved once per repo, not once per call", async () => { + const { router, lookups } = harness({ "/fj": "git.example.com" }); + await router.prForBranch("/fj", "a"); + await router.prForBranch("/fj", "b"); + await router.openPrs("/fj", 30); + assert.deepEqual(lookups, ["/fj"]); +}); + +test("concurrent first calls for one repo still resolve the host once", async () => { + const { router, lookups } = harness({ "/fj": "git.example.com" }); + await Promise.all([router.prForBranch("/fj", "a"), router.prForBranch("/fj", "b")]); + assert.deepEqual(lookups, ["/fj"], "an in-flight lookup must be shared, not duplicated"); +}); + +// --------------------------------------------------------------------------- +// Budget: GitHub-only, so it delegates rather than being averaged or faked. +// --------------------------------------------------------------------------- + +test("budget reporting delegates to GitHub, the only provider with a quota", async () => { + const { router, calls } = harness({}); + router.budget(); + router.history(); + await router.refresh(); + router.setPaused(true); + router.onBudgetChange(() => {}); + assert.deepEqual(calls, [ + "github.budget", + "github.history", + "github.refresh", + "github.setPaused(true)", + "github.onBudgetChange", + ]); +}); + +test("stats sums the providers so the call-reduction figure stays whole", () => { + // Four gateways now, all read: github 3, and forgejo/unsupported/none 5 each from + // `fake` (which reports 5 for anything not named "github"). + const { router } = harness({}); + assert.deepEqual(router.stats(), { execs: 18, exemptExecs: 4, cacheHits: 8 }); +}); + +test("close closes every provider", () => { + const { router, calls } = harness({}); + router.close(); + assert.deepEqual(calls.sort(), [ + "forgejo.close", + "github.close", + "none.close", + "unsupported.close", + ]); +}); + +// --------------------------------------------------------------------------- +// Turning a git remote into Forgejo coordinates +// --------------------------------------------------------------------------- + +test("ORIGIN_REMOTE_ARGV reads the origin URL without touching the network", () => { + assert.deepEqual(ORIGIN_REMOTE_ARGV, ["remote", "get-url", "origin"]); +}); + +test("forgejoRefFromRemote derives base URL, slug and token", () => { + const ref = forgejoRefFromRemote("git@git.example.com:acme/app.git", { + MAKIT_FORGEJO_TOKEN: "t0k", + }); + assert.deepEqual(ref, { + baseUrl: "https://git.example.com", + owner: "acme", + repo: "app", + token: "t0k", + }); +}); + +test("forgejoRefFromRemote accepts the common token env names in priority order", () => { + const pick = (env: Record<string, string>) => + forgejoRefFromRemote("https://git.example.com/a/b", env)?.token; + assert.equal( + pick({ + MAKIT_FORGEJO_TOKEN: "m", + FORGEJO_ACCESS_TOKEN: "a", + FORGEJO_TOKEN: "f", + GITEA_TOKEN: "g", + }), + "m", + ); + assert.equal(pick({ FORGEJO_ACCESS_TOKEN: "a", FORGEJO_TOKEN: "f" }), "a"); + assert.equal(pick({ FORGEJO_TOKEN: "f", GITEA_TOKEN: "g" }), "f"); + assert.equal(pick({ GITEA_TOKEN: "g" }), "g"); + assert.equal(pick({}), undefined); +}); + +test("forgejoRefFromRemote honours a base-URL override for the host it names", () => { + for (const key of ["MAKIT_FORGEJO_BASE_URL", "FORGEJO_BASE_URL"]) { + const ref = forgejoRefFromRemote("https://git.example.com/a/b", { + [key]: "https://git.example.com/forge", + }); + assert.equal(ref?.baseUrl, "https://git.example.com/forge", key); + } +}); + +// A configured instance URL scopes the credentials to THAT host. Without this a +// single global FORGEJO_ACCESS_TOKEN -- the normal way to configure one instance +// -- would be attached to every non-GitHub remote, so cloning any public Gitea +// repo would ship the user's internal token to a third party. +test("a configured instance never lends its token to a different host", () => { + const env = { + FORGEJO_BASE_URL: "https://forgejo.internal.example", + FORGEJO_ACCESS_TOKEN: "secret", + }; + const own = forgejoRefFromRemote("https://forgejo.internal.example/a/b", env); + assert.equal(own?.token, "secret"); + assert.equal(own?.baseUrl, "https://forgejo.internal.example"); + + const foreign = forgejoRefFromRemote("https://codeberg.org/a/b", env); + assert.equal(foreign?.token, undefined, "the internal token must not leave its host"); + // Still usable unauthenticated against its own host, not the configured one. + assert.equal(foreign?.baseUrl, "https://codeberg.org"); +}); + +test("the base-URL override matches on host, ignoring scheme, port and path", () => { + const env = { FORGEJO_BASE_URL: "http://git.example.com:3000/forge", FORGEJO_TOKEN: "t" }; + const ref = forgejoRefFromRemote("git@git.example.com:a/b.git", env); + assert.equal(ref?.baseUrl, "http://git.example.com:3000/forge"); + assert.equal(ref?.token, "t"); +}); + +test("with no instance configured the token applies to the remote's own host", () => { + // The single-instance case: there is nothing to scope against, so the token is + // attached to whatever host the remote names. + const ref = forgejoRefFromRemote("https://git.example.com/a/b", { FORGEJO_TOKEN: "t" }); + assert.equal(ref?.token, "t"); + assert.equal(ref?.baseUrl, "https://git.example.com"); +}); + +test("forgejoRefFromRemote returns null for a remote it cannot read", () => { + assert.equal(forgejoRefFromRemote("", {}), null); + assert.equal(forgejoRefFromRemote("https://git.example.com/only-owner", {}), null); +}); + +// --------------------------------------------------------------------------- +// Which providers are actually in play. The poll cadence needs this: GitHub's +// degradation ladder exists to ration GitHub quota, and must not throttle a +// Forgejo-only setup where there is no quota to ration. +// --------------------------------------------------------------------------- + +test("providersInUse is empty until a repo has been routed", () => { + const { router } = harness({ "/fj": "git.example.com" }); + assert.deepEqual([...router.providersInUse()], []); +}); + +test("providersInUse learns each provider as repos are routed", async () => { + const { router } = harness({ "/fj": "git.example.com", "/gh": "github.com" }); + await router.prForBranch("/fj", "b"); + assert.deepEqual([...router.providersInUse()], ["forgejo"]); + await router.prForBranch("/gh", "b"); + assert.deepEqual([...router.providersInUse()].sort(), ["forgejo", "github"]); +}); + +test("close() forgets the provider mix along with the routing cache", async () => { + const { router } = harness({ "/fj": "git.example.com" }); + await router.prForBranch("/fj", "b"); + router.close(); + assert.deepEqual([...router.providersInUse()], []); +}); + +// --------------------------------------------------------------------------- +// Detection-driven routing. Before this, EVERY non-GitHub host was assumed to be +// Forgejo, so a GitLab remote was polled against an API that does not exist there +// and reported `unknown` -- identical to the instance being down. +// --------------------------------------------------------------------------- + +test("a Gitea instance routes to the Forgejo provider (same REST API)", async () => { + const { router, calls } = harness({ "/gt": "gitea.example" }, { "gitea.example": "gitea" }); + await router.prForBranch("/gt", "b"); + assert.deepEqual(calls, ["forgejo.prForBranch(/gt,b)"]); +}); + +test("a GitLab instance routes to the unsupported provider, not Forgejo", async () => { + const { router, calls } = harness({ "/gl": "gitlab.example" }, { "gitlab.example": "gitlab" }); + await router.prForBranch("/gl", "b"); + assert.ok(calls.includes("unsupported.prForBranch(/gl,b)"), calls.join(",")); + assert.ok(!calls.some((c) => c.startsWith("forgejo.")), "must not query a Forgejo API that is not there"); +}); + +test("an unidentifiable forge routes to the unsupported provider", async () => { + const { router, calls } = harness({ "/x": "mystery.example" }, { "mystery.example": "unknown" }); + await router.prForBranch("/x", "b"); + assert.ok(calls.includes("unsupported.prForBranch(/x,b)"), calls.join(",")); +}); + +test("an unsupported host is reported once, not once per poll", async () => { + const { router, calls } = harness({ "/gl": "gitlab.example" }, { "gitlab.example": "gitlab" }); + await router.prForBranch("/gl", "a"); + await router.prForBranch("/gl", "b"); + await router.openPrs("/gl", 30); + assert.equal(calls.filter((c) => c.startsWith("warn(")).length, 1, calls.join(",")); + assert.deepEqual( + calls.filter((c) => c.startsWith("warn(")), + ["warn(gitlab.example,gitlab)"], + ); +}); + +test("GitHub is never probed -- the host is decisive", async () => { + const { router, probes } = harness({ "/gh": "github.com" }); + await router.prForBranch("/gh", "b"); + assert.deepEqual(probes, [], "no round trip should be spent identifying github.com"); +}); + +test("detection runs once per repo, like the rest of the routing decision", async () => { + const { router, probes } = harness({ "/fj": "git.example" }); + await router.prForBranch("/fj", "a"); + await router.prForBranch("/fj", "b"); + await router.openPrs("/fj", 30); + assert.deepEqual(probes, ["https://git.example"]); +}); + +test("an unsupported forge counts as its own provider in the mix", async () => { + const { router } = harness({ "/gl": "gitlab.example" }, { "gitlab.example": "gitlab" }); + await router.prForBranch("/gl", "b"); + assert.deepEqual([...router.providersInUse()], ["unsupported"]); +}); + +test("a detection failure falls back to GitHub rather than breaking the poll", async () => { + const calls: string[] = []; + const router = createForgeRouter({ + github: githubFake(calls), + forgejo: fake("forgejo", calls), + unsupported: fake("unsupported", calls), + none: fake("none", calls), + resolveInstance: async () => ({ host: "git.example", baseUrl: "https://git.example" }), + detect: async () => { + throw new Error("probe exploded"); + }, + }); + await router.prForBranch("/fj", "b"); + assert.deepEqual(calls, ["github.prForBranch(/fj,b)"]); +}); + +// --------------------------------------------------------------------------- +// F1/F2 — the router records what it decided, because nothing else retains it: +// `chosen` holds only the gateway promise. +// --------------------------------------------------------------------------- + +test("forgeFor is undefined until a repo has been routed", () => { + const { router } = harness({ "/fj": "git.example" }); + assert.equal(router.forgeFor("/fj"), undefined); +}); + +test("forgeFor reports the software, host and whether a credential exists", async () => { + const { router } = harness({ "/gt": "gitea.example" }, { "gitea.example": "gitea" }); + await router.prForBranch("/gt", "b"); + assert.deepEqual(router.forgeFor("/gt"), { + software: "gitea", + host: "gitea.example", + authed: true, + source: "detected", + }); +}); + +test("a GitHub repo reports no authed flag — gh's budget is not host auth", async () => { + const { router } = harness({ "/gh": "github.com" }); + await router.prForBranch("/gh", "b"); + assert.deepEqual(router.forgeFor("/gh"), { + software: "github", + host: "github.com", + source: "detected", + }); +}); + +test("an unsupported forge is still recorded, so the UI can name it", async () => { + const { router } = harness({ "/gl": "gitlab.example" }, { "gitlab.example": "gitlab" }); + await router.prForBranch("/gl", "b"); + assert.equal(router.forgeFor("/gl")?.software, "gitlab"); +}); + +test("a repo with no readable remote records nothing rather than guessing github.com", async () => { + // `forge: undefined` on the DTO means "not measured"; inventing a host here + // would make a local-only repo claim to be on GitHub. + const { router } = harness({ "/mystery": null }); + await router.prForBranch("/mystery", "b"); + assert.equal(router.forgeFor("/mystery"), undefined); +}); + +test("close() forgets the decisions", async () => { + const { router } = harness({ "/fj": "git.example" }); + await router.prForBranch("/fj", "b"); + router.close(); + assert.equal(router.forgeFor("/fj"), undefined); +}); + +// --------------------------------------------------------------------------- +// P2 / D3" — the provider override DRIVES ROUTING. +// +// The whole point of the control: detection returns `unknown` for a private +// instance that answers 401 to an anonymous probe, and for one behind a proxy +// that hides `/api/forgejo/v1/version`. Both route to the *unsupported* provider, +// where the repo is unusable with no recourse. The override is the recourse — so +// it must pick the gateway, not merely be displayed. +// --------------------------------------------------------------------------- + +test("an override to Forgejo rescues a repo detection could not identify", async () => { + // Detection says `unknown`, which today lands on `unsupported` — unusable. + const { router, calls, choices } = harness({ "/fj": "private.example" }, { "private.example": "unknown" }); + choices.set("/fj", "forgejo"); + await router.prForBranch("/fj", "b"); + assert.deepEqual(calls, ["forgejo.prForBranch(/fj,b)"]); +}); + +test("an override skips the probe entirely — the probe is what failed", async () => { + // Not an optimisation. A proxy that hides the version endpoint makes the probe + // useless; spending it anyway would delay every poll for no information. + const { router, probes, choices } = harness({ "/fj": "private.example" }, { "private.example": "unknown" }); + choices.set("/fj", "forgejo"); + await router.prForBranch("/fj", "b"); + assert.deepEqual(probes, []); +}); + +test("an override to Gitea routes to the Forgejo provider and records gitea", async () => { + const { router, calls, choices } = harness({ "/gt": "gt.example" }, { "gt.example": "unknown" }); + choices.set("/gt", "gitea"); + await router.prForBranch("/gt", "b"); + assert.deepEqual(calls, ["forgejo.prForBranch(/gt,b)"]); + assert.equal(router.forgeFor("/gt")?.software, "gitea"); +}); + +test("an override to GitHub sends a non-github.com host to the gh gateway", async () => { + // A GitHub Enterprise host is not github.com, so the host rule alone sends it to + // Forgejo, where it fails. This is the only way to reach `gh` for such a repo. + const { router, calls, choices } = harness({ "/ghe": "github.acme.test" }); + choices.set("/ghe", "github"); + await router.prForBranch("/ghe", "b"); + assert.deepEqual(calls, ["github.prForBranch(/ghe,b)"]); +}); + +test("an override to None talks to no forge at all", async () => { + // "Stops checking pull requests" has to mean no provider call and no remote read, + // otherwise it is a label rather than an instruction. + const { router, calls, lookups, probes, choices } = harness({ "/mirror": "gt.example" }); + choices.set("/mirror", "none"); + const lookup = await router.prForBranch("/mirror", "b"); + assert.deepEqual(calls, ["none.prForBranch(/mirror,b)"]); + assert.deepEqual(lookups, [], "None must not even read the origin remote"); + assert.deepEqual(probes, []); + // `none`, not `unknown`: we are not failing to look, we were told not to. + assert.deepEqual(lookup, { kind: "none" }); +}); + +test("None counts as its own provider in the mix, so cadence can ignore it", async () => { + const { router, choices } = harness({ "/mirror": "gt.example" }); + choices.set("/mirror", "none"); + await router.prForBranch("/mirror", "b"); + assert.deepEqual([...router.providersInUse()], ["none"]); +}); + +test("changing the override re-routes WITHOUT a restart", async () => { + // The routing cache keys on the repo path alone, so without re-checking the + // choice the setting would appear to do nothing until the daemon restarted — + // which is indistinguishable from the feature being broken. + const { router, calls, choices } = harness({ "/r": "git.example" }); + await router.prForBranch("/r", "b"); + assert.deepEqual(calls, ["forgejo.prForBranch(/r,b)"]); + choices.set("/r", "github"); + await router.prForBranch("/r", "b"); + assert.deepEqual(calls, ["forgejo.prForBranch(/r,b)", "github.prForBranch(/r,b)"]); +}); + +test("an unchanged override still resolves the host only once", async () => { + // Re-checking the choice must not throw away the cache that makes the home-screen + // fan-out cheap. + const { router, lookups, choices } = harness({ "/r": "git.example" }); + choices.set("/r", "forgejo"); + await router.prForBranch("/r", "b"); + await router.prForBranch("/r", "c"); + await router.openPrs("/r", 10); + assert.deepEqual(lookups, ["/r"]); +}); + +test("forgeFor says the decision came from the override, not from detection", async () => { + // The UI must not caption an override "detected": that is the one thing the + // reader would use to decide whether to trust it. + const { router, choices } = harness({ "/fj": "private.example" }, { "private.example": "unknown" }); + choices.set("/fj", "forgejo"); + await router.prForBranch("/fj", "b"); + assert.deepEqual(router.forgeFor("/fj"), { + software: "forgejo", + host: "private.example", + authed: true, + source: "override", + }); +}); + +test("a detected decision is labelled detected", async () => { + const { router } = harness({ "/gt": "gitea.example" }, { "gitea.example": "gitea" }); + await router.prForBranch("/gt", "b"); + assert.equal(router.forgeFor("/gt")?.source, "detected"); +}); + +test("Auto is unchanged: detection still decides", async () => { + const { router, calls, probes } = harness({ "/gl": "gitlab.example" }, { "gitlab.example": "gitlab" }); + await router.prForBranch("/gl", "b"); + assert.deepEqual(probes, ["https://gitlab.example"]); + assert.equal(calls[0], "warn(gitlab.example,gitlab)"); + assert.equal(calls[1], "unsupported.prForBranch(/gl,b)"); +}); + +// --------------------------------------------------------------------------- +// P2 — "no remote" and "not measured yet" must be separable. +// +// `settingsDtoFor` derived hasRemote from `forge !== undefined`, which made the +// app's "Auto: not identified yet" branch UNREACHABLE: every repo that had not +// been polled yet claimed to have no remote. rev 3.2 pinned that these two read +// differently, so the router has to record the remote as its own fact. +// --------------------------------------------------------------------------- + +test("hasRemoteFor is undefined until the repo has been routed", () => { + const { router } = harness({ "/r": "git.example" }); + assert.equal(router.hasRemoteFor("/r"), undefined); +}); + +test("hasRemoteFor is true once a readable remote has been routed", async () => { + const { router } = harness({ "/r": "git.example" }); + await router.prForBranch("/r", "b"); + assert.equal(router.hasRemoteFor("/r"), true); +}); + +test("hasRemoteFor is false for a repo whose origin cannot be read", async () => { + // The local-only repo. `forgeFor` is undefined here too — which is exactly why + // one field cannot carry both facts. + const { router } = harness({ "/local": null }); + await router.prForBranch("/local", "b"); + assert.equal(router.hasRemoteFor("/local"), false); + assert.equal(router.forgeFor("/local"), undefined); +}); + +test("close() forgets the remote facts along with the decisions", async () => { + const { router } = harness({ "/r": "git.example" }); + await router.prForBranch("/r", "b"); + router.close(); + assert.equal(router.hasRemoteFor("/r"), undefined); +}); + +test("a transient lookup failure does NOT discard an explicit override", async () => { + // Found while reviewing the override work. The router falls back to GitHub when + // routing throws, which was right when nothing could contradict it. With an + // override it is wrong twice over: it ignores an explicit instruction, and `gh` + // cannot talk to the host anyway — so every call fails. + // + // Worse, the fallback is CACHED against the choice that produced it, so one + // transient error pins the repo to the wrong provider until the setting changes + // or the daemon restarts. + const calls: string[] = []; + const choices = new Map<string, ProviderChoice>([["/fj", "forgejo"]]); + const router = createForgeRouter({ + github: githubFake(calls), + forgejo: fake("forgejo", calls), + unsupported: fake("unsupported", calls), + none: fake("none", calls), + providerFor: (p) => choices.get(p) ?? "auto", + resolveInstance: async () => { + throw new Error("git remote read exploded"); + }, + detect: async () => "forgejo", + }); + await router.prForBranch("/fj", "b"); + assert.deepEqual(calls, ["forgejo.prForBranch(/fj,b)"]); + // And it is not pinned to a wrong answer by that one failure. + await router.openPrs("/fj", 10); + assert.deepEqual(calls, ["forgejo.prForBranch(/fj,b)", "forgejo.openPrs(/fj)"]); +}); + +test("Auto still falls back to GitHub when routing throws", async () => { + // The status quo for a repo with no opinion attached, unchanged. + const calls: string[] = []; + const router = createForgeRouter({ + github: githubFake(calls), + forgejo: fake("forgejo", calls), + unsupported: fake("unsupported", calls), + none: fake("none", calls), + resolveInstance: async () => { + throw new Error("boom"); + }, + detect: async () => "forgejo", + }); + await router.prForBranch("/x", "b"); + assert.deepEqual(calls, ["github.prForBranch(/x,b)"]); +}); + +test("a failed remote read is RECORDED as no-remote, not left as 'unmeasured'", async () => { + // Review finding: the fallback path recorded nothing, so `hasRemoteFor` stayed + // `undefined` — indistinguishable from a repo that has not been routed yet, which + // is the exact three-states-in-one-boolean confusion this pair of methods exists to + // stop. + const calls: string[] = []; + const router = createForgeRouter({ + github: githubFake(calls), + forgejo: fake("forgejo", calls), + unsupported: fake("unsupported", calls), + none: fake("none", calls), + resolveInstance: async () => { + throw new Error("git remote read exploded"); + }, + detect: async () => "forgejo", + }); + await router.prForBranch("/x", "b"); + assert.equal(router.hasRemoteFor("/x"), false); +}); + +test("a failure AFTER the remote was read keeps the remote fact true", async () => { + // The trap in the obvious fix. Two different failures reach the same catch: the + // remote read failing (no remote) and DETECTION failing (a perfectly good remote we + // could not classify). Recording `false` unconditionally would turn the second into + // a claim that the repo has no origin — a fact we already measured as true. + const calls: string[] = []; + const router = createForgeRouter({ + github: githubFake(calls), + forgejo: fake("forgejo", calls), + unsupported: fake("unsupported", calls), + none: fake("none", calls), + resolveInstance: async () => ({ host: "git.example", baseUrl: "https://git.example" }), + detect: async () => { + throw new Error("probe exploded"); + }, + }); + await router.prForBranch("/y", "b"); + assert.equal(router.hasRemoteFor("/y"), true, "the remote WAS read; only detection failed"); +}); + +test("stats sums EVERY provider, matching what the doc comment claims", async () => { + // Review finding: the sum covered github and forgejo only, while the comment said + // it "covers every provider in play". Both omitted gateways return zeros today, so + // the number was right by accident — and would drift silently the moment either + // started counting a call. + const calls: string[] = []; + const counting = (n: number): ForgeGateway => ({ + ...fake(`c${n}`, calls), + stats: () => ({ execs: n, exemptExecs: n, cacheHits: n }), + }); + const router = createForgeRouter({ + github: { ...githubFake(calls), stats: () => ({ execs: 1, exemptExecs: 1, cacheHits: 1 }) } as GithubGateway, + forgejo: counting(2), + unsupported: counting(4), + none: counting(8), + resolveInstance: async () => ({ host: "git.example", baseUrl: "https://git.example" }), + detect: async () => "forgejo", + }); + assert.deepEqual(router.stats(), { execs: 15, exemptExecs: 15, cacheHits: 15 }); +}); + +test("close closes EVERY provider, not just the two with caches", async () => { + const calls: string[] = []; + const router = createForgeRouter({ + github: githubFake(calls), + forgejo: fake("forgejo", calls), + unsupported: fake("unsupported", calls), + none: fake("none", calls), + resolveInstance: async () => ({ host: "git.example", baseUrl: "https://git.example" }), + detect: async () => "forgejo", + }); + router.close(); + assert.deepEqual(calls.sort(), ["forgejo.close", "github.close", "none.close", "unsupported.close"]); +}); + +test("a fallback route is NOT cached, so the repo recovers when the read works", async () => { + // Review finding: `pick` cached whatever `route` resolved, including the + // catch-block fallback, and nothing evicted it. One failed `git remote` read at + // startup therefore sent every later poll for a Forgejo repo to `gh` for the + // lifetime of the daemon, where it failed and the PR pill read `unknown` forever. + const calls: string[] = []; + let attempts = 0; + const router = createForgeRouter({ + github: githubFake(calls), + forgejo: fake("forgejo", calls), + unsupported: fake("unsupported", calls), + none: fake("none", calls), + resolveInstance: async () => { + attempts += 1; + if (attempts === 1) throw new Error("transient git failure"); + return { host: "git.example", baseUrl: "https://git.example" }; + }, + detect: async () => "forgejo", + }); + await router.prForBranch("/r", "b"); + assert.deepEqual(calls, ["github.prForBranch(/r,b)"], "first call falls back"); + await router.prForBranch("/r", "b"); + assert.deepEqual( + calls, + ["github.prForBranch(/r,b)", "forgejo.prForBranch(/r,b)"], + "the second call retries and reaches the real provider", + ); +}); + +test("a successful route is still cached, so the fan-out stays cheap", async () => { + // The fix must not turn every call into a fresh `git remote` read. + const calls: string[] = []; + let lookups = 0; + const router = createForgeRouter({ + github: githubFake(calls), + forgejo: fake("forgejo", calls), + unsupported: fake("unsupported", calls), + none: fake("none", calls), + resolveInstance: async () => { + lookups += 1; + return { host: "git.example", baseUrl: "https://git.example" }; + }, + detect: async () => "forgejo", + }); + await router.prForBranch("/r", "b"); + await router.prForBranch("/r", "c"); + await router.openPrs("/r", 5); + assert.equal(lookups, 1); +}); + +test("the unsupported gateway names THIS repo's forge, not the last one detected", async () => { + // `currentSoftware` was a single shared variable set by whichever repo was detected + // most recently, so a mutation on a GitLab repo could report Bitbucket's name after + // another repo was probed. The decision is per repo; the message must be too. + // The REAL unsupported gateway, since the message is what is under test. + const calls: string[] = []; + const software: Record<string, ForgeSoftwareName> = { + "gitlab.example": "gitlab", + "weird.example": "unknown", + }; + let inspector: { forgeFor(p: string): { software: ForgeSoftwareName } | undefined } | undefined; + const router = createForgeRouter({ + github: githubFake(calls), + forgejo: fake("forgejo", calls), + unsupported: createUnsupportedGateway({ + softwareFor: (repoPath) => inspector?.forgeFor(repoPath)?.software, + }), + none: fake("none", calls), + resolveInstance: async (repoPath: string) => { + const host = repoPath === "/gl" ? "gitlab.example" : "weird.example"; + return { host, baseUrl: `https://${host}` }; + }, + detect: async (baseUrl: string) => software[baseUrl.replace("https://", "")] ?? "unknown", + }); + inspector = router; + await router.prForBranch("/gl", "b"); + // Probing the second repo used to overwrite the shared `currentSoftware`. + await router.prForBranch("/mystery", "b"); + const r = await router.mutatePr("/gl", "b", 1, "merge-squash"); + assert.equal(r.ok, false); + assert.match(r.error ?? "", /GitLab/); +}); + +// --------------------------------------------------------------------------- +// The production wiring's own `git remote` reads. +// --------------------------------------------------------------------------- + +test("the origin remote is read ONCE per repo, not once per gateway call", async () => { + // Review finding: the router caches its routing promise, but the Forgejo gateway + // calls `resolveRepo` -- and therefore `git remote get-url origin` -- at the top of + // prForBranch, openPrs and mutatePr. The gateway's own cache is consulted AFTER + // that, so even a cache HIT paid for a subprocess, and N worktrees of one repo + // spawned N processes per poll tick. + // A NON-GitHub remote on purpose. With `github.com` the router picks the gh gateway, + // the Forgejo gateway's `resolveRepo` never runs, and one read for three calls is + // guaranteed by the routing cache alone -- so the test would pass with the memo + // deleted, which is exactly the finding it is meant to cover. + const execs: string[] = []; + const gateway = createDefaultForgeGateway({ + exec: async (cmd: string, args: readonly string[], cwd?: string) => { + execs.push(`${cmd} ${args.join(" ")} @${cwd ?? ""}`); + return { code: 0, stdout: "https://git.example.com/acme/app.git", stderr: "" }; + }, + env: {}, + }); + // Detection and the REST calls both fail closed (no network in a unit test), which is + // fine: what is counted is the subprocess, not the outcome. + await gateway.prForBranch("/r", "a"); + await gateway.prForBranch("/r", "b"); + await gateway.openPrs("/r", 30); + const remoteReads = execs.filter((e) => e.includes("remote get-url origin")).length; + assert.equal(remoteReads, 1, `one read for three calls, got ${remoteReads}`); + gateway.close(); +}); + +test("two different repos still get their own read", async () => { + const execs: string[] = []; + const gateway = createDefaultForgeGateway({ + exec: async (_cmd: string, _args: readonly string[], cwd?: string) => { + execs.push(`${cwd ?? ""}`); + return { code: 0, stdout: "https://git.example.com/acme/app.git", stderr: "" }; + }, + env: {}, + }); + await gateway.prForBranch("/one", "a"); + await gateway.prForBranch("/two", "a"); + assert.equal(new Set(execs).size, 2); + gateway.close(); +}); diff --git a/server/src/forge/router.ts b/server/src/forge/router.ts new file mode 100644 index 00000000..b9a704d9 --- /dev/null +++ b/server/src/forge/router.ts @@ -0,0 +1,548 @@ +/** + * router.ts — picks a forge provider per repository. + * + * The decision has three inputs, in this order: + * + * 1. **The repo's own provider setting** (SPEC-48 D3"). `forgejo`/`gitea` go to the + * REST gateway, `github` to the `gh` one, and `none` to a gateway that talks to + * no forge at all. An override is honoured WITHOUT probing, because the cases it + * exists for are the ones where the probe cannot answer. + * 2. **`github.com` (or a subdomain)** → the `gh`-backed gateway. The host is + * decisive here, so no probe is spent. + * 3. **Detection** — the instance is asked what software it runs (see `detect.ts`). + * `forgejo`/`gitea` reach the REST gateway; GitLab or anything unidentifiable + * reaches the `unsupported` gateway, which makes no requests and says so. + * + * An unreadable remote stays on `gh`: that is the status quo for every directory that + * is not a git checkout, and changing where such repos fail would be a behaviour + * change with no upside. That answer is deliberately NOT cached, so a transient + * failure does not pin the repo to the wrong provider. + * + * Deliberately implements {@link GithubGateway} rather than a narrower type, so + * `server.ts` and `manager.ts` need no changes: the budget surface they depend on + * is forwarded to the gh-backed gateway, which is the only provider that HAS a + * quota (Forgejo exposes no `rate_limit` endpoint and no rate-limit headers). A + * Forgejo repo therefore contributes nothing to the budget panel, which is + * accurate rather than a stub. + * + */ + +import type { Exec } from "../github/gateway.js"; +import { createGithubGateway, type GithubGateway } from "../github/gateway.js"; +import type { OpenPr } from "../git.js"; +import type { + ForgeGateway, + ForgeProviderId, + ForgeSoftwareName, + GatewayStats, + PrLookup, + PrMutation, + ProviderMix, +} from "./types.js"; +import { createFetchHttp, createForgejoGateway, type ForgejoRepoRef } from "./forgejo/gateway.js"; +import { parseForgejoRemote } from "./forgejo/map.js"; +import { createForgeDetector, isGitHubHost } from "./detect.js"; +import { createUnsupportedGateway } from "./unsupported.js"; +import { createNoForgeGateway } from "./none.js"; +import type { ProviderChoice } from "../repo_settings.js"; + +// Re-exported: routing is where callers reach for it, detection is where it lives. +export { isGitHubHost }; + +/** + * Reads the origin URL. Declared here rather than imported from + * `github/queries.ts` so the neutral router does not depend on a provider. + */ +export const ORIGIN_REMOTE_ARGV = ["remote", "get-url", "origin"] as const; + +/** Where a repo lives, and how to reach its API. */ +export interface ForgeInstance { + /** Host of the `origin` remote, including a non-default port. */ + host: string; + /** API base, e.g. `https://git.example.com` or a sub-path install. */ + baseUrl: string; + /** Token for this instance, if one is configured for it. */ + token?: string; +} + +/** Timeout for the local `git remote` read. Local, so this is generous. */ +const REMOTE_TIMEOUT_MS = 5_000; + + +/** + * Turn a git remote URL into Forgejo coordinates, or null when it cannot be read. + * + * Configuration comes from the environment: + * + * `MAKIT_FORGEJO_BASE_URL` / `FORGEJO_BASE_URL` + * The instance's URL. Needed only when `https://<host>` is not right -- + * an instance behind a sub-path, or plain HTTP on a private network. + * `MAKIT_FORGEJO_TOKEN` / `FORGEJO_ACCESS_TOKEN` / `FORGEJO_TOKEN` / + * `GITEA_TOKEN` + * API token, most specific name first. + * + * **A configured instance URL scopes the credentials to that host.** This is a + * security property, not a convenience: configuring one instance means setting a + * single global token, and without scoping that token would be attached to every + * non-GitHub remote — so opening any public Gitea/Forgejo repo would send the + * user's internal token to a third party. A foreign host is still queried, just + * unauthenticated, which is the correct outcome for a public repo. + * + * With no instance configured there is nothing to scope against (the + * single-instance case), so the token applies to the remote's own host. + */ +export function forgejoRefFromRemote( + remoteUrl: string, + env: Record<string, string | undefined>, +): ForgejoRepoRef | null { + const parsed = parseForgejoRemote(remoteUrl); + if (parsed === null) return null; + + const configured = firstSet(env, ["MAKIT_FORGEJO_BASE_URL", "FORGEJO_BASE_URL"]); + const token = firstSet(env, [ + "MAKIT_FORGEJO_TOKEN", + "FORGEJO_ACCESS_TOKEN", + "FORGEJO_TOKEN", + "GITEA_TOKEN", + ]); + + // Compared on HOSTNAME alone -- no scheme, no port, no path. The override + // exists precisely to supply those, and an scp-form remote + // (`git@host:owner/repo`) cannot express a port at all, so an instance whose + // API is on :3000 would never match if the port counted. + const configuredHost = configured === undefined ? undefined : hostnameOf(configured); + const isConfiguredInstance = + configuredHost !== undefined && configuredHost.length > 0 && configuredHost === hostnameOnly(parsed.host); + + return { + baseUrl: isConfiguredInstance && configured !== undefined ? configured : `https://${parsed.host}`, + owner: parsed.owner, + repo: parsed.repo, + // Withheld from any host other than the configured one -- see the note above. + token: configuredHost === undefined || isConfiguredInstance ? token : undefined, + }; +} + +/** First env var of [names] that is set and non-empty. */ +function firstSet(env: Record<string, string | undefined>, names: string[]): string | undefined { + for (const name of names) { + const v = env[name]; + if (v !== undefined && v.length > 0) return v; + } + return undefined; +} + +/** Hostname of a URL (no port), lower-cased; empty string when unparseable. */ +function hostnameOf(url: string): string { + try { + return new URL(url).hostname.toLowerCase(); + } catch { + return ""; + } +} + +/** Strip a `:port` suffix from a bare host. */ +function hostnameOnly(host: string): string { + return host.toLowerCase().split(":")[0]; +} + +/** + * What routing concluded about one repo. Recorded because nothing else retains it: + * `chosen` holds only the gateway promise, so without this a caller asking "which + * forge is this repo on?" would have to re-probe. + */ +export interface RepoForge { + software: ForgeSoftwareName; + host: string; + /** + * Whether a credential is configured **for that host**. Never the token itself, + * and omitted for GitHub, where `gh`'s budget is not host-specific + * authentication and reporting it would be a guess dressed as a fact. + */ + authed?: boolean; + /** + * Whether this repo's provider came from probing the instance or from the user's + * override (SPEC-48 D3"). + * + * Recorded because the UI must not caption an override "detected": whether the + * answer was measured or asserted is the one thing a reader would use to decide + * how much to trust it — and when an override is in force, detection's answer is + * deliberately never asked for. + */ + source: "detected" | "override"; +} + +/** + * The narrow port `repo_service` needs. Deliberately not part of the gateway: + * `listRepos` receives a `GithubGateway`, and widening that contract to carry + * inspection would put two responsibilities on one interface. + */ +export interface ForgeInspector { + forgeFor(repoPath: string): RepoForge | undefined; + /** + * Whether `origin` could be read, or `undefined` when this repo has not been + * routed yet. + * + * Its own fact rather than `forgeFor(p) !== undefined`, because those two + * questions have three answers between them and one boolean cannot hold them: + * *not measured yet*, *no remote so no forge is possible*, and *a forge we + * identified*. Deriving "has a remote" from the forge decision collapsed the + * first two, which made the app's "not identified yet" wording unreachable and + * had every un-polled repo claim to have no remote. + */ + hasRemoteFor(repoPath: string): boolean | undefined; +} + +/** + * Invalidation, kept apart from {@link ForgeInspector} on purpose: inspection is a + * read and this is a write, and the consumers are different components. Only the + * one place that re-points a project needs it (SPEC-48 D4′), so putting it on the + * read port would hand every reader a way to clear the cache. + */ +export interface ForgeForgetful { + forgetRepo(repoPath: string): void; +} + +export interface ForgeRouterDeps { + github: GithubGateway; + forgejo: ForgeGateway; + /** Used for a forge makit cannot talk to (GitLab, or unidentifiable). */ + unsupported: ForgeGateway; + /** Used for a repo whose provider the user set to `none`. See `none.ts`. */ + none: ForgeGateway; + /** + * The user's per-repo provider override (SPEC-48 D3"), or `auto` to believe + * detection. Read at routing time rather than injected once, because the setting + * changes while the daemon runs. + */ + providerFor?: (repoPath: string) => ProviderChoice; + /** Where a repo lives, or null when the remote cannot be read. */ + resolveInstance: (repoPath: string) => Promise<ForgeInstance | null>; + /** Ask the instance what software it runs. See `detect.ts`. */ + detect: (baseUrl: string, token?: string) => Promise<ForgeSoftwareName>; + /** Called once per host that turns out to be unsupported, for logging. */ + onUnsupported?: (host: string, software: ForgeSoftwareName) => void; +} + +export function createForgeRouter( + deps: ForgeRouterDeps, +): GithubGateway & ProviderMix & ForgeInspector & ForgeForgetful { + /** + * Cache of the chosen provider per repo. Stores the PROMISE, not the resolved + * value, so the home-screen fan-out — which hits every worktree of a repo at + * once — shares one `git remote` read instead of spawning one per worktree. + * + * The CHOICE that produced it is stored alongside, so a changed override + * re-routes on the next call. Without that, setting a provider would appear to do + * nothing until the daemon restarted, which is indistinguishable from the feature + * being broken. + */ + const chosen = new Map<string, { choice: ProviderChoice; gateway: Promise<ForgeGateway> }>(); + /** + * Providers actually reached. Recorded rather than inferred from config because + * only routing knows the truth, and the poll cadence depends on it. + */ + const inUse = new Set<ForgeProviderId>(); + /** Hosts already reported as unsupported, so the log says it once, not per tick. */ + const warned = new Set<string>(); + /** What routing concluded, per repo. See {@link RepoForge}. */ + const decided = new Map<string, RepoForge>(); + /** Whether `origin` was readable, per repo. See {@link ForgeInspector.hasRemoteFor}. */ + const remotes = new Map<string, boolean>(); + + function pick(repoPath: string): Promise<ForgeGateway> { + const choice = deps.providerFor?.(repoPath) ?? "auto"; + const hit = chosen.get(repoPath); + // Re-check the choice, but keep the cache when it has not changed: re-resolving + // on every call would throw away the read-sharing the fan-out depends on. + if (hit !== undefined && hit.choice === choice) return hit.gateway; + const routed = route(repoPath, choice); + const p = routed.then((r) => r.gateway); + // Cache only a route that was actually DECIDED. A fallback produced by a failed + // `git remote` read or a failed probe must not be cached: nothing evicts these + // entries, so one transient failure at startup used to pin a Forgejo repo to the + // `gh` gateway for the lifetime of the daemon -- every later poll failing, the PR + // pill reading `unknown` forever, and no way back short of a restart. + chosen.set(repoPath, { choice, gateway: p }); + void routed.then((r) => { + if (!r.cacheable && chosen.get(repoPath)?.gateway === p) chosen.delete(repoPath); + }); + return p; + } + + /** A routing answer, plus whether it was decided (cacheable) or fallen back to. */ + interface Routed { + gateway: ForgeGateway; + cacheable: boolean; + } + + function route(repoPath: string, choice: ProviderChoice): Promise<Routed> { + return (async (): Promise<Routed> => { + // `none` short-circuits before the remote is even read. "Talks to no forge" + // has to include not looking one up, or it is a label rather than an + // instruction — and the decision is the user's, so there is nothing to learn. + if (choice === "none") { + inUse.add("none"); + decided.delete(repoPath); + remotes.delete(repoPath); + return { gateway: deps.none, cacheable: true }; + } + + const inst = await deps.resolveInstance(repoPath); + remotes.set(repoPath, inst !== null); + + // An override is honoured WITHOUT probing. That is the point: the cases it + // exists for are exactly the ones where the probe cannot answer — a private + // instance that 401s an anonymous request, or one behind a proxy that hides + // the version endpoint. Spending the probe anyway would delay every poll to + // learn nothing. + if (choice !== "auto") { + if (inst !== null) { + decided.set(repoPath, { + software: choice, + host: inst.host, + // Omitted for GitHub for the same reason detection omits it. + ...(choice === "github" + ? {} + : { authed: inst.token !== undefined && inst.token.length > 0 }), + source: "override", + }); + } + if (choice === "github") { + inUse.add("github"); + return { gateway: deps.github, cacheable: true }; + } + inUse.add("forgejo"); + return { gateway: deps.forgejo, cacheable: true }; + } + + // No readable remote: stay on gh, which is the status quo for anything that + // is not a checkout. Routing it elsewhere would change where such a + // directory fails, for no gain. + if (inst === null || isGitHubHost(inst.host)) { + inUse.add("github"); + if (inst !== null) { + decided.set(repoPath, { software: "github", host: inst.host, source: "detected" }); + } + // An unreadable remote is not a decision -- it is the absence of one, and it + // is exactly the transient case that must be retried rather than pinned. + return { gateway: deps.github, cacheable: inst !== null }; + } + const software = await deps.detect(inst.baseUrl, inst.token); + decided.set(repoPath, { + software, + host: inst.host, + authed: inst.token !== undefined && inst.token.length > 0, + source: "detected", + }); + if (software === "forgejo" || software === "gitea") { + inUse.add("forgejo"); + return { gateway: deps.forgejo, cacheable: true }; + } + inUse.add("unsupported"); + if (!warned.has(inst.host)) { + warned.add(inst.host); + deps.onUnsupported?.(inst.host, software); + } + // `unknown` means the probe could not classify it; re-probe next time rather + // than concluding forever. A named-but-unsupported forge IS a decision. + return { gateway: deps.unsupported, cacheable: software !== "unknown" }; + })().catch(() => { + // A transient failure must not discard an EXPLICIT choice. Falling back to gh + // here would ignore an instruction the user gave and send the repo to a + // provider that cannot talk to its host — and because the result is cached + // against the choice that produced it, one failed `git remote` read would pin + // the repo to the wrong provider until the setting changed or the daemon + // restarted. + // + // Record the remote as unreadable ONLY if nothing was recorded, because two + // different failures land here: the remote read failing (no remote) and + // DETECTION failing (a good remote we could not classify, already recorded as + // `true`). Setting `false` unconditionally would turn the second into a claim + // that the repo has no origin — a fact we just measured otherwise. + if (!remotes.has(repoPath)) remotes.set(repoPath, false); + // `auto` keeps the original behaviour: with no opinion attached, gh is the + // status quo for a repo we could not read. + if (choice === "forgejo" || choice === "gitea") { + inUse.add("forgejo"); + return { gateway: deps.forgejo, cacheable: false }; + } + if (choice === "none") { + inUse.add("none"); + return { gateway: deps.none, cacheable: false }; + } + inUse.add("github"); + return { gateway: deps.github, cacheable: false }; + }); + } + + return { + async prForBranch(repoPath: string, branch: string, opts?: { interactive?: boolean }): Promise<PrLookup> { + return (await pick(repoPath)).prForBranch(repoPath, branch, opts); + }, + async openPrs(repoPath: string, limit: number, opts?: { interactive?: boolean }): Promise<OpenPr[]> { + return (await pick(repoPath)).openPrs(repoPath, limit, opts); + }, + async mutatePr( + repoPath: string, + branch: string, + number: number, + verb: PrMutation, + ): Promise<{ ok: boolean; error?: string }> { + return (await pick(repoPath)).mutatePr(repoPath, branch, number, verb); + }, + + // Budget: forwarded verbatim. See the module note on why this is not merged. + budget: () => deps.github.budget(), + history: () => deps.github.history(), + refresh: () => deps.github.refresh(), + setPaused: (paused: boolean) => deps.github.setPaused(paused), + onBudgetChange: (fn) => deps.github.onBudgetChange(fn), + + /** + * Summed across EVERY provider, so the ≥80% call-reduction figure covers every + * one in play. + * + * `unsupported` and `none` report zeros today, so omitting them was right by + * accident — and would have drifted silently the moment either started counting a + * call. Reading all four costs nothing and keeps the number honest by + * construction rather than by coincidence. + */ + stats(): GatewayStats { + const all = [deps.github, deps.forgejo, deps.unsupported, deps.none].map((g) => g.stats()); + return { + execs: all.reduce((n, s) => n + s.execs, 0), + exemptExecs: all.reduce((n, s) => n + s.exemptExecs, 0), + cacheHits: all.reduce((n, s) => n + s.cacheHits, 0), + }; + }, + providersInUse: () => new Set(inUse), + forgeFor: (repoPath: string) => decided.get(repoPath), + hasRemoteFor: (repoPath: string) => remotes.get(repoPath), + /** + * Discard everything routing learned about [repoPath]. + * + * Called when a project is re-pointed (SPEC-48 D4′): the repo at the old path is + * no longer the project's repo, so its cached gateway, forge decision and remote + * fact must not be reported — and detection has to run again for the new path, + * because the forge may have changed with the move. + */ + forgetRepo(repoPath: string): void { + chosen.delete(repoPath); + decided.delete(repoPath); + remotes.delete(repoPath); + }, + close(): void { + chosen.clear(); + inUse.clear(); + warned.clear(); + decided.clear(); + remotes.clear(); + // All four, for the same reason `stats` reads all four: the two no-op gateways + // close to nothing today, and a provider that later acquires a timer or socket + // must not depend on someone remembering to add it here. + for (const g of [deps.github, deps.forgejo, deps.unsupported, deps.none]) g.close(); + }, + }; +} + +/** + * The production wiring: a gh-backed GitHub gateway, a REST-backed Forgejo + * gateway, and the router over both. `exec` is git.ts's `run`, so `gh` still + * resolves through PATH and the test PATH-shim keeps working. + */ +export function createDefaultForgeGateway(opts: { + exec: Exec; + env?: Record<string, string | undefined>; + /** See {@link ForgeRouterDeps.providerFor}. */ + providerFor?: (repoPath: string) => ProviderChoice; +}): GithubGateway { + const env = opts.env ?? process.env; + /** + * `origin`'s URL, memoised per repo and SHARED while in flight. + * + * Both the router and the Forgejo gateway need it, and the gateway asks at the top of + * `prForBranch`, `openPrs` and `mutatePr` -- BEFORE consulting its own cache. So even + * a cache hit paid for a `git remote get-url origin` subprocess, and the home-screen + * fan-out across a repo's worktrees spawned one process per worktree per poll tick. + * + * Cached for the process lifetime rather than with a TTL: a repo's `origin` does not + * change under a running daemon, and the two cases that DO change it both clear the + * entry -- re-pointing a project (`forgetRemote`) and shutdown (`close`). + */ + const remoteUrls = new Map<string, Promise<string | null>>(); + + const readRemote = (repoPath: string): Promise<string | null> => { + const hit = remoteUrls.get(repoPath); + if (hit !== undefined) return hit; + const p = (async (): Promise<string | null> => { + const r = await opts.exec("git", [...ORIGIN_REMOTE_ARGV], repoPath, REMOTE_TIMEOUT_MS); + if (r.code !== 0) return null; + const url = r.stdout.trim(); + return url.length > 0 ? url : null; + })().catch(() => null); + // A failed read is NOT retained: it is the transient case, and pinning it would + // repeat the bug the routing cache had. + void p.then((url) => { + if (url === null && remoteUrls.get(repoPath) === p) remoteUrls.delete(repoPath); + }); + remoteUrls.set(repoPath, p); + return p; + }; + const http = createFetchHttp(); + const detector = createForgeDetector({ http }); + // Late-bound so the unsupported gateway can ask the router what THIS repo turned + // out to be. A single shared "most recently detected" value named the wrong forge + // as soon as a second repo was probed. + let inspector: ForgeInspector | undefined; + const router = createForgeRouter({ + github: createGithubGateway({ exec: opts.exec }), + forgejo: createForgejoGateway({ + http, + resolveRepo: async (repoPath) => { + const url = await readRemote(repoPath); + return url === null ? null : forgejoRefFromRemote(url, env); + }, + }), + unsupported: createUnsupportedGateway({ + softwareFor: (repoPath) => inspector?.forgeFor(repoPath)?.software, + }), + none: createNoForgeGateway(), + providerFor: opts.providerFor, + resolveInstance: async (repoPath) => { + const url = await readRemote(repoPath); + if (url === null) return null; + const ref = forgejoRefFromRemote(url, env); + if (ref === null) return null; + const host = parseForgejoRemote(url)?.host; + return host === undefined ? null : { host, baseUrl: ref.baseUrl, token: ref.token }; + }, + detect: (baseUrl, token) => detector.detect(baseUrl, token), + onUnsupported: (host, software) => { + const what = software === "unknown" ? "an unrecognised forge" : software; + // Once per host. Silent failure here is what made this class of bug + // indistinguishable from an outage. + console.warn( + `[makit] ${host} looks like ${what}; makit has no provider for it, so pull-request status is unavailable for repositories there.`, + ); + }, + }); + inspector = router; + // Positive detections are cached with `expiresAt: null`, so the router's own + // `close()` -- which clears its per-repo maps -- would leave them behind. A closed + // gateway must not answer from a probe made before it was closed. + const close = router.close.bind(router); + router.close = (): void => { + detector.clear(); + remoteUrls.clear(); + close(); + }; + const forget = router.forgetRepo.bind(router); + router.forgetRepo = (repoPath: string): void => { + // A re-pointed project is a different directory: its remembered `origin` is now + // another repo's, which is exactly the value that must not be reused. + remoteUrls.delete(repoPath); + forget(repoPath); + }; + return router; +} diff --git a/server/src/forge/types.ts b/server/src/forge/types.ts new file mode 100644 index 00000000..3c69c224 --- /dev/null +++ b/server/src/forge/types.ts @@ -0,0 +1,114 @@ +/** + * types.ts — the provider-neutral forge contract. + * + * Split into two interfaces on purpose (interface segregation). Everything the + * app actually needs from a forge — "is there a PR on this branch", "list open + * PRs", "run this PR action" — is in {@link ForgeGateway}. The quota accounting + * in {@link BudgetReporting} is a GitHub-only concern: self-hosted Forgejo + * exposes no `rate_limit` endpoint and sends no rate-limit response headers, so + * there is nothing for it to report. + * + * Keeping them separate is what stops a Forgejo provider from having to fake a + * budget it cannot measure. A stub returning "unlimited" would be a lie the + * footer would render as fact, and a stub throwing would turn a UI affordance + * into a crash — {@link hasBudgetReporting} lets the caller ask instead. + */ + +import type { OpenPr, PullRequestInfo } from "../git.js"; + +/** Three-way PR lookup result — a failed lookup is never `none` (SPEC-32 §6.5). */ +export type PrLookup = + | { kind: "pr"; pr: PullRequestInfo } + | { kind: "none" } + | { kind: "unknown"; reason: "throttled" | "error" }; + +/** A state-changing PR action the app can run on the user's behalf. */ +export type PrMutation = "ready" | "update-branch" | "merge-squash"; + +/** Call/cache counters — how the ≥80% call-reduction claim is measured. */ +export interface GatewayStats { + /** Provider calls that spent quota (GitHub) or hit the network (Forgejo). */ + execs: number; + /** Quota-exempt reads. Free, but still round trips. */ + exemptExecs: number; + /** Reads served from cache without a round trip. */ + cacheHits: number; +} + +/** + * The forge operations the app depends on. Implemented by both providers — + * `gh`-backed for GitHub, REST-backed for Forgejo. + */ +export interface ForgeGateway { + /** + * The latest PR whose head is `branch`, or `none` when there is genuinely no + * PR. A lookup that could not be completed returns `unknown`, never `none`: + * reporting "no PR" for a failed call erases the pill and reads as fact. + */ + prForBranch(repoPath: string, branch: string, opts?: { interactive?: boolean }): Promise<PrLookup>; + /** + * All open PRs for a repo (the "New worktree from PR" picker). + * + * `interactive: true` marks a user-initiated call — a click, not a poll — so a + * provider that sheds load must still serve it rather than return an empty + * list the user would read as "this repo has no open PRs". + */ + openPrs(repoPath: string, limit: number, opts?: { interactive?: boolean }): Promise<OpenPr[]>; + /** + * Run a state-changing PR verb. Always interactive (a button press), and must + * invalidate any cached lookup for `branch` on success — otherwise the UI keeps + * reporting the state the mutation just changed until the TTL expires. + */ + mutatePr( + repoPath: string, + branch: string, + number: number, + verb: PrMutation, + ): Promise<{ ok: boolean; error?: string }>; + stats(): GatewayStats; + close(): void; +} + +/** + * Quota accounting. GitHub-only — see the module note. + * + * `BudgetSnapshot` is deliberately loose here (`unknown`) so this module does not + * drag GitHub's budget vocabulary into the neutral contract; the GitHub gateway + * re-declares it with the precise type. + */ +export interface BudgetReporting { + budget(): unknown; + history(): Array<{ mine: number; others: number }>; + refresh(): Promise<unknown>; + setPaused(paused: boolean): void; + onBudgetChange(fn: (s: never) => void): () => void; +} + +/** The providers this build can route to. */ +export type ForgeProviderId = "github" | "forgejo" | "unsupported" | "none"; + +/** Forge software an instance may run, as reported by detection. */ +export type ForgeSoftwareName = "github" | "forgejo" | "gitea" | "gitlab" | "unknown"; + +/** + * Reports which providers are actually in play, learned from the repos routed so + * far. Consumed by the poll cadence: GitHub's degradation ladder must not + * throttle a setup that contains no GitHub repos (see `cadence.ts`). + */ +export interface ProviderMix { + providersInUse(): ReadonlySet<ForgeProviderId>; +} + +/** Whether a gateway can report which providers it is routing to. */ +export function hasProviderMix<G>(gateway: G): gateway is G & ProviderMix { + return typeof (gateway as Partial<ProviderMix>).providersInUse === "function"; +} + +/** + * Whether a gateway can report quota. Use this before wiring budget events or + * the budget UI, rather than assuming every provider has a quota to report. + */ +export function hasBudgetReporting<G extends ForgeGateway>(gateway: G): gateway is G & BudgetReporting { + const g = gateway as unknown as Partial<BudgetReporting>; + return typeof g.budget === "function" && typeof g.onBudgetChange === "function"; +} diff --git a/server/src/forge/unsupported.test.ts b/server/src/forge/unsupported.test.ts new file mode 100644 index 00000000..acf5bc6b --- /dev/null +++ b/server/src/forge/unsupported.test.ts @@ -0,0 +1,35 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { createUnsupportedGateway } from "./unsupported.js"; +import { hasBudgetReporting } from "./types.js"; + +test("a lookup is unknown, never none -- we never asked", async () => { + const g = createUnsupportedGateway(); + assert.deepEqual(await g.prForBranch("/r", "b"), { kind: "unknown", reason: "error" }); +}); + +test("it makes no requests, so polling an unsupported forge costs nothing", async () => { + const g = createUnsupportedGateway(); + await g.prForBranch("/r", "b"); + await g.openPrs("/r", 30); + assert.deepEqual(g.stats(), { execs: 0, exemptExecs: 0, cacheHits: 0 }); +}); + +test("a mutation names the forge, so the user learns why the button did nothing", async () => { + const g = createUnsupportedGateway({ software: () => "gitlab" }); + const r = await g.mutatePr("/r", "b", 1, "merge-squash"); + assert.equal(r.ok, false); + assert.match(r.error ?? "", /GitLab/); + assert.match(r.error ?? "", /merge-squash/); +}); + +test("an unidentified forge gets a vaguer but still honest message", async () => { + const g = createUnsupportedGateway({ software: () => "unknown" }); + const r = await g.mutatePr("/r", "b", 1, "ready"); + assert.match(r.error ?? "", /this forge/); +}); + +test("it claims no budget", () => { + assert.equal(hasBudgetReporting(createUnsupportedGateway()), false); +}); diff --git a/server/src/forge/unsupported.ts b/server/src/forge/unsupported.ts new file mode 100644 index 00000000..d4cbfa8f --- /dev/null +++ b/server/src/forge/unsupported.ts @@ -0,0 +1,64 @@ +/** + * unsupported.ts — the provider for a forge makit cannot talk to. + * + * Exists so an unsupported forge fails HONESTLY and CHEAPLY. Before detection, + * a GitLab or Bitbucket remote was routed to the Forgejo provider, where every + * poll spent a real HTTP request to an API that does not exist there and came + * back as `unknown` — the same result as a Forgejo instance being down, so the + * user had no way to tell "makit doesn't support this" from "the network is + * broken". + * + * This makes no requests at all, and a mutation says what is actually wrong. + */ + +import type { OpenPr } from "../git.js"; +import type { ForgeGateway, ForgeSoftwareName, GatewayStats, PrLookup, PrMutation } from "./types.js"; + +export interface UnsupportedGatewayDeps { + /** What the detector found, for the message. */ + software?: () => ForgeSoftwareName; + /** + * What the detector found for a SPECIFIC repo, for the message. + * + * Preferred over {@link software}, which is a single shared value: it was set by + * whichever repo was probed most recently, so a mutation on a GitLab repo could + * name a different forge entirely once another repo had been detected. The + * decision is per repo, so the message must be too. + */ + softwareFor?: (repoPath: string) => ForgeSoftwareName | undefined; +} + +/** Human name for the message; `unknown` gets a vaguer phrasing. */ +function describe(software: ForgeSoftwareName): string { + switch (software) { + case "gitlab": + return "GitLab"; + case "unknown": + return "this forge"; + default: + return software; + } +} + +export function createUnsupportedGateway(deps: UnsupportedGatewayDeps = {}): ForgeGateway { + const stats: GatewayStats = { execs: 0, exemptExecs: 0, cacheHits: 0 }; + const name = (repoPath: string): string => + describe(deps.softwareFor?.(repoPath) ?? deps.software?.() ?? "unknown"); + + return { + // `unknown`, never `none`: we did not look, so we cannot claim there is no PR. + prForBranch: async (): Promise<PrLookup> => ({ kind: "unknown", reason: "error" }), + openPrs: async (): Promise<OpenPr[]> => [], + mutatePr: async ( + repoPath: string, + _branch: string, + _number: number, + verb: PrMutation, + ): Promise<{ ok: boolean; error?: string }> => ({ + ok: false, + error: `makit has no ${name(repoPath)} provider yet, so it cannot run "${verb}" on this repository.`, + }), + stats: () => ({ ...stats }), + close: () => {}, + }; +} diff --git a/server/src/git.pr_checkout.test.ts b/server/src/git.pr_checkout.test.ts new file mode 100644 index 00000000..bab4e5fa --- /dev/null +++ b/server/src/git.pr_checkout.test.ts @@ -0,0 +1,298 @@ +/** + * Checking out a pull request when the forge is NOT GitHub (SPEC-48 P2). + * + * The gap this closes: "New worktree from PR" listed Forgejo PRs correctly — the + * picker routes through the forge gateway — and then ran `gh pr checkout` to create + * the worktree. On a Forgejo remote that fails, so the flow was broken exactly + * halfway: the user sees their PRs, picks one, and the worktree never appears. + * + * Tested against a real local bare repo carrying `refs/pull/<n>/head`, which is how + * Gitea and Forgejo actually expose PR heads — so this exercises the real git + * plumbing with no network and no forge. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { chmodSync, mkdtempSync, writeFileSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; + +import { addWorktreeForPr } from "./git.js"; + +interface Fixture { + origin: string; + clone: string; + base: string; + /** The commit at the tip of the PR head. */ + prHead: string; + cleanup: () => void; +} + +/** + * A bare "origin" with one PR published at `refs/pull/7/head`, plus a clone. + * + * The PR commit is deliberately NOT reachable from any branch in the clone: that is + * the whole point of fetching the pull ref, and a fixture where the commit is + * already present would pass even if nothing were fetched. + */ +function fixture(opts: { sameRepoBranch?: boolean } = {}): Fixture { + const dir = mkdtempSync(join(tmpdir(), "makit-prco-")); + const origin = join(dir, "origin.git"); + const work = join(dir, "work"); + const clone = join(dir, "clone"); + const g = (cwd: string, ...a: string[]) => execFileSync("git", a, { cwd }).toString(); + + execFileSync("git", ["init", "-q", "--bare", "-b", "main", origin]); + execFileSync("git", ["clone", "-q", origin, work]); + g(work, "config", "user.email", "t@t.io"); + g(work, "config", "user.name", "T"); + writeFileSync(join(work, "README.md"), "base\n"); + g(work, "add", "."); + g(work, "commit", "-q", "-m", "base"); + g(work, "push", "-q", "origin", "main"); + + // The PR's head commit. + g(work, "checkout", "-q", "-b", "feature/login"); + writeFileSync(join(work, "feature.txt"), "pr work\n"); + g(work, "add", "."); + g(work, "commit", "-q", "-m", "the PR commit"); + const prHead = g(work, "rev-parse", "HEAD").trim(); + // Published the way a forge publishes it. `sameRepoBranch` also pushes the branch, + // which is what distinguishes a same-repo PR from a fork's. + g(work, "push", "-q", "origin", "HEAD:refs/pull/7/head"); + if (opts.sameRepoBranch === true) g(work, "push", "-q", "origin", "feature/login"); + + execFileSync("git", ["clone", "-q", origin, clone]); + const base = mkdtempSync(join(tmpdir(), "makit-prco-wt-")); + return { + origin, + clone, + base, + prHead, + cleanup: () => { + rmSync(dir, { recursive: true, force: true }); + rmSync(base, { recursive: true, force: true }); + }, + }; +} + +test("a non-GitHub PR is checked out from refs/pull/<n>/head, without gh", async () => { + const f = fixture(); + try { + const r = await addWorktreeForPr({ + repoPath: f.clone, + prNumber: 7, + headRefName: "feature/login", + baseDir: f.base, + checkout: "pull-ref", + }); + // The PR's actual commit is checked out — not the base, which is what a silently + // skipped fetch would leave behind. + const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: r.path }).toString().trim(); + assert.equal(head, f.prHead); + assert.equal(readFileSync(join(r.path, "feature.txt"), "utf8"), "pr work\n"); + } finally { + f.cleanup(); + } +}); + +test("it lands on a PR-unique branch, not the PR's head ref name", async () => { + // Same reason the gh path passes `--branch`: the primary checkout commonly sits on + // the head ref already, and git refuses to check out a branch twice in one repo. + const f = fixture({ sameRepoBranch: true }); + try { + execFileSync("git", ["checkout", "-q", "-b", "feature/login", "origin/feature/login"], { + cwd: f.clone, + }); + const r = await addWorktreeForPr({ + repoPath: f.clone, + prNumber: 7, + headRefName: "feature/login", + baseDir: f.base, + checkout: "pull-ref", + }); + assert.equal(r.branch, "pr-7-feature-login"); + } finally { + f.cleanup(); + } +}); + +test("a same-repo PR tracks its head branch, so a push updates the PR", async () => { + const f = fixture({ sameRepoBranch: true }); + try { + const r = await addWorktreeForPr({ + repoPath: f.clone, + prNumber: 7, + headRefName: "feature/login", + baseDir: f.base, + checkout: "pull-ref", + }); + const upstream = execFileSync( + "git", + ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], + { cwd: r.path }, + ) + .toString() + .trim(); + assert.equal(upstream, "origin/feature/login"); + } finally { + f.cleanup(); + } +}); + +test("a fork PR still checks out, just without an upstream", async () => { + // The head branch does not exist on `origin` for a fork. Refusing the checkout + // would make reviewing a contributor's PR impossible; the worktree is the point, + // and pushing to someone else's fork was never possible anyway. + const f = fixture(); + try { + const r = await addWorktreeForPr({ + repoPath: f.clone, + prNumber: 7, + headRefName: "contributor-branch", + baseDir: f.base, + checkout: "pull-ref", + }); + const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: r.path }).toString().trim(); + assert.equal(head, f.prHead); + // And genuinely has no upstream: `@{u}` fails rather than resolving to something + // wrong, which would send a push to the wrong branch. + assert.throws(() => + execFileSync("git", ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], { + cwd: r.path, + stdio: ["ignore", "pipe", "ignore"], + }), + ); + } finally { + f.cleanup(); + } +}); + +test("a PR number the forge does not publish fails and leaves no litter", async () => { + // The empty detached worktree must be rolled back, exactly as the gh path does — + // otherwise a mistyped or closed PR leaves a directory that looks like a worktree. + const f = fixture(); + try { + await assert.rejects( + addWorktreeForPr({ + repoPath: f.clone, + prNumber: 999, + headRefName: "nope", + baseDir: f.base, + checkout: "pull-ref", + }), + ); + const worktrees = execFileSync("git", ["worktree", "list"], { cwd: f.clone }).toString(); + assert.equal(worktrees.includes("pr-999"), false, "the failed worktree was removed"); + } finally { + f.cleanup(); + } +}); + +test("the honoured worktree root is the caller's, so per-repo roots still apply", async () => { + const f = fixture(); + try { + const r = await addWorktreeForPr({ + repoPath: f.clone, + prNumber: 7, + headRefName: "feature/login", + baseDir: f.base, + checkout: "pull-ref", + }); + // The EXACT path, not a prefix: `startsWith` also accepts a sibling such as + // `${base}-other/...`, so the assertion held for a worktree outside the root the + // caller chose -- which is the only thing this test exists to check. + const base = execFileSync("realpath", [f.base]).toString().trim(); + const repoName = basename(f.clone); + assert.equal(r.path, join(base, repoName, "pr-7-feature-login")); + } finally { + f.cleanup(); + } +}); + +// --------------------------------------------------------------------------- +// The GitHub path, which had NO test before this refactor. +// +// `addWorktreeForPr` was one function that always ran `gh pr checkout`; it is now two +// strategies behind a discriminator. That is exactly the shape of change where a +// working path regresses silently, so the gh invocation is pinned here — argv and +// all — via a PATH shim, the same technique manager.test.ts uses. +// --------------------------------------------------------------------------- + +test("the GitHub strategy still runs `gh pr checkout <n> --branch <unique>`", async () => { + const f = fixture({ sameRepoBranch: true }); + const bin = mkdtempSync(join(tmpdir(), "makit-fake-gh-")); + const argvLog = join(bin, "argv.txt"); + const prevPath = process.env.PATH; + try { + const gh = join(bin, "gh"); + // Records its argv, then does what the real `gh pr checkout --branch` does, so the + // rest of the function (HEAD read, branch reporting) runs against a real result. + writeFileSync( + gh, + [ + "#!/bin/sh", + `printf '%s\\n' "$*" >> "${argvLog}"`, + 'git fetch --quiet origin refs/pull/7/head || exit 1', + 'git checkout -q -b "$5" FETCH_HEAD || exit 1', + "", + ].join("\n"), + ); + chmodSync(gh, 0o755); + process.env.PATH = `${bin}:${prevPath ?? ""}`; + + const r = await addWorktreeForPr({ + repoPath: f.clone, + prNumber: 7, + headRefName: "feature/login", + baseDir: f.base, + // No `checkout` passed: the default must remain gh, so every existing caller + // keeps its behaviour. + }); + + assert.equal( + readFileSync(argvLog, "utf8").trim(), + "pr checkout 7 --branch pr-7-feature-login", + "argv unchanged, including the PR-unique --branch that avoids a checkout collision", + ); + assert.equal(r.branch, "pr-7-feature-login"); + const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: r.path }).toString().trim(); + assert.equal(head, f.prHead); + } finally { + if (prevPath === undefined) delete process.env.PATH; + else process.env.PATH = prevPath; + rmSync(bin, { recursive: true, force: true }); + f.cleanup(); + } +}); + +test("a failing gh still rolls back the empty worktree", async () => { + // The rollback moved into a shared branch during the refactor; pinned for gh too so + // one strategy cannot keep it while the other loses it. + const f = fixture(); + const bin = mkdtempSync(join(tmpdir(), "makit-fake-gh-")); + const prevPath = process.env.PATH; + try { + const gh = join(bin, "gh"); + writeFileSync(gh, "#!/bin/sh\necho 'no PR for you' >&2\nexit 1\n"); + chmodSync(gh, 0o755); + process.env.PATH = `${bin}:${prevPath ?? ""}`; + + await assert.rejects( + addWorktreeForPr({ + repoPath: f.clone, + prNumber: 7, + headRefName: "feature/login", + baseDir: f.base, + }), + /no PR for you|gh pr checkout/, + ); + const worktrees = execFileSync("git", ["worktree", "list"], { cwd: f.clone }).toString(); + assert.equal(worktrees.includes("pr-7"), false, "no litter left behind"); + } finally { + if (prevPath === undefined) delete process.env.PATH; + else process.env.PATH = prevPath; + rmSync(bin, { recursive: true, force: true }); + f.cleanup(); + } +}); diff --git a/server/src/git.test.ts b/server/src/git.test.ts index 4007ad84..0b7614fd 100644 --- a/server/src/git.test.ts +++ b/server/src/git.test.ts @@ -7,6 +7,7 @@ import { join } from "node:path"; import { detectDefaultBranch, + resolveDefaultBranch, detectCurrentBranch, listWorktrees, diffStat, @@ -560,3 +561,176 @@ test("syncBaseBranch refuses when the branch is checked out in two worktrees", a rmSync(base, { recursive: true, force: true }); } }); + +// --------------------------------------------------------------------------- +// SPEC-48 — the default-branch override, and why it must be checked rather than +// trusted. +// +// The consumer is concrete: `origin/HEAD` is genuinely absent after a +// `--single-branch` clone or a default-branch rename, and makit then diffs and +// bases PRs against the wrong branch. The override is the fix. But it is stored +// after a SYNTAX check only, and a branch can be deleted after it was chosen, so +// resolution has to confirm the ref still exists. +// --------------------------------------------------------------------------- + +test("resolveDefaultBranch prefers an override that exists over detection", async () => { + const repo = makeRepo(); + try { + execFileSync("git", ["branch", "release"], { cwd: repo }); + assert.equal(await detectDefaultBranch(repo), "main", "detection would say main"); + assert.equal(await resolveDefaultBranch(repo, "release"), "release"); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); + +test("resolveDefaultBranch falls back to detection when the override is gone", async () => { + // A branch chosen months ago and since deleted must not silently break the diff + // numbers: a stale override is a worse base than git's own answer, not a better + // one, so it loses rather than winning and failing. + const repo = makeRepo(); + try { + assert.equal(await resolveDefaultBranch(repo, "deleted-long-ago"), "main"); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); + +test("resolveDefaultBranch with no override is exactly detection", async () => { + const repo = makeRepo(); + try { + assert.equal(await resolveDefaultBranch(repo, undefined), await detectDefaultBranch(repo)); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); + +test("an override rescues a repo whose origin/HEAD points at a branch that is gone", async () => { + // The real failure, reproduced: `origin/HEAD` still names `master` after the + // default branch was renamed to `trunk`, so every diff is measured against a ref + // that no longer resolves. + const repo = makeRepo(); + try { + const g = (...args: string[]) => execFileSync("git", args, { cwd: repo }); + g("branch", "trunk"); + g("remote", "add", "origin", "https://example.test/x/y.git"); + // Point origin/HEAD at a remote branch that does not exist locally. + g("update-ref", "refs/remotes/origin/master", "HEAD"); + g("symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/master"); + assert.equal(await detectDefaultBranch(repo), "master", "git's answer, and it is wrong"); + assert.equal(await resolveDefaultBranch(repo, "trunk"), "trunk"); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); + +test("a default-branch override naming a remote-only branch is honoured", async () => { + // Review finding: `branchExists` checks `refs/heads/<b>` only, so an override naming + // a branch that exists on the remote but is not checked out locally was treated as + // stale and silently dropped. That is the normal state after a `--single-branch` + // clone -- the very case the override exists for: `trunk` is visible as + // `origin/trunk` and nothing else. + const repo = makeRepo(); + try { + const g = (...args: string[]) => execFileSync("git", args, { cwd: repo }); + g("remote", "add", "origin", "https://example.test/x/y.git"); + g("update-ref", "refs/remotes/origin/trunk", "HEAD"); + assert.equal(await branchExists(repo, "trunk"), false, "not a local branch"); + assert.equal( + await resolveDefaultBranch(repo, "trunk"), + "origin/trunk", + "the remote knows it, so the override stands -- qualified so it resolves", + ); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); + +test("an override naming nothing at all is still dropped", async () => { + // The guard must not become "accept anything": a branch deleted from both sides is + // a worse base than git's own answer. + const repo = makeRepo(); + try { + execFileSync("git", ["remote", "add", "origin", "https://example.test/x/y.git"], { cwd: repo }); + assert.equal(await resolveDefaultBranch(repo, "never-existed"), "main"); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); + +test("a remote-only override is returned in a form git can actually resolve", async () => { + // Review finding, and a bug the previous round introduced: accepting an override that + // exists only as `refs/remotes/origin/<b>` and then returning the BARE name yields a + // ref git cannot resolve. `gitrevisions` checks `refs/<name>`, `refs/tags/<name>`, + // `refs/heads/<name>` and `refs/remotes/<name>` -- never `refs/remotes/origin/<name>` + // -- so `diffStat`, `commitsAhead` and `git worktree add` all received a base that + // resolves nowhere: silent zero diffs, zero counts, failed worktree creation. + const repo = makeRepo(); + try { + const g = (...args: string[]) => execFileSync("git", args, { cwd: repo }); + g("remote", "add", "origin", "https://example.test/x/y.git"); + g("update-ref", "refs/remotes/origin/trunk", "HEAD"); + + const base = await resolveDefaultBranch(repo, "trunk"); + assert.equal(base, "origin/trunk", "qualified, so it resolves"); + // Proven against git rather than asserted by shape. + const resolved = execFileSync("git", ["rev-parse", "--verify", "--quiet", base!], { + cwd: repo, + }) + .toString() + .trim(); + assert.ok(resolved.length > 0, "git resolves what we returned"); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); + +test("a LOCAL override is returned bare, so the sync path still works", async () => { + // `syncBaseBranch` fetches and fast-forwards a LOCAL branch (`git fetch origin <b>`, + // then `<b>..origin/<b>`), so a qualified name would break it. The two consumers want + // different things, and which refs exist is exactly what distinguishes them. + const repo = makeRepo(); + try { + execFileSync("git", ["branch", "trunk"], { cwd: repo }); + assert.equal(await resolveDefaultBranch(repo, "trunk"), "trunk"); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); + +test("syncBaseBranch refuses a remote-tracking base instead of mangling it", async () => { + // The other half of the fix. Without this guard the local path runs on + // `origin/trunk`: `git fetch origin origin/trunk`, then + // `origin/trunk..origin/origin/trunk` -- both nonsense, and the reported reason would + // blame the fetch rather than say there is nothing to catch up. + const repo = makeRepo(); + try { + const r = await syncBaseBranch(repo, "origin/trunk"); + assert.equal(r.updated, false); + assert.match(r.reason ?? "", /no local branch/i); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); + +test("a LOCAL branch named origin/... is still synced, not mistaken for a remote ref", async () => { + // Review finding on the previous fix: the guard matched the `origin/` PREFIX, but + // `refs/heads/origin/release` is a legal local branch, and `resolveDefaultBranch` + // returns such a name bare. The prefix test then refused to fast-forward a perfectly + // ordinary local base. The discriminator has to be which ref actually exists, not how + // the name is spelled. + const repo = makeRepo(); + try { + execFileSync("git", ["branch", "origin/release"], { cwd: repo }); + const r = await syncBaseBranch(repo, "origin/release"); + // It reaches the real sync path, so it fails on the ABSENT REMOTE rather than being + // waved through as "nothing to catch up". + assert.doesNotMatch( + r.reason ?? "", + /no local branch/i, + "a local branch must not be skipped as remote-tracking", + ); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); diff --git a/server/src/git.ts b/server/src/git.ts index 2de3ca25..c29a07d7 100644 --- a/server/src/git.ts +++ b/server/src/git.ts @@ -102,6 +102,58 @@ export async function detectDefaultBranch(repoPath: string): Promise<string | nu return detectCurrentBranch(repoPath); } +/** + * The default branch in force for a repo: the user's override when it still + * resolves, otherwise git's own answer. + * + * The **one** place that rule lives, so the repo snapshot's diff numbers, worktree + * creation and wrap-up's base sync cannot disagree about what "default" means — + * three consumers reading `detectDefaultBranch` directly is how the override ended + * up affecting none of them. + * + * The override is CHECKED, not trusted. It is stored after a syntax check only + * (`validateBranch`), it is chosen from branches that existed at the time, and a + * branch can be deleted afterwards. A stale override is a worse base than + * detection, not a better one, so it loses rather than winning and then failing + * deep inside a `git diff` where the message is unrecognisable. + * + * "Known" includes `origin/<branch>`, and the RETURNED FORM differs by which ref + * exists: a local branch comes back bare (the sync path fast-forwards a local + * branch), a remote-only one comes back as `origin/<branch>` (so every `git` + * invocation can resolve it). Callers must therefore treat the result as a REV, and + * only `syncBaseBranch` cares about the distinction -- which it checks. + * + * Checking costs one `rev-parse` and REPLACES detection's one-to-three calls when + * the override holds, so the common case gets cheaper rather than dearer. + */ +export async function resolveDefaultBranch( + repoPath: string, + override: string | undefined, +): Promise<string | null> { + if (override !== undefined && override.length > 0) { + // A local branch is returned BARE, because `syncBaseBranch` fetches and + // fast-forwards a local branch and a qualified name would break it. + if (await branchExists(repoPath, override)) return override; + // Known only on the remote: returned QUALIFIED, because git's revision rules never + // resolve a bare name against `refs/remotes/origin/` (`gitrevisions` checks + // `refs/<name>`, `refs/tags/<name>`, `refs/heads/<name>`, `refs/remotes/<name>` -- + // not `refs/remotes/origin/<name>`). Returning the bare name handed `diffStat`, + // `commitsAhead` and `git worktree add` a base that resolves nowhere: silent zero + // diffs, zero counts, and failed worktree creation. + if (await remoteBranchExists(repoPath, override)) return `origin/${override}`; + } + return detectDefaultBranch(repoPath); +} + +/** Whether `refs/remotes/origin/<branch>` exists. */ +async function remoteBranchExists(repoPath: string, branch: string): Promise<boolean> { + const r = await git( + ["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${branch}`], + repoPath, + ); + return r.code === 0; +} + /** The currently checked-out branch, or null when HEAD is detached. */ export async function detectCurrentBranch(repoPath: string): Promise<string | null> { const r = await git(["rev-parse", "--abbrev-ref", "HEAD"], repoPath); @@ -452,18 +504,39 @@ export function listOpenPrs( return gateway.openPrs(repoPath, limit, opts); } +/** + * How a PR's head is fetched into a worktree. + * + * Two strategies rather than one, because neither generalises: + * + * `gh` — `gh pr checkout`. Kept for GitHub because it already handles the + * fork case and sets up push tracking, and replacing a working path + * with a hand-rolled equivalent would be a regression risk taken for + * tidiness. + * `pull-ref` — plain git against `refs/pull/<n>/head`, which is how Gitea and + * Forgejo publish PR heads. `gh` cannot be used here at all: it + * speaks only to GitHub, so on a Forgejo remote the picker listed the + * PRs and the checkout then failed. + */ +export type PrCheckoutStrategy = "gh" | "pull-ref"; + /** * Create a worktree that checks out an existing PR's head branch. A fresh - * detached worktree is added first, then `gh pr checkout` fetches the PR head - * (handling same-repo and fork PRs) and switches the worktree to it. Returns - * the canonical worktree path + the checked-out branch name. Throws on - * failure — this is a user-initiated mutation whose error must surface. + * detached worktree is added first, then the PR head is fetched into it and a + * PR-unique local branch is created. Returns the canonical worktree path + the + * checked-out branch name. Throws on failure — this is a user-initiated mutation + * whose error must surface. + * + * [checkout] selects the provider strategy; see {@link PrCheckoutStrategy}. It + * defaults to `gh` so GitHub's behaviour is unchanged for any caller that does not + * pass one. */ export async function addWorktreeForPr(opts: { repoPath: string; prNumber: number; headRefName: string; baseDir?: string; + checkout?: PrCheckoutStrategy; }): Promise<{ path: string; branch: string }> { const base = opts.baseDir ?? worktreeBaseDir(); const repoName = basename(resolve(opts.repoPath)); @@ -472,38 +545,104 @@ export async function addWorktreeForPr(opts: { const slug = slugify(opts.headRefName); const name = slug ? `pr-${opts.prNumber}-${slug}` : `pr-${opts.prNumber}`; const target = join(base, repoName, name); - // Detached checkout of HEAD so the worktree dir exists; gh then moves it to - // the PR head. No timeout: populating a worktree can take a while. + // Detached checkout of HEAD so the worktree dir exists; the strategy then moves + // it to the PR head. No timeout: populating a worktree can take a while. const add = await run("git", ["worktree", "add", "--detach", target], opts.repoPath); if (add.code !== 0) { throw new Error(`git worktree add failed: ${add.stderr.trim() || add.stdout.trim() || `exit ${add.code}`}`); } - // Always check out onto a PR-unique local branch (`name`). gh's default - // reuses the PR head-ref as the branch name, which git rejects when that - // branch is already checked out in another worktree of this repo (commonly - // the primary checkout sits on it), breaking the flow. A dedicated per-PR - // branch avoids the collision entirely; `--branch` still tracks the PR head, - // so pushes update the PR. - const checkout = await run( - "gh", - ["pr", "checkout", String(opts.prNumber), "--branch", name], - target, - ); - if (checkout.code !== 0) { + + const failed = + (opts.checkout ?? "gh") === "pull-ref" + ? await checkoutViaPullRef(target, opts.prNumber, opts.headRefName, name) + : await checkoutViaGh(target, opts.prNumber, name); + if (failed !== null) { // Roll back the empty detached worktree so we don't leave litter behind. // Best-effort: don't let a rollback failure mask the real checkout error. await removeWorktree(opts.repoPath, target, true).catch(() => {}); - throw new Error(`gh pr checkout ${opts.prNumber} failed: ${checkout.stderr.trim() || `exit ${checkout.code}`}`); + throw new Error(failed); } - // Report the actual checked-out branch (`name`, from --branch above) by - // reading HEAD, falling back to headRefName only if the read fails. Callers - // use this to highlight the worktree's row. + + // Report the actual checked-out branch (`name`) by reading HEAD, falling back to + // `name` only if the read fails. Callers use this to highlight the worktree's row. const head = await run("git", ["rev-parse", "--abbrev-ref", "HEAD"], target); const actual = head.code === 0 ? head.stdout.trim() : ""; const branch = actual && actual !== "HEAD" ? actual : name; return { path: realpathSync(target), branch }; } +/** GitHub: `gh` does the work. Returns an error message, or null on success. */ +async function checkoutViaGh( + target: string, + prNumber: number, + branchName: string, +): Promise<string | null> { + // Always check out onto a PR-unique local branch. gh's default reuses the PR + // head-ref as the branch name, which git rejects when that branch is already + // checked out in another worktree of this repo (commonly the primary checkout sits + // on it), breaking the flow. A dedicated per-PR branch avoids the collision + // entirely; `--branch` still tracks the PR head, so pushes update the PR. + const r = await run("gh", ["pr", "checkout", String(prNumber), "--branch", branchName], target); + return r.code === 0 + ? null + : `gh pr checkout ${prNumber} failed: ${r.stderr.trim() || `exit ${r.code}`}`; +} + +/** + * Forgejo / Gitea: fetch `refs/pull/<n>/head` and branch from it. + * + * That ref is created by the forge for **every** PR including forks', which is why + * it is used instead of the head branch name — a fork's branch does not exist on + * `origin` at all. + * + * Upstream tracking is set only when the head branch really is on `origin` (a + * same-repo PR), so a push updates the PR. For a fork it is deliberately left + * unset: pointing it anywhere would aim a push at a branch that is not the PR's, + * and pushing to a contributor's fork was never possible from here anyway. + */ +async function checkoutViaPullRef( + target: string, + prNumber: number, + headRefName: string, + branchName: string, +): Promise<string | null> { + const ref = `refs/pull/${prNumber}/head`; + const fetched = await run("git", ["fetch", "--quiet", "origin", ref], target); + if (fetched.code !== 0) { + return `fetching ${ref} failed: ${fetched.stderr.trim() || `exit ${fetched.code}`}. The forge may not publish this pull request, or it may be closed.`; + } + const checkout = await run("git", ["checkout", "-q", "-b", branchName, "FETCH_HEAD"], target); + if (checkout.code !== 0) { + return `checking out PR #${prNumber} failed: ${checkout.stderr.trim() || `exit ${checkout.code}`}`; + } + // Best-effort, and only when the branch genuinely exists on origin. `--` is not + // available here, so the ref is checked first rather than trusted. + if (headRefName.length > 0) { + const onOrigin = await run( + "git", + ["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${headRefName}`], + target, + ); + if (onOrigin.code === 0) { + const upstream = await run( + "git", + ["branch", `--set-upstream-to=origin/${headRefName}`, branchName], + target, + ); + // Best-effort, but not silent: without an upstream a later `git push` in this + // worktree does not update the PR, and "my push did nothing" is unanswerable + // if the reason was never recorded. Not fatal -- the worktree is the point, + // and it is checked out correctly either way. + if (upstream.code !== 0) { + log.warn( + `[makit] PR #${prNumber}: could not track origin/${headRefName}, so pushing from this worktree will not update the pull request: ${upstream.stderr.trim() || `exit ${upstream.code}`}`, + ); + } + } + } + return null; +} + /** * Rename a worktree's local branch via `git branch -m`. Runs in the worktree * so the currently checked-out branch is the one renamed. Throws on failure. @@ -644,6 +783,17 @@ export interface BaseSyncResult { * a total failure. */ export async function syncBaseBranch(repoPath: string, branch: string): Promise<BaseSyncResult> { + // A remote-tracking base (`origin/trunk`) has no local branch to fast-forward, so + // there is nothing to catch up -- and running the local path on it would issue + // `git fetch origin origin/trunk` and compare `origin/trunk..origin/origin/trunk`, + // both nonsense. `resolveDefaultBranch` returns this form when the default exists + // only on the remote. + // + // BUT: a local branch can be *literally* named `origin/release` (i.e. + // `refs/heads/origin/release`). Check if it's actually local before refusing. + if (branch.startsWith("origin/") && !(await branchExists(repoPath, branch))) { + return { updated: false, reason: `${branch} has no local branch to catch up` }; + } // `run` without a cap, not `git`: this talks to the network inside a // user-initiated action, and a large or slow repo can legitimately outlast the // 15s read cap (see GIT_READ_TIMEOUT_MS — mutations are deliberately uncapped). diff --git a/server/src/github/gateway.ts b/server/src/github/gateway.ts index 8729c055..f49d6b79 100644 --- a/server/src/github/gateway.ts +++ b/server/src/github/gateway.ts @@ -25,6 +25,7 @@ import { route, type RequestPlan, type RouteChoice } from "./router.js"; import { allow, decide } from "./policy.js"; import { normalizeChecks, rollupChecks } from "../git.js"; import type { OpenPr, PullRequestInfo } from "../git.js"; +import type { ForgeGateway, GatewayStats, PrLookup } from "../forge/types.js"; import { OPEN_PRS_PLAN, OPEN_PRS_TIMEOUT_MS, @@ -38,7 +39,6 @@ import { combinedStatusRestArgv, openPrsArgv, prMutationArgv, - type PrMutation, openPrsRestArgv, originRemoteArgv, parsePrUrl, @@ -53,11 +53,13 @@ import { unresolvedThreadsArgv, } from "./queries.js"; -/** Three-way PR lookup result — a failed lookup is never `none` (§6.5). */ -export type PrLookup = - | { kind: "pr"; pr: PullRequestInfo } - | { kind: "none" } - | { kind: "unknown"; reason: "throttled" | "error" }; +/** + * Three-way PR lookup result — a failed lookup is never `none` (§6.5). + * + * Re-exported from the provider-neutral contract so both providers and every + * existing importer keep one definition. + */ +export type { PrLookup, GatewayStats } from "../forge/types.js"; /** Result of a `gh` invocation. Matches git.ts's private `run` — never rejects. */ export interface ExecResult { @@ -75,54 +77,20 @@ export interface TimerHandle { } /** Exec/cache counters — spec §10 success criterion 1 (measure the ≥80% cut). */ -export interface GatewayStats { - /** - * `gh` calls that SPENT quota. Excludes the exempt `/rate_limit` read (see - * {@link exemptExecs}) and the local `git remote` lookup, so this is the number - * the >=80% call-reduction claim is measured against without arithmetic. - */ - execs: number; - /** Quota-exempt `gh api rate_limit` reads. Free, but still subprocesses. */ - exemptExecs: number; - /** Reads served from cache without an exec. */ - cacheHits: number; -} +export type { GatewayStats as GithubGatewayStats } from "../forge/types.js"; -export interface GithubGateway { - prForBranch(repoPath: string, branch: string, opts?: { interactive?: boolean }): Promise<PrLookup>; +export interface GithubGateway extends ForgeGateway { /** - * All open PRs for a repo (the "New worktree from PR" picker). - * - * Pass `interactive: true` for a user-initiated call: the picker is a click, - * not a poller, so it must draw on the reserve rather than silently return an - * empty list — which the user would read as "this repo has no open PRs" - * (spec §6.3). - */ - openPrs(repoPath: string, limit: number, opts?: { interactive?: boolean }): Promise<OpenPr[]>; - /** - * Run a state-changing `gh pr` verb on the user's behalf (`ready` to take a PR - * out of draft, `update-branch` to merge the base into it). - * - * Always interactive — it is a button press, never a poller — so it spends from - * the reserve rather than being shed. Invalidates the cached lookup for - * [branch] on success, otherwise the UI would keep reporting the state the - * mutation just changed until the TTL expired. + * GitHub's quota, which only this provider has: Forgejo exposes no + * `rate_limit` endpoint and sends no rate-limit headers. Hence these live here + * rather than on {@link ForgeGateway} — see `../forge/types.ts`. */ - mutatePr( - repoPath: string, - branch: string, - number: number, - verb: PrMutation, - ): Promise<{ ok: boolean; error?: string }>; budget(): BudgetSnapshot; /** 60-slot per-minute `{mine, others}` ring for the sparkline (spec §6.6). */ history(): Array<{ mine: number; others: number }>; refresh(): Promise<BudgetSnapshot>; setPaused(paused: boolean): void; onBudgetChange(fn: (s: BudgetSnapshot) => void): () => void; - close(): void; - /** Exec vs. cache-hit counters (T6 surfaces this for the ≥80% claim). */ - stats(): GatewayStats; } export interface GatewayDeps { diff --git a/server/src/github/policy.ts b/server/src/github/policy.ts index cdee88f8..fc79de27 100644 --- a/server/src/github/policy.ts +++ b/server/src/github/policy.ts @@ -64,7 +64,11 @@ export interface Policy { reserve: number; } -const POLL_FAST_MS = 5_000; +/** + * The unthrottled cadence. Exported because a provider with no quota to ration + * (Forgejo) must be able to claim it without going through the GitHub ladder. + */ +export const POLL_FAST_MS = 5_000; const POLL_SLOW_MS = 30_000; const POLL_CRAWL_MS = 120_000; const POLL_PAUSED_MS = Infinity; diff --git a/server/src/github/queries.ts b/server/src/github/queries.ts index af86434e..779bdd4a 100644 --- a/server/src/github/queries.ts +++ b/server/src/github/queries.ts @@ -19,6 +19,7 @@ import type { OpenPr } from "../git.js"; import type { RequestPlan } from "./router.js"; +import type { PrMutation } from "../forge/types.js"; /** Timeout (ms) for `gh pr` reads, matching git.ts. */ export const PR_TIMEOUT_MS = 5_000; @@ -298,8 +299,14 @@ export function parsePrUrl(prUrl: string): { owner: string; repo: string; number return { owner: m[1], repo: m[2], number: m[3] }; } -/** A state-changing `gh pr` action the app can run on the user's behalf. */ -export type PrMutation = "ready" | "update-branch" | "merge-squash"; +/** + * A state-changing PR action the app can run on the user's behalf. + * + * Re-exported from the provider-neutral contract: the verbs are the same on every + * forge even though how each is performed is not (Forgejo has no `pr ready` -- + * see forge/forgejo/map.ts). + */ +export type { PrMutation } from "../forge/types.js"; /** * argv for a PR mutation, addressed **by number**. diff --git a/server/src/manager.ts b/server/src/manager.ts index 1aa72574..0fb82e47 100644 --- a/server/src/manager.ts +++ b/server/src/manager.ts @@ -8,7 +8,7 @@ import { EventEmitter } from "node:events"; import { randomUUID } from "node:crypto"; -import { existsSync } from "node:fs"; +import { existsSync, realpathSync } from "node:fs"; import { basename, resolve, join } from "node:path"; import type { AgentAdapter } from "./adapters/adapter.js"; import type { AskUser } from "./uicall.js"; @@ -16,7 +16,17 @@ import { listAgents, fingerprintAgent, type AgentDescriptor } from "./adapters/c import { CapabilityCache } from "./adapters/capability_cache.js"; import { Session } from "./session.js"; import { sessionTokens } from "./ws/session_tokens.js"; -import { DEFAULT_SESSION_TITLE, type ApprovalPolicy, type ProjectDTO, type RepoDTO, type SessionConfigOption, type SessionDTO, type SessionEvent, type SessionOrigin } from "./protocol.js"; +import { + DEFAULT_SESSION_TITLE, + type ApprovalPolicy, + type ProjectDTO, + type RepoDTO, + type RepoSettingsDTO, + type SessionConfigOption, + type SessionDTO, + type SessionEvent, + type SessionOrigin, +} from "./protocol.js"; import { spawnBoundError, spawnDepth, type LineageNode } from "./lineage.js"; import { listPiSessions, parseTranscript, type PiSessionMeta } from "./pi-sessions.js"; import { DetachedAdapter } from "./adapters/detached.js"; @@ -26,11 +36,12 @@ import { listAcpSessions } from "./adapters/acp.js"; import { listCodexThreads } from "./adapters/codex.js"; import type { AgentSessionInfo } from "./adapters/adapter.js"; import { listRepos, enrichPrs, type LastKnownPr } from "./repo_service.js"; -import { createGithubGateway, type GithubGateway } from "./github/gateway.js"; +import type { GithubGateway } from "./github/gateway.js"; +import { createDefaultForgeGateway } from "./forge/router.js"; import type { PersistedProject } from "./project-store.js"; import { isGitRepo, - detectDefaultBranch, + resolveDefaultBranch, listWorktrees, addWorktree, addWorktreeForPr, @@ -39,11 +50,11 @@ import { deleteBranch, syncBaseBranch, listOpenPrs, + type PrCheckoutStrategy, findOpenPr, branchExists, slugify, slugifyBranch, - worktreeBaseDir, run, type OpenPr, type WorktreeEntry, @@ -51,6 +62,16 @@ import { import type { PrMutation } from "./github/queries.js"; import type { EventStore } from "./storage/event_store.js"; import { log } from "./log.js"; +import { + parseRepoSettings, + resolveProvider, + resolveWorktreeRoot, + validateRepoPath, + validateWorktreeRoot, + type ProviderChoice, + type RepoSettings, +} from "./repo_settings.js"; +import type { ForgeForgetful, ForgeInspector } from "./forge/router.js"; /** * What a {@link SessionManager.wrapUpWorktree} run actually did. The base-branch @@ -178,9 +199,31 @@ function reason(e: unknown): string { export const DEFAULT_CLOSE_GRACE_MS = 10_000; interface ProjectEntry { + /** + * Persisted per-repo settings, verbatim. Held as an opaque record so a save + * cannot drop keys this build does not understand; typed and validated at the + * point of use (`repo_settings.ts`). + */ + settings?: Record<string, unknown>; dto: ProjectDTO; } +/** + * A path in its canonical form, or `resolve`d when it cannot be canonicalised. + * + * Used wherever two paths are compared for "same directory". Falls back rather + * than throwing because a project whose directory has since been deleted must + * still compare, and still be re-pointable -- that is the case re-pointing exists + * to fix. + */ +function canonicalPath(p: string): string { + try { + return realpathSync(resolve(p)); + } catch { + return resolve(p); + } +} + export class SessionManager extends EventEmitter { private readonly projects = new Map<string, ProjectEntry>(); private readonly sessions = new Map<string, Session>(); @@ -211,7 +254,16 @@ export class SessionManager extends EventEmitter { /** Deadline for a graceful `adapter.close()` before we reap regardless. */ private readonly closeGraceMs: number; private bridge?: BridgeBinding; - private readonly _gateway: GithubGateway; + /** + * The forge gateway. + * + * Typed as the gateway PLUS the optional inspection/invalidation ports, so the + * three call sites that ask it what it decided no longer need an + * `as unknown as` double cast. `Partial` is the honest shape: a test may inject a + * plain `GithubGateway`, and only the real router implements the ports — which is + * exactly why the calls are optional-chained rather than assumed. + */ + private readonly _gateway: GithubGateway & Partial<ForgeInspector & ForgeForgetful>; constructor(opts: ManagerOpts) { super(); @@ -222,10 +274,23 @@ export class SessionManager extends EventEmitter { this.store = opts.store; this.capabilityCache = opts.capabilityCache; this.closeGraceMs = opts.closeGraceMs ?? DEFAULT_CLOSE_GRACE_MS; - // The single GitHub gateway (SPEC-32). A real one over git.ts's `run` unless - // a fake is injected; `run` resolves `gh` via PATH, so the test PATH-shim + // The single forge gateway (SPEC-32). A router over every provider unless a + // fake is injected: it dispatches per repo on that repo's own provider setting + // and, failing that, on what detection reports -- github.com to the `gh`-backed + // gateway, Forgejo/Gitea to the REST one -- and forwards the budget surface to + // the gh gateway, which owns the only quota that exists. `run` resolves `gh` + // via PATH, so the test PATH-shim // keeps working. Constructed here does NOT self-refresh (no subprocess). - this._gateway = opts.gateway ?? createGithubGateway({ exec: run }); + // + // `providerFor` is passed as a bound method, not a captured value: the router + // calls it per routing decision, so a provider the user changes at runtime is + // honoured on the next poll (SPEC-48 D3"). + this._gateway = + opts.gateway ?? + createDefaultForgeGateway({ + exec: run, + providerFor: (repoPath) => this.providerFor(repoPath), + }); for (const entry of opts.projects) { // A bare path gets a fresh server-generated id; a restored `{ id, path }` // keeps its id so a client's persisted projectId stays valid across a @@ -240,6 +305,8 @@ export class SessionManager extends EventEmitter { pinned: true, lastActivityAt: Date.now(), }, + // Carried verbatim so a save cannot drop keys this build does not know. + settings: typeof entry === "string" ? undefined : entry.settings, }); } this.rehydrate(); @@ -288,7 +355,13 @@ export class SessionManager extends EventEmitter { /** Listing for the home screen. */ listProjects(): ProjectDTO[] { - return [...this.projects.values()].map((p) => p.dto); + return [...this.projects.values()].map((p) => ({ + ...p.dto, + // Include validated settings if present; undefined if absent (optional in DTO) + // No cast: the DTO now declares the keys that are actually persisted, so the + // compiler checks this shape instead of the cast hiding a mismatch. + ...(p.settings ? { settings: p.settings } : {}), + })); } /** @@ -299,8 +372,18 @@ export class SessionManager extends EventEmitter { */ addProject(path: string): ProjectDTO { const resolved = resolve(path); + // Compared CANONICALLY, so `/tmp/x` and `/private/tmp/x` — one directory on + // macOS — cannot become two projects. Settings and the forge decision are both + // looked up by path, so two projects at one directory answer for each other. + // `repointProject` already refuses this; comparing with `resolve` alone here let + // the ordinary add route create exactly the state the other one forbids. + // + // The path is still STORED as `resolved` rather than canonicalised: that is what + // persisted ids are already mapped to, and rewriting it would change the stored + // value for every existing project on the next save. + const canonical = canonicalPath(resolved); const existing = [...this.projects.values()].find( - (p) => resolve(p.dto.path) === resolved, + (p) => canonicalPath(p.dto.path) === canonical, ); if (existing) return existing.dto; @@ -317,6 +400,112 @@ export class SessionManager extends EventEmitter { return dto; } + /** + * Apply a settings patch to a project and persist it. Returns false for an + * unknown id. + * + * A `null` value **clears** that key rather than storing null: absent means + * "inherit", so clearing is how the UI says "go back to inheriting" without a + * sentinel. Unknown keys already on disk are untouched — this merges into the + * stored record rather than replacing it, so a newer app's field survives an + * older daemon writing a neighbouring one. + */ + updateProjectSettings(id: string, patch: Record<string, unknown>): boolean { + const entry = this.projects.get(id); + if (entry === undefined) return false; + const next: Record<string, unknown> = { ...(entry.settings ?? {}) }; + for (const [k, v] of Object.entries(patch)) { + if (v === null) delete next[k]; + else next[k] = v; + } + entry.settings = Object.keys(next).length === 0 ? undefined : next; + this.notifyProjectsChanged(); + return true; + } + + /** Persisted settings for a project id, verbatim (for the DTO + tests). */ + projectSettings(id: string): Record<string, unknown> | undefined { + return this.projects.get(id)?.settings; + } + + /** + * Re-point a project at a new root path, **keeping its id** (SPEC-48 D4′). + * + * Not equivalent to remove-and-re-add, which is why it exists: re-adding mints a + * fresh `PersistedProject.id`, and everything keyed to that id — per-repo + * settings, session history — is lost. A repo that merely moved on disk should + * keep its identity, and preserving the id across a move is the entire reason the + * id exists rather than the path being the key. + * + * Three refusals, each for a failure that would otherwise be silent: + * + * - **not a git repo** — the constraint D4′ states. A project pointed at a + * plain directory has no branches, no forge and no diff, and presents as + * broken rather than as misconfigured. + * - **already another project's path** — settings and the forge decision are + * both looked up BY PATH, so two projects at one path would silently answer + * for each other. + * - anything {@link validateRepoPath} rejects. + * + * The forge decision for the OLD path is discarded, so detection re-runs against + * the new one: D4′ requires it, because the forge and the default branch may both + * change with the move. + * + * Known limitation, stated rather than hidden: sessions already bound to a + * worktree keep their recorded paths. For the case this exists for — a repo that + * moved — worktrees live under the worktree root, which is a separate setting and + * unaffected; a session whose worktree was the repo directory itself will still + * point at the old location. + */ + async repointProject( + id: string, + rawPath: string, + ): Promise<{ ok: true; path: string } | { ok: false; error: string }> { + const entry = this.projects.get(id); + if (entry === undefined) return { ok: false, error: `No project ${id}.` }; + const entryPathBefore = entry.dto.path; + + const checked = validateRepoPath(rawPath); + if (!checked.ok) return { ok: false, error: checked.error }; + const next = checked.value; + + // Both sides of every comparison below are canonicalised. Comparing a + // canonicalised new path against a stored one that is not is how a duplicate + // slips through: on macOS `/tmp/x` and `/private/tmp/x` are the same directory, + // and a project restored from `projects.json` holds whichever spelling was + // written. Two projects at one path would then look distinct while sharing + // settings and a forge decision, because both are looked up BY PATH. + const previous = canonicalPath(entry.dto.path); + // Re-submitting the same directory -- possibly under a different spelling, since + // `/tmp/x` and `/private/tmp/x` are one place -- is a no-op, not a conflict with + // itself. Reports the path actually in force rather than the canonical form, + // because nothing was stored and claiming otherwise would show the client a + // value the store does not hold. + if (next === previous) return { ok: true, path: entryPathBefore }; + + for (const [otherId, other] of this.projects) { + if (otherId !== id && canonicalPath(other.dto.path) === next) { + return { + ok: false, + error: `${next} is already open in makit as "${other.dto.name}".`, + }; + } + } + + if (!(await isGitRepo(next))) { + return { ok: false, error: `${next} is not a git repository.` }; + } + + entry.dto = { ...entry.dto, path: next, name: basename(next) }; + // Drop the routing decision for where the repo used to be, so the forge is + // re-detected instead of reported from a stale probe. Keyed on the path the + // gateway was actually called with -- the DTO's own value, not its canonical + // form, since that is what became the cache key. + this._gateway.forgetRepo?.(entryPathBefore); + this.notifyProjectsChanged(); + return { ok: true, path: next }; + } + /** Remove a project by id. Throws on an unknown id. Sessions are left as-is. */ removeProject(id: string): void { if (!this.projects.has(id)) throw new Error(`unknown project: ${id}`); @@ -326,7 +515,13 @@ export class SessionManager extends EventEmitter { private notifyProjectsChanged(): void { this.onProjectsChanged?.( - [...this.projects.values()].map((p) => ({ id: p.dto.id, path: p.dto.path })), + [...this.projects.values()].map((p) => + // `settings` is included only when present, so an untouched project keeps + // its two-key shape on disk and the file stays diffable. + p.settings === undefined || Object.keys(p.settings).length === 0 + ? { id: p.dto.id, path: p.dto.path } + : { id: p.dto.id, path: p.dto.path, settings: p.settings }, + ), ); } @@ -618,7 +813,7 @@ export class SessionManager extends EventEmitter { const base = baseBranch && (await branchExists(repoPath, baseBranch)) ? baseBranch - : await detectDefaultBranch(repoPath); + : await this.defaultBranchFor(repoPath); // Unborn HEAD (no commits yet): `git worktree add -b` would fail, so run // the session in the repo dir instead of forking a worktree. if (!base) return { path: repoPath, branch: null }; @@ -643,6 +838,7 @@ export class SessionManager extends EventEmitter { // path. const dirName = this.uniqueWorktreeDir(repoPath, branch.replace(/\//g, "-")); const path = await addWorktree({ + baseDir: this.worktreeRootFor(repoPath), repoPath, name: dirName, branch, @@ -680,7 +876,36 @@ export class SessionManager extends EventEmitter { const prs = await listOpenPrs(this._gateway, repoPath); const pr = prs.find((p) => p.number === prNumber); if (!pr) throw new Error(`PR #${prNumber} is not an open PR of this repo`); - return addWorktreeForPr({ repoPath, prNumber, headRefName: pr.headRefName }); + return addWorktreeForPr({ + repoPath, + prNumber, + headRefName: pr.headRefName, + baseDir: this.worktreeRootFor(repoPath), + // Read AFTER `listOpenPrs`, which is what routes the repo — so detection has + // run and its decision is available rather than empty. + checkout: this.prCheckoutStrategyFor(repoPath), + }); + } + + /** + * How a PR should be checked out for [repoPath] (SPEC-48). + * + * Resolved from the SAME two sources the router uses to pick a gateway, and in the + * same order — the user's override first, then routing's decision — so the checkout + * cannot disagree with the provider that served the PR list. "New worktree from PR" + * used to list Forgejo PRs correctly and then run `gh pr checkout`, so it failed + * halfway for every non-GitHub repo. + * + * Falls back to `gh` when nothing is known: that is the status quo for an + * unreadable remote, and the router makes the same choice for the same reason. + */ + prCheckoutStrategyFor(repoPath: string): PrCheckoutStrategy { + const chosen = this.providerFor(repoPath); + if (chosen === "forgejo" || chosen === "gitea") return "pull-ref"; + if (chosen === "github") return "gh"; + // `auto` (or `none`, which never reaches a checkout): believe detection. + const software = this._gateway.forgeFor?.(repoPath)?.software; + return software === "forgejo" || software === "gitea" ? "pull-ref" : "gh"; } /** @@ -890,7 +1115,7 @@ export class SessionManager extends EventEmitter { const { repoPath, branchDeleted, branchReason } = await this._removeWorktreeAndBranch(projectId, worktreePath, expectBranch); - const base = baseBranch ?? (await detectDefaultBranch(repoPath)); + const base = baseBranch ?? (await this.defaultBranchFor(repoPath)); if (!base) { return { branchDeleted, @@ -1084,14 +1309,87 @@ export class SessionManager extends EventEmitter { } /** - * Find an unused worktree directory name under `<worktreeBaseDir>/<repoName>`, - * appending `-2`, `-3`, … on collision. Needed because two distinct branches - * can flatten to the same dir name (`feat/new-ui` → `feat-new-ui`), so a - * unique branch is not enough to guarantee `git worktree add`'s target path - * is free. Mirrors {@link addWorktree}'s target layout. + * The worktree root in force for [repoPath] — the repo's override, else + * `MAKIT_WORKTREE_DIR`, else `~/.worktrees` (SPEC-48 D8'). + * + * Re-validated on read, not trusted from the file: `projects.json` is plain JSON + * a user can edit by hand, so a write-time check alone is not a guarantee. An + * invalid stored value falls back to the inherited root rather than failing the + * worktree creation, and says so once. + */ + worktreeRootFor(repoPath: string): string { + const settings = this.settingsForPath(repoPath); + const resolved = resolveWorktreeRoot(settings, process.env); + if (resolved.source !== "override") return resolved.value; + const checked = validateWorktreeRoot(resolved.value); + if (checked.ok) return checked.value; + log.warn( + `[makit] ignoring invalid worktree root for ${repoPath}: ${checked.error} — using the inherited root instead`, + ); + return resolveWorktreeRoot(undefined, process.env).value; + } + + /** + * Parsed settings for the project owning [repoPath], or `{}`. + * + * Compared CANONICALLY on both sides, like `addProject` and `repointProject`. + * `addProject` stores the resolved -- not canonicalised -- spelling, so a project + * added through a symlinked path used to fail this lookup whenever a caller supplied + * the canonical path (which is what git hands back). Every override then silently + * fell back to the default while still being shown in the UI. */ - private uniqueWorktreeDir(repoPath: string, base: string): string { - const parent = join(worktreeBaseDir(), basename(resolve(repoPath))); + private settingsForPath(repoPath: string): RepoSettings { + const target = canonicalPath(repoPath); + for (const p of this.projects.values()) { + if (canonicalPath(p.dto.path) === target) return parseRepoSettings(p.settings); + } + return {}; + } + + /** + * The provider the user chose for [repoPath], or `auto` to believe detection + * (SPEC-48 D3"). + * + * Public because the forge router calls it **at routing time**, once per routing + * decision — not at construction. That is what makes a changed setting take + * effect on the next poll instead of at the next daemon restart, and a setting + * that only applies after a restart is indistinguishable from one that does + * nothing. + * + * A path makit does not know reports `auto`: an unknown repo has no override, and + * throwing here would break routing for a directory that is merely unregistered. + */ + providerFor(repoPath: string): ProviderChoice { + return resolveProvider(this.settingsForPath(repoPath)).value; + } + + /** + * The default branch in force for [repoPath] — the repo's override when it still + * resolves, else git's own answer (SPEC-48 D14/rev 3). + * + * The ONE place the three consumers read from: the repos snapshot (whose + * `defaultBranch` is what the diff +/- numbers and ahead counts are measured + * against), `createWorktree`'s base, and `wrapUpWorktree`'s base sync. Each used + * to call `detectDefaultBranch` directly, which is why storing an override changed + * nothing anywhere — the same mistake R5 caught for the worktree root. + */ + async defaultBranchFor(repoPath: string): Promise<string | null> { + return resolveDefaultBranch(repoPath, this.settingsForPath(repoPath).defaultBranch); + } + + /** + * Find an unused worktree directory name under + * `<worktreeRootFor(repoPath)>/<repoName>`, appending `-2`, `-3`, … on collision. + * Needed because two distinct branches can flatten to the same dir name + * (`feat/new-ui` → `feat-new-ui`), so a unique branch is not enough to guarantee + * `git worktree add`'s target path is free. Mirrors {@link addWorktree}'s layout. + * + * Reads the SAME per-repo root the creation paths use. If it did not, collision + * detection would look in one directory while `git worktree add` wrote to + * another — the two would disagree and a real collision would slip through. + */ + uniqueWorktreeDir(repoPath: string, base: string): string { + const parent = join(this.worktreeRootFor(repoPath), basename(resolve(repoPath))); let candidate = base; let n = 1; while (existsSync(join(parent, candidate))) { @@ -1120,7 +1418,60 @@ export class SessionManager extends EventEmitter { lastKnown: LastKnownPr = () => null, ): Promise<RepoDTO[]> { const includePrs = opts.includePrs ?? true; - return listRepos(this.listProjects(), this.allSessions(), includePrs, this._gateway, lastKnown); + return listRepos( + this.listProjects(), + this.allSessions(), + includePrs, + this._gateway, + lastKnown, + (p) => this.settingsDtoFor(p), + ); + } + + /** + * One project's settings as the app sees them: **effective values with their + * sources**, so the UI labels rather than guesses. + * + * The forge is read from the router's own decision record, which is `undefined` + * until that repo has actually been routed — reported as absent rather than as a + * guess, because "not measured yet" and "no forge" are different statements and + * only one of them is worth investigating. + */ + private settingsDtoFor(project: ProjectDTO): RepoSettingsDTO { + const stored = parseRepoSettings(this.projects.get(project.id)?.settings); + const worktreeRoot = resolveWorktreeRoot(stored, process.env); + const provider = resolveProvider(stored); + const forge = this._gateway.forgeFor?.(project.path); + return { + // Re-validated here too: an override read back from a hand-edited file must + // not be reported as in force if it would be refused on use. + // A stored override that no longer validates falls back to whatever the chain + // says WITHOUT relabelling it: dropping the override can land on the env var, + // and `source` exists so the app states the origin rather than guessing it. + worktreeRoot: + worktreeRoot.source === "override" && !validateWorktreeRoot(worktreeRoot.value).ok + ? resolveWorktreeRoot(undefined, process.env) + : worktreeRoot, + provider, + // Present ONLY when overridden. Absent means "no override" — the app already + // has `RepoDTO.defaultBranch` from git, so repeating it here would be two + // sources for one fact. + defaultBranch: + stored.defaultBranch !== undefined + ? { value: stored.defaultBranch, source: "override" as const } + : undefined, + logoHue: stored.logoHue, + // Asked of the router as its own question, NOT derived from `forge`. Those two + // facts have three states between them — not measured, no remote, a forge — and + // one boolean cannot hold three: deriving it made every un-polled repo claim to + // have no origin, which is the one reading that sends the user hunting for a + // problem that does not exist. + // + // `true` when the router has not reached this repo yet, so the app says "not + // identified yet" (a probe pending) rather than "no remote" (a conclusion). + hasRemote: this._gateway.hasRemoteFor?.(project.path) ?? true, + forge, + }; } /** diff --git a/server/src/project-store.test.ts b/server/src/project-store.test.ts index 36ab3941..c6941d38 100644 --- a/server/src/project-store.test.ts +++ b/server/src/project-store.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -119,3 +119,61 @@ test("browseDirectory throws on a non-directory path", () => { rmSync(root, { recursive: true, force: true }); } }); + +// --------------------------------------------------------------------------- +// Per-repo settings must survive load → save → load, unknown keys included: an +// older daemon paired with a newer app must not silently drop a field, and +// rewriting one key must not lose its siblings. +// --------------------------------------------------------------------------- + +test("settings round-trip losslessly, including keys this build does not know", () => { + const dir = mkdtempSync(join(tmpdir(), "makit-ps-settings-")); + const file = join(dir, "projects.json"); + const repo = mkdtempSync(join(tmpdir(), "makit-repo-")); + writeFileSync( + file, + JSON.stringify({ + projects: [ + { id: "a", path: repo, settings: { worktreeRoot: "/h/t", futureThing: { deep: 1 } } }, + ], + }), + ); + + const once = loadProjects(file); + assert.deepEqual(once[0].settings, { worktreeRoot: "/h/t", futureThing: { deep: 1 } }); + + saveProjects(file, once); + const twice = loadProjects(file); + assert.deepEqual(twice[0].settings, { worktreeRoot: "/h/t", futureThing: { deep: 1 } }); +}); + +test("a project with no settings keeps the exact two-key shape on disk", () => { + // Otherwise every untouched project gains `"settings": {}` on the next save and + // the file churns for no reason. + const dir = mkdtempSync(join(tmpdir(), "makit-ps-plain-")); + const file = join(dir, "projects.json"); + const repo = mkdtempSync(join(tmpdir(), "makit-repo-")); + saveProjects(file, [{ id: "a", path: repo }]); + const raw = JSON.parse(readFileSync(file, "utf8")) as { projects: Record<string, unknown>[] }; + assert.deepEqual(Object.keys(raw.projects[0]).sort(), ["id", "path"]); +}); + +test("a malformed settings value degrades that repo, it does not stop the load", () => { + const dir = mkdtempSync(join(tmpdir(), "makit-ps-bad-")); + const file = join(dir, "projects.json"); + const a = mkdtempSync(join(tmpdir(), "makit-repo-a-")); + const b = mkdtempSync(join(tmpdir(), "makit-repo-b-")); + writeFileSync( + file, + JSON.stringify({ + projects: [ + { id: "a", path: a, settings: "not-an-object" }, + { id: "b", path: b, settings: { worktreeRoot: "/h/t" } }, + ], + }), + ); + const loaded = loadProjects(file); + assert.equal(loaded.length, 2, "the good repo must still load"); + assert.equal(loaded[0].settings, undefined); + assert.deepEqual(loaded[1].settings, { worktreeRoot: "/h/t" }); +}); diff --git a/server/src/project-store.ts b/server/src/project-store.ts index 33219b55..2d92f0ec 100644 --- a/server/src/project-store.ts +++ b/server/src/project-store.ts @@ -28,6 +28,15 @@ import { log } from "./log.js"; export interface PersistedProject { id: string; path: string; + /** + * Per-repo settings, as persisted. Kept as an opaque record rather than a typed + * `RepoSettings` so that **unknown keys survive a round trip**: an older daemon + * paired with a newer app must not silently drop a field it does not + * understand, and a hand-edited file must not lose its siblings when one key is + * rewritten. Typing and validation happen in `repo_settings.ts`, at the point of + * use. + */ + settings?: Record<string, unknown>; } /** Absolute path of the projects persistence file. */ @@ -79,7 +88,15 @@ export function loadProjects(file: string): PersistedProject[] { typeof (entry as { path?: unknown }).path === "string" ) { const { id, path } = entry as PersistedProject; - if (isDirectory(path)) out.push({ id, path }); + if (!isDirectory(path)) continue; + const rawSettings = (entry as { settings?: unknown }).settings; + // Carried through verbatim. A malformed value is dropped here rather than + // rejected, so one bad repo cannot stop the daemon starting. + const settings = + typeof rawSettings === "object" && rawSettings !== null && !Array.isArray(rawSettings) + ? (rawSettings as Record<string, unknown>) + : undefined; + out.push(settings === undefined ? { id, path } : { id, path, settings }); } } return out; @@ -96,7 +113,13 @@ export function loadProjects(file: string): PersistedProject[] { export function saveProjects(file: string, projects: PersistedProject[]): void { try { mkdirSync(dirname(file), { recursive: true }); - const rows = projects.map((p) => ({ id: p.id, path: p.path })); + // `settings` is written only when present, so an untouched project keeps the + // exact two-key shape it has always had and the file stays diffable. + const rows = projects.map((p) => + p.settings === undefined || Object.keys(p.settings).length === 0 + ? { id: p.id, path: p.path } + : { id: p.id, path: p.path, settings: p.settings }, + ); writeFileSync(file, JSON.stringify({ projects: rows }, null, 2) + "\n"); } catch (e) { log.warn(`[makit] failed to write projects file ${file}: ${(e as Error).message}`); diff --git a/server/src/protocol.ts b/server/src/protocol.ts index 1d449fb9..e5c13fce 100644 --- a/server/src/protocol.ts +++ b/server/src/protocol.ts @@ -680,6 +680,30 @@ export interface ProjectDTO { path: string; pinned: boolean; lastActivityAt: number; + /** + * Per-repo settings, VERBATIM as persisted (SPEC-48). + * + * The key names are the stored ones -- `provider` and `logoHue`, not `gitProvider` + * and `logo`. They differed until a review caught it, and a cast in the manager hid + * the mismatch, so a client reading `settings.gitProvider` always got `undefined`. + * + * Unknown keys are preserved on purpose: a newer app's field must survive an older + * daemon writing a neighbouring one, so this is deliberately open rather than a + * closed shape. The RESOLVED, effective values live in `RepoDTO.settings` + * ({@link RepoSettingsDTO}), which is what the UI should render. + */ + settings?: { + /** Provider override; absent means "believe detection". */ + provider?: string | null; + /** Absolute canonicalised worktree root; absent inherits. */ + worktreeRoot?: string | null; + /** Default-branch override; absent inherits git's answer. */ + defaultBranch?: string | null; + /** Monogram palette index; absent derives the hue from the name. */ + logoHue?: number | null; + /** Anything a newer client stored. Never dropped. */ + [key: string]: unknown; + }; } /** @@ -762,6 +786,47 @@ export interface WorktreeDTO { * Repo-centric home-screen unit. Wraps a {@link ProjectDTO} with git * intelligence: the current + default branch and the list of live worktrees. */ +/** Where an effective per-repo value came from. Drives the badge, never inferred. */ +export type SettingSourceDTO = "override" | "environment" | "default"; + +/** An effective value plus its source, so the app labels rather than guesses. */ +export interface ResolvedDTO<T> { + value: T; + source: SettingSourceDTO; +} + +/** + * Per-repo settings as the app sees them: **effective values with their sources**, + * not the raw stored record. + * + * The app is told facts and never derives them — the rule that stopped it + * re-deriving the forge from a PR URL. So the server resolves the chain + * (`override → environment → default`) and sends the answer plus why. + */ +export interface RepoSettingsDTO { + /** Where new worktrees for this repo are created. Never blank. */ + worktreeRoot: ResolvedDTO<string>; + /** `auto` believes detection; `none` means talk to no forge at all. */ + provider: ResolvedDTO<"auto" | "none" | "forgejo" | "gitea" | "github">; + /** Absent when neither an override nor `origin/HEAD` gave one. */ + defaultBranch?: ResolvedDTO<string>; + /** Monogram hue index; absent = derive it from the name. */ + logoHue?: number; + /** + * Whether the repo has an `origin` remote at all. False means no forge is + * possible — a **different statement** from "not identified yet", and rendering + * them alike implies a probe is pending when none can help. + */ + hasRemote: boolean; + /** + * What detection concluded. **Absent means not measured yet**, never "no forge": + * routing only happens when a PR operation runs, so a quiet repo may genuinely + * not know. `authed` is omitted for GitHub, where `gh`'s budget is not + * host-specific authentication. The token is never sent. + */ + forge?: { software: string; host: string; authed?: boolean }; +} + export interface RepoDTO { id: string; name: string; @@ -772,6 +837,12 @@ export interface RepoDTO { defaultBranch: string | null; currentBranch: string | null; worktrees: WorktreeDTO[]; + /** + * Per-repo settings. Optional so an older app renders no settings section rather + * than a fabricated one, and a newer app paired with an older server does the + * same. + */ + settings?: RepoSettingsDTO; } export interface SessionDTO { diff --git a/server/src/repo_service.ts b/server/src/repo_service.ts index 9a1380d9..d8647572 100644 --- a/server/src/repo_service.ts +++ b/server/src/repo_service.ts @@ -13,9 +13,17 @@ import type { ProjectDTO, PullRequestDTO, RepoDTO, WorktreeDTO } from "./protocol.js"; import type { Session } from "./session.js"; import type { GithubGateway } from "./github/gateway.js"; +import type { RepoSettingsDTO } from "./protocol.js"; + +/** + * Supplies one project's settings DTO. Injected rather than reached for: the + * resolution chain lives in `repo_settings.ts` and the forge decision in the + * router, and `listRepos` should not know about either. + */ +export type RepoSettingsLookup = (project: ProjectDTO) => RepoSettingsDTO | undefined; import { isGitRepo, - detectDefaultBranch, + resolveDefaultBranch, detectCurrentBranch, listWorktrees, diffStat, @@ -65,24 +73,40 @@ export async function listRepos( includePrs: boolean, gateway: GithubGateway, lastKnown: LastKnownPr, + settingsFor?: RepoSettingsLookup, ): Promise<RepoDTO[]> { // Bounded fan-out across projects (SPEC-17 P3 × #66 concurrency cap). - const repos = await mapLimit(projects, PROJECT_CONCURRENCY, (p) => repoSnapshot(p, sessions)); + const repos = await mapLimit(projects, PROJECT_CONCURRENCY, async (p) => { + // Settings are resolved BEFORE the snapshot because the snapshot needs one of + // them: `defaultBranch` is the base every diff +/- number and ahead count is + // measured against, so an override that arrived only in the settings blob would + // leave the row claiming one base while the numbers used another. + const settings = settingsFor?.(p); + const repo = await repoSnapshot(p, sessions, settings?.defaultBranch?.value); + return settings === undefined ? repo : { ...repo, settings }; + }); return includePrs ? enrichPrs(repos, gateway, lastKnown) : repos; } /** * Git-only snapshot of one project (no `gh`/network). Per-worktree diff stats * are read in parallel but bounded ({@link WORKTREE_CONCURRENCY}). + * + * [defaultBranchOverride] is the user's stored choice; it wins only if the branch + * still resolves — see {@link resolveDefaultBranch}. */ -async function repoSnapshot(dto: ProjectDTO, sessions: Session[]): Promise<RepoDTO> { +async function repoSnapshot( + dto: ProjectDTO, + sessions: Session[], + defaultBranchOverride?: string, +): Promise<RepoDTO> { const repoPath = dto.path; const gitRepo = await isGitRepo(repoPath); // Branch detection + worktree enumeration are independent reads — run // them concurrently rather than in a serial chain. const [defaultBranch, currentBranch, entries] = gitRepo ? await Promise.all([ - detectDefaultBranch(repoPath), + resolveDefaultBranch(repoPath, defaultBranchOverride), detectCurrentBranch(repoPath), listWorktrees(repoPath), ]) diff --git a/server/src/repo_settings.test.ts b/server/src/repo_settings.test.ts new file mode 100644 index 00000000..84decae6 --- /dev/null +++ b/server/src/repo_settings.test.ts @@ -0,0 +1,264 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, sep } from "node:path"; + +import { + defaultWorktreeRoot, + parseRepoSettings, + resolveProvider, + resolveWorktreeRoot, + validateBranch, + validateProvider, + validateRepoPath, + validateWorktreeRoot, +} from "./repo_settings.js"; + +/** A throwaway "home" so the containment rule can be exercised for real. */ +function home(): string { + return mkdtempSync(join(tmpdir(), "makit-home-")); +} + +// --------------------------------------------------------------------------- +// Resolution: the effective value AND its source, because the UI labels it. +// --------------------------------------------------------------------------- + +test("an override wins, and is reported as an override", () => { + const r = resolveWorktreeRoot({ worktreeRoot: "/h/custom" }, { MAKIT_WORKTREE_DIR: "/h/env" }, "/h"); + assert.deepEqual(r, { value: "/h/custom", source: "override" }); +}); + +test("with no override the env var is used, and named as the environment", () => { + // Named, because the app cannot change the daemon's env and must render it + // read-only rather than offering an edit that would silently fail. + const r = resolveWorktreeRoot({}, { MAKIT_WORKTREE_DIR: "/h/env" }, "/h"); + assert.deepEqual(r, { value: "/h/env", source: "environment" }); +}); + +test("with neither, the built-in default is used", () => { + const r = resolveWorktreeRoot(undefined, {}, "/h"); + assert.deepEqual(r, { value: join("/h", ".worktrees"), source: "default" }); + assert.equal(defaultWorktreeRoot("/h"), join("/h", ".worktrees")); +}); + +test("an exported-but-empty env var falls through to the default", () => { + // Honouring "" would create worktrees at the filesystem root. + const r = resolveWorktreeRoot({}, { MAKIT_WORKTREE_DIR: "" }, "/h"); + assert.equal(r.source, "default"); +}); + +test("an empty override string is not an override", () => { + const r = resolveWorktreeRoot({ worktreeRoot: "" }, {}, "/h"); + assert.equal(r.source, "default"); +}); + +test("provider resolves to auto when unset, and to an override when set", () => { + assert.deepEqual(resolveProvider(undefined), { value: "auto", source: "default" }); + assert.deepEqual(resolveProvider({ provider: "none" }), { value: "none", source: "override" }); +}); + +// --------------------------------------------------------------------------- +// Validation. Each case is chosen to reach a DIFFERENT rule, in the order the +// rules actually run — a case that trips an earlier rule proves nothing about a +// later one. +// --------------------------------------------------------------------------- + +test("a relative path is rejected by the absolute rule", () => { + const v = validateWorktreeRoot("work/trees", "/h"); + assert.equal(v.ok, false); + assert.match((v as { error: string }).error, /absolute/i); +}); + +test("an absolute path containing '..' is rejected ON SIGHT, not collapsed", () => { + // Collapsing would yield a valid path that is not the one the user typed. + const h = home(); + // Built by string concatenation, NOT `path.join`: join collapses `..` itself, so + // a joined path can never exercise this rule. + const v = validateWorktreeRoot(`${h}${sep}work${sep}..${sep}..${sep}etc`, h); + assert.equal(v.ok, false); + assert.match((v as { error: string }).error, /\.\./); +}); + +test("a not-yet-existing root is ACCEPTED and canonicalised via its ancestor", () => { + // The common case: the user names a directory before creating it. `realpath` + // fails outright on a missing path, so a naive rule would reject this. + const h = home(); + const v = validateWorktreeRoot(join(h, "work", "trees", "deep"), h); + assert.equal(v.ok, true); + // Compared against the REAL home: on macOS /var is a symlink to /private/var, so + // the canonicalised result legitimately differs from the input. Resolving that is + // the point of the rule, not a bug in it. + assert.equal( + (v as { value: string }).value, + join(realpathSync(h), "work", "trees", "deep"), + ); +}); + +test("an existing root is stored canonicalised", () => { + const h = home(); + mkdirSync(join(h, "trees")); + const v = validateWorktreeRoot(join(h, "trees"), h); + assert.equal(v.ok, true); + assert.ok((v as { value: string }).value.endsWith(`${sep}trees`)); +}); + +test("a symlink whose target escapes home is rejected", () => { + // Reached with a REAL symlink: a '..' string is rejected by the earlier rule and + // never gets here, so it cannot exercise canonicalisation. + const h = home(); + const outside = mkdtempSync(join(tmpdir(), "makit-outside-")); + symlinkSync(outside, join(h, "escape")); + const v = validateWorktreeRoot(join(h, "escape", "trees"), h); + assert.equal(v.ok, false); + assert.match((v as { error: string }).error, /home directory/i); +}); + +test("a path outside home is rejected even when it exists", () => { + const h = home(); + const v = validateWorktreeRoot(tmpdir(), h); + assert.equal(v.ok, false); +}); + +test("a file where a directory is required is rejected", () => { + const h = home(); + writeFileSync(join(h, "afile"), "x"); + const v = validateWorktreeRoot(join(h, "afile"), h); + assert.equal(v.ok, false); + assert.match((v as { error: string }).error, /not a directory/i); +}); + +test("empty input is rejected", () => { + assert.equal(validateWorktreeRoot(" ", "/h").ok, false); +}); + +test("home itself is allowed", () => { + const h = home(); + assert.equal(validateWorktreeRoot(h, h).ok, true); +}); + +// --------------------------------------------------------------------------- +// Provider + branch +// --------------------------------------------------------------------------- + +test("provider accepts exactly the five choices and nothing else", () => { + for (const p of ["auto", "none", "forgejo", "gitea", "github"]) { + assert.equal(validateProvider(p).ok, true, p); + } + for (const bad of ["gitlab", "", "GITHUB", 7, null, undefined]) { + assert.equal(validateProvider(bad).ok, false, String(bad)); + } +}); + +test("branch validation rejects what git itself would refuse", () => { + assert.equal(validateBranch("main").ok, true); + assert.equal(validateBranch("feat/thing-1").ok, true); + for (const bad of ["", " ", "a b", "a~b", "a^b", "a:b", "a?b", "a*b", "a[b", "a..b", "-lead", "x.lock"]) { + assert.equal(validateBranch(bad).ok, false, JSON.stringify(bad)); + } +}); + +// --------------------------------------------------------------------------- +// Defensive parse: a hand-edited file must never stop the daemon. +// --------------------------------------------------------------------------- + +test("a known key of the wrong type is dropped, not trusted", () => { + // `worktreeRoot: 42` must never reach path handling. + assert.deepEqual(parseRepoSettings({ worktreeRoot: 42 }), {}); + assert.deepEqual(parseRepoSettings({ provider: "gitlab" }), {}); + assert.deepEqual(parseRepoSettings({ defaultBranch: "a b" }), {}); + assert.deepEqual(parseRepoSettings({ logoHue: -1 }), {}); + assert.deepEqual(parseRepoSettings({ logoHue: 1.5 }), {}); +}); + +test("a non-object degrades to inherit-everything rather than throwing", () => { + for (const bad of [null, undefined, 7, "x", []]) { + assert.deepEqual(parseRepoSettings(bad), {}); + } +}); + +test("provider 'auto' is not stored — the default stays implicit", () => { + assert.deepEqual(parseRepoSettings({ provider: "auto" }), {}); +}); + +test("valid values survive the parse", () => { + assert.deepEqual( + parseRepoSettings({ + worktreeRoot: "/h/trees", + provider: "gitea", + defaultBranch: "develop", + logoHue: 3, + }), + { worktreeRoot: "/h/trees", provider: "gitea", defaultBranch: "develop", logoHue: 3 }, + ); +}); + +// --------------------------------------------------------------------------- +// SPEC-48 D4' — re-pointing a repository's root path. +// +// A different rule set from the worktree root, and the differences are the +// interesting part: +// +// - it must ALREADY EXIST. A worktree root is created on demand, so naming one +// before it exists is the common case; a repository you have not got is not a +// repository, and accepting the path would detach the project from its +// sessions with nothing to reattach to. +// - it is NOT confined to $HOME. That rule exists for the worktree root because +// the daemon creates and, via prune, REMOVES directories under it. makit never +// deletes a repo path, and a checkout on an external volume or a shared mount +// is ordinary — refusing it would be security theatre with a real cost. +// --------------------------------------------------------------------------- + +test("a relative repo path is refused — it would resolve against the daemon's cwd", () => { + const r = validateRepoPath("Work/Diana"); + assert.equal(r.ok, false); +}); + +test("a repo path containing '..' is refused on sight, not collapsed", () => { + // Same reasoning as the worktree root: collapsing yields a path the user did not + // type, and this one decides where every session's git operations run. + const r = validateRepoPath("/Users" + sep + "x" + sep + ".." + sep + "etc"); + assert.equal(r.ok, false); + assert.match(r.ok ? "" : r.error, /\.\./); +}); + +test("a repo path that does not exist is refused", () => { + const r = validateRepoPath(join(tmpdir(), "makit-definitely-not-here-4919")); + assert.equal(r.ok, false); +}); + +test("a file is refused — a repository is a directory", () => { + const dir = mkdtempSync(join(tmpdir(), "makit-rp-")); + const file = join(dir, "a-file"); + writeFileSync(file, "x"); + try { + assert.equal(validateRepoPath(file).ok, false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("an existing directory is accepted and canonicalised", () => { + // Canonicalised so `/tmp` and `/private/tmp` cannot become two projects for one + // directory — which would make settings lookup by path ambiguous. + const dir = mkdtempSync(join(tmpdir(), "makit-rp-")); + try { + const r = validateRepoPath(dir); + assert.equal(r.ok, true); + assert.equal(r.ok && r.value, realpathSync(dir)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("a repo path OUTSIDE $HOME is accepted — unlike the worktree root", () => { + // The asymmetry is deliberate, and asserted so it cannot be "tidied" into + // consistency later: nothing is ever deleted under a repo path. + const dir = mkdtempSync(join(tmpdir(), "makit-rp-")); + try { + assert.equal(validateRepoPath(dir).ok, true, "a repo on /tmp is legitimate"); + assert.equal(validateWorktreeRoot(dir).ok, false, "a worktree root there is not"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/server/src/repo_settings.ts b/server/src/repo_settings.ts new file mode 100644 index 00000000..d897f897 --- /dev/null +++ b/server/src/repo_settings.ts @@ -0,0 +1,338 @@ +/** + * repo_settings.ts — per-repo settings: the schema, how a value resolves, and + * what is allowed to be written. + * + * Three responsibilities, kept apart on purpose: + * + * 1. {@link RepoSettings} — the persisted shape. Every field optional, because + * **absent means "inherit"**, never "empty". A blank worktree root that + * silently means `~/.worktrees` is how worktrees end up somewhere the user + * did not expect. + * 2. {@link resolveWorktreeRoot} and friends — the effective value plus the + * SOURCE it came from, so the UI can label it rather than guess. + * 3. {@link validateWorktreeRoot} — what a client may store. + * + * The resolution chain is deliberately three levels, not four: + * `repo override → env var → built-in default`. There is no global settings store + * to inherit from, and inventing a level for one that does not exist would be a + * framework rather than a capability. + */ + +import { existsSync, realpathSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, join, normalize, sep } from "node:path"; + +/** Where an effective value came from. Rendered as a badge; never inferred by the app. */ +export type SettingSource = "override" | "environment" | "default"; + +/** One resolved setting: the value in force, and why. */ +export interface Resolved<T> { + value: T; + source: SettingSource; +} + +/** + * The provider a repo should use, or `auto` to believe detection. + * + * `none` is not the absence of a choice — it is the instruction "talk to no forge + * for this repository". A purely local repo and a mirror whose forge you do not + * care about both need it, and neither is served by `auto` failing. + */ +export type ProviderChoice = "auto" | "none" | "forgejo" | "gitea" | "github"; + +const PROVIDER_CHOICES: readonly ProviderChoice[] = [ + "auto", + "none", + "forgejo", + "gitea", + "github", +]; + +/** Persisted per-repo settings. Absent field = inherit; never a sentinel value. */ +export interface RepoSettings { + /** Absolute, canonicalised worktree root for this repo. */ + worktreeRoot?: string; + /** Provider override; `auto` is stored as absent so the default stays implicit. */ + provider?: Exclude<ProviderChoice, "auto">; + /** Default branch override, used when `origin/HEAD` is absent or wrong. */ + defaultBranch?: string; + /** Monogram hue index, chosen from a fixed palette (see the app's RepoMonogram). */ + logoHue?: number; +} + +/** + * How many hues a repo monogram can take, mirroring `RepoMonogram`'s palette in + * `app/lib/ui/home/repo_monogram.dart`. + * + * Enforced as a RANGE rather than trusted: `paletteAt` wraps with `%`, so a stored + * `6` would render as index 0 -- a colour the user never chose, indistinguishable + * from having chosen 0. Rejecting out-of-range keeps one hue per stored value. + */ +export const LOGO_HUE_COUNT = 6; + +/** Built-in worktree root when nothing else says otherwise. */ +export function defaultWorktreeRoot(home: string = homedir()): string { + return join(home, ".worktrees"); +} + +/** + * The worktree root in force for a repo, and where it came from. + * + * `MAKIT_WORKTREE_DIR` is a **level in the chain**, not a competitor: it already + * exists and must keep working, and because the app cannot change the daemon's + * environment it is reported as `environment` and rendered read-only. + * + * An empty-string env var resolves to the default rather than to `""` — an + * exported-but-blank variable is a mistake, and honouring it would create + * worktrees at the filesystem root. + */ +export function resolveWorktreeRoot( + settings: RepoSettings | undefined, + env: Record<string, string | undefined>, + home: string = homedir(), +): Resolved<string> { + const override = settings?.worktreeRoot; + if (override !== undefined && override.length > 0) { + return { value: override, source: "override" }; + } + const fromEnv = env.MAKIT_WORKTREE_DIR; + if (fromEnv !== undefined && fromEnv.length > 0) { + return { value: fromEnv, source: "environment" }; + } + return { value: defaultWorktreeRoot(home), source: "default" }; +} + +/** The provider choice in force. Absent = `auto`. */ +export function resolveProvider(settings: RepoSettings | undefined): Resolved<ProviderChoice> { + const p = settings?.provider; + return p === undefined ? { value: "auto", source: "default" } : { value: p, source: "override" }; +} + +/** A rejected write, with a reason the UI can show verbatim. */ +export interface Invalid { + ok: false; + error: string; +} +export interface Valid<T> { + ok: true; + value: T; +} +export type Validation<T> = Valid<T> | Invalid; + +/** + * Validate and canonicalise a worktree root. + * + * The order of the rules matters, and each exists for a different attack or + * mistake: + * + * 1. **Absolute only.** A relative root would resolve against the daemon's cwd, + * which is not a place the user can see or reason about. + * 2. **No `..` segment, rejected on sight** — not collapsed. Collapsing would + * silently produce a valid path that is not the one the user typed, which is + * exactly how a confused write becomes a surprising delete later (prune). + * 3. **Canonicalise through the nearest EXISTING ancestor.** A worktree root + * that does not exist yet is the common case — `~/work/worktrees` before it + * has been created — and `realpath` fails outright on a missing path, so a + * naive rule would reject the normal case. The remaining, not-yet-created + * segments are then required to be plain names. + * 4. **The resolved ancestor must be inside `$HOME`.** The daemon creates and, + * via prune, REMOVES directories under this root; a root outside the home + * directory turns a settings row into a filesystem weapon. + * + * Callers must re-validate on read-back before use: `projects.json` is plain JSON + * a user can edit by hand, so a write-time check alone is not a guarantee. + */ +export function validateWorktreeRoot( + raw: string, + home: string = homedir(), +): Validation<string> { + const input = raw.trim(); + if (input.length === 0) return { ok: false, error: "Worktree root cannot be empty." }; + if (!isAbsolute(input)) { + return { ok: false, error: "Worktree root must be an absolute path." }; + } + // Split the RAW input, not a normalised copy: `normalize` COLLAPSES `..`, so + // checking the normalised form makes this rule dead code and lets + // `/home/you/work/../../etc` through to be rejected later by the containment + // rule with a misleading message. Found by a test that could not fail until the + // test itself stopped using `path.join`, which collapses too. + if (input.split(sep).some((seg) => seg === "..")) { + return { + ok: false, + error: "Worktree root must not contain '..'. Give the path you mean, not a path relative to another.", + }; + } + + // Walk up to the nearest existing ancestor and canonicalise THAT, so a + // not-yet-created root is accepted while symlink escapes are still resolved. + let existing = normalize(input); + const trailing: string[] = []; + while (!existsSync(existing)) { + const parent = dirname(existing); + if (parent === existing) { + return { ok: false, error: `No part of ${input} exists, so it cannot be checked.` }; + } + trailing.unshift(existing.slice(parent.length + 1)); + existing = parent; + } + + let realAncestor: string; + try { + realAncestor = realpathSync(existing); + if (!statSync(realAncestor).isDirectory()) { + return { ok: false, error: `${existing} is not a directory.` }; + } + } catch { + return { ok: false, error: `Could not resolve ${existing}.` }; + } + + const realHome = (() => { + try { + return realpathSync(home); + } catch { + return home; + } + })(); + if (realAncestor !== realHome && !realAncestor.startsWith(realHome + sep)) { + return { + ok: false, + error: "Worktree root must be inside your home directory.", + }; + } + + return { ok: true, value: trailing.length === 0 ? realAncestor : join(realAncestor, ...trailing) }; +} + +/** + * Validate and canonicalise a repository's root path, for re-pointing a project + * that moved on disk (D4′). + * + * Shares two rules with {@link validateWorktreeRoot} — absolute only, and `..` + * rejected on sight rather than collapsed — and deliberately differs on two: + * + * - **It must already exist.** A worktree root is created on demand, so naming + * one before it exists is the normal case. A repository you have not got is + * not a repository: accepting the path would detach the project from its + * sessions with nothing to reattach to, which is the exact failure the P1 + * notice existed to avoid. + * - **It is NOT confined to `$HOME`.** That rule protects the worktree root + * because the daemon creates and, via prune, REMOVES directories beneath it. + * makit never deletes a repo path, and a checkout on an external volume or a + * shared mount is ordinary — refusing it would be security theatre with a real + * cost to real users. + * + * Canonicalised so `/tmp` and `/private/tmp` cannot become two projects for one + * directory: settings and the forge decision are both looked up BY PATH, so two + * spellings of one repo would silently disagree about its configuration. + * + * Being a git repository is NOT checked here — that needs a subprocess, and this + * stays synchronous and pure-ish so it can be unit-tested and reused. The caller + * checks it (see `SessionManager.repointProject`). + */ +export function validateRepoPath(raw: string): Validation<string> { + const input = raw.trim(); + if (input.length === 0) return { ok: false, error: "Repository path cannot be empty." }; + if (!isAbsolute(input)) { + return { ok: false, error: "Repository path must be an absolute path." }; + } + // Split the RAW input: `normalize` collapses `..`, which would make this dead + // code — the mistake this file has already made once. + if (input.split(sep).some((seg) => seg === "..")) { + return { + ok: false, + error: "Repository path must not contain '..'. Give the path you mean.", + }; + } + let real: string; + try { + real = realpathSync(normalize(input)); + } catch { + // Not "does not exist": `realpath` also fails on a permission error or an I/O + // error, and telling someone their present-but-unreadable directory is missing + // sends them to create a path that is already there. + return { ok: false, error: `Could not resolve ${input}. Check it exists and is readable.` }; + } + try { + if (!statSync(real).isDirectory()) { + // Reports the CANONICAL path, which is the thing actually inspected: with + // `/tmp` symlinked to `/private/tmp`, echoing the input describes a different + // place from the one that failed. + return { ok: false, error: `${real} is not a directory.` }; + } + } catch { + return { ok: false, error: `Could not inspect ${real}.` }; + } + return { ok: true, value: real }; +} + +/** Validate a provider choice coming off the wire. */ +export function validateProvider(raw: unknown): Validation<ProviderChoice> { + if (typeof raw !== "string" || !PROVIDER_CHOICES.includes(raw as ProviderChoice)) { + return { ok: false, error: `Unknown provider '${String(raw)}'.` }; + } + return { ok: true, value: raw as ProviderChoice }; +} + +/** + * Validate a default-branch override. + * + * Rejects the characters git itself refuses in a ref name, so a typo cannot be + * stored and then fail deep inside a `git` invocation where the message is + * unrecognisable. + */ +export function validateBranch(raw: string): Validation<string> { + const b = raw.trim(); + if (b.length === 0) return { ok: false, error: "Branch name cannot be empty." }; + const bad = (): Invalid => ({ ok: false, error: `'${b}' is not a valid branch name.` }); + // The rules `git check-ref-format --branch` applies, in the same spirit: a name + // stored here is later handed to git as an argv element, and one git refuses fails + // deep inside a plumbing call where the message is unrecognisable. Shell + // metacharacters need no special handling -- every call goes through `execFile` + // with an argv array, never a shell string. + if (/[\s~^:?*[\\]/.test(b)) return bad(); + // ASCII control characters and DEL. + // eslint-disable-next-line no-control-regex + if (/[\u0000-\u001f\u007f]/.test(b)) return bad(); + if (b.includes("..") || b.includes("@{")) return bad(); + if (b.startsWith("-") || b.startsWith("/") || b.startsWith(".")) return bad(); + if (b.endsWith("/") || b.endsWith(".") || b.endsWith(".lock")) return bad(); + if (b.includes("//")) return bad(); + // No path component may begin with `.` or end with `.lock` -- `feat/.x` is refused + // by git even though the whole string does not start with a dot. + if (b.split("/").some((seg) => seg.length === 0 || seg.startsWith(".") || seg.endsWith(".lock"))) { + return bad(); + } + return { ok: true, value: b }; +} + +/** + * Parse a persisted `settings` object defensively. + * + * Unknown keys are **preserved** by the caller (see `project-store`), but a KNOWN + * key of the wrong type is dropped rather than trusted: a hand-edited + * `worktreeRoot: 42` must not reach path handling. Never throws — a bad settings + * object degrades that one repo to "inherit everything". + */ +export function parseRepoSettings(raw: unknown): RepoSettings { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {}; + const r = raw as Record<string, unknown>; + const out: RepoSettings = {}; + if (typeof r.worktreeRoot === "string" && r.worktreeRoot.length > 0) { + out.worktreeRoot = r.worktreeRoot; + } + const provider = validateProvider(r.provider); + if (provider.ok && provider.value !== "auto") out.provider = provider.value; + if (typeof r.defaultBranch === "string") { + const b = validateBranch(r.defaultBranch); + if (b.ok) out.defaultBranch = b.value; + } + if ( + typeof r.logoHue === "number" && + Number.isInteger(r.logoHue) && + r.logoHue >= 0 && + r.logoHue < LOGO_HUE_COUNT + ) { + out.logoHue = r.logoHue; + } + return out; +} diff --git a/server/src/repo_settings_wiring.test.ts b/server/src/repo_settings_wiring.test.ts new file mode 100644 index 00000000..67c62764 --- /dev/null +++ b/server/src/repo_settings_wiring.test.ts @@ -0,0 +1,694 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { tmpdir, homedir } from "node:os"; +import { join } from "node:path"; + +import { SessionManager } from "./manager.js"; +import { createForgeRouter } from "./forge/router.js"; +import { createNoForgeGateway } from "./forge/none.js"; +import { resolveWorktreeRoot, validateWorktreeRoot } from "./repo_settings.js"; + +/** + * The behaviour the feature exists for: two repos, different roots, resolved from + * persisted settings — and the invalid-value path, which must degrade rather than + * break worktree creation. + * + * Exercised through `SessionManager.worktreeRootFor`, the ONE place all three + * consumers (`addWorktree`, `addWorktreeForPr`, `uniqueWorktreeDir`) now read + * from. Before this, only the env var was consulted and every repo shared a root. + */ +function manager(projects: Array<{ id: string; path: string; settings?: Record<string, unknown> }>) { + return new SessionManager({ + adapterFactory: (() => { + throw new Error("not used"); + }) as never, + onProjectsChanged: () => {}, + defaultModel: "m", + store: undefined as never, + capabilityCache: undefined as never, + projects, + gateway: { + prForBranch: async () => ({ kind: "none" }) as never, + openPrs: async () => [], + mutatePr: async () => ({ ok: true }), + budget: () => ({}) as never, + history: () => [], + refresh: async () => ({}) as never, + setPaused: () => {}, + onBudgetChange: () => () => {}, + close: () => {}, + stats: () => ({ execs: 0, exemptExecs: 0, cacheHits: 0 }), + } as never, + }); +} + +/** As {@link manager}, but with a gateway that also answers forge inspection. */ +function managerWithInspector( + projects: Array<{ id: string; path: string; settings?: Record<string, unknown> }>, + inspector: { + forgeFor: (p: string) => unknown; + hasRemoteFor: (p: string) => boolean | undefined; + }, +) { + const m = manager(projects); + Object.assign((m as unknown as { _gateway: object })._gateway, inspector); + return m; +} + +test("two repos with different overrides get different worktree roots", () => { + const home = homedir(); + const a = mkdtempSync(join(tmpdir(), "makit-a-")); + const b = mkdtempSync(join(tmpdir(), "makit-b-")); + const rootA = join(home, ".makit-test-trees-a"); + const m = manager([ + { id: "a", path: a, settings: { worktreeRoot: rootA } }, + { id: "b", path: b }, + ]); + assert.equal(m.worktreeRootFor(a), rootA, "A follows its override"); + assert.equal( + m.worktreeRootFor(b), + resolveWorktreeRoot(undefined, process.env).value, + "B inherits", + ); +}); + +test("an override that is no longer valid degrades to the inherited root", () => { + // `projects.json` is hand-editable, so a stored value can go bad after the write + // check passed. Worktree creation must still work. + const a = mkdtempSync(join(tmpdir(), "makit-c-")); + const m = manager([{ id: "a", path: a, settings: { worktreeRoot: "/etc/nope" } }]); + assert.equal(m.worktreeRootFor(a), resolveWorktreeRoot(undefined, process.env).value); +}); + +test("a repo makit does not know inherits rather than throwing", () => { + const m = manager([]); + assert.equal( + m.worktreeRootFor("/tmp/not-a-project"), + resolveWorktreeRoot(undefined, process.env).value, + ); +}); + +test("the stored root is used verbatim once validated, symlinks resolved", () => { + // The home directory is required, not incidental: `validateWorktreeRoot` refuses a + // root outside $HOME. `mkdtempSync` gives it a unique name so parallel runs cannot + // collide, and the `finally` removes it -- this used to leave a directory behind in + // the developer's home on every run. + const real = mkdtempSync(join(homedir(), ".makit-test-trees-real-")); + // Created before the `try` so the `finally` always owns it: removing it on the last + // line of the body leaked the directory whenever an assertion above it failed. + const a = mkdtempSync(join(tmpdir(), "makit-d-")); + try { + const checked = validateWorktreeRoot(real); + assert.equal(checked.ok, true); + const m = manager([{ id: "a", path: a, settings: { worktreeRoot: real } }]); + assert.equal(m.worktreeRootFor(a), (checked as { value: string }).value); + } finally { + rmSync(a, { recursive: true, force: true }); + rmSync(real, { recursive: true, force: true }); + } +}); + +test("collision detection looks in the repo's OWN root, not the global one", () => { + // Reviewer finding R5: if `uniqueWorktreeDir` consulted the inherited root while + // `addWorktree` wrote to the override, the two would disagree and a real + // collision would slip through. Proven by making a name collide in the + // OVERRIDDEN root only. + const repo = mkdtempSync(join(tmpdir(), "makit-coll-")); + const repoName = repo.split("/").pop()!; + // Unique per run and removed afterwards: a fixed name accumulated one + // `<repoName>` subdirectory in the developer's home on every single run. + const overrideRoot = mkdtempSync(join(homedir(), ".makit-test-trees-collide-")); + try { + mkdirSync(join(overrideRoot, repoName, "feat-x"), { recursive: true }); + + const overridden = manager([ + { id: "a", path: repo, settings: { worktreeRoot: overrideRoot } }, + ]); + assert.notEqual( + overridden.uniqueWorktreeDir(repo, "feat-x"), + "feat-x", + "the existing dir under the OVERRIDE must be seen", + ); + + const inherited = manager([{ id: "a", path: repo }]); + assert.equal( + inherited.uniqueWorktreeDir(repo, "feat-x"), + "feat-x", + "the same name is free under the inherited root", + ); + } finally { + rmSync(repo, { recursive: true, force: true }); + rmSync(overrideRoot, { recursive: true, force: true }); + } +}); + +// --------------------------------------------------------------------------- +// P2 — the provider choice reaches the router, and the DTO stops conflating +// "no remote" with "not measured yet". +// --------------------------------------------------------------------------- + +test("the manager reports a repo's stored provider choice, so routing can honour it", () => { + // The router asks this at routing time (not at construction), which is what lets + // a changed setting take effect without restarting the daemon. + const a = mkdtempSync(join(tmpdir(), "makit-p1-")); + const b = mkdtempSync(join(tmpdir(), "makit-p2-")); + const m = manager([ + { id: "a", path: a, settings: { provider: "forgejo" } }, + { id: "b", path: b }, + ]); + assert.equal(m.providerFor(a), "forgejo"); + assert.equal(m.providerFor(b), "auto", "no override means believe detection"); +}); + +test("a repo makit does not know reports auto rather than throwing", () => { + const m = manager([]); + assert.equal(m.providerFor("/nowhere"), "auto"); +}); + +test("an un-routed repo reports hasRemote true — not measured is not 'no remote'", async () => { + // The bug this pins: hasRemote was `forge !== undefined`, so a repo the router had + // not reached yet claimed to have no origin. That made the app's "not identified + // yet" wording unreachable and sent the reader looking for a missing remote that + // was never missing. + const a = mkdtempSync(join(tmpdir(), "makit-hr1-")); + const m = manager([{ id: "a", path: a }]); + const [repo] = await m.listRepos({ includePrs: false }); + assert.equal(repo.settings?.hasRemote, true); + assert.equal(repo.settings?.forge, undefined, "and the forge is still absent"); +}); + +test("a routed repo with no readable origin reports hasRemote false", async () => { + const a = mkdtempSync(join(tmpdir(), "makit-hr2-")); + const m = managerWithInspector([{ id: "a", path: a }], { + forgeFor: () => undefined, + hasRemoteFor: () => false, + }); + const [repo] = await m.listRepos({ includePrs: false }); + assert.equal(repo.settings?.hasRemote, false); +}); + +test("a routed repo with a forge reports hasRemote true and the forge", async () => { + const a = mkdtempSync(join(tmpdir(), "makit-hr3-")); + const forge = { software: "forgejo" as const, host: "git.example", authed: true, source: "override" as const }; + const m = managerWithInspector([{ id: "a", path: a }], { + forgeFor: () => forge, + hasRemoteFor: () => true, + }); + const [repo] = await m.listRepos({ includePrs: false }); + assert.equal(repo.settings?.hasRemote, true); + assert.deepEqual(repo.settings?.forge, forge); +}); + +// --------------------------------------------------------------------------- +// SPEC-48 — the default-branch override reaches all THREE consumers. +// +// The same shape as the worktree-root fix (T2/R5): a resolver is not a feature +// until every consumer reads from it. `detectDefaultBranch` was called directly in +// three places, so the stored override affected none of them — the diff numbers, +// the base a new worktree branches from, and the branch wrap-up syncs. +// --------------------------------------------------------------------------- + +/** + * A real repo with one commit on `main`, plus each of [extra] as a branch carrying + * its OWN extra commit. + * + * The divergent commit is load-bearing, not decoration: a branch created from + * `main` without one has the same tip, so `merge-base` cannot tell which of the two + * a worktree forked from and the assertion would pass no matter what the production + * code chose. + */ +function repoWithBranches(extra: string[]): string { + const dir = mkdtempSync(join(tmpdir(), "makit-db-")); + const g = (...args: string[]) => execFileSync("git", args, { cwd: dir }); + g("init", "-q", "-b", "main"); + g("config", "user.email", "t@t.io"); + g("config", "user.name", "Test"); + writeFileSync(join(dir, "README.md"), "hi\n"); + g("add", "."); + g("commit", "-q", "-m", "initial"); + for (const b of extra) { + g("checkout", "-q", "-b", b); + writeFileSync(join(dir, `${b}.txt`), `${b}\n`); + g("add", "."); + g("commit", "-q", "-m", `on ${b}`); + g("checkout", "-q", "main"); + } + return dir; +} + +test("the manager resolves a repo's default branch through the override", async () => { + const a = repoWithBranches(["trunk"]); + try { + const m = manager([{ id: "a", path: a, settings: { defaultBranch: "trunk" } }]); + assert.equal(await m.defaultBranchFor(a), "trunk"); + } finally { + rmSync(a, { recursive: true, force: true }); + } +}); + +test("without an override the manager falls back to git's answer", async () => { + const a = repoWithBranches([]); + try { + const m = manager([{ id: "a", path: a }]); + assert.equal(await m.defaultBranchFor(a), "main"); + } finally { + rmSync(a, { recursive: true, force: true }); + } +}); + +test("the repos snapshot reports the overridden default branch, so the diff base follows", async () => { + // `RepoDTO.defaultBranch` is what `diffStat` and `commitsAhead` measure against + // (repo_service.ts). If the snapshot ignores the override, every +/- number in the + // UI is measured from the wrong base while the Settings row claims otherwise. + const a = repoWithBranches(["trunk"]); + try { + const m = manager([{ id: "a", path: a, settings: { defaultBranch: "trunk" } }]); + const [repo] = await m.listRepos({ includePrs: false }); + assert.equal(repo.defaultBranch, "trunk"); + } finally { + rmSync(a, { recursive: true, force: true }); + } +}); + +test("a new worktree branches from the overridden default", async () => { + // The base a session's branch forks from. Getting this wrong means the PR is + // opened against the wrong base and shows unrelated commits. + const a = repoWithBranches(["trunk"]); + const root = mkdtempSync(join(homedir(), ".makit-test-db-")); + try { + const m = manager([{ id: "a", path: a, settings: { defaultBranch: "trunk", worktreeRoot: root } }]); + const { path: wt } = await m.createWorktree("a", undefined, "from-trunk"); + const mergeBase = execFileSync("git", ["merge-base", "HEAD", "trunk"], { cwd: wt }) + .toString() + .trim(); + const trunkTip = execFileSync("git", ["rev-parse", "trunk"], { cwd: a }).toString().trim(); + assert.equal(mergeBase, trunkTip, "the new branch forked from trunk, not main"); + } finally { + rmSync(a, { recursive: true, force: true }); + rmSync(root, { recursive: true, force: true }); + } +}); + +// --------------------------------------------------------------------------- +// SPEC-48 D4' — re-pointing a project that moved on disk. +// +// Why this is not "remove and re-add": that mints a new `PersistedProject.id`, and +// everything keyed to it — per-repo settings, session history — is lost. Preserving +// the id across a move is the entire reason the id exists. +// --------------------------------------------------------------------------- + +test("re-pointing keeps the project id and its settings", async () => { + // The whole justification. If the settings do not survive, the user has done + // remove-and-re-add by a longer route. + const from = repoWithBranches([]); + const to = repoWithBranches([]); + try { + const m = manager([{ id: "a", path: from, settings: { logoHue: 4 } }]); + const r = await m.repointProject("a", to); + assert.equal(r.ok, true); + const [dto] = m.listProjects(); + assert.equal(dto.id, "a", "the id is preserved"); + assert.equal(dto.path, realpathSync(to)); + assert.deepEqual(m.projectSettings("a"), { logoHue: 4 }); + } finally { + rmSync(from, { recursive: true, force: true }); + rmSync(to, { recursive: true, force: true }); + } +}); + +test("re-pointing at something that is not a git repo is refused", async () => { + // The constraint D4' names explicitly: re-validate that the target is a git repo, + // because a project silently pointed at a plain directory has no branches, no + // forge and no diff — and looks merely broken rather than misconfigured. + const from = repoWithBranches([]); + const plain = mkdtempSync(join(tmpdir(), "makit-plain-")); + try { + const m = manager([{ id: "a", path: from }]); + const r = await m.repointProject("a", plain); + assert.equal(r.ok, false); + assert.match(r.ok ? "" : r.error, /git/i); + assert.equal(m.listProjects()[0].path, from, "and the project is untouched"); + } finally { + rmSync(from, { recursive: true, force: true }); + rmSync(plain, { recursive: true, force: true }); + } +}); + +test("re-pointing onto another project's path is refused", async () => { + // Two projects at one path makes settings and the forge decision — both looked up + // BY PATH — ambiguous, so one repo would silently answer for the other. + const a = repoWithBranches([]); + const b = repoWithBranches([]); + try { + const m = manager([ + { id: "a", path: a }, + { id: "b", path: b }, + ]); + const r = await m.repointProject("a", b); + assert.equal(r.ok, false); + assert.match(r.ok ? "" : r.error, /already/i); + assert.equal(m.listProjects()[0].path, a); + } finally { + rmSync(a, { recursive: true, force: true }); + rmSync(b, { recursive: true, force: true }); + } +}); + +test("re-pointing a project at its own path is a no-op, not a duplicate error", async () => { + // Re-submitting the same value must not read as a conflict with itself. + const a = repoWithBranches([]); + try { + const m = manager([{ id: "a", path: a }]); + assert.equal((await m.repointProject("a", a)).ok, true); + } finally { + rmSync(a, { recursive: true, force: true }); + } +}); + +test("re-pointing an unknown project is refused rather than creating one", async () => { + const a = repoWithBranches([]); + try { + const m = manager([]); + const r = await m.repointProject("ghost", a); + assert.equal(r.ok, false); + assert.equal(m.listProjects().length, 0); + } finally { + rmSync(a, { recursive: true, force: true }); + } +}); + +test("a new path given via a symlink is stored canonicalised", async () => { + // `settingsForPath` and the router's decision map are both keyed by path, so an + // uncanonicalised value would mean the repo's own settings stop resolving for it. + // The symlink must point at a DIFFERENT directory: aliasing the project's own path + // is the no-op case and would prove nothing about the stored value. + const from = repoWithBranches([]); + const to = repoWithBranches([]); + const link = join(mkdtempSync(join(tmpdir(), "makit-link-")), "alias"); + symlinkSync(to, link); + try { + const m = manager([{ id: "a", path: from, settings: { provider: "gitea" } }]); + assert.equal((await m.repointProject("a", link)).ok, true); + const stored = m.listProjects()[0].path; + assert.equal(stored, realpathSync(to), "stored resolved, not as the alias"); + assert.equal(m.providerFor(stored), "gitea", "and its settings still resolve"); + } finally { + rmSync(from, { recursive: true, force: true }); + rmSync(to, { recursive: true, force: true }); + } +}); + +test("re-pointing at an equivalent spelling of the same directory changes nothing", async () => { + // `/tmp/x` and `/private/tmp/x` are one directory on macOS. Treating that as a + // move would re-run detection for no reason and report a path the store does not + // hold, so the no-op reports what is actually in force. + const a = repoWithBranches([]); + const alias = join(mkdtempSync(join(tmpdir(), "makit-alias-")), "same"); + symlinkSync(a, alias); + try { + const m = manager([{ id: "a", path: a }]); + const r = await m.repointProject("a", alias); + assert.equal(r.ok, true); + assert.equal(r.ok && r.path, m.listProjects()[0].path, "reports the path in force"); + } finally { + rmSync(a, { recursive: true, force: true }); + } +}); + +test("re-pointing re-runs detection rather than keeping the old forge decision", async () => { + // D4' names this: the forge and the default branch may both change with the move, + // so a cached decision for the OLD path must not be what the UI reports. + const from = repoWithBranches([]); + const to = repoWithBranches([]); + const forgotten: string[] = []; + try { + const m = manager([{ id: "a", path: from }]); + Object.assign((m as unknown as { _gateway: object })._gateway, { + forgetRepo: (p: string) => forgotten.push(p), + }); + assert.equal((await m.repointProject("a", to)).ok, true); + assert.deepEqual(forgotten, [from], 'keyed on the path the gateway was called with'); + } finally { + rmSync(from, { recursive: true, force: true }); + rmSync(to, { recursive: true, force: true }); + } +}); + +// --------------------------------------------------------------------------- +// SPEC-48 — "New worktree from PR" has to work on BOTH providers. +// +// Listing already routed through the gateway, so the picker showed Forgejo PRs +// correctly. The CHECKOUT did not: it ran `gh pr checkout` unconditionally, which +// speaks only to GitHub. The flow was therefore broken exactly halfway — the user +// saw their PRs, picked one, and the worktree never appeared. +// +// The strategy is chosen from the router's own decision, so it agrees with whichever +// provider actually served the list. +// --------------------------------------------------------------------------- + +test("a GitHub repo checks out via gh, preserving today's behaviour", async () => { + const a = repoWithBranches([]); + try { + const m = managerWithInspector([{ id: "a", path: a }], { + forgeFor: () => ({ software: "github", host: "github.com", source: "detected" }), + hasRemoteFor: () => true, + }); + assert.equal(m.prCheckoutStrategyFor(a), "gh"); + } finally { + rmSync(a, { recursive: true, force: true }); + } +}); + +test("a Forgejo repo checks out via the pull ref, because gh cannot reach it", async () => { + const a = repoWithBranches([]); + try { + const m = managerWithInspector([{ id: "a", path: a }], { + forgeFor: () => ({ software: "forgejo", host: "git.example", authed: true, source: "detected" }), + hasRemoteFor: () => true, + }); + assert.equal(m.prCheckoutStrategyFor(a), "pull-ref"); + } finally { + rmSync(a, { recursive: true, force: true }); + } +}); + +test("a Gitea repo does the same — one REST API, one checkout path", async () => { + const a = repoWithBranches([]); + try { + const m = managerWithInspector([{ id: "a", path: a }], { + forgeFor: () => ({ software: "gitea", host: "git.example", authed: true, source: "detected" }), + hasRemoteFor: () => true, + }); + assert.equal(m.prCheckoutStrategyFor(a), "pull-ref"); + } finally { + rmSync(a, { recursive: true, force: true }); + } +}); + +test("an OVERRIDE to Forgejo also changes how the PR is checked out", async () => { + // The override's whole purpose is a repo detection could not identify. If it moved + // the listing but not the checkout, "New worktree from PR" would still fail for + // exactly the repos the override exists to rescue. + const a = repoWithBranches([]); + try { + const m = managerWithInspector([{ id: "a", path: a, settings: { provider: "forgejo" } }], { + // Detection reports the unidentifiable case; the override is what decides. + forgeFor: () => ({ software: "forgejo", host: "priv.example", authed: true, source: "override" }), + hasRemoteFor: () => true, + }); + assert.equal(m.prCheckoutStrategyFor(a), "pull-ref"); + } finally { + rmSync(a, { recursive: true, force: true }); + } +}); + +test("an unrouted or unreadable repo falls back to gh, the status quo", async () => { + // Same rule the router itself follows for an unreadable remote: don't change where + // such a repo fails. + const a = repoWithBranches([]); + try { + const m = manager([{ id: "a", path: a }]); + assert.equal(m.prCheckoutStrategyFor(a), "gh"); + } finally { + rmSync(a, { recursive: true, force: true }); + } +}); + +test("the provider override wins over a stale detection record", async () => { + // Belt and braces: the strategy is read from the same override the router honours, + // so it cannot disagree with the gateway that served the list even if the decision + // record is behind. + const a = repoWithBranches([]); + try { + const m = managerWithInspector([{ id: "a", path: a, settings: { provider: "github" } }], { + forgeFor: () => ({ software: "forgejo", host: "old.example", authed: true, source: "detected" }), + hasRemoteFor: () => true, + }); + assert.equal(m.prCheckoutStrategyFor(a), "gh", "the user said GitHub"); + } finally { + rmSync(a, { recursive: true, force: true }); + } +}); + +test("the picker itself routes per repo: Forgejo and GitHub side by side", async () => { + // The end of the chain the user actually touches: `manager.listOpenPrs` is what the + // `worktree.prs` command calls. Two repos in ONE manager, so this cannot pass by a + // global default -- which is the way a routing bug usually hides. + const served: string[] = []; + const fj = repoWithBranches([]); + const gh = repoWithBranches([]); + const stub = (name: string) => ({ + prForBranch: async () => ({ kind: "none" }) as never, + openPrs: async () => { + served.push(name); + return []; + }, + mutatePr: async () => ({ ok: true }), + stats: () => ({ execs: 0, exemptExecs: 0, cacheHits: 0 }), + close: () => {}, + }); + try { + const router = createForgeRouter({ + github: { + ...stub("github"), + budget: () => ({}) as never, + history: () => [], + refresh: async () => ({}) as never, + setPaused: () => {}, + onBudgetChange: () => () => {}, + } as never, + forgejo: stub("forgejo") as never, + unsupported: stub("unsupported") as never, + none: stub("none") as never, + // Wired to the manager below, exactly as production wires it. + providerFor: (p) => m.providerFor(p), + resolveInstance: async (p) => ({ + host: p === gh ? "github.com" : "git.example", + baseUrl: "https://git.example", + }), + detect: async () => "unknown" as never, + }); + const m = manager([ + { id: "fj", path: fj, settings: { provider: "forgejo" } }, + { id: "gh", path: gh }, + ]); + Object.assign((m as unknown as { _gateway: object })._gateway, router); + + await m.listOpenPrs("fj"); + await m.listOpenPrs("gh"); + assert.deepEqual(served, ["forgejo", "github"], "each repo's PRs came from its own provider"); + } finally { + rmSync(fj, { recursive: true, force: true }); + rmSync(gh, { recursive: true, force: true }); + } +}); + +test("a repo set to None offers no PRs, so the picker cannot reach a checkout", async () => { + // The checkout strategy for `none` is moot only because the list is empty. Asserted + // rather than assumed: if None ever returned PRs, the user could pick one and the + // checkout would run against a forge they told makit to ignore. + const a = repoWithBranches([]); + try { + const router = createForgeRouter({ + github: { + prForBranch: async () => ({ kind: "none" }) as never, + openPrs: async () => [{ number: 1, title: "t", headRefName: "h", isDraft: false, url: "u" }], + mutatePr: async () => ({ ok: true }), + stats: () => ({ execs: 0, exemptExecs: 0, cacheHits: 0 }), + close: () => {}, + budget: () => ({}) as never, + history: () => [], + refresh: async () => ({}) as never, + setPaused: () => {}, + onBudgetChange: () => () => {}, + } as never, + forgejo: {} as never, + unsupported: {} as never, + none: createNoForgeGateway(), + providerFor: (p) => m.providerFor(p), + resolveInstance: async () => ({ host: "github.com", baseUrl: "https://github.com" }), + detect: async () => "unknown" as never, + }); + const m = manager([{ id: "a", path: a, settings: { provider: "none" } }]); + Object.assign((m as unknown as { _gateway: object })._gateway, router); + assert.deepEqual(await m.listOpenPrs("a"), []); + } finally { + rmSync(a, { recursive: true, force: true }); + } +}); + +// --------------------------------------------------------------------------- +// Review findings on the P2 work itself. +// --------------------------------------------------------------------------- + +test("addProject will not open one directory twice under two spellings", async () => { + // Found while reviewing `repointProject`: that method refuses a duplicate path + // using a CANONICAL comparison, but `addProject` compared with `resolve` only, + // which does not follow symlinks. So the state repointProject carefully forbids — + // two projects at one directory, where settings and the forge decision are both + // looked up BY PATH and therefore answer for each other — was still reachable by + // the ordinary "add a project" route, and the two entry points disagreed about + // what counts as the same repo. + const real = repoWithBranches([]); + const link = join(mkdtempSync(join(tmpdir(), "makit-addlink-")), "alias"); + symlinkSync(real, link); + try { + const m = manager([]); + const first = m.addProject(real); + const second = m.addProject(link); + assert.equal(second.id, first.id, "the same directory is the same project"); + assert.equal(m.listProjects().length, 1); + } finally { + rmSync(real, { recursive: true, force: true }); + } +}); + +test("a project reached by its canonical path still resolves its own settings", async () => { + // Review finding: `addProject` stores the RESOLVED (not canonicalised) spelling, + // while `settingsForPath` compared with `resolve` on both sides. A project added via + // a symlinked path therefore failed lookup when a caller supplied the canonical + // path, and `worktreeRootFor`, `providerFor` and `defaultBranchFor` all silently + // fell back to the defaults -- the override present on disk and shown in the UI, + // doing nothing. + const real = repoWithBranches([]); + // The parent is retained so it can be removed too: only the symlink inside it was + // being cleaned up, leaving one empty temp directory per run. + const linkParent = mkdtempSync(join(tmpdir(), "makit-slink-")); + const link = join(linkParent, "alias"); + symlinkSync(real, link); + try { + const m = manager([{ id: "a", path: link, settings: { provider: "gitea" } }]); + assert.equal(m.providerFor(link), "gitea", "found by the stored spelling"); + assert.equal( + m.providerFor(realpathSync(real)), + "gitea", + "and by the canonical one, which is what git hands back", + ); + } finally { + rmSync(linkParent, { recursive: true, force: true }); + rmSync(real, { recursive: true, force: true }); + } +}); + +test("an invalid override falls back WITHOUT mislabelling an environment root", async () => { + // Review finding: the fallback re-resolved correctly and then overwrote the source + // with "default". `SettingSourceDTO` exists so the app labels the origin rather than + // guessing, so with MAKIT_WORKTREE_DIR set the badge stated the wrong one. + const a = repoWithBranches([]); + const envRoot = mkdtempSync(join(homedir(), ".makit-test-env-")); + const prev = process.env.MAKIT_WORKTREE_DIR; + process.env.MAKIT_WORKTREE_DIR = envRoot; + try { + const m = manager([{ id: "a", path: a, settings: { worktreeRoot: "/etc/nope" } }]); + const [repo] = await m.listRepos({ includePrs: false }); + assert.equal(repo.settings?.worktreeRoot.value, envRoot); + assert.equal(repo.settings?.worktreeRoot.source, "environment"); + } finally { + if (prev === undefined) delete process.env.MAKIT_WORKTREE_DIR; + else process.env.MAKIT_WORKTREE_DIR = prev; + rmSync(a, { recursive: true, force: true }); + rmSync(envRoot, { recursive: true, force: true }); + } +}); diff --git a/server/src/server.ts b/server/src/server.ts index 894e6850..3d506455 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -64,6 +64,7 @@ import { register as registerSessionCommands } from "./ws/commands/session.js"; import { register as registerProjectCommands } from "./ws/commands/project.js"; import { register as registerWorktreeCommands } from "./ws/commands/worktree.js"; import { register as registerRepoCommands } from "./ws/commands/repo.js"; +import { register as registerRepoSettingsCommands } from "./ws/commands/repo_settings.js"; import { register as registerGithubCommands } from "./ws/commands/github.js"; import { register as registerMetricsCommands } from "./ws/commands/metrics.js"; import { register as registerDebugCommands } from "./ws/commands/debug.js"; @@ -73,7 +74,7 @@ import { watchWorktrees } from "./worktree_watcher.js"; import { watchPrs } from "./pr_watcher.js"; import { watchBudget } from "./github/budget_watch.js"; import { fetchOpenPr } from "./git.js"; -import { decide } from "./github/policy.js"; +import { forgePollIntervalMs } from "./forge/cadence.js"; import { attachMediaRoute } from "./media/route.js"; import { sharedMediaStore } from "./media/store.js"; import { @@ -357,7 +358,7 @@ export function startWsServer(opts: ServerOpts) { // quota burn (≥2N calls every 5s); feeding the policy's pollIntervalMs lets // the 5s→30s→120s→paused ladder actually take effect. `Infinity` (paused) // stops polling rather than busy-looping. - intervalMs: () => decide(gateway.budget()).pollIntervalMs, + intervalMs: () => forgePollIntervalMs(gateway), }); https.on("close", () => prWatcher.close()); @@ -783,6 +784,14 @@ export function startWsServer(opts: ServerOpts) { stopForward: (grantId: string, deviceId?: string) => void forwardGrants.stop(grantId, ownerOf(deviceId)), rescanPorts: () => void portsService.rescanNow(), + // A per-repo settings write or a re-point changed the projects. BOTH snapshots + // go out: `repos.snapshot` carries the settings, and `projects.snapshot` + // carries `path`/`name`, which a re-point also changes -- sending only the + // first left every client showing the old location. + onProjectsChanged: () => { + broadcastSnapshots(); + void broadcastReposSnapshot(); + }, }, registry, ); @@ -929,7 +938,7 @@ export function startWsServer(opts: ServerOpts) { // -------- command handlers (OCP registry) ------------------------------- // (registration is delegated to the module-level `buildCommandRouter` so the - // capability-map completeness test can build the real router — see below.) + // capability-map completeness test can build the real router -- see below.) // -------- session fan-out + snapshots ----------------------------------- @@ -1128,6 +1137,7 @@ export function buildCommandRouter( registerProjectCommands(r, deps); registerWorktreeCommands(r, deps); registerRepoCommands(r, deps); + registerRepoSettingsCommands(r, deps); registerGithubCommands(r, deps); registerMetricsCommands(r, deps); registerPortsCommands(r, deps); diff --git a/server/src/ws/commands/deps.ts b/server/src/ws/commands/deps.ts index ac747e71..cfb607b5 100644 --- a/server/src/ws/commands/deps.ts +++ b/server/src/ws/commands/deps.ts @@ -35,6 +35,13 @@ export interface CommandDeps { readonly budgetWatch: BudgetWatch<WsClient>; /** Re-send the projects + sessions snapshots to every authed client. */ broadcastSnapshots(): void; + /** + * Called when per-repo settings change, so every client re-renders from one + * source. REQUIRED, for the same reason `onPortsWatchersChanged` is: a router built + * without it acks a settings write and then no client re-renders, which is + * indistinguishable from the write being lost. `server.ts` always supplies it. + */ + onProjectsChanged(): void; /** Recompute + broadcast the repo-centric snapshot (git-only then PR-enriched). */ broadcastReposSnapshot(): Promise<void>; /** Broadcast the current GitHub budget to every authed client (SPEC-32). */ diff --git a/server/src/ws/commands/repo_settings.ts b/server/src/ws/commands/repo_settings.ts new file mode 100644 index 00000000..d81b3614 --- /dev/null +++ b/server/src/ws/commands/repo_settings.ts @@ -0,0 +1,204 @@ +/** + * Per-repo settings commands (SPEC-48). + * + * `repo.settings.set` is the only write, and it is **gated on a loopback + * connection**. That is not a UI convention — it is checked here, on the server, + * against `WsClient.isLocal`, which `server.ts` derives from the socket's real + * remote address. The precedent is SPEC-37 decision 6, where the same flag already + * refuses a non-loopback client's reported pid: *"a non-loopback client must + * connect normally but may not ask us to sample an arbitrary pid."* + * + * The reason is concrete: `worktreeRoot` is a path the daemon **creates + * directories under and, via prune, removes**. A paired phone that could set it + * arbitrarily would be directing host filesystem operations at a path of its + * choosing. Reads are unrestricted; writes are host-only. + * + * A refusal is an explicit error, never a silent no-op — a settings row that + * appears to save and does not is worse than one that says it cannot. + */ + +import { WireErrorCode } from "../../protocol/codec.js"; +import type { CommandRouter } from "../command_router.js"; +import type { CommandDeps } from "./deps.js"; +import { + LOGO_HUE_COUNT, + validateBranch, + validateProvider, + validateWorktreeRoot, + type RepoSettings, +} from "../../repo_settings.js"; + +/** Fields a client may write, and how each is validated. */ +type Patch = Partial<Record<keyof RepoSettings, unknown>>; + +/** + * The keys a client may write. Checked BEFORE the value, because the clear-a-setting + * branch used to run first and so skipped this rule entirely for a `null` value: + * `{wroktreeRoot: null}` was acked and the typo written into the patch. A settings + * write that silently stores a misspelling is worse than one that refuses, because + * the user believes the setting exists. + * + * A `Set` rather than a property test, so inherited names (`__proto__`, + * `constructor`) are not keys — assigning `applied["__proto__"]` invokes the + * prototype setter instead of creating an own property. + */ +const WRITABLE_KEYS: ReadonlySet<string> = new Set<keyof RepoSettings>([ + "worktreeRoot", + "provider", + "defaultBranch", + "logoHue", +]); + +export function register(r: CommandRouter, deps: CommandDeps): void { + const { manager, onProjectsChanged } = deps; + + r.register("repo.settings.set", (ctx) => { + if (!ctx.client.isLocal) { + ctx.err( + // No `forbidden` code exists on this wire; `unauthorized` is the closest + // honest one and the app already renders it as a refusal. + WireErrorCode.Unauthorized, + "Repository settings can only be changed on the machine running makit.", + ); + return; + } + + const projectId = typeof ctx.env.projectId === "string" ? ctx.env.projectId : ""; + if (projectId.length === 0) { + ctx.err(WireErrorCode.BadRequest, "projectId is required."); + return; + } + const patch = ctx.env.settings; + if (typeof patch !== "object" || patch === null || Array.isArray(patch)) { + ctx.err(WireErrorCode.BadRequest, "settings must be an object."); + return; + } + + const applied: Record<string, unknown> = {}; + for (const [key, raw] of Object.entries(patch as Patch)) { + // The key rule comes first, so it applies to a clear as well as to a write. + if (!WRITABLE_KEYS.has(key)) { + ctx.err(WireErrorCode.BadRequest, `Unknown setting '${key}'.`); + return; + } + // `null` clears a setting: absent means inherit, so clearing is how the UI + // says "go back to inheriting" without inventing a sentinel value. + if (raw === null) { + applied[key] = null; + continue; + } + switch (key) { + case "worktreeRoot": { + if (typeof raw !== "string") { + ctx.err(WireErrorCode.BadRequest, "worktreeRoot must be a string."); + return; + } + const v = validateWorktreeRoot(raw); + if (!v.ok) { + ctx.err(WireErrorCode.BadRequest, v.error); + return; + } + applied.worktreeRoot = v.value; + break; + } + case "provider": { + const v = validateProvider(raw); + if (!v.ok) { + ctx.err(WireErrorCode.BadRequest, v.error); + return; + } + // `auto` is the default, so it is stored as absence rather than as a + // value — otherwise "believe detection" and "no opinion" would differ on + // disk while meaning the same thing. + applied.provider = v.value === "auto" ? null : v.value; + break; + } + case "defaultBranch": { + if (typeof raw !== "string") { + ctx.err(WireErrorCode.BadRequest, "defaultBranch must be a string."); + return; + } + const v = validateBranch(raw); + if (!v.ok) { + ctx.err(WireErrorCode.BadRequest, v.error); + return; + } + applied.defaultBranch = v.value; + break; + } + case "logoHue": { + if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0 || raw >= LOGO_HUE_COUNT) { + ctx.err( + WireErrorCode.BadRequest, + `logoHue must be an integer from 0 to ${LOGO_HUE_COUNT - 1}.`, + ); + return; + } + applied.logoHue = raw; + break; + } + default: + // Unreachable: `WRITABLE_KEYS` already rejected anything not listed above. + // Kept so adding a key to that set without handling it here fails loudly + // rather than silently storing an unvalidated value. + ctx.err(WireErrorCode.BadRequest, `Unknown setting '${key}'.`); + return; + } + } + + const ok = manager.updateProjectSettings(projectId, applied); + if (!ok) { + ctx.err(WireErrorCode.BadRequest, `No project ${projectId}.`); + return; + } + ctx.ack(); + // `updateProjectSettings` has already persisted. Re-broadcast so every client + // re-renders from one source rather than from whatever it optimistically + // assumed — including the client that just wrote. + onProjectsChanged?.(); + }); + + /** + * Re-point a project at a new root path (D4′). + * + * A separate command from `repo.settings.set`, not a field in its patch, because + * it is not a setting: it mutates the project record, must re-validate that the + * target is a git repository, and re-runs forge detection. Folding an async + * filesystem-and-subprocess check into a loop that validates plain values would + * also break that loop's all-or-nothing property — one bad field would abort a + * move that had already happened. + * + * Same loopback gate, for a sharper reason than the worktree root: this names the + * directory every session's git commands run in. + */ + r.register("repo.path.set", async (ctx) => { + if (!ctx.client.isLocal) { + ctx.err( + WireErrorCode.Unauthorized, + "A repository's path can only be changed on the machine running makit.", + ); + return; + } + const projectId = typeof ctx.env.projectId === "string" ? ctx.env.projectId : ""; + if (projectId.length === 0) { + ctx.err(WireErrorCode.BadRequest, "projectId is required."); + return; + } + const path = typeof ctx.env.path === "string" ? ctx.env.path : ""; + if (path.length === 0) { + ctx.err(WireErrorCode.BadRequest, "path is required."); + return; + } + + const result = await manager.repointProject(projectId, path); + if (!result.ok) { + // Verbatim: the reasons are actionable ("not a git repository", "already open + // as X"), and a generic message would discard the only part the user can act + // on. + ctx.err(WireErrorCode.BadRequest, result.error); + return; + } + ctx.ack(); + onProjectsChanged?.(); + }); +} diff --git a/server/test/ws/agents_catalog.test.ts b/server/test/ws/agents_catalog.test.ts index c6356853..8ffbb1a5 100644 --- a/server/test/ws/agents_catalog.test.ts +++ b/server/test/ws/agents_catalog.test.ts @@ -65,7 +65,10 @@ function routerWith(manager: Partial<CommandDeps["manager"]>): { router: Command onPortsWatchersChanged: () => {}, sendPortsSnapshot: () => {}, ...docsDepsStub, - ...portsDepsStub, + // Required since a settings write that acks without re-broadcasting is + // indistinguishable from a lost write; this harness observes neither. + onProjectsChanged: () => {}, + ...portsDepsStub, askDevice: async () => ({}) as Envelope, } satisfies CommandDeps; register(router, deps); diff --git a/server/test/ws/pr_commands.test.ts b/server/test/ws/pr_commands.test.ts index 5e42915f..2fee15fd 100644 --- a/server/test/ws/pr_commands.test.ts +++ b/server/test/ws/pr_commands.test.ts @@ -65,6 +65,9 @@ function routerWith(manager: Partial<CommandDeps["manager"]>) { onPortsWatchersChanged: () => {}, sendPortsSnapshot: () => {}, ...docsDepsStub, + // Required since a settings write that acks without re-broadcasting is + // indistinguishable from a lost write; this harness observes neither. + onProjectsChanged: () => {}, ...portsDepsStub, askDevice: async () => ({}) as Envelope, } satisfies CommandDeps; diff --git a/server/test/ws/repo_settings_commands.test.ts b/server/test/ws/repo_settings_commands.test.ts new file mode 100644 index 00000000..07f15e01 --- /dev/null +++ b/server/test/ws/repo_settings_commands.test.ts @@ -0,0 +1,373 @@ +/** + * `repo.settings.set` — the only per-repo write, and the loopback gate on it. + * + * The gate is the point of this file. A paired phone that could set `worktreeRoot` + * would be directing host filesystem operations at a path of its choosing: the + * daemon creates directories under that root and, via prune, removes them. So the + * refusal is asserted here, on the server, against `WsClient.isLocal` — the same + * flag that already refuses a non-loopback client's reported pid (SPEC-37 D6). + */ +import { after, test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir, homedir } from "node:os"; +import { join, sep } from "node:path"; + +import { CommandRouter } from "../../src/ws/command_router.js"; +import { register } from "../../src/ws/commands/repo_settings.js"; +import type { CommandDeps } from "../../src/ws/commands/deps.js"; +import { portsDepsStub } from "./ports_deps_stub.js"; +import type { WsClient, OutgoingFrame } from "../../src/ws/client.js"; +import type { Envelope } from "../../src/protocol.js"; + +type FakeClient = WsClient & { sent: OutgoingFrame[] }; + +function fakeClient(isLocal: boolean): FakeClient { + const sent: OutgoingFrame[] = []; + return { + sent, + authed: true, + subscribed: new Set<string>(), + watchingMetrics: false, + watchingPorts: false, + watchingDocs: false, + isLocal, + send: (frame) => sent.push(frame), + close: () => {}, + }; +} + +const cmd = (fields: Partial<Envelope>): Envelope => + ({ v: 1, t: "cmd", id: "c1", kind: "repo.settings.set", ...fields }) as Envelope; + +function harness() { + const written: Array<[string, Record<string, unknown>]> = []; + let broadcasts = 0; + const router = new CommandRouter(); + const deps = { + manager: { + updateProjectSettings: (id: string, patch: Record<string, unknown>) => { + if (id !== "p1") return false; + written.push([id, patch]); + return true; + }, + }, + gateway: {} as never, + budgetWatch: {} as never, + broadcastSnapshots: () => {}, + broadcastReposSnapshot: async () => {}, + broadcastBudget: () => {}, + onMetricsWatchersChanged: () => {}, + sendMetricsHistory: () => {}, + onPortsWatchersChanged: () => {}, + sendPortsSnapshot: () => {}, + onProjectsChanged: () => { + broadcasts += 1; + }, + ...portsDepsStub, + askDevice: async () => ({}) as Envelope, + } as unknown as CommandDeps; + register(router, deps); + return { router, written, broadcasts: () => broadcasts }; +} + +const ackOf = (c: FakeClient) => c.sent.find((f) => f.t === "ack"); +const errOf = (c: FakeClient) => c.sent.find((f) => f.t === "err") as undefined | { message: string }; + +/** A root that will pass validation: inside the real home, and it exists. */ +const createdRoots: string[] = []; + +function goodRoot(): string { + // Inside the real home because the handler calls `validateWorktreeRoot` with no + // `home` argument, so the containment rule is checked against the real one. Unique + // per call and removed in `after`, instead of leaving a directory behind in the + // developer's home and on CI agents. + const root = mkdtempSync(join(homedir(), ".makit-test-cmd-trees-")); + createdRoots.push(root); + return root; +} + +after(() => { + for (const root of createdRoots) rmSync(root, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// The gate +// --------------------------------------------------------------------------- + +test("a non-loopback client is REFUSED, and nothing is written", async () => { + const { router, written } = harness(); + const c = fakeClient(false); + await router.dispatch(c, cmd({ projectId: "p1", settings: { worktreeRoot: goodRoot() } })); + assert.equal(ackOf(c), undefined, "must not ack"); + assert.match(errOf(c)?.message ?? "", /machine running makit/i); + assert.deepEqual(written, [], "must not write"); +}); + +test("the refusal is an explicit error, never a silent no-op", async () => { + // A row that appears to save and does not is worse than one that says it cannot. + const { router } = harness(); + const c = fakeClient(false); + await router.dispatch(c, cmd({ projectId: "p1", settings: { logoHue: 2 } })); + assert.equal(c.sent.filter((f) => f.t === "err").length, 1); +}); + +test("a loopback client is allowed", async () => { + const { router, written, broadcasts } = harness(); + const c = fakeClient(true); + const root = goodRoot(); + await router.dispatch(c, cmd({ projectId: "p1", settings: { worktreeRoot: root } })); + assert.ok(ackOf(c), errOf(c)?.message ?? "no ack"); + assert.equal(written.length, 1); + assert.equal(broadcasts(), 1, "every client must re-render from one source"); +}); + +// --------------------------------------------------------------------------- +// Validation happens server-side, per field +// --------------------------------------------------------------------------- + +test("a relative worktree root is refused", async () => { + const { router, written } = harness(); + const c = fakeClient(true); + await router.dispatch(c, cmd({ projectId: "p1", settings: { worktreeRoot: "work/trees" } })); + assert.match(errOf(c)?.message ?? "", /absolute/i); + assert.deepEqual(written, []); +}); + +test("a worktree root containing '..' is refused on sight", async () => { + const { router } = harness(); + const c = fakeClient(true); + const raw = `${homedir()}${sep}work${sep}..${sep}..${sep}etc`; + await router.dispatch(c, cmd({ projectId: "p1", settings: { worktreeRoot: raw } })); + assert.match(errOf(c)?.message ?? "", /\.\./); +}); + +test("a worktree root outside home is refused", async () => { + const { router } = harness(); + const c = fakeClient(true); + await router.dispatch(c, cmd({ projectId: "p1", settings: { worktreeRoot: mkdtempSync(join(tmpdir(), "outside-")) } })); + assert.match(errOf(c)?.message ?? "", /home directory/i); +}); + +test("the stored root is the canonicalised one, not the raw string", async () => { + const { router, written } = harness(); + const c = fakeClient(true); + await router.dispatch(c, cmd({ projectId: "p1", settings: { worktreeRoot: goodRoot() } })); + const stored = written[0][1].worktreeRoot as string; + assert.ok(stored.length > 0); + assert.ok(!stored.includes(".."), "no traversal survives the write"); +}); + +test("an unknown provider is refused; the five known ones are accepted", async () => { + for (const p of ["auto", "none", "forgejo", "gitea", "github"]) { + const { router, written } = harness(); + const c = fakeClient(true); + await router.dispatch(c, cmd({ projectId: "p1", settings: { provider: p } })); + assert.ok(ackOf(c), `${p}: ${errOf(c)?.message}`); + // `auto` is stored as absence, so the default stays implicit on disk. + assert.equal(written[0][1].provider, p === "auto" ? null : p); + } + const { router, written } = harness(); + const c = fakeClient(true); + await router.dispatch(c, cmd({ projectId: "p1", settings: { provider: "gitlab" } })); + assert.match(errOf(c)?.message ?? "", /gitlab/); + assert.deepEqual(written, []); +}); + +test("an invalid branch name is refused before it can reach git", async () => { + const { router } = harness(); + const c = fakeClient(true); + await router.dispatch(c, cmd({ projectId: "p1", settings: { defaultBranch: "bad name" } })); + assert.match(errOf(c)?.message ?? "", /not a valid branch/i); +}); + +test("null clears a setting — that is how the UI says 'inherit again'", async () => { + const { router, written } = harness(); + const c = fakeClient(true); + await router.dispatch(c, cmd({ projectId: "p1", settings: { worktreeRoot: null } })); + assert.ok(ackOf(c)); + assert.equal(written[0][1].worktreeRoot, null); +}); + +test("an unknown setting key is refused rather than quietly stored", async () => { + // Otherwise a typo becomes a permanent unused key in projects.json. + const { router, written } = harness(); + const c = fakeClient(true); + await router.dispatch(c, cmd({ projectId: "p1", settings: { wroktreeRoot: "/x" } })); + assert.match(errOf(c)?.message ?? "", /unknown setting/i); + assert.deepEqual(written, []); +}); + +test("one bad field rejects the whole patch — no partial application", async () => { + const { router, written } = harness(); + const c = fakeClient(true); + await router.dispatch( + c, + cmd({ projectId: "p1", settings: { logoHue: 1, provider: "gitlab" } }), + ); + assert.ok(errOf(c)); + assert.deepEqual(written, [], "a half-applied patch would be worse than none"); +}); + +test("a missing projectId and a non-object settings are both bad requests", async () => { + for (const env of [{ settings: {} }, { projectId: "p1", settings: 7 }, { projectId: "p1" }]) { + const { router } = harness(); + const c = fakeClient(true); + await router.dispatch(c, cmd(env as Partial<Envelope>)); + assert.ok(errOf(c), JSON.stringify(env)); + } +}); + +test("an unknown project is reported, not silently ignored", async () => { + const { router } = harness(); + const c = fakeClient(true); + await router.dispatch(c, cmd({ projectId: "nope", settings: { logoHue: 1 } })); + assert.match(errOf(c)?.message ?? "", /No project nope/); +}); + +// --------------------------------------------------------------------------- +// `repo.path.set` — re-pointing a project that moved on disk (SPEC-48 D4'). +// +// A separate command from `repo.settings.set` because it is not a setting: it +// mutates the project record itself, has to re-validate that the target is a git +// repo, and re-runs forge detection. Folding it into the settings patch would put +// an async filesystem-and-subprocess check inside a loop that validates plain +// values, and one bad field would then abort a half-done move. +// +// The same loopback gate applies, for a sharper reason than the worktree root: this +// decides the directory every session's git commands run in. +// --------------------------------------------------------------------------- + +const pathCmd = (fields: Partial<Envelope>): Envelope => + ({ v: 1, t: "cmd", id: "c1", kind: "repo.path.set", ...fields }) as Envelope; + +/** As {@link harness}, but recording `repointProject` and its answer. */ +function pathHarness(result: { ok: true; path: string } | { ok: false; error: string }) { + const calls: Array<[string, string]> = []; + let broadcasts = 0; + const router = new CommandRouter(); + const deps = { + manager: { + repointProject: async (id: string, path: string) => { + calls.push([id, path]); + return result; + }, + }, + gateway: {} as never, + budgetWatch: {} as never, + broadcastSnapshots: () => {}, + broadcastReposSnapshot: async () => {}, + broadcastBudget: () => {}, + onMetricsWatchersChanged: () => {}, + sendMetricsHistory: () => {}, + onPortsWatchersChanged: () => {}, + sendPortsSnapshot: () => {}, + onProjectsChanged: () => { + broadcasts += 1; + }, + ...portsDepsStub, + askDevice: async () => ({}) as Envelope, + } as unknown as CommandDeps; + register(router, deps); + return { router, calls, broadcasts: () => broadcasts }; +} + +test("a non-loopback client cannot re-point a repository", async () => { + // Sharper than the worktree-root gate: this names the directory every session's + // git commands run in, so a paired phone could redirect all of them. + const { router, calls } = pathHarness({ ok: true, path: "/x" }); + const c = fakeClient(false); + await router.dispatch(c, pathCmd({ projectId: "p1", path: "/tmp/anything" })); + assert.equal(calls.length, 0, "nothing was attempted"); + assert.match(errOf(c)?.message ?? "", /machine running makit/); +}); + +test("a loopback client re-points and the snapshot is re-broadcast", async () => { + const { router, calls, broadcasts } = pathHarness({ ok: true, path: "/real/path" }); + const c = fakeClient(true); + await router.dispatch(c, pathCmd({ projectId: "p1", path: "/real/path" })); + assert.deepEqual(calls, [["p1", "/real/path"]]); + assert.ok(ackOf(c), "acked"); + assert.equal(broadcasts(), 1, "every client re-renders from one source"); +}); + +test("a refusal is an explicit error carrying the reason verbatim", async () => { + // The reasons are actionable — "not a git repository", "already open as X" — and a + // generic failure would throw away the only part the user can act on. + const { router, broadcasts } = pathHarness({ + ok: false, + error: "/tmp/plain is not a git repository.", + }); + const c = fakeClient(true); + await router.dispatch(c, pathCmd({ projectId: "p1", path: "/tmp/plain" })); + assert.equal(errOf(c)?.message, "/tmp/plain is not a git repository."); + assert.equal(ackOf(c), undefined, "not acked"); + assert.equal(broadcasts(), 0, "and nothing is re-broadcast"); +}); + +test("a missing projectId or path is a bad request, not a crash", async () => { + const { router, calls } = pathHarness({ ok: true, path: "/x" }); + const noId = fakeClient(true); + await router.dispatch(noId, pathCmd({ path: "/tmp/x" })); + assert.ok(errOf(noId)); + const noPath = fakeClient(true); + await router.dispatch(noPath, pathCmd({ projectId: "p1" })); + assert.ok(errOf(noPath)); + assert.equal(calls.length, 0); +}); + +// --------------------------------------------------------------------------- +// Review findings: the key-name rule was skipped for a `null` value. +// --------------------------------------------------------------------------- + +test("an unknown key is refused even when its value is null", async () => { + // The clear-a-setting branch ran BEFORE the switch, so the unknown-key rule never + // saw a null value: `{wroktreeRoot: null}` was acked and the typo was written into + // the patch. A settings write that silently stores a misspelling is worse than one + // that refuses, because the user believes the setting exists. + const { router, written } = harness(); + const c = fakeClient(true); + await router.dispatch(c, cmd({ projectId: "p1", settings: { wroktreeRoot: null } })); + assert.match(errOf(c)?.message ?? "", /wroktreeRoot/); + assert.deepEqual(written, [], "and nothing is written"); +}); + +test("__proto__ is refused rather than reaching the patch object", async () => { + // With the key rule skipped, `applied["__proto__"] = null` invoked the prototype + // setter instead of creating an own property. Harmless to stored data, but it is + // input the stated rule refuses, and refusing it here is cheaper than reasoning + // about every object it is later spread into. + const { router, written } = harness(); + const c = fakeClient(true); + // Built with JSON.parse, not a literal: `{__proto__: null}` in source sets the + // PROTOTYPE and creates no key, so a literal cannot reproduce this at all. Off the + // wire the value arrives decoded from JSON, which does create an own property -- + // so this is the only faithful reproduction of the real input. + const hostile = JSON.parse('{"__proto__": null, "worktreeRoot": null}') as Record<string, unknown>; + await router.dispatch(c, cmd({ projectId: "p1", settings: hostile })); + assert.ok(errOf(c), "refused"); + assert.deepEqual(written, []); +}); + +test("a known key with a null value still clears, as the UI relies on", async () => { + // The guard must not break the reset buttons. + const { router, written } = harness(); + const c = fakeClient(true); + await router.dispatch(c, cmd({ projectId: "p1", settings: { worktreeRoot: null } })); + assert.ok(ackOf(c)); + assert.deepEqual(written, [["p1", { worktreeRoot: null }]]); +}); + +test("a logoHue outside the palette is refused, not wrapped", async () => { + // `RepoMonogram.paletteAt` wraps with `%`, so a stored 6 renders as index 0 — a + // valid-looking colour the user never chose, and indistinguishable from having + // chosen 0. Rejecting at the boundary keeps one hue per stored value. + const { router, written } = harness(); + const c = fakeClient(true); + await router.dispatch(c, cmd({ projectId: "p1", settings: { logoHue: 6 } })); + assert.ok(errOf(c)); + assert.deepEqual(written, []); + const ok = fakeClient(true); + await router.dispatch(ok, cmd({ projectId: "p1", settings: { logoHue: 5 } })); + assert.ok(ackOf(ok), "the last valid index is still accepted"); +}); diff --git a/server/test/ws/send_message_attachments.test.ts b/server/test/ws/send_message_attachments.test.ts index 898ac87e..a24d2d61 100644 --- a/server/test/ws/send_message_attachments.test.ts +++ b/server/test/ws/send_message_attachments.test.ts @@ -87,6 +87,9 @@ function harness() { onPortsWatchersChanged: () => {}, sendPortsSnapshot: () => {}, ...docsDepsStub, + // Required since a settings write that acks without re-broadcasting is + // indistinguishable from a lost write; this harness observes neither. + onProjectsChanged: () => {}, ...portsDepsStub, askDevice: async () => ({}) as Envelope, } satisfies CommandDeps; @@ -241,7 +244,8 @@ test("a pending session promoted by an image-only turn gets a usable label", asy onPortsWatchersChanged: () => {}, sendPortsSnapshot: () => {}, ...docsDepsStub, - ...portsDepsStub, + onProjectsChanged: () => {}, + ...portsDepsStub, askDevice: async () => ({}) as Envelope, } satisfies CommandDeps);