Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/git/parseGitRemoteUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Parse a git remote URL from a .git/config file content.
* Tries the specified remote name first, falls back to the first available remote.
*/
export function parseGitRemoteUrl(gitConfig: string, remoteName: string): string | undefined {
const remoteRegex = /\[remote "([^"]+)"\]\s*\n((?:\s+[^[].+\n?)*)/g;
const urlRegex = /\s*url\s*=\s*(.+)/;

let fallbackUrl: string | undefined;
let match: RegExpExecArray | null;

while ((match = remoteRegex.exec(gitConfig)) !== null) {
const name = match[1];
const body = match[2];
const urlMatch = body.match(urlRegex);

if (urlMatch) {
const url = urlMatch[1].trim();
if (name === remoteName) {
return url;
}
if (!fallbackUrl) {
fallbackUrl = url;
}
}
}

return fallbackUrl;
}
76 changes: 76 additions & 0 deletions src/git/repository.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import {parseGitRemoteUrl} from "./parseGitRemoteUrl";

describe("parseGitRemoteUrl", () => {
const sampleGitConfig = `[core]
\trepositoryformatversion = 0
\tfilemode = true
[remote "origin"]
\turl = git@github.com:user/repo.git
\tfetch = +refs/heads/*:refs/remotes/origin/*
[remote "upstream"]
\turl = https://github.com/org/repo.git
\tfetch = +refs/heads/*:refs/remotes/upstream/*
[branch "main"]
\tremote = origin
\tmerge = refs/heads/main
`;

it("should find the specified remote by name", () => {
expect(parseGitRemoteUrl(sampleGitConfig, "origin")).toBe("git@github.com:user/repo.git");
});

it("should find a different remote by name", () => {
expect(parseGitRemoteUrl(sampleGitConfig, "upstream")).toBe("https://github.com/org/repo.git");
});

it("should fall back to first remote if specified remote is not found", () => {
expect(parseGitRemoteUrl(sampleGitConfig, "nonexistent")).toBe("git@github.com:user/repo.git");
});

it("should return undefined for empty config", () => {
expect(parseGitRemoteUrl("", "origin")).toBeUndefined();
});

it("should return undefined for config with no remotes", () => {
const configWithoutRemotes = `[core]
\trepositoryformatversion = 0
\tfilemode = true
[branch "main"]
\tremote = origin
\tmerge = refs/heads/main
`;
expect(parseGitRemoteUrl(configWithoutRemotes, "origin")).toBeUndefined();
});

it("should handle HTTPS URLs", () => {
const config = `[remote "origin"]
\turl = https://github.com/owner/project.git
\tfetch = +refs/heads/*:refs/remotes/origin/*
`;
expect(parseGitRemoteUrl(config, "origin")).toBe("https://github.com/owner/project.git");
});

it("should handle SSH URLs without .git suffix", () => {
const config = `[remote "origin"]
\turl = git@github.com:owner/project
\tfetch = +refs/heads/*:refs/remotes/origin/*
`;
expect(parseGitRemoteUrl(config, "origin")).toBe("git@github.com:owner/project");
});

it("should handle enterprise GitHub URLs", () => {
const config = `[remote "origin"]
\turl = https://github.enterprise.com/org/repo.git
\tfetch = +refs/heads/*:refs/remotes/origin/*
`;
expect(parseGitRemoteUrl(config, "origin")).toBe("https://github.enterprise.com/org/repo.git");
});

it("should handle config with spaces around equals sign", () => {
const config = `[remote "origin"]
\turl = git@github.com:user/repo.git
\tfetch = +refs/heads/*:refs/remotes/origin/*
`;
expect(parseGitRemoteUrl(config, "origin")).toBe("git@github.com:user/repo.git");
});
});
72 changes: 71 additions & 1 deletion src/git/repository.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import * as fs from "fs";
import * as path from "path";

import * as vscode from "vscode";
import {Octokit} from "@octokit/rest";

Expand All @@ -8,6 +11,7 @@ import {getGitHubApiUri, getRemoteName, useEnterprise} from "../configuration/co
import {Protocol} from "../external/protocol";
import {logDebug, logError} from "../log";
import {API, GitExtension, RefType, RepositoryState} from "../typings/git";
import {parseGitRemoteUrl} from "./parseGitRemoteUrl";
import {RepositoryPermission, getRepositoryPermission} from "./repository-permissions";

interface GitHubUrls {
Expand All @@ -20,7 +24,14 @@ async function getGitExtension(): Promise<API | undefined> {
const gitExtension = vscode.extensions.getExtension<GitExtension>("vscode.git");
if (gitExtension) {
if (!gitExtension.isActive) {
await gitExtension.activate();
try {
await gitExtension.activate();
} catch (e) {
// In VS Code forks (Kiro, Cursor, etc.), activating built-in extensions
// may be restricted. Fall back gracefully.
logDebug("Unable to activate vscode.git extension, falling back to manual git detection");
return undefined;
}
}
const git = gitExtension.exports.getAPI(1);

Expand Down Expand Up @@ -127,9 +138,68 @@ export async function getGitHubUrls(): Promise<GitHubUrls[] | null> {
}
}

// Fallback: When vscode.git is unavailable (e.g., in VS Code forks),
// try to discover GitHub repositories by reading .git/config directly
if (vscode.workspace.workspaceFolders) {
const fallbackUrls = getGitHubUrlsFromGitConfig();
if (fallbackUrls && fallbackUrls.length > 0) {
return fallbackUrls;
}
}

return null;
}

/**
* Fallback method to discover GitHub repository URLs by reading .git/config directly.
* Used when the vscode.git extension cannot be activated (e.g., in VS Code forks).
*/
function getGitHubUrlsFromGitConfig(): GitHubUrls[] | null {
const workspaceFolders = vscode.workspace.workspaceFolders;
if (!workspaceFolders) {
return null;
}

const results: GitHubUrls[] = [];
const remoteName = getRemoteName();

for (const folder of workspaceFolders) {
if (folder.uri.scheme !== "file") {
continue;
}

try {
const gitConfigPath = path.join(folder.uri.fsPath, ".git", "config");
const gitConfig = fs.readFileSync(gitConfigPath, "utf8");

// Parse remotes from git config
const url = parseGitRemoteUrl(gitConfig, remoteName);
if (!url) {
continue;
}

const host = useEnterprise() ? new URL(getGitHubApiUri()).host : "github.com";

if (
url.indexOf("github.com") !== -1 ||
(useEnterprise() && url.indexOf(host) !== -1) ||
url.indexOf(".ghe.com") !== -1
) {
results.push({
workspaceUri: folder.uri,
url,
protocol: new Protocol(url)
});
}
} catch {
// .git/config doesn't exist or can't be read — skip this folder
logDebug(`Could not read .git/config for workspace folder: ${folder.uri.fsPath}`);
}
}

return results.length > 0 ? results : null;
}

export interface GitHubRepoContext {
client: Octokit;
repositoryState: RepositoryState | undefined;
Expand Down