Merge pull request #1287 from markusn/feature/ssh-git-packages

feat(coding-agent): add SSH URL support for git packages
This commit is contained in:
Mario Zechner 2026-02-05 17:09:28 +01:00 committed by GitHub
commit 6c6d937b2d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 576 additions and 48 deletions

View file

@ -6,7 +6,7 @@ import { basename, dirname, join, relative, resolve, sep } from "node:path";
import ignore from "ignore";
import { minimatch } from "minimatch";
import { CONFIG_DIR_NAME } from "../config.js";
import { looksLikeGitUrl } from "../utils/git.js";
import { type GitSource, parseGitUrl } from "../utils/git.js";
import type { PackageSource, SettingsManager } from "./settings-manager.js";
export interface PathMetadata {
@ -68,15 +68,6 @@ type NpmSource = {
pinned: boolean;
};
type GitSource = {
type: "git";
repo: string;
host: string;
path: string;
ref?: string;
pinned: boolean;
};
type LocalSource = {
type: "local";
path: string;
@ -894,21 +885,10 @@ export class DefaultPackageManager implements PackageManager {
};
}
if (source.startsWith("git:") || looksLikeGitUrl(source)) {
const repoSpec = source.startsWith("git:") ? source.slice("git:".length).trim() : source;
const [repo, ref] = repoSpec.split("@");
const normalized = repo.replace(/^https?:\/\//, "").replace(/\.git$/, "");
const parts = normalized.split("/");
const host = parts.shift() ?? "";
const repoPath = parts.join("/");
return {
type: "git",
repo: normalized,
host,
path: repoPath,
ref,
pinned: Boolean(ref),
};
// Try parsing as git URL
const gitParsed = parseGitUrl(source);
if (gitParsed) {
return gitParsed;
}
return { type: "local", path: source };
@ -961,6 +941,8 @@ export class DefaultPackageManager implements PackageManager {
/**
* Get a unique identity for a package, ignoring version/ref.
* Used to detect when the same package is in both global and project settings.
* For git packages, uses normalized host/path to ensure SSH and HTTPS URLs
* for the same repository are treated as identical.
*/
private getPackageIdentity(source: string, scope?: SourceScope): string {
const parsed = this.parseSource(source);
@ -968,7 +950,8 @@ export class DefaultPackageManager implements PackageManager {
return `npm:${parsed.name}`;
}
if (parsed.type === "git") {
return `git:${parsed.repo}`;
// Use host/path for identity to normalize SSH and HTTPS
return `git:${parsed.host}/${parsed.path}`;
}
if (scope) {
const baseDir = this.getBaseDirForScope(scope);
@ -1046,8 +1029,8 @@ export class DefaultPackageManager implements PackageManager {
this.ensureGitIgnore(gitRoot);
}
mkdirSync(dirname(targetDir), { recursive: true });
const cloneUrl = source.repo.startsWith("http") ? source.repo : `https://${source.repo}`;
await this.runCommand("git", ["clone", cloneUrl, targetDir]);
await this.runCommand("git", ["clone", source.repo, targetDir]);
if (source.ref) {
await this.runCommand("git", ["checkout", source.ref], { cwd: targetDir });
}

View file

@ -33,6 +33,7 @@ import { allTools } from "./core/tools/index.js";
import { runMigrations, showDeprecationWarnings } from "./migrations.js";
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.js";
import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.js";
import { parseGitUrl } from "./utils/git.js";
/**
* Read all content from piped stdin.
@ -122,15 +123,13 @@ function normalizeExtensionSource(source: string): { type: "npm" | "git" | "loca
const match = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@.+)?$/);
return { type: "npm", key: match?.[1] ?? spec };
}
if (source.startsWith("git:")) {
const repo = source.slice("git:".length).trim().split("@")[0] ?? "";
return { type: "git", key: repo.replace(/^https?:\/\//, "").replace(/\.git$/, "") };
}
// Raw git URLs
if (source.startsWith("https://") || source.startsWith("http://")) {
const repo = source.split("@")[0] ?? "";
return { type: "git", key: repo.replace(/^https?:\/\//, "").replace(/\.git$/, "") };
// Try parsing as git URL
const parsed = parseGitUrl(source);
if (parsed) {
return { type: "git", key: `${parsed.host}/${parsed.path}` };
}
return { type: "local", key: source };
}

View file

@ -1,6 +1,71 @@
const GIT_HOSTS = ["github.com", "gitlab.com", "bitbucket.org", "codeberg.org"];
import hostedGitInfo from "hosted-git-info";
export function looksLikeGitUrl(source: string): boolean {
const normalized = source.replace(/^https?:\/\//, "");
return GIT_HOSTS.some((host) => normalized.startsWith(`${host}/`));
/**
* Parsed git URL information.
*/
export type GitSource = {
/** Always "git" for git sources */
type: "git";
/** Clone URL (always valid for git clone, without ref suffix) */
repo: string;
/** Git host domain (e.g., "github.com") */
host: string;
/** Repository path (e.g., "user/repo") */
path: string;
/** Git ref (branch, tag, commit) if specified */
ref?: string;
/** True if ref was specified (package won't be auto-updated) */
pinned: boolean;
};
/**
* Parse any git URL (SSH or HTTPS) into a GitSource.
*/
export function parseGitUrl(source: string): GitSource | null {
let url = source.startsWith("git:") ? source.slice(4).trim() : source;
// Try hosted-git-info, converting @ref to #ref if needed
let info = hostedGitInfo.fromUrl(url);
const lastAt = url.lastIndexOf("@");
if ((info?.project?.includes("@") || !info) && lastAt > 0) {
info = hostedGitInfo.fromUrl(`${url.slice(0, lastAt)}#${url.slice(lastAt + 1)}`) ?? info;
url = url.slice(0, lastAt); // strip ref from url for repo field
}
// Try with https:// prefix for shorthand URLs
if (!info) {
info = hostedGitInfo.fromUrl(`https://${url}`);
if (info) url = `https://${url}`; // make repo a valid clone URL
}
if (info) {
return {
type: "git",
repo: url,
host: info.domain || "",
path: `${info.user}/${info.project}`,
ref: info.committish || undefined,
pinned: Boolean(info.committish),
};
}
// Fallback for codeberg (not in hosted-git-info)
const normalized = url.replace(/^https?:\/\//, "").replace(/@[^/]*$/, "");
const codebergHost = "codeberg.org";
if (normalized.startsWith(`${codebergHost}/`)) {
const ref = url.match(/@([^/]+)$/)?.[1];
const repoUrl = ref ? url.slice(0, url.lastIndexOf("@")) : url;
// Ensure repo is a valid clone URL
const cloneableRepo = repoUrl.startsWith("http") ? repoUrl : `https://${repoUrl}`;
return {
type: "git",
repo: cloneableRepo,
host: codebergHost,
path: normalized.slice(codebergHost.length + 1).replace(/\.git$/, ""),
ref,
pinned: Boolean(ref),
};
}
return null;
}