diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md
index 9db574eb4..6322add82 100644
--- a/KNOWLEDGE_BASE.md
+++ b/KNOWLEDGE_BASE.md
@@ -256,6 +256,12 @@ Deleting a parent does not automatically delete other people's replies: an autho
Sources: [reply validation](app/Http/Requests/CreateCommitReplyRequest.php), [commit page](app/Http/Controllers/CommitController.php), [reply notifications](app/Actions/CreateReplyNotifications.php), [commit deletion](app/Actions/DeleteCommit.php).
+### Related commits
+
+Under a commit, /dev/null shows up to three commits from other threads that say something close to it. It only suggests commits you could find through search: nothing from people you block, who block you or whom you mute, and nothing still pending. When nothing is close, nothing shows.
+
+Sources: [related commits query](app/Queries/RelatedCommitsQuery.php), [related commits rail](resources/js/components/related-commits.tsx).
+
### Quoting commits
Quote a visible commit to publish a new top-level commit with your own commentary and an embedded card pointing to the original. Quotes follow normal commit limits, image support, and moderation. The quote composer has a preview and link-card choice, but no poll or scheduling. Quoting is different from replying: the quote appears in logs as its own post, and it is also listed among the original's replies and counted in its reply count. The original author is notified after approval, except for self-quotes.
diff --git a/app/Http/Controllers/Api/RelatedCommitController.php b/app/Http/Controllers/Api/RelatedCommitController.php
new file mode 100644
index 000000000..965f07584
--- /dev/null
+++ b/app/Http/Controllers/Api/RelatedCommitController.php
@@ -0,0 +1,21 @@
+get($commit, $user));
+ }
+}
diff --git a/app/Http/Controllers/RelatedCommitController.php b/app/Http/Controllers/RelatedCommitController.php
new file mode 100644
index 000000000..94d89094d
--- /dev/null
+++ b/app/Http/Controllers/RelatedCommitController.php
@@ -0,0 +1,20 @@
+json(['commits' => $related->get($commit, $user)]);
+ }
+}
diff --git a/app/Http/Requests/ListApiRelatedCommitsRequest.php b/app/Http/Requests/ListApiRelatedCommitsRequest.php
new file mode 100644
index 000000000..e680f0f8c
--- /dev/null
+++ b/app/Http/Requests/ListApiRelatedCommitsRequest.php
@@ -0,0 +1,28 @@
+route('commit');
+ assert($commit instanceof Commit);
+
+ return $commit->isVisibleTo($this->user()) ? Response::allow() : Response::denyAsNotFound();
+ }
+
+ /**
+ * @return array{}
+ */
+ public function rules(): array
+ {
+ return [];
+ }
+}
diff --git a/app/Http/Requests/ListRelatedCommitsRequest.php b/app/Http/Requests/ListRelatedCommitsRequest.php
new file mode 100644
index 000000000..7959be05a
--- /dev/null
+++ b/app/Http/Requests/ListRelatedCommitsRequest.php
@@ -0,0 +1,28 @@
+route('commit');
+ assert($commit instanceof Commit);
+
+ return $commit->isVisibleTo($this->user()) ? Response::allow() : Response::denyAsNotFound();
+ }
+
+ /**
+ * @return array{}
+ */
+ public function rules(): array
+ {
+ return [];
+ }
+}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 87bd21f7f..90808d042 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -153,6 +153,11 @@ private function bootRateLimitingDefaults(): void
$headers,
)));
+ // Every commit page asks for its related commits, a vector index scan with no outside call...
+ RateLimiter::for('related-commits', fn (Request $request): Limit => Limit::perMinute(60)->by(
+ $request->user() instanceof User ? $this->userId($request) : 'ip:'.$request->ip(),
+ ));
+
// Exports stream every commit a user ever made...
RateLimiter::for('exports', fn (Request $request): Limit => Limit::perHour(3)->by($this->userId($request)));
}
diff --git a/app/Queries/RelatedCommitsQuery.php b/app/Queries/RelatedCommitsQuery.php
new file mode 100644
index 000000000..d22f12e9f
--- /dev/null
+++ b/app/Queries/RelatedCommitsQuery.php
@@ -0,0 +1,62 @@
+
+ */
+ public function get(Commit $commit, ?User $viewer): Collection
+ {
+ $nearest = Commit::query()
+ ->select('commits.id')
+ ->selectRaw(
+ 'commits.embedding <=> (select source.embedding from commits as source where source.id = ?) as distance',
+ [$commit->id],
+ )
+ ->topLevel()
+ ->whereKeyNot($commit->threadRootId())
+ ->visibleTo($viewer)
+ ->withoutMutedAuthorsFor($viewer)
+ ->orderBy('distance')
+ ->limit(self::LIMIT);
+
+ return DB::transaction(function () use ($nearest, $viewer): Collection {
+ DB::statement('set local hnsw.iterative_scan = strict_order');
+
+ return Commit::query()
+ ->joinSub($nearest, 'nearest', 'nearest.id', '=', 'commits.id')
+ ->where('nearest.distance', '<=', self::MAX_DISTANCE)
+ ->withVisibleReplyCount($viewer)
+ ->withContinuationId($viewer)
+ ->withCardDataFor($viewer)
+ ->orderBy('nearest.distance')
+ ->get()
+ ->toBase();
+ });
+ }
+}
diff --git a/resources/js/components/related-commits.tsx b/resources/js/components/related-commits.tsx
new file mode 100644
index 000000000..fe94f6583
--- /dev/null
+++ b/resources/js/components/related-commits.tsx
@@ -0,0 +1,31 @@
+import { CommitCard } from '@/components/commit-card';
+import { useRelatedCommits } from '@/hooks/use-related-commits';
+import { laneOf } from '@/lib/utils';
+
+export function RelatedCommits({ commitId }: { commitId: string }) {
+ const commits = useRelatedCommits(commitId);
+
+ if (commits.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ {commits.map((commit, index) => (
+
+ ))}
+
+ );
+}
diff --git a/resources/js/hooks/use-related-commits.ts b/resources/js/hooks/use-related-commits.ts
new file mode 100644
index 000000000..da751e194
--- /dev/null
+++ b/resources/js/hooks/use-related-commits.ts
@@ -0,0 +1,46 @@
+import { router } from '@inertiajs/react';
+import { useEffect, useState } from 'react';
+import RelatedCommitController from '@/actions/App/Http/Controllers/RelatedCommitController';
+import { fetchRelatedCommits, hiddenAfterFlash } from '@/lib/related-commits';
+import type { Commit } from '@/types';
+
+export function useRelatedCommits(commitId: string): Commit[] {
+ const [loaded, setLoaded] = useState<{
+ id: string;
+ commits: Commit[];
+ hidden: ReadonlySet;
+ }>({ id: '', commits: [], hidden: new Set() });
+
+ useEffect(() => {
+ const controller = new AbortController();
+
+ fetchRelatedCommits(
+ RelatedCommitController.index(commitId).url,
+ controller.signal,
+ )
+ .then((commits) =>
+ setLoaded({ id: commitId, commits, hidden: new Set() }),
+ )
+ .catch(() => {});
+
+ return () => controller.abort();
+ }, [commitId]);
+
+ useEffect(
+ () =>
+ router.on('flash', (event) =>
+ setLoaded((current) => ({
+ ...current,
+ hidden: hiddenAfterFlash(
+ current.hidden,
+ event.detail.flash,
+ ),
+ })),
+ ),
+ [],
+ );
+
+ return loaded.id === commitId
+ ? loaded.commits.filter((commit) => !loaded.hidden.has(commit.id))
+ : [];
+}
diff --git a/resources/js/lib/related-commits.ts b/resources/js/lib/related-commits.ts
new file mode 100644
index 000000000..43d886bd0
--- /dev/null
+++ b/resources/js/lib/related-commits.ts
@@ -0,0 +1,54 @@
+import type { Commit, RelatedCommits } from '@/types';
+
+/**
+ * Takes the url rather than the commit id so the node test runner, which cannot resolve Wayfinder's imports, can load it.
+ */
+export function fetchRelatedCommits(
+ url: string,
+ signal: AbortSignal,
+): Promise {
+ return fetch(url, {
+ headers: {
+ Accept: 'application/json',
+ 'X-Requested-With': 'XMLHttpRequest',
+ },
+ signal,
+ })
+ .then((response) =>
+ response.ok
+ ? response.json().then((data: RelatedCommits) => data.commits)
+ : [],
+ )
+ .catch((error: unknown) => {
+ if (signal.aborted) {
+ throw error;
+ }
+
+ return [];
+ });
+}
+
+/**
+ * The rail keeps the order it was fetched in, so a deleted commit's card is hidden rather than dropped, and a restore
+ * mounts it afresh as the feeds do.
+ */
+export function hiddenAfterFlash(
+ hidden: ReadonlySet,
+ {
+ deleted,
+ restored,
+ }: { deleted?: { id: string }; restored?: { id: string } },
+): ReadonlySet {
+ if (deleted) {
+ return new Set(hidden).add(deleted.id);
+ }
+
+ if (restored && hidden.has(restored.id)) {
+ const shown = new Set(hidden);
+ shown.delete(restored.id);
+
+ return shown;
+ }
+
+ return hidden;
+}
diff --git a/resources/js/pages/commit/show.tsx b/resources/js/pages/commit/show.tsx
index a2e8f6134..1daf2f57d 100644
--- a/resources/js/pages/commit/show.tsx
+++ b/resources/js/pages/commit/show.tsx
@@ -4,6 +4,7 @@ import LogController from '@/actions/App/Http/Controllers/LogController';
import { CommitCard, CommitTombstone } from '@/components/commit-card';
import { CopyLinkButton } from '@/components/copy-link-button';
import { GraphCanvas } from '@/components/graph-canvas';
+import { RelatedCommits } from '@/components/related-commits';
import { useContinuation } from '@/hooks/use-continuation';
import AppLayout from '@/layouts/app-layout';
import { isDeleted, laneOf, shortHash } from '@/lib/utils';
@@ -164,6 +165,8 @@ export default function Show({
)}
+
+ {!isDeleted(commit) && }
>
);
}
diff --git a/resources/js/types/commit.ts b/resources/js/types/commit.ts
index 8af6a4e68..12790e4d3 100644
--- a/resources/js/types/commit.ts
+++ b/resources/js/types/commit.ts
@@ -132,6 +132,10 @@ export type ThreadContinuation = {
next_id: string | null;
};
+export type RelatedCommits = {
+ commits: Commit[];
+};
+
export type CursorPaginated = {
data: T[];
next_cursor: string | null;
diff --git a/routes/api.php b/routes/api.php
index 5f408c30a..9e8459c35 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -40,6 +40,7 @@
use App\Http\Controllers\Api\OrgPingController;
use App\Http\Controllers\Api\OrgReadController;
use App\Http\Controllers\Api\OrgTypingController;
+use App\Http\Controllers\Api\RelatedCommitController;
use App\Http\Controllers\Api\RemoteCandidateController;
use App\Http\Controllers\Api\StarredCommitController;
use App\Http\Controllers\Api\SuggestedUserController;
@@ -89,6 +90,8 @@
->name('api-user-following.index');
Route::get('commits/{commit}', [ApiCommitController::class, 'show'])
->name('api-commit.show');
+ Route::get('commits/{commit}/related', [RelatedCommitController::class, 'index'])
+ ->name('api-related-commit.index');
});
// The Mac app's latest release, which its update check compares with, unsigned so it sends nothing about anyone...
diff --git a/routes/web.php b/routes/web.php
index 1d5961477..967683c04 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -85,6 +85,7 @@
use App\Http\Controllers\PrivacyController;
use App\Http\Controllers\ProductMetricsController;
use App\Http\Controllers\PushSubscriptionController;
+use App\Http\Controllers\RelatedCommitController;
use App\Http\Controllers\RemoteCandidateController;
use App\Http\Controllers\ReportedCommitController;
use App\Http\Controllers\SafetyController;
@@ -254,6 +255,9 @@
->name('commit-reply.index');
Route::get('commits/{commit}/continuation', [CommitContinuationController::class, 'index'])
->name('commit-continuation.index');
+Route::get('commits/{commit}/related', [RelatedCommitController::class, 'index'])
+ ->middleware('throttle:related-commits')
+ ->name('related-commit.index');
// Search...
Route::get('search', [SearchController::class, 'index'])
diff --git a/tests/Feature/Controllers/Api/RelatedCommitControllerTest.php b/tests/Feature/Controllers/Api/RelatedCommitControllerTest.php
new file mode 100644
index 000000000..848d15d08
--- /dev/null
+++ b/tests/Feature/Controllers/Api/RelatedCommitControllerTest.php
@@ -0,0 +1,38 @@
+create(['embedding' => embeddingVector(0)]);
+ $related = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+
+ $this->getJson(route('api-related-commit.index', $commit))
+ ->assertOk()
+ ->assertJsonCount(1, 'data')
+ ->assertJsonPath('data.0.id', $related->id)
+ ->assertJsonPath('data.0.author.handle', $related->user->handle)
+ ->assertJsonMissingPath('data.0.starred')
+ ->assertJsonMissingPath('data.0.embedding');
+});
+
+it('shows a signed request whether it starred each related commit', function (): void {
+ $user = User::factory()->create();
+ $keyPair = apiKeyPairFor($user);
+ $commit = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ $related = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ $related->stars()->attach($user);
+
+ signedApiRequest($keyPair, 'GET', route('api-related-commit.index', $commit))
+ ->assertOk()
+ ->assertJsonPath('data.0.id', $related->id)
+ ->assertJsonPath('data.0.starred', true);
+});
+
+it('does not answer for a commit that is not visible', function (): void {
+ $commit = Commit::factory()->pending()->create(['embedding' => embeddingVector(0)]);
+
+ $this->getJson(route('api-related-commit.index', $commit))->assertNotFound();
+});
diff --git a/tests/Feature/Controllers/RelatedCommitControllerTest.php b/tests/Feature/Controllers/RelatedCommitControllerTest.php
new file mode 100644
index 000000000..87ccfe57d
--- /dev/null
+++ b/tests/Feature/Controllers/RelatedCommitControllerTest.php
@@ -0,0 +1,73 @@
+create();
+ $commit = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ $related = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+
+ $this->actingAs($viewer)
+ ->getJson(route('related-commit.index', $commit))
+ ->assertOk()
+ ->assertJsonCount(1, 'commits')
+ ->assertJsonPath('commits.0.id', $related->id)
+ ->assertJsonPath('commits.0.user.handle', $related->user->handle)
+ ->assertJsonMissingPath('commits.0.embedding');
+});
+
+it('lets guests see the related commits of an approved commit', function (): void {
+ $commit = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ $related = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+
+ $this->getJson(route('related-commit.index', $commit))
+ ->assertOk()
+ ->assertJsonPath('commits.0.id', $related->id);
+});
+
+it('does not answer for a commit that is not visible', function (): void {
+ $commit = Commit::factory()->pending()->create(['embedding' => embeddingVector(0)]);
+
+ $this->actingAs(User::factory()->create())
+ ->getJson(route('related-commit.index', $commit))
+ ->assertNotFound();
+});
+
+it('does not answer across a block', function (): void {
+ $viewer = User::factory()->create();
+ $commit = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ $viewer->blocking()->attach($commit->user);
+
+ $this->actingAs($viewer)
+ ->getJson(route('related-commit.index', $commit))
+ ->assertNotFound();
+});
+
+it('does not answer for a deleted commit', function (): void {
+ Queue::fake();
+ $commit = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ resolve(DeleteCommit::class)->handle($commit);
+
+ $this->getJson(route('related-commit.index', $commit))->assertNotFound();
+});
+
+it('limits each guest IP without limiting other visitors', function (): void {
+ $commit = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+
+ $this->withServerVariables(['REMOTE_ADDR' => '192.0.2.1']);
+
+ foreach (range(1, 60) as $attempt) {
+ $this->getJson(route('related-commit.index', $commit))->assertOk();
+ }
+
+ $this->getJson(route('related-commit.index', $commit))->assertTooManyRequests();
+
+ $this->withServerVariables(['REMOTE_ADDR' => '192.0.2.2']);
+
+ $this->getJson(route('related-commit.index', $commit))->assertOk();
+});
diff --git a/tests/Pest.php b/tests/Pest.php
index 486ba977c..ac43ed690 100644
--- a/tests/Pest.php
+++ b/tests/Pest.php
@@ -63,6 +63,18 @@ function embeddingVector(int $dimension): array
return $vector;
}
+/**
+ * @return list
+ */
+function embeddingAtDistance(float $distance): array
+{
+ $vector = array_fill(0, OpenAI::EMBEDDING_DIMENSIONS, 0.0);
+ $vector[0] = 1 - $distance;
+ $vector[1] = sqrt(1 - (1 - $distance) ** 2);
+
+ return $vector;
+}
+
/**
* @param list> $embeddings
* @return array{data: list}>}
diff --git a/tests/Unit/Queries/RelatedCommitsQueryTest.php b/tests/Unit/Queries/RelatedCommitsQueryTest.php
new file mode 100644
index 000000000..b0cc2366d
--- /dev/null
+++ b/tests/Unit/Queries/RelatedCommitsQueryTest.php
@@ -0,0 +1,216 @@
+
+ */
+function relatedCommitIds(Commit $commit, ?User $viewer = null): array
+{
+ return resolve(RelatedCommitsQuery::class)->get($commit, $viewer)->pluck('id')->all();
+}
+
+it('lists the nearest top-level commits, nearest first', function (): void {
+ $source = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ $farther = Commit::factory()->create(['embedding' => embeddingAtDistance(0.3)]);
+ $nearest = Commit::factory()->create(['embedding' => embeddingAtDistance(0.1)]);
+
+ expect(relatedCommitIds($source))->toBe([$nearest->id, $farther->id]);
+});
+
+it('never lists the thread being read', function (): void {
+ $root = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ $reply = Commit::factory()->for($root, 'parent')->create(['embedding' => embeddingVector(0)]);
+ Commit::factory()->for($root, 'parent')->create(['embedding' => embeddingVector(0)]);
+ $other = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+
+ expect(relatedCommitIds($reply))->toBe([$other->id])
+ ->and(relatedCommitIds($root))->toBe([$other->id]);
+});
+
+it("uses a reply's own vector as the question", function (): void {
+ $root = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ $reply = Commit::factory()->for($root, 'parent')->create(['embedding' => embeddingVector(1)]);
+ Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ $closeToReply = Commit::factory()->create(['embedding' => embeddingVector(1)]);
+
+ expect(relatedCommitIds($reply))->toBe([$closeToReply->id]);
+});
+
+it('leaves replies out', function (): void {
+ $source = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ $otherRoot = Commit::factory()->create(['embedding' => embeddingVector(1)]);
+ Commit::factory()->for($otherRoot, 'parent')->create(['embedding' => embeddingVector(0)]);
+
+ expect(relatedCommitIds($source))->toBeEmpty();
+});
+
+it('finds top-level commits through the vector index behind many closer replies', function (): void {
+ DB::statement('set local enable_sort = off');
+ $source = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ $otherRoot = Commit::factory()->create(['embedding' => embeddingVector(1)]);
+ foreach (range(1, 80) as $step) {
+ Commit::factory()->for($otherRoot, 'parent')->create(['embedding' => embeddingAtDistance($step / 1000)]);
+ }
+
+ $related = Commit::factory()->count(3)->create(['embedding' => embeddingAtDistance(0.2)]);
+
+ expect(relatedCommitIds($source))->toEqualCanonicalizing($related->pluck('id')->all());
+});
+
+it('drops commits past the distance cutoff', function (): void {
+ $source = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ Commit::factory()->create(['embedding' => embeddingAtDistance(RelatedCommitsQuery::MAX_DISTANCE + 0.01)]);
+
+ expect(relatedCommitIds($source))->toBeEmpty();
+});
+
+it('includes a commit right at the cutoff', function (): void {
+ $source = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ $atCutoff = Commit::factory()->create(['embedding' => embeddingAtDistance(RelatedCommitsQuery::MAX_DISTANCE)]);
+
+ expect(relatedCommitIds($source))->toBe([$atCutoff->id]);
+});
+
+it('caps the rail', function (): void {
+ $source = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ Commit::factory()->count(RelatedCommitsQuery::LIMIT + 1)->create(['embedding' => embeddingVector(0)]);
+
+ expect(relatedCommitIds($source))->toHaveCount(RelatedCommitsQuery::LIMIT);
+});
+
+it('returns nothing for a commit without an embedding', function (): void {
+ $source = Commit::factory()->create(['embedding' => null]);
+ Commit::factory()->create(['embedding' => embeddingVector(0)]);
+
+ expect(relatedCommitIds($source))->toBeEmpty();
+});
+
+it("returns nothing after the commit's content was erased", function (): void {
+ Queue::fake();
+ $source = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ Commit::factory()->create(['embedding' => embeddingVector(0)]);
+
+ resolve(DeleteCommit::class)->handle($source);
+ $this->travel(30)->seconds();
+ resolve(DeleteCommitContent::class)->handle($source);
+
+ expect(relatedCommitIds($source))->toBeEmpty();
+});
+
+it('skips candidates without an embedding', function (): void {
+ $source = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ $embedded = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ Commit::factory()->create(['embedding' => null]);
+
+ expect(relatedCommitIds($source))->toBe([$embedded->id]);
+});
+
+it('hides pending and rejected commits from other viewers', function (): void {
+ $source = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ $approved = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ Commit::factory()->pending()->create(['embedding' => embeddingVector(0)]);
+ Commit::factory()->rejected()->create(['embedding' => embeddingVector(0)]);
+
+ expect(relatedCommitIds($source, User::factory()->create()))->toBe([$approved->id]);
+});
+
+it('shows an author their own pending commit', function (): void {
+ $author = User::factory()->create();
+ $source = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ $pending = Commit::factory()->for($author)->pending()->create(['embedding' => embeddingVector(0)]);
+
+ expect(relatedCommitIds($source, $author))->toBe([$pending->id]);
+});
+
+it('hides approved commits across a block in either direction', function (): void {
+ $viewer = User::factory()->create();
+ $blocked = User::factory()->create();
+ $blocker = User::factory()->create();
+ $viewer->blocking()->attach($blocked);
+ $blocker->blocking()->attach($viewer);
+
+ $source = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ $reachable = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ Commit::factory()->for($blocked)->create(['embedding' => embeddingVector(0)]);
+ Commit::factory()->for($blocker)->create(['embedding' => embeddingVector(0)]);
+
+ expect(relatedCommitIds($source, $viewer))->toBe([$reachable->id]);
+});
+
+it('hides muted authors', function (): void {
+ $viewer = User::factory()->create();
+ $muted = User::factory()->create();
+ $viewer->muting()->attach($muted);
+
+ $source = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ $reachable = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ Commit::factory()->for($muted)->create(['embedding' => embeddingVector(0)]);
+
+ expect(relatedCommitIds($source, $viewer))->toBe([$reachable->id]);
+});
+
+it('hides deleted commits', function (): void {
+ $source = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ $kept = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ Commit::factory()->create(['embedding' => embeddingVector(0)])->delete();
+
+ expect(relatedCommitIds($source))->toBe([$kept->id]);
+});
+
+it('gives guests approved commits only', function (): void {
+ $source = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ $approved = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ Commit::factory()->pending()->create(['embedding' => embeddingVector(0)]);
+ Commit::factory()->rejected()->create(['embedding' => embeddingVector(0)]);
+
+ expect(relatedCommitIds($source))->toBe([$approved->id]);
+});
+
+it('loads the commits as the feeds do, so the card renders them', function (): void {
+ $viewer = User::factory()->create();
+ $source = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ $commit = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ $viewer->starredCommits()->attach($commit);
+ Poll::factory()->for($commit)->withOptions('Tabs', 'Spaces')->create();
+
+ $result = resolve(RelatedCommitsQuery::class)->get($source, $viewer)->sole();
+
+ expect($result->user->is($commit->user))->toBeTrue()
+ ->and($result->stars_count)->toBe(1)
+ ->and($result->starred)->toBeTrue()
+ ->and($result->replies_count)->toBe(0)
+ ->and($result->relationLoaded('poll'))->toBeTrue()
+ ->and($result->poll->options)->toHaveCount(2)
+ ->and($result->getAttributes())->not->toHaveKey('embedding');
+});
+
+it('finds the candidates in one query', function (): void {
+ $source = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ Commit::factory()->create(['embedding' => embeddingVector(0)]);
+
+ DB::enableQueryLog();
+ resolve(RelatedCommitsQuery::class)->get($source, null);
+
+ expect(collect(DB::getQueryLog())->pluck('query')->filter(fn (string $query): bool => str_contains($query, '<=>')))->toHaveCount(1);
+});
+
+it('asks no outside service anything', function (): void {
+ Http::fake();
+ $source = Commit::factory()->create(['embedding' => embeddingVector(0)]);
+ Commit::factory()->create(['embedding' => embeddingVector(0)]);
+
+ relatedCommitIds($source);
+
+ Http::assertNothingSent();
+});
diff --git a/tests/Unit/RelatedCommits.test.ts b/tests/Unit/RelatedCommits.test.ts
new file mode 100644
index 000000000..6f88986aa
--- /dev/null
+++ b/tests/Unit/RelatedCommits.test.ts
@@ -0,0 +1,67 @@
+import assert from 'node:assert/strict';
+import { afterEach, test } from 'node:test';
+import { fetchRelatedCommits, hiddenAfterFlash } from '../../resources/js/lib/related-commits.ts';
+import type { Commit } from '../../resources/js/types';
+
+const realFetch = globalThis.fetch;
+
+afterEach(() => {
+ globalThis.fetch = realFetch;
+});
+
+function respondWith(response: () => Promise): void {
+ globalThis.fetch = () => response();
+}
+
+void test('returns the commits of the response', async () => {
+ const commits = [{ id: 'related' }] as Commit[];
+ respondWith(() => Promise.resolve(Response.json({ commits })));
+
+ assert.deepEqual(
+ await fetchRelatedCommits('/commits/a/related', new AbortController().signal),
+ commits,
+ );
+});
+
+void test('returns an empty list when the server asks to slow down', async () => {
+ respondWith(() => Promise.resolve(new Response(null, { status: 429 })));
+
+ assert.deepEqual(
+ await fetchRelatedCommits('/commits/a/related', new AbortController().signal),
+ [],
+ );
+});
+
+void test('returns an empty list when the network fails', async () => {
+ respondWith(() => Promise.reject(new TypeError('Failed to fetch')));
+
+ assert.deepEqual(
+ await fetchRelatedCommits('/commits/a/related', new AbortController().signal),
+ [],
+ );
+});
+
+void test('rejects when the request was aborted, so a stale response never lands', async () => {
+ const controller = new AbortController();
+ controller.abort();
+ respondWith(() => Promise.reject(new DOMException('Aborted', 'AbortError')));
+
+ await assert.rejects(
+ fetchRelatedCommits('/commits/a/related', controller.signal),
+ { name: 'AbortError' },
+ );
+});
+
+void test('hides a deleted commit and shows it again once restored', () => {
+ const hidden = hiddenAfterFlash(new Set(), { deleted: { id: 'related' } });
+
+ assert.deepEqual([...hidden], ['related']);
+ assert.deepEqual([...hiddenAfterFlash(hidden, { restored: { id: 'related' } })], []);
+});
+
+void test('leaves the hidden commits alone on other flashes', () => {
+ const hidden = new Set(['related']);
+
+ assert.equal(hiddenAfterFlash(hidden, {}), hidden);
+ assert.deepEqual([...hiddenAfterFlash(hidden, { restored: { id: 'other' } })], ['related']);
+});