Fix execution workspace reuse and slugify worktrees
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
c610192c53
commit
3c66683169
4 changed files with 176 additions and 46 deletions
|
|
@ -4,6 +4,7 @@ import { sessionCodec as codexSessionCodec } from "@paperclipai/adapter-codex-lo
|
||||||
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
|
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
|
||||||
import {
|
import {
|
||||||
applyPersistedExecutionWorkspaceConfig,
|
applyPersistedExecutionWorkspaceConfig,
|
||||||
|
buildRealizedExecutionWorkspaceFromPersisted,
|
||||||
buildExplicitResumeSessionOverride,
|
buildExplicitResumeSessionOverride,
|
||||||
formatRuntimeWorkspaceWarningLog,
|
formatRuntimeWorkspaceWarningLog,
|
||||||
prioritizeProjectWorkspaceCandidatesForRun,
|
prioritizeProjectWorkspaceCandidatesForRun,
|
||||||
|
|
@ -154,6 +155,51 @@ describe("applyPersistedExecutionWorkspaceConfig", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("buildRealizedExecutionWorkspaceFromPersisted", () => {
|
||||||
|
it("reuses the persisted execution workspace path instead of deriving a new worktree", () => {
|
||||||
|
const result = buildRealizedExecutionWorkspaceFromPersisted({
|
||||||
|
base: buildResolvedWorkspace({
|
||||||
|
cwd: "/tmp/project-primary",
|
||||||
|
repoRef: "main",
|
||||||
|
}),
|
||||||
|
workspace: {
|
||||||
|
id: "execution-workspace-1",
|
||||||
|
companyId: "company-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
projectWorkspaceId: "workspace-1",
|
||||||
|
sourceIssueId: "issue-1",
|
||||||
|
mode: "isolated_workspace",
|
||||||
|
strategyType: "git_worktree",
|
||||||
|
name: "PAP-880-thumbs-capture-for-evals-feature",
|
||||||
|
status: "active",
|
||||||
|
cwd: "/tmp/reused-worktree",
|
||||||
|
repoUrl: "https://example.com/paperclip.git",
|
||||||
|
baseRef: "main",
|
||||||
|
branchName: "PAP-880-thumbs-capture-for-evals-feature",
|
||||||
|
providerType: "git_worktree",
|
||||||
|
providerRef: "/tmp/reused-worktree",
|
||||||
|
derivedFromExecutionWorkspaceId: null,
|
||||||
|
lastUsedAt: new Date(),
|
||||||
|
openedAt: new Date(),
|
||||||
|
closedAt: null,
|
||||||
|
cleanupEligibleAt: null,
|
||||||
|
cleanupReason: null,
|
||||||
|
config: null,
|
||||||
|
metadata: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.created).toBe(false);
|
||||||
|
expect(result.strategy).toBe("git_worktree");
|
||||||
|
expect(result.cwd).toBe("/tmp/reused-worktree");
|
||||||
|
expect(result.worktreePath).toBe("/tmp/reused-worktree");
|
||||||
|
expect(result.branchName).toBe("PAP-880-thumbs-capture-for-evals-feature");
|
||||||
|
expect(result.source).toBe("task_session");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("stripWorkspaceRuntimeFromExecutionRunConfig", () => {
|
describe("stripWorkspaceRuntimeFromExecutionRunConfig", () => {
|
||||||
it("removes workspace runtime before heartbeat execution", () => {
|
it("removes workspace runtime before heartbeat execution", () => {
|
||||||
const input = {
|
const input = {
|
||||||
|
|
|
||||||
|
|
@ -247,6 +247,43 @@ describe("realizeExecutionWorkspace", () => {
|
||||||
expect(second.branchName).toBe(first.branchName);
|
expect(second.branchName).toBe(first.branchName);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("slugifies unsafe issue titles for branch names and worktree folders", async () => {
|
||||||
|
const repoRoot = await createTempRepo();
|
||||||
|
|
||||||
|
const realized = await realizeExecutionWorkspace({
|
||||||
|
base: {
|
||||||
|
baseCwd: repoRoot,
|
||||||
|
source: "project_primary",
|
||||||
|
projectId: "project-1",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
repoUrl: null,
|
||||||
|
repoRef: "HEAD",
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
workspaceStrategy: {
|
||||||
|
type: "git_worktree",
|
||||||
|
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
issue: {
|
||||||
|
id: "issue-unsafe",
|
||||||
|
identifier: "PAP-991",
|
||||||
|
title: "there should be a setting for the allowance of thumbs up / thumbs down data; `rm -rf`",
|
||||||
|
},
|
||||||
|
agent: {
|
||||||
|
id: "agent-1",
|
||||||
|
name: "Codex Coder",
|
||||||
|
companyId: "company-1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(realized.branchName).toBe(
|
||||||
|
"PAP-991-there-should-be-a-setting-for-the-allowance-of-thumbs-up-thumbs-down-data-rm-rf",
|
||||||
|
);
|
||||||
|
expect(realized.branchName?.includes("/")).toBe(false);
|
||||||
|
expect(path.basename(realized.cwd)).toBe(realized.branchName);
|
||||||
|
});
|
||||||
|
|
||||||
it("runs a configured provision command inside the derived worktree", async () => {
|
it("runs a configured provision command inside the derived worktree", async () => {
|
||||||
const repoRoot = await createTempRepo();
|
const repoRoot = await createTempRepo();
|
||||||
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
|
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { execFile as execFileCallback } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { and, asc, desc, eq, gt, inArray, sql } from "drizzle-orm";
|
import { and, asc, desc, eq, gt, inArray, sql } from "drizzle-orm";
|
||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import type { BillingType, ExecutionWorkspaceConfig } from "@paperclipai/shared";
|
import type { BillingType, ExecutionWorkspace, ExecutionWorkspaceConfig } from "@paperclipai/shared";
|
||||||
import {
|
import {
|
||||||
agents,
|
agents,
|
||||||
agentRuntimeState,
|
agentRuntimeState,
|
||||||
|
|
@ -37,6 +37,8 @@ import {
|
||||||
persistAdapterManagedRuntimeServices,
|
persistAdapterManagedRuntimeServices,
|
||||||
realizeExecutionWorkspace,
|
realizeExecutionWorkspace,
|
||||||
releaseRuntimeServicesForRun,
|
releaseRuntimeServicesForRun,
|
||||||
|
type ExecutionWorkspaceInput,
|
||||||
|
type RealizedExecutionWorkspace,
|
||||||
sanitizeRuntimeServiceBaseEnv,
|
sanitizeRuntimeServiceBaseEnv,
|
||||||
} from "./workspace-runtime.js";
|
} from "./workspace-runtime.js";
|
||||||
import { issueService } from "./issues.js";
|
import { issueService } from "./issues.js";
|
||||||
|
|
@ -109,6 +111,32 @@ export function stripWorkspaceRuntimeFromExecutionRunConfig(config: Record<strin
|
||||||
return nextConfig;
|
return nextConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildRealizedExecutionWorkspaceFromPersisted(input: {
|
||||||
|
base: ExecutionWorkspaceInput;
|
||||||
|
workspace: ExecutionWorkspace;
|
||||||
|
}): RealizedExecutionWorkspace {
|
||||||
|
const cwd = readNonEmptyString(input.workspace.cwd) ?? readNonEmptyString(input.workspace.providerRef);
|
||||||
|
if (!cwd) {
|
||||||
|
throw new Error(`Execution workspace ${input.workspace.id} has no local path to reuse.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const strategy = input.workspace.strategyType === "git_worktree" ? "git_worktree" : "project_primary";
|
||||||
|
return {
|
||||||
|
baseCwd: input.base.baseCwd,
|
||||||
|
source: input.workspace.mode === "shared_workspace" ? "project_primary" : "task_session",
|
||||||
|
projectId: input.workspace.projectId ?? input.base.projectId,
|
||||||
|
workspaceId: input.workspace.projectWorkspaceId ?? input.base.workspaceId,
|
||||||
|
repoUrl: input.workspace.repoUrl ?? input.base.repoUrl,
|
||||||
|
repoRef: input.workspace.baseRef ?? input.base.repoRef,
|
||||||
|
strategy,
|
||||||
|
cwd,
|
||||||
|
branchName: input.workspace.branchName ?? null,
|
||||||
|
worktreePath: strategy === "git_worktree" ? (readNonEmptyString(input.workspace.providerRef) ?? cwd) : null,
|
||||||
|
warnings: [],
|
||||||
|
created: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function buildExecutionWorkspaceConfigSnapshot(config: Record<string, unknown>): Partial<ExecutionWorkspaceConfig> | null {
|
function buildExecutionWorkspaceConfigSnapshot(config: Record<string, unknown>): Partial<ExecutionWorkspaceConfig> | null {
|
||||||
const strategy = parseObject(config.workspaceStrategy);
|
const strategy = parseObject(config.workspaceStrategy);
|
||||||
const snapshot: Partial<ExecutionWorkspaceConfig> = {};
|
const snapshot: Partial<ExecutionWorkspaceConfig> = {};
|
||||||
|
|
@ -2085,7 +2113,7 @@ export function heartbeatService(db: Db) {
|
||||||
(explicitResumeSessionDisplayId ? { sessionId: explicitResumeSessionDisplayId } : null) ??
|
(explicitResumeSessionDisplayId ? { sessionId: explicitResumeSessionDisplayId } : null) ??
|
||||||
normalizeSessionParams(sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null));
|
normalizeSessionParams(sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null));
|
||||||
const config = parseObject(agent.adapterConfig);
|
const config = parseObject(agent.adapterConfig);
|
||||||
const executionWorkspaceMode = resolveExecutionWorkspaceMode({
|
const requestedExecutionWorkspaceMode = resolveExecutionWorkspaceMode({
|
||||||
projectPolicy: projectExecutionWorkspacePolicy,
|
projectPolicy: projectExecutionWorkspacePolicy,
|
||||||
issueSettings: issueExecutionWorkspaceSettings,
|
issueSettings: issueExecutionWorkspaceSettings,
|
||||||
legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null,
|
legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null,
|
||||||
|
|
@ -2094,15 +2122,8 @@ export function heartbeatService(db: Db) {
|
||||||
agent,
|
agent,
|
||||||
context,
|
context,
|
||||||
previousSessionParams,
|
previousSessionParams,
|
||||||
{ useProjectWorkspace: executionWorkspaceMode !== "agent_default" },
|
{ useProjectWorkspace: requestedExecutionWorkspaceMode !== "agent_default" },
|
||||||
);
|
);
|
||||||
const workspaceManagedConfig = buildExecutionWorkspaceAdapterConfig({
|
|
||||||
agentConfig: config,
|
|
||||||
projectPolicy: projectExecutionWorkspacePolicy,
|
|
||||||
issueSettings: issueExecutionWorkspaceSettings,
|
|
||||||
mode: executionWorkspaceMode,
|
|
||||||
legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null,
|
|
||||||
});
|
|
||||||
const issueRef = issueContext
|
const issueRef = issueContext
|
||||||
? {
|
? {
|
||||||
id: issueContext.id,
|
id: issueContext.id,
|
||||||
|
|
@ -2116,10 +2137,32 @@ export function heartbeatService(db: Db) {
|
||||||
: null;
|
: null;
|
||||||
const existingExecutionWorkspace =
|
const existingExecutionWorkspace =
|
||||||
issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null;
|
issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null;
|
||||||
|
const shouldReuseExisting =
|
||||||
|
issueRef?.executionWorkspacePreference === "reuse_existing" &&
|
||||||
|
existingExecutionWorkspace &&
|
||||||
|
existingExecutionWorkspace.status !== "archived";
|
||||||
|
const persistedExecutionWorkspaceMode = shouldReuseExisting && existingExecutionWorkspace
|
||||||
|
? issueExecutionWorkspaceModeForPersistedWorkspace(existingExecutionWorkspace.mode)
|
||||||
|
: null;
|
||||||
|
const effectiveExecutionWorkspaceMode: ReturnType<typeof resolveExecutionWorkspaceMode> =
|
||||||
|
persistedExecutionWorkspaceMode === "isolated_workspace" ||
|
||||||
|
persistedExecutionWorkspaceMode === "operator_branch" ||
|
||||||
|
persistedExecutionWorkspaceMode === "agent_default"
|
||||||
|
? persistedExecutionWorkspaceMode
|
||||||
|
: requestedExecutionWorkspaceMode;
|
||||||
|
const workspaceManagedConfig = shouldReuseExisting
|
||||||
|
? { ...config }
|
||||||
|
: buildExecutionWorkspaceAdapterConfig({
|
||||||
|
agentConfig: config,
|
||||||
|
projectPolicy: projectExecutionWorkspacePolicy,
|
||||||
|
issueSettings: issueExecutionWorkspaceSettings,
|
||||||
|
mode: requestedExecutionWorkspaceMode,
|
||||||
|
legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null,
|
||||||
|
});
|
||||||
const persistedWorkspaceManagedConfig = applyPersistedExecutionWorkspaceConfig({
|
const persistedWorkspaceManagedConfig = applyPersistedExecutionWorkspaceConfig({
|
||||||
config: workspaceManagedConfig,
|
config: workspaceManagedConfig,
|
||||||
workspaceConfig: existingExecutionWorkspace?.config ?? null,
|
workspaceConfig: existingExecutionWorkspace?.config ?? null,
|
||||||
mode: executionWorkspaceMode,
|
mode: effectiveExecutionWorkspaceMode,
|
||||||
});
|
});
|
||||||
const mergedConfig = issueAssigneeOverrides?.adapterConfig
|
const mergedConfig = issueAssigneeOverrides?.adapterConfig
|
||||||
? { ...persistedWorkspaceManagedConfig, ...issueAssigneeOverrides.adapterConfig }
|
? { ...persistedWorkspaceManagedConfig, ...issueAssigneeOverrides.adapterConfig }
|
||||||
|
|
@ -2140,39 +2183,43 @@ export function heartbeatService(db: Db) {
|
||||||
heartbeatRunId: run.id,
|
heartbeatRunId: run.id,
|
||||||
executionWorkspaceId: existingExecutionWorkspace?.id ?? null,
|
executionWorkspaceId: existingExecutionWorkspace?.id ?? null,
|
||||||
});
|
});
|
||||||
const executionWorkspace = await realizeExecutionWorkspace({
|
const executionWorkspaceBase = {
|
||||||
base: {
|
baseCwd: resolvedWorkspace.cwd,
|
||||||
baseCwd: resolvedWorkspace.cwd,
|
source: resolvedWorkspace.source,
|
||||||
source: resolvedWorkspace.source,
|
projectId: resolvedWorkspace.projectId,
|
||||||
projectId: resolvedWorkspace.projectId,
|
workspaceId: resolvedWorkspace.workspaceId,
|
||||||
workspaceId: resolvedWorkspace.workspaceId,
|
repoUrl: resolvedWorkspace.repoUrl,
|
||||||
repoUrl: resolvedWorkspace.repoUrl,
|
repoRef: resolvedWorkspace.repoRef,
|
||||||
repoRef: resolvedWorkspace.repoRef,
|
} satisfies ExecutionWorkspaceInput;
|
||||||
},
|
const executionWorkspace = shouldReuseExisting && existingExecutionWorkspace
|
||||||
config: runtimeConfig,
|
? buildRealizedExecutionWorkspaceFromPersisted({
|
||||||
issue: issueRef,
|
base: executionWorkspaceBase,
|
||||||
agent: {
|
workspace: existingExecutionWorkspace,
|
||||||
id: agent.id,
|
})
|
||||||
name: agent.name,
|
: await realizeExecutionWorkspace({
|
||||||
companyId: agent.companyId,
|
base: executionWorkspaceBase,
|
||||||
},
|
config: runtimeConfig,
|
||||||
recorder: workspaceOperationRecorder,
|
issue: issueRef,
|
||||||
});
|
agent: {
|
||||||
|
id: agent.id,
|
||||||
|
name: agent.name,
|
||||||
|
companyId: agent.companyId,
|
||||||
|
},
|
||||||
|
recorder: workspaceOperationRecorder,
|
||||||
|
});
|
||||||
const resolvedProjectId = executionWorkspace.projectId ?? issueRef?.projectId ?? executionProjectId ?? null;
|
const resolvedProjectId = executionWorkspace.projectId ?? issueRef?.projectId ?? executionProjectId ?? null;
|
||||||
const resolvedProjectWorkspaceId = issueRef?.projectWorkspaceId ?? resolvedWorkspace.workspaceId ?? null;
|
const resolvedProjectWorkspaceId = issueRef?.projectWorkspaceId ?? resolvedWorkspace.workspaceId ?? null;
|
||||||
const shouldReuseExisting =
|
|
||||||
issueRef?.executionWorkspacePreference === "reuse_existing" &&
|
|
||||||
existingExecutionWorkspace &&
|
|
||||||
existingExecutionWorkspace.status !== "archived";
|
|
||||||
let persistedExecutionWorkspace = null;
|
let persistedExecutionWorkspace = null;
|
||||||
const nextExecutionWorkspaceMetadataBase = {
|
const nextExecutionWorkspaceMetadataBase = {
|
||||||
...(existingExecutionWorkspace?.metadata ?? {}),
|
...(existingExecutionWorkspace?.metadata ?? {}),
|
||||||
source: executionWorkspace.source,
|
source: executionWorkspace.source,
|
||||||
createdByRuntime: executionWorkspace.created,
|
createdByRuntime: executionWorkspace.created,
|
||||||
} as Record<string, unknown>;
|
} as Record<string, unknown>;
|
||||||
const nextExecutionWorkspaceMetadata = configSnapshot
|
const nextExecutionWorkspaceMetadata = shouldReuseExisting
|
||||||
? mergeExecutionWorkspaceConfig(nextExecutionWorkspaceMetadataBase, configSnapshot)
|
? nextExecutionWorkspaceMetadataBase
|
||||||
: nextExecutionWorkspaceMetadataBase;
|
: configSnapshot
|
||||||
|
? mergeExecutionWorkspaceConfig(nextExecutionWorkspaceMetadataBase, configSnapshot)
|
||||||
|
: nextExecutionWorkspaceMetadataBase;
|
||||||
try {
|
try {
|
||||||
persistedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace
|
persistedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace
|
||||||
? await executionWorkspacesSvc.update(existingExecutionWorkspace.id, {
|
? await executionWorkspacesSvc.update(existingExecutionWorkspace.id, {
|
||||||
|
|
@ -2193,11 +2240,11 @@ export function heartbeatService(db: Db) {
|
||||||
projectWorkspaceId: resolvedProjectWorkspaceId,
|
projectWorkspaceId: resolvedProjectWorkspaceId,
|
||||||
sourceIssueId: issueRef?.id ?? null,
|
sourceIssueId: issueRef?.id ?? null,
|
||||||
mode:
|
mode:
|
||||||
executionWorkspaceMode === "isolated_workspace"
|
requestedExecutionWorkspaceMode === "isolated_workspace"
|
||||||
? "isolated_workspace"
|
? "isolated_workspace"
|
||||||
: executionWorkspaceMode === "operator_branch"
|
: requestedExecutionWorkspaceMode === "operator_branch"
|
||||||
? "operator_branch"
|
? "operator_branch"
|
||||||
: executionWorkspaceMode === "agent_default"
|
: requestedExecutionWorkspaceMode === "agent_default"
|
||||||
? "adapter_managed"
|
? "adapter_managed"
|
||||||
: "shared_workspace",
|
: "shared_workspace",
|
||||||
strategyType: executionWorkspace.strategy === "git_worktree" ? "git_worktree" : "project_primary",
|
strategyType: executionWorkspace.strategy === "git_worktree" ? "git_worktree" : "project_primary",
|
||||||
|
|
@ -2272,8 +2319,8 @@ export function heartbeatService(db: Db) {
|
||||||
const nextIssueWorkspaceMode = issueExecutionWorkspaceModeForPersistedWorkspace(persistedExecutionWorkspace.mode);
|
const nextIssueWorkspaceMode = issueExecutionWorkspaceModeForPersistedWorkspace(persistedExecutionWorkspace.mode);
|
||||||
const shouldSwitchIssueToExistingWorkspace =
|
const shouldSwitchIssueToExistingWorkspace =
|
||||||
issueRef?.executionWorkspacePreference === "reuse_existing" ||
|
issueRef?.executionWorkspacePreference === "reuse_existing" ||
|
||||||
executionWorkspaceMode === "isolated_workspace" ||
|
requestedExecutionWorkspaceMode === "isolated_workspace" ||
|
||||||
executionWorkspaceMode === "operator_branch";
|
requestedExecutionWorkspaceMode === "operator_branch";
|
||||||
const nextIssuePatch: Record<string, unknown> = {};
|
const nextIssuePatch: Record<string, unknown> = {};
|
||||||
if (issueRef?.executionWorkspaceId !== persistedExecutionWorkspace.id) {
|
if (issueRef?.executionWorkspaceId !== persistedExecutionWorkspace.id) {
|
||||||
nextIssuePatch.executionWorkspaceId = persistedExecutionWorkspace.id;
|
nextIssuePatch.executionWorkspaceId = persistedExecutionWorkspace.id;
|
||||||
|
|
@ -2326,7 +2373,7 @@ export function heartbeatService(db: Db) {
|
||||||
context.paperclipWorkspace = {
|
context.paperclipWorkspace = {
|
||||||
cwd: executionWorkspace.cwd,
|
cwd: executionWorkspace.cwd,
|
||||||
source: executionWorkspace.source,
|
source: executionWorkspace.source,
|
||||||
mode: executionWorkspaceMode,
|
mode: effectiveExecutionWorkspaceMode,
|
||||||
strategy: executionWorkspace.strategy,
|
strategy: executionWorkspace.strategy,
|
||||||
projectId: executionWorkspace.projectId,
|
projectId: executionWorkspace.projectId,
|
||||||
workspaceId: executionWorkspace.workspaceId,
|
workspaceId: executionWorkspace.workspaceId,
|
||||||
|
|
|
||||||
|
|
@ -194,9 +194,9 @@ function toRuntimeServiceRef(record: RuntimeServiceRecord, overrides?: Partial<R
|
||||||
function sanitizeSlugPart(value: string | null | undefined, fallback: string): string {
|
function sanitizeSlugPart(value: string | null | undefined, fallback: string): string {
|
||||||
const raw = (value ?? "").trim().toLowerCase();
|
const raw = (value ?? "").trim().toLowerCase();
|
||||||
const normalized = raw
|
const normalized = raw
|
||||||
.replace(/[^a-z0-9/_-]+/g, "-")
|
.replace(/[^a-z0-9_-]+/g, "-")
|
||||||
.replace(/-+/g, "-")
|
.replace(/-+/g, "-")
|
||||||
.replace(/^[-/]+|[-/]+$/g, "");
|
.replace(/^[-_]+|[-_]+$/g, "");
|
||||||
return normalized.length > 0 ? normalized : fallback;
|
return normalized.length > 0 ? normalized : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -231,9 +231,9 @@ function renderWorkspaceTemplate(template: string, input: {
|
||||||
function sanitizeBranchName(value: string): string {
|
function sanitizeBranchName(value: string): string {
|
||||||
return value
|
return value
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/[^A-Za-z0-9._/-]+/g, "-")
|
.replace(/[^A-Za-z0-9_-]+/g, "-")
|
||||||
.replace(/-+/g, "-")
|
.replace(/-+/g, "-")
|
||||||
.replace(/^[-/.]+|[-/.]+$/g, "")
|
.replace(/^[-_]+|[-_]+$/g, "")
|
||||||
.slice(0, 120) || "paperclip-work";
|
.slice(0, 120) || "paperclip-work";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue