authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-09 16:52:28-07:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-09 22:50:45-07:00
log442bc9540ea52e82ad15aee06e52da2c5e16f948
treec4adbb1e139af75a22f7783bbf93f10d3aaf5e36
parentf7570712a741136999a77d5a8f6490a8c7d9e249

Normalize remote URL matching and migrate GitHubCli tests

- Treat remotes that only differ by trailing slash after `.git` as the same remote - Add a GitCore regression test for trailing-slash remote reuse - Refactor `GitHubCli` tests to `@effect/vitest` layer-based effect tests with reset cleanup

3 files changed, 113 insertions(+), 88 deletions(-)

apps/server/src/git/Layers/GitCore.test.ts+19
...@@ -1323,6 +1323,25 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -1323,6 +1323,25 @@ it.layer(TestLayer)("git integration", (it) => {
1323 }),1323 }),
1324 );1324 );
13251325
1326 it.effect("reuses an existing remote when the target URL only differs by a trailing slash after .git", () =>
1327 Effect.gen(function* () {
1328 const tmp = yield* makeTmpDir();
1329 yield* initRepoWithCommit(tmp);
1330 const core = yield* GitCore;
1331
1332 yield* git(tmp, ["remote", "add", "origin", "git@github.com:pingdotgg/t3code.git"]);
1333
1334 const remoteName = yield* core.ensureRemote({
1335 cwd: tmp,
1336 preferredName: "origin",
1337 url: "git@github.com:pingdotgg/t3code.git/",
1338 });
1339
1340 expect(remoteName).toBe("origin");
1341 expect((yield* git(tmp, ["remote"])).split("\n").filter(Boolean)).toEqual(["origin"]);
1342 }),
1343 );
1344
1326 it.effect("reports status details and dirty state", () =>1345 it.effect("reports status details and dirty state", () =>
1327 Effect.gen(function* () {1346 Effect.gen(function* () {
1328 const tmp = yield* makeTmpDir();1347 const tmp = yield* makeTmpDir();
apps/server/src/git/Layers/GitCore.ts+1-1
...@@ -106,7 +106,7 @@ function sanitizeRemoteName(value: string): string {...@@ -106,7 +106,7 @@ function sanitizeRemoteName(value: string): string {
106}106}
107107
108function normalizeRemoteUrl(value: string): string {108function normalizeRemoteUrl(value: string): string {
109 return value.trim().replace(/\.git$/i, "").replace(/\/+$/g, "").toLowerCase();109 return value.trim().replace(/\/+$/g, "").replace(/\.git$/i, "").toLowerCase();
110}110}
111111
112function parseRemoteFetchUrls(stdout: string): Map<string, string> {112function parseRemoteFetchUrls(stdout: string): Map<string, string> {
apps/server/src/git/Layers/GitHubCli.test.ts+93-87
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import { describe, expect, it, vi } from "vitest";1import { assert, it } from "@effect/vitest";
2import { Effect } from "effect";2import { Effect, Layer } from "effect";
3import { afterEach, expect, vi } from "vitest";
34
4vi.mock("../../processRunner", () => ({5vi.mock("../../processRunner", () => ({
5 runProcess: vi.fn(),6 runProcess: vi.fn(),
...@@ -10,113 +11,118 @@ import { GitHubCli } from "../Services/GitHubCli.ts";...@@ -10,113 +11,118 @@ import { GitHubCli } from "../Services/GitHubCli.ts";
10import { GitHubCliLive } from "./GitHubCli.ts";11import { GitHubCliLive } from "./GitHubCli.ts";
1112
12const mockedRunProcess = vi.mocked(runProcess);13const mockedRunProcess = vi.mocked(runProcess);
14const layer = it.layer(Layer.empty);
1315
14describe("GitHubCliLive", () => {16afterEach(() => {
15 it("parses pull request view output", async () => {17 mockedRunProcess.mockReset();
16 mockedRunProcess.mockResolvedValueOnce({18});
17 stdout: JSON.stringify({19
18 number: 42,20layer("GitHubCliLive", (it) => {
19 title: "Add PR thread creation",21 it.effect("parses pull request view output", () =>
20 url: "https://github.com/pingdotgg/codething-mvp/pull/42",22 Effect.gen(function* () {
21 baseRefName: "main",23 mockedRunProcess.mockResolvedValueOnce({
22 headRefName: "feature/pr-threads",24 stdout: JSON.stringify({
23 state: "OPEN",25 number: 42,
24 mergedAt: null,26 title: "Add PR thread creation",
25 isCrossRepository: true,27 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
26 headRepository: {28 baseRefName: "main",
27 nameWithOwner: "octocat/codething-mvp",29 headRefName: "feature/pr-threads",
28 },30 state: "OPEN",
29 headRepositoryOwner: {31 mergedAt: null,
30 login: "octocat",32 isCrossRepository: true,
31 },33 headRepository: {
32 }),34 nameWithOwner: "octocat/codething-mvp",
33 stderr: "",35 },
34 code: 0,36 headRepositoryOwner: {
35 signal: null,37 login: "octocat",
36 timedOut: false,38 },
37 });39 }),
40 stderr: "",
41 code: 0,
42 signal: null,
43 timedOut: false,
44 });
3845
39 const result = await Effect.runPromise(46 const result = yield* Effect.gen(function* () {
40 Effect.gen(function* () {
41 const gh = yield* GitHubCli;47 const gh = yield* GitHubCli;
42 return yield* gh.getPullRequest({48 return yield* gh.getPullRequest({
43 cwd: "/repo",49 cwd: "/repo",
44 reference: "#42",50 reference: "#42",
45 });51 });
46 }).pipe(Effect.provide(GitHubCliLive)),52 }).pipe(Effect.provide(GitHubCliLive));
47 );
4853
49 expect(result).toEqual({54 assert.deepStrictEqual(result, {
50 number: 42,55 number: 42,
51 title: "Add PR thread creation",56 title: "Add PR thread creation",
52 url: "https://github.com/pingdotgg/codething-mvp/pull/42",57 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
53 baseRefName: "main",58 baseRefName: "main",
54 headRefName: "feature/pr-threads",59 headRefName: "feature/pr-threads",
55 state: "open",60 state: "open",
56 isCrossRepository: true,61 isCrossRepository: true,
57 headRepositoryNameWithOwner: "octocat/codething-mvp",62 headRepositoryNameWithOwner: "octocat/codething-mvp",
58 headRepositoryOwnerLogin: "octocat",63 headRepositoryOwnerLogin: "octocat",
59 });64 });
60 expect(mockedRunProcess).toHaveBeenCalledWith(65 expect(mockedRunProcess).toHaveBeenCalledWith(
61 "gh",66 "gh",
62 [67 [
63 "pr",68 "pr",
64 "view",69 "view",
65 "#42",70 "#42",
66 "--json",71 "--json",
67 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",72 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
68 ],73 ],
69 expect.objectContaining({ cwd: "/repo" }),74 expect.objectContaining({ cwd: "/repo" }),
70 );75 );
71 });76 }),
77 );
7278
73 it("reads repository clone URLs", async () => {79 it.effect("reads repository clone URLs", () =>
74 mockedRunProcess.mockResolvedValueOnce({80 Effect.gen(function* () {
75 stdout: JSON.stringify({81 mockedRunProcess.mockResolvedValueOnce({
76 nameWithOwner: "octocat/codething-mvp",82 stdout: JSON.stringify({
77 url: "https://github.com/octocat/codething-mvp",83 nameWithOwner: "octocat/codething-mvp",
78 sshUrl: "git@github.com:octocat/codething-mvp.git",84 url: "https://github.com/octocat/codething-mvp",
79 }),85 sshUrl: "git@github.com:octocat/codething-mvp.git",
80 stderr: "",86 }),
81 code: 0,87 stderr: "",
82 signal: null,88 code: 0,
83 timedOut: false,89 signal: null,
84 });90 timedOut: false,
91 });
8592
86 const result = await Effect.runPromise(93 const result = yield* Effect.gen(function* () {
87 Effect.gen(function* () {
88 const gh = yield* GitHubCli;94 const gh = yield* GitHubCli;
89 return yield* gh.getRepositoryCloneUrls({95 return yield* gh.getRepositoryCloneUrls({
90 cwd: "/repo",96 cwd: "/repo",
91 repository: "octocat/codething-mvp",97 repository: "octocat/codething-mvp",
92 });98 });
93 }).pipe(Effect.provide(GitHubCliLive)),99 }).pipe(Effect.provide(GitHubCliLive));
94 );
95100
96 expect(result).toEqual({101 assert.deepStrictEqual(result, {
97 nameWithOwner: "octocat/codething-mvp",102 nameWithOwner: "octocat/codething-mvp",
98 url: "https://github.com/octocat/codething-mvp",103 url: "https://github.com/octocat/codething-mvp",
99 sshUrl: "git@github.com:octocat/codething-mvp.git",104 sshUrl: "git@github.com:octocat/codething-mvp.git",
100 });105 });
101 });106 }),
107 );
102108
103 it("surfaces a friendly error when the pull request is not found", async () => {109 it.effect("surfaces a friendly error when the pull request is not found", () =>
104 mockedRunProcess.mockRejectedValueOnce(110 Effect.gen(function* () {
105 new Error(111 mockedRunProcess.mockRejectedValueOnce(
106 "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)",112 new Error(
107 ),113 "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)",
108 );114 ),
115 );
109116
110 const error = await Effect.runPromise(117 const error = yield* Effect.gen(function* () {
111 Effect.gen(function* () {
112 const gh = yield* GitHubCli;118 const gh = yield* GitHubCli;
113 return yield* gh.getPullRequest({119 return yield* gh.getPullRequest({
114 cwd: "/repo",120 cwd: "/repo",
115 reference: "4888",121 reference: "4888",
116 });122 });
117 }).pipe(Effect.provide(GitHubCliLive), Effect.flip),123 }).pipe(Effect.provide(GitHubCliLive), Effect.flip);
118 );
119124
120 expect(error.message).toContain("Pull request not found");125 assert.equal(error.message.includes("Pull request not found"), true);
121 });126 }),
127 );
122});128});