Skip to content

Commit ad138f4

Browse files
committed
Fix URL parser edge cases from PR review
- Strip .git suffix from repo/project names (clone URLs now work) - Conservative self-hosted detection (hostname.startsWith instead of includes) - CLI: Reorder args so options before URL are handled correctly Fixes: 1. URLs like https://github.com/owner/repo.git now parse correctly 2. notgitlab.com no longer incorrectly matches as GitLab 3. now works (options can be anywhere) Added 8 new tests for edge cases. Agent-Id: agent-ce81a04d-72f2-4289-8eb7-c3074d7d8030
1 parent 1f1933d commit ad138f4

File tree

3 files changed

+89
-12
lines changed

3 files changed

+89
-12
lines changed

src/bin/index.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,23 @@ program.addCommand(mcpCommand);
3030
program.addCommand(agentCommand);
3131

3232
// Auto-detect URL mode: ctxc index <url> -> ctxc index url <url>
33-
// This allows users to skip the 'url' subcommand when providing a URL directly
33+
// Scan for URL anywhere after 'index' to support: ctxc index -i name https://...
3434
const indexIdx = process.argv.indexOf("index");
35-
if (indexIdx !== -1 && indexIdx + 1 < process.argv.length) {
36-
const nextArg = process.argv[indexIdx + 1];
35+
if (indexIdx !== -1) {
3736
const subcommands = ["url", "github", "gitlab", "bitbucket", "website"];
38-
if (
39-
nextArg.match(/^https?:\/\//) &&
40-
!subcommands.includes(nextArg)
41-
) {
42-
process.argv.splice(indexIdx + 1, 0, "url");
37+
// Find first URL-like argument after 'index'
38+
for (let i = indexIdx + 1; i < process.argv.length; i++) {
39+
const arg = process.argv[i];
40+
// Stop if we hit a known subcommand
41+
if (subcommands.includes(arg)) break;
42+
// Found a URL - reorder args to put 'url' and the URL right after 'index'
43+
if (arg.match(/^https?:\/\//)) {
44+
// Remove the URL from its current position
45+
process.argv.splice(i, 1);
46+
// Insert 'url' <url> right after 'index'
47+
process.argv.splice(indexIdx + 1, 0, "url", arg);
48+
break;
49+
}
4350
}
4451
}
4552

src/core/url-parser.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,3 +161,60 @@ describe("parseSourceUrl", () => {
161161
});
162162
});
163163

164+
165+
describe("Edge cases", () => {
166+
describe(".git suffix handling", () => {
167+
it("strips .git suffix from GitHub URLs", () => {
168+
const result = parseSourceUrl("https://github.com/owner/repo.git");
169+
expect(result.type).toBe("github");
170+
expect(result.config).toEqual({ owner: "owner", repo: "repo", ref: "HEAD" });
171+
expect(result.defaultIndexName).toBe("repo");
172+
});
173+
174+
it("strips .git suffix from GitLab URLs", () => {
175+
const result = parseSourceUrl("https://gitlab.com/group/project.git");
176+
expect(result.type).toBe("gitlab");
177+
expect(result.config).toEqual({ projectId: "group/project", ref: "HEAD", baseUrl: undefined });
178+
expect(result.defaultIndexName).toBe("project");
179+
});
180+
181+
it("strips .git suffix from Bitbucket URLs", () => {
182+
const result = parseSourceUrl("https://bitbucket.org/workspace/repo.git");
183+
expect(result.type).toBe("bitbucket");
184+
expect(result.config).toEqual({
185+
workspace: "workspace",
186+
repo: "repo",
187+
ref: "HEAD",
188+
baseUrl: undefined,
189+
});
190+
expect(result.defaultIndexName).toBe("repo");
191+
});
192+
});
193+
194+
describe("Conservative self-hosted detection", () => {
195+
it("detects gitlab.company.com as GitLab", () => {
196+
const result = parseSourceUrl("https://gitlab.company.com/team/project");
197+
expect(result.type).toBe("gitlab");
198+
});
199+
200+
it("does NOT match notgitlab.com as GitLab", () => {
201+
const result = parseSourceUrl("https://notgitlab.com/some/path");
202+
expect(result.type).toBe("website");
203+
});
204+
205+
it("does NOT match mygitlabserver.com as GitLab", () => {
206+
const result = parseSourceUrl("https://mygitlabserver.com/some/path");
207+
expect(result.type).toBe("website");
208+
});
209+
210+
it("detects bitbucket.company.com as Bitbucket", () => {
211+
const result = parseSourceUrl("https://bitbucket.company.com/workspace/repo");
212+
expect(result.type).toBe("bitbucket");
213+
});
214+
215+
it("does NOT match notbitbucket.org as Bitbucket", () => {
216+
const result = parseSourceUrl("https://notbitbucket.org/some/path");
217+
expect(result.type).toBe("website");
218+
});
219+
});
220+
});

src/core/url-parser.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ import type { GitLabSourceConfig } from "../sources/gitlab.js";
99
import type { BitBucketSourceConfig } from "../sources/bitbucket.js";
1010
import type { WebsiteSourceConfig } from "../sources/website.js";
1111

12+
/**
13+
* Strip .git suffix from repo/project names
14+
*/
15+
function stripGitSuffix(name: string): string {
16+
return name.endsWith(".git") ? name.slice(0, -4) : name;
17+
}
18+
19+
1220
/**
1321
* Result of parsing a source URL
1422
*/
@@ -43,12 +51,12 @@ export function parseSourceUrl(urlString: string): ParsedUrl {
4351
}
4452

4553
// GitLab (gitlab.com or hostname contains "gitlab")
46-
if (hostname === "gitlab.com" || hostname.includes("gitlab")) {
54+
if (hostname === "gitlab.com" || hostname.startsWith("gitlab.")) {
4755
return parseGitLabUrl(url);
4856
}
4957

5058
// Bitbucket (bitbucket.org or hostname contains "bitbucket")
51-
if (hostname === "bitbucket.org" || hostname.includes("bitbucket")) {
59+
if (hostname === "bitbucket.org" || hostname.startsWith("bitbucket.")) {
5260
return parseBitBucketUrl(url);
5361
}
5462

@@ -76,7 +84,7 @@ function parseGitHubUrl(url: URL): ParsedUrl {
7684
}
7785

7886
const owner = pathParts[0];
79-
const repo = pathParts[1];
87+
const repo = stripGitSuffix(pathParts[1]);
8088
let ref = "HEAD";
8189

8290
// Check for tree/branch or commit/sha patterns
@@ -121,6 +129,11 @@ function parseGitLabUrl(url: URL): ParsedUrl {
121129
}
122130
}
123131

132+
// Strip .git suffix from project name if present
133+
const lastPart = projectParts[projectParts.length - 1];
134+
if (lastPart.endsWith(".git")) {
135+
projectParts[projectParts.length - 1] = stripGitSuffix(lastPart);
136+
}
124137
const projectId = projectParts.join("/");
125138
const projectName = projectParts[projectParts.length - 1];
126139

@@ -149,7 +162,7 @@ function parseBitBucketUrl(url: URL): ParsedUrl {
149162
}
150163

151164
const workspace = pathParts[0];
152-
const repo = pathParts[1];
165+
const repo = stripGitSuffix(pathParts[1]);
153166
let ref = "HEAD";
154167

155168
// Check for /src/branch or /branch/name patterns

0 commit comments

Comments
 (0)