Skip to content

Commit f1d8a6e

Browse files
arul28claude
andcommitted
ship: iter 1 — address greptile/codex review on PR #248
- githubService.publishCurrentProject: clean up local origin remote when remote-add or push fails after createRepository succeeded, so retry isn't blocked by remote_already_exists guard (greptile P1 #3184587072) - projectScaffoldService: throw coded errors with friendly messages (target_exists, invalid_github_url) so renderer can match by .code instead of raw .message (greptile P1 #3184587147) - projectScaffoldService.listMyGitHubRepos: cache by sha256(token).slice(0,16) instead of token.slice(0,8) — fine-grained PATs all share the github_p prefix and would collide (codex P1 #3184597051) - CloneProjectForm: tighten URL validation to GitHub-only shapes so UI surfaces a friendly hint before the IPC call (codex P2 #3184597055) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4547434 commit f1d8a6e

5 files changed

Lines changed: 176 additions & 24 deletions

File tree

apps/desktop/src/main/services/github/githubService.test.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -760,7 +760,8 @@ describe("githubService.publishCurrentProject", () => {
760760
.mockResolvedValueOnce({ exitCode: 1, stdout: "", stderr: "" })
761761
.mockResolvedValueOnce({ exitCode: 0, stdout: "", stderr: "" })
762762
.mockResolvedValueOnce({ exitCode: 0, stdout: "abc\n", stderr: "" })
763-
.mockResolvedValueOnce({ exitCode: 1, stdout: "", stderr: "Authentication failed" });
763+
.mockResolvedValueOnce({ exitCode: 1, stdout: "", stderr: "Authentication failed" })
764+
.mockResolvedValueOnce({ exitCode: 0, stdout: "", stderr: "" }); // best-effort remote remove
764765

765766
mockFetch.mockResolvedValueOnce(
766767
jsonResponse(201, {
@@ -775,4 +776,52 @@ describe("githubService.publishCurrentProject", () => {
775776
makeService().publishCurrentProject({ name: "proj", isPrivate: true }),
776777
).rejects.toThrow(/authentication failed/i);
777778
});
779+
780+
it("removes the local origin when push fails so retry isn't blocked by remote_already_exists", async () => {
781+
runGitMock
782+
.mockResolvedValueOnce({ exitCode: 1, stdout: "", stderr: "" }) // get-url origin
783+
.mockResolvedValueOnce({ exitCode: 0, stdout: "", stderr: "" }) // remote add origin
784+
.mockResolvedValueOnce({ exitCode: 0, stdout: "abc\n", stderr: "" }) // rev-parse HEAD
785+
.mockResolvedValueOnce({ exitCode: 1, stdout: "", stderr: "fatal: auth" }) // push fails
786+
.mockResolvedValueOnce({ exitCode: 0, stdout: "", stderr: "" }); // remote remove origin
787+
788+
mockFetch.mockResolvedValueOnce(
789+
jsonResponse(201, {
790+
clone_url: "https://github.com/alice/proj.git",
791+
ssh_url: "git@github.com:alice/proj.git",
792+
html_url: "https://github.com/alice/proj",
793+
default_branch: "main",
794+
}),
795+
);
796+
797+
await expect(
798+
makeService().publishCurrentProject({ name: "proj", isPrivate: true }),
799+
).rejects.toThrow();
800+
801+
const gitCalls = runGitMock.mock.calls.map((c) => c[0]);
802+
expect(gitCalls).toContainEqual(["remote", "remove", "origin"]);
803+
});
804+
805+
it("removes the local origin when remote add fails after createRepository succeeded", async () => {
806+
runGitMock
807+
.mockResolvedValueOnce({ exitCode: 1, stdout: "", stderr: "" }) // get-url origin
808+
.mockResolvedValueOnce({ exitCode: 1, stdout: "", stderr: "fatal: cannot lock" }) // remote add fails
809+
.mockResolvedValueOnce({ exitCode: 0, stdout: "", stderr: "" }); // remote remove origin
810+
811+
mockFetch.mockResolvedValueOnce(
812+
jsonResponse(201, {
813+
clone_url: "https://github.com/alice/proj.git",
814+
ssh_url: "git@github.com:alice/proj.git",
815+
html_url: "https://github.com/alice/proj",
816+
default_branch: "main",
817+
}),
818+
);
819+
820+
await expect(
821+
makeService().publishCurrentProject({ name: "proj", isPrivate: true }),
822+
).rejects.toThrow(/Failed to add origin remote/);
823+
824+
const gitCalls = runGitMock.mock.calls.map((c) => c[0]);
825+
expect(gitCalls).toContainEqual(["remote", "remove", "origin"]);
826+
});
778827
});

apps/desktop/src/main/services/github/githubService.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,11 +692,24 @@ export function createGithubService({
692692
isPrivate: args.isPrivate,
693693
});
694694

695+
// After createRepository succeeds, the GitHub repo exists. If we then
696+
// fail to add the local origin or push to it, we must remove the local
697+
// origin we may have added so that a retry isn't blocked by the
698+
// remote_already_exists guard at the top of this function.
699+
const cleanupLocalOrigin = async (): Promise<void> => {
700+
try {
701+
await runGit(["remote", "remove", "origin"], { cwd: projectRoot, timeoutMs: 8_000 });
702+
} catch {
703+
// best-effort
704+
}
705+
};
706+
695707
const remoteAddRes = await runGit(["remote", "add", "origin", created.cloneUrl], {
696708
cwd: projectRoot,
697709
timeoutMs: 8_000,
698710
});
699711
if (remoteAddRes.exitCode !== 0) {
712+
await cleanupLocalOrigin();
700713
throw new Error(`Failed to add origin remote: ${remoteAddRes.stderr.trim() || `exit ${remoteAddRes.exitCode}`}`);
701714
}
702715

@@ -705,6 +718,7 @@ export function createGithubService({
705718
if (headRes.exitCode === 0) {
706719
const pushRes = await runGit(["push", "-u", "origin", "HEAD"], { cwd: projectRoot, timeoutMs: 5 * 60_000 });
707720
if (pushRes.exitCode !== 0) {
721+
await cleanupLocalOrigin();
708722
throw new Error(`Failed to push to origin: ${pushRes.stderr.trim() || `exit ${pushRes.exitCode}`}`);
709723
}
710724
resultState = "pushed";

apps/desktop/src/main/services/projects/projectScaffoldService.test.ts

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -206,9 +206,14 @@ describe("createLocalProject", () => {
206206
githubService: makeGithubServiceStub(),
207207
});
208208

209-
await expect(
210-
service.createLocalProject({ name: "collision", parentDir }),
211-
).rejects.toThrow("target_exists");
209+
let caught: any;
210+
try {
211+
await service.createLocalProject({ name: "collision", parentDir });
212+
} catch (err) {
213+
caught = err;
214+
}
215+
expect(caught).toBeInstanceOf(Error);
216+
expect(caught.code).toBe("target_exists");
212217
});
213218

214219
it("allows reusing an empty existing directory as the target", async () => {
@@ -242,9 +247,14 @@ describe("cloneRepository", () => {
242247
}),
243248
});
244249

245-
await expect(
246-
service.cloneRepository({ url: "https://gitlab.com/foo/bar.git", parentDir }),
247-
).rejects.toThrow("invalid_github_url");
250+
let caught: any;
251+
try {
252+
await service.cloneRepository({ url: "https://gitlab.com/foo/bar.git", parentDir });
253+
} catch (err) {
254+
caught = err;
255+
}
256+
expect(caught).toBeInstanceOf(Error);
257+
expect(caught.code).toBe("invalid_github_url");
248258
});
249259

250260
it("rejects empty URLs", async () => {
@@ -254,9 +264,14 @@ describe("cloneRepository", () => {
254264
githubService: makeGithubServiceStub(),
255265
});
256266

257-
await expect(
258-
service.cloneRepository({ url: "", parentDir }),
259-
).rejects.toThrow("invalid_github_url");
267+
let caught: any;
268+
try {
269+
await service.cloneRepository({ url: "", parentDir });
270+
} catch (err) {
271+
caught = err;
272+
}
273+
expect(caught).toBeInstanceOf(Error);
274+
expect(caught.code).toBe("invalid_github_url");
260275
});
261276

262277
it("invokes git clone with the URL and target path, deriving name from the URL", async () => {
@@ -305,9 +320,14 @@ describe("cloneRepository", () => {
305320
githubService: makeGithubServiceStub(),
306321
});
307322

308-
await expect(
309-
service.cloneRepository({ url: "https://github.com/octocat/Hello-World", parentDir }),
310-
).rejects.toThrow("target_exists");
323+
let caught: any;
324+
try {
325+
await service.cloneRepository({ url: "https://github.com/octocat/Hello-World", parentDir });
326+
} catch (err) {
327+
caught = err;
328+
}
329+
expect(caught).toBeInstanceOf(Error);
330+
expect(caught.code).toBe("target_exists");
311331
});
312332

313333
it("surfaces git clone failures with stderr", async () => {
@@ -436,6 +456,40 @@ describe("listMyGitHubRepos", () => {
436456
expect(apiRequest).toHaveBeenCalledTimes(1);
437457
});
438458

459+
it("invalidates the cache when the token changes (fine-grained PATs share the same prefix)", async () => {
460+
const apiRequest = vi
461+
.fn()
462+
.mockResolvedValueOnce({
463+
data: [
464+
{ owner: { login: "alice" }, name: "first", full_name: "alice/first", private: false, pushed_at: null, default_branch: "main", html_url: "", clone_url: "", ssh_url: "" },
465+
],
466+
response: null,
467+
})
468+
.mockResolvedValueOnce({
469+
data: [
470+
{ owner: { login: "bob" }, name: "second", full_name: "bob/second", private: false, pushed_at: null, default_branch: "main", html_url: "", clone_url: "", ssh_url: "" },
471+
],
472+
response: null,
473+
});
474+
475+
const getTokenOrThrow = vi
476+
.fn<[], string>()
477+
.mockReturnValueOnce("github_pat_AAAAAAAA_one")
478+
.mockReturnValueOnce("github_pat_BBBBBBBB_two");
479+
480+
const service = createProjectScaffoldService({
481+
logger: makeLogger(),
482+
githubService: makeGithubServiceStub({ apiRequest, getTokenOrThrow }),
483+
});
484+
485+
const first = await service.listMyGitHubRepos({});
486+
const second = await service.listMyGitHubRepos({});
487+
488+
expect(apiRequest).toHaveBeenCalledTimes(2);
489+
expect(first.repos.map((r) => r.fullName)).toEqual(["alice/first"]);
490+
expect(second.repos.map((r) => r.fullName)).toEqual(["bob/second"]);
491+
});
492+
439493
it("stops paginating after 5 pages even if every page is full", async () => {
440494
const apiRequest = vi.fn();
441495
const fullPage = Array.from({ length: 100 }, (_, i) => ({

apps/desktop/src/main/services/projects/projectScaffoldService.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import crypto from "node:crypto";
12
import fs from "node:fs";
23
import os from "node:os";
34
import path from "node:path";
@@ -69,14 +70,22 @@ function isGitIdentityError(stderr: string): boolean {
6970
);
7071
}
7172

73+
function codedError(message: string, code: string): Error & { code: string } {
74+
return Object.assign(new Error(message), { code });
75+
}
76+
77+
function hashToken(token: string): string {
78+
return crypto.createHash("sha256").update(token).digest("hex").slice(0, 16);
79+
}
80+
7281
export function createProjectScaffoldService({
7382
logger,
7483
githubService,
7584
}: {
7685
logger: Logger;
7786
githubService: GithubService;
7887
}) {
79-
let cachedRepos: { tokenPrefix: string; expiresAt: number; repos: MyGitHubRepoSummary[] } | null = null;
88+
let cachedRepos: { tokenHash: string; expiresAt: number; repos: MyGitHubRepoSummary[] } | null = null;
8089

8190
const createLocalProject = async (input: CreateProjectInput): Promise<CreateProjectResult> => {
8291
const name = validateProjectName(input.name);
@@ -88,7 +97,7 @@ export function createProjectScaffoldService({
8897
const rootPath = path.join(parentDir, name);
8998

9099
if (fs.existsSync(rootPath) && isDirectoryNonEmpty(rootPath)) {
91-
throw new Error("target_exists");
100+
throw codedError("A folder already exists at that path.", "target_exists");
92101
}
93102

94103
fs.mkdirSync(rootPath, { recursive: true });
@@ -154,11 +163,11 @@ export function createProjectScaffoldService({
154163
const cloneRepository = async (input: CloneProjectInput): Promise<CloneProjectResult> => {
155164
const url = (input.url ?? "").trim();
156165
if (!url) {
157-
throw new Error("invalid_github_url");
166+
throw codedError("Enter a GitHub repository URL.", "invalid_github_url");
158167
}
159168
const repoRef = githubService.parseGitHubRepoFromRemoteUrl(url);
160169
if (!repoRef) {
161-
throw new Error("invalid_github_url");
170+
throw codedError("Only GitHub repository URLs are supported.", "invalid_github_url");
162171
}
163172

164173
const parentDir = (input.parentDir ?? "").trim();
@@ -171,7 +180,7 @@ export function createProjectScaffoldService({
171180
const rootPath = path.join(parentDir, name);
172181

173182
if (fs.existsSync(rootPath) && isDirectoryNonEmpty(rootPath)) {
174-
throw new Error("target_exists");
183+
throw codedError("A folder already exists at that path.", "target_exists");
175184
}
176185

177186
fs.mkdirSync(parentDir, { recursive: true });
@@ -198,10 +207,10 @@ export function createProjectScaffoldService({
198207
throw wrapped;
199208
}
200209

201-
const tokenPrefix = token.slice(0, 8);
210+
const tokenHash = hashToken(token);
202211
const now = Date.now();
203212
let repos: MyGitHubRepoSummary[];
204-
if (cachedRepos && cachedRepos.tokenPrefix === tokenPrefix && cachedRepos.expiresAt > now) {
213+
if (cachedRepos && cachedRepos.tokenHash === tokenHash && cachedRepos.expiresAt > now) {
205214
repos = cachedRepos.repos;
206215
} else {
207216
const collected: MyGitHubRepoSummary[] = [];
@@ -241,7 +250,7 @@ export function createProjectScaffoldService({
241250
if (items.length < perPage) break;
242251
}
243252
repos = collected;
244-
cachedRepos = { tokenPrefix, expiresAt: now + REPO_LIST_CACHE_TTL_MS, repos };
253+
cachedRepos = { tokenHash, expiresAt: now + REPO_LIST_CACHE_TTL_MS, repos };
245254
}
246255

247256
const search = (input.search ?? "").trim().toLowerCase();

apps/desktop/src/renderer/components/projects/CloneProjectForm.tsx

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,33 @@ export type CloneProjectFormProps = {
3737

3838
type Tab = "url" | "my-repos";
3939

40-
const URL_PATTERN = /^(https?:\/\/[^\s]+|git@[^\s:]+:[^\s]+|ssh:\/\/[^\s]+)$/i;
4140
const SLUG_PATTERN = /([^/:]+?)(?:\.git)?$/;
4241

42+
// Mirrors the backend `parseGitHubRepoFromRemoteUrl` accepted shapes so the
43+
// UI surfaces a friendly hint before the IPC call would reject with
44+
// `invalid_github_url`.
45+
function isGitHubRepoUrl(raw: string): boolean {
46+
const trimmed = raw.trim();
47+
if (!trimmed) return false;
48+
const sshScp = trimmed.match(/^git@github\.com:(.+)$/i);
49+
if (sshScp) {
50+
const slug = sshScp[1]!.replace(/\.git$/i, "").trim();
51+
const [owner, name] = slug.split("/");
52+
return Boolean(owner && name);
53+
}
54+
if (/^(https?|ssh):\/\//i.test(trimmed)) {
55+
try {
56+
const url = new URL(trimmed);
57+
if (!/github\.com$/i.test(url.hostname)) return false;
58+
const parts = url.pathname.replace(/^\/+/, "").replace(/\.git$/i, "").split("/");
59+
return Boolean(parts[0]?.trim() && parts[1]?.trim());
60+
} catch {
61+
return false;
62+
}
63+
}
64+
return false;
65+
}
66+
4367
const inputStyle: CSSProperties = {
4468
height: 36,
4569
padding: "0 12px",
@@ -209,7 +233,7 @@ function UrlTab({
209233

210234
const trimmedUrl = url.trim();
211235
const trimmedName = name.trim();
212-
const urlValid = trimmedUrl.length > 0 && URL_PATTERN.test(trimmedUrl);
236+
const urlValid = isGitHubRepoUrl(trimmedUrl);
213237
const previewPath = useMemo(
214238
() => (parentDir && trimmedName ? joinPath(parentDir, trimmedName) : ""),
215239
[parentDir, trimmedName],
@@ -293,7 +317,9 @@ function UrlTab({
293317
disabled={pending}
294318
/>
295319
{url.length > 0 && !urlValid ? (
296-
<InlineHint tone="danger">URL must look like https://, git@…:…, or ssh://…</InlineHint>
320+
<InlineHint tone="danger">
321+
Enter a GitHub URL like https://github.com/owner/repo or git@github.com:owner/repo.git
322+
</InlineHint>
297323
) : null}
298324
</Field>
299325

0 commit comments

Comments
 (0)