feat: declare workspace repository in installation config

This commit is contained in:
2026-08-14 16:32:58 +02:00
parent 9db4463a83
commit 747020a330
9 changed files with 294 additions and 13 deletions
+68
View File
@@ -9,6 +9,7 @@ const execFileAsync = promisify(execFile);
export interface GitStatus {
branch: string;
repository?: WorkspaceRepositoryIdentity;
head?: string;
ahead: number;
behind: number;
@@ -16,6 +17,12 @@ export interface GitStatus {
lastError?: WorkspaceErrorCode;
}
export interface WorkspaceRepositoryIdentity {
host: string;
repository: string;
transport: "https" | "ssh" | "local";
}
export interface EvidenceTreeObject {
mode: "100644" | "100755";
oid: string;
@@ -29,6 +36,62 @@ export class WorkspaceRegistryError extends Error {
}
}
function invalidRemote(): never {
throw new WorkspaceRegistryError("workspace_invalid", "Workspace Git remote is invalid");
}
function safeRepositoryPath(raw: string): string {
let decoded: string;
try {
decoded = decodeURIComponent(raw).replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/, "");
} catch {
return invalidRemote();
}
if (
decoded.length === 0
|| decoded.includes("\\")
|| decoded.split("/").some((part) => part === "" || part === "." || part === "..")
|| /[\p{Cc}\s?#]/u.test(decoded)
) return invalidRemote();
return decoded;
}
/** Convert a configured remote to the only repository identity safe for API/UI responses. */
export function normalizeRepositoryIdentity(remote: string): WorkspaceRepositoryIdentity {
if (remote.length === 0 || remote.trim() !== remote || remote.includes("\0")) return invalidRemote();
if (isAbsolute(remote) || remote.startsWith("file://")) {
return { host: "local", repository: "configured-repository", transport: "local" };
}
const scp = /^git@([^:/\s]+):(.+)$/.exec(remote);
if (scp) {
return { host: scp[1].toLowerCase(), repository: safeRepositoryPath(scp[2]), transport: "ssh" };
}
let parsed: URL;
try {
parsed = new URL(remote);
} catch {
return invalidRemote();
}
if (parsed.search || parsed.hash || !parsed.hostname || parsed.port) return invalidRemote();
if (parsed.protocol === "https:") {
if (parsed.username || parsed.password) return invalidRemote();
return {
host: parsed.hostname.toLowerCase(),
repository: safeRepositoryPath(parsed.pathname),
transport: "https",
};
}
if (parsed.protocol === "ssh:") {
if (parsed.password || (parsed.username !== "" && parsed.username !== "git")) return invalidRemote();
return {
host: parsed.hostname.toLowerCase(),
repository: safeRepositoryPath(parsed.pathname),
transport: "ssh",
};
}
return invalidRemote();
}
function isMissing(path: string): boolean {
try {
lstatSync(path);
@@ -81,6 +144,7 @@ export class GitWorkspaceRepository {
readonly statePath: string;
readonly locksPath: string;
private readonly hooksPath: string;
private readonly identity?: WorkspaceRepositoryIdentity;
constructor(private readonly config: WorkspaceRegistryConfig) {
if (!isAbsolute(config.root)) {
@@ -92,6 +156,9 @@ export class GitWorkspaceRepository {
this.statePath = join(this.root, "state");
this.locksPath = join(this.root, "locks");
this.hooksPath = join(this.locksPath, "empty-hooks");
this.identity = config.remoteUrl === undefined
? undefined
: normalizeRepositoryIdentity(config.remoteUrl);
}
async ensureLayout(): Promise<void> {
@@ -134,6 +201,7 @@ export class GitWorkspaceRepository {
const [ahead = "0", behind = "0"] = tracking ? tracking.trim().split(/\s+/) : [];
return {
branch: this.config.branch,
...(this.identity ? { repository: this.identity } : {}),
head,
ahead: Number(ahead),
behind: Number(behind),
+4
View File
@@ -11,6 +11,7 @@ import {
GitWorkspaceRepository,
WorkspaceRegistryError,
WorkspaceRepositoryLock,
normalizeRepositoryIdentity,
type GitStatus,
} from "./git-repository.js";
import {
@@ -492,6 +493,9 @@ export class WorkspaceRegistry {
if (!active) throw safeError;
return {
branch: this.config.branch,
...(this.config.remoteUrl
? { repository: normalizeRepositoryIdentity(this.config.remoteUrl) }
: {}),
head: active.head,
ahead: 0,
behind: 0,