refactor(patch): simplify patch repository management and enhance git configuration handling

- Removed redundant helper functions for retrieving git configurations for applications and composes, streamlining the codebase.
- Updated the `ensurePatchRepo` function to directly handle repository cloning based on the application or compose type, improving clarity and maintainability.
- Refactored patch creation logic to eliminate unnecessary checks and streamline the process of creating patches.
- Enhanced the handling of output paths in repository cloning functions across different git providers, ensuring consistent behavior.
This commit is contained in:
Mauricio Siu
2026-02-17 01:11:23 -06:00
parent 20320639ce
commit 9818e3c3ba
8 changed files with 71 additions and 381 deletions

View File

@@ -32,90 +32,6 @@ import {
apiUpdatePatch,
} from "@/server/db/schema";
// Helper to get git config from application
const getApplicationGitConfig = (
app: Awaited<ReturnType<typeof findApplicationById>>,
) => {
switch (app.sourceType) {
case "github":
return {
gitUrl: `https://github.com/${app.owner}/${app.repository}.git`,
gitBranch: app.branch || "main",
sshKeyId: null,
};
case "gitlab":
return {
gitUrl: `https://gitlab.com/${app.gitlabOwner}/${app.gitlabRepository}.git`,
gitBranch: app.gitlabBranch || "main",
sshKeyId: null,
};
case "gitea":
return {
gitUrl: app.gitea?.giteaUrl
? `${app.gitea.giteaUrl}/${app.giteaOwner}/${app.giteaRepository}.git`
: "",
gitBranch: app.giteaBranch || "main",
sshKeyId: null,
};
case "bitbucket":
return {
gitUrl: `https://bitbucket.org/${app.bitbucketOwner}/${app.bitbucketRepository}.git`,
gitBranch: app.bitbucketBranch || "main",
sshKeyId: null,
};
case "git":
return {
gitUrl: app.customGitUrl || "",
gitBranch: app.customGitBranch || "main",
sshKeyId: app.customGitSSHKeyId,
};
default:
return null;
}
};
// Helper to get git config from compose
const getComposeGitConfig = (
compose: Awaited<ReturnType<typeof findComposeById>>,
) => {
switch (compose.sourceType) {
case "github":
return {
gitUrl: `https://github.com/${compose.owner}/${compose.repository}.git`,
gitBranch: compose.branch || "main",
sshKeyId: null,
};
case "gitlab":
return {
gitUrl: `https://gitlab.com/${compose.gitlabOwner}/${compose.gitlabRepository}.git`,
gitBranch: compose.gitlabBranch || "main",
sshKeyId: null,
};
case "gitea":
return {
gitUrl: compose.gitea?.giteaUrl
? `${compose.gitea.giteaUrl}/${compose.giteaOwner}/${compose.giteaRepository}.git`
: "",
gitBranch: compose.giteaBranch || "main",
sshKeyId: null,
};
case "bitbucket":
return {
gitUrl: `https://bitbucket.org/${compose.bitbucketOwner}/${compose.bitbucketRepository}.git`,
gitBranch: compose.bitbucketBranch || "main",
sshKeyId: null,
};
case "git":
return {
gitUrl: compose.customGitUrl || "",
gitBranch: compose.customGitBranch || "main",
sshKeyId: compose.customGitSSHKeyId,
};
default:
return null;
}
};
export const patchRouter = createTRPCRouter({
// CRUD Operations
create: protectedProcedure
@@ -222,70 +138,10 @@ export const patchRouter = createTRPCRouter({
type: z.enum(["application", "compose"]),
}),
)
.mutation(async ({ input, ctx }) => {
if (input.type === "application") {
const app = await findApplicationById(input.id);
if (
app.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this application",
});
}
const gitConfig = getApplicationGitConfig(app);
if (!gitConfig || !gitConfig.gitUrl) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Application does not have a git source configured",
});
}
return await ensurePatchRepo({
appName: app.appName,
type: "application",
gitUrl: gitConfig.gitUrl,
gitBranch: gitConfig.gitBranch,
sshKeyId: gitConfig.sshKeyId,
serverId: app.serverId,
});
}
if (input.type === "compose") {
const compose = await findComposeById(input.id);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this compose",
});
}
const gitConfig = getComposeGitConfig(compose);
if (!gitConfig || !gitConfig.gitUrl) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Compose does not have a git source configured",
});
}
return await ensurePatchRepo({
appName: compose.appName,
type: "compose",
gitUrl: gitConfig.gitUrl,
gitBranch: gitConfig.gitBranch,
sshKeyId: gitConfig.sshKeyId,
serverId: compose.serverId,
});
}
throw new TRPCError({
code: "BAD_REQUEST",
message: "Either application or compose must be provided",
.mutation(async ({ input }) => {
return await ensurePatchRepo({
type: input.type,
id: input.id,
});
}),
@@ -432,48 +288,14 @@ export const patchRouter = createTRPCRouter({
});
}
// Generate patch diff
const patchContent = await generatePatch({
codePath: input.repoPath,
filePath: input.filePath,
newContent: input.content,
serverId,
});
if (!patchContent.trim()) {
// No changes - remove existing patch if any
const existingPatch = await findPatchByFilePath(
input.filePath,
input.id,
input.type,
);
if (existingPatch) {
await deletePatch(existingPatch.patchId);
}
return { deleted: true, patchId: null };
}
// Check if patch exists
const existingPatch = await findPatchByFilePath(
input.filePath,
input.id,
input.type,
);
if (existingPatch) {
await updatePatch(existingPatch.patchId, { content: patchContent });
return { deleted: false, patchId: existingPatch.patchId };
}
const newPatch = await createPatch({
filePath: input.filePath,
content: patchContent,
enabled: true,
content: input.content,
applicationId: input.type === "application" ? input.id : undefined,
composeId: input.type === "compose" ? input.id : undefined,
});
return { deleted: false, patchId: newPatch.patchId };
return newPatch;
}),
// Cleanup

View File

@@ -1,16 +1,18 @@
import path, { join } from "node:path";
import { join } from "node:path";
import { paths } from "@dokploy/server/constants";
import { findSSHKeyById } from "@dokploy/server/services/ssh-key";
import { TRPCError } from "@trpc/server";
import { execAsync, execAsyncRemote } from "../utils/process/execAsync";
import { cloneBitbucketRepository } from "../utils/providers/bitbucket";
import { cloneGitRepository } from "../utils/providers/git";
import { cloneGiteaRepository } from "../utils/providers/gitea";
import { cloneGithubRepository } from "../utils/providers/github";
import { cloneGitlabRepository } from "../utils/providers/gitlab";
import { findApplicationById } from "./application";
import { findComposeById } from "./compose";
interface PatchRepoConfig {
appName: string;
type: "application" | "compose";
gitUrl: string;
gitBranch: string;
sshKeyId?: string | null;
serverId?: string | null;
id: string;
}
/**
@@ -18,98 +20,51 @@ interface PatchRepoConfig {
* Returns path to the repo
*/
export const ensurePatchRepo = async ({
appName,
type,
gitUrl,
gitBranch,
sshKeyId,
serverId,
id,
}: PatchRepoConfig): Promise<string> => {
const { PATCH_REPOS_PATH, SSH_PATH } = paths(!!serverId);
const repoPath = join(PATCH_REPOS_PATH, type, appName);
const knownHostsPath = path.join(SSH_PATH, "known_hosts");
let serverId: string | null = null;
// Check if repo exists
const checkCommand = `test -d "${repoPath}/.git" && echo "exists" || echo "not_exists"`;
if (type === "application") {
const application = await findApplicationById(id);
serverId = application.buildServerId || application.serverId;
} else {
const compose = await findComposeById(id);
serverId = compose.serverId;
}
const application =
type === "application"
? await findApplicationById(id)
: await findComposeById(id);
const { PATCH_REPOS_PATH } = paths(!!serverId);
const repoPath = join(PATCH_REPOS_PATH, type, application.appName);
const applicationEntity = {
...application,
type,
serverId: serverId,
outputPathOverride: repoPath,
};
let command = "set -e;";
if (application.sourceType === "github") {
command += await cloneGithubRepository(applicationEntity);
} else if (application.sourceType === "gitlab") {
command += await cloneGitlabRepository(applicationEntity);
} else if (application.sourceType === "gitea") {
command += await cloneGiteaRepository(applicationEntity);
} else if (application.sourceType === "bitbucket") {
command += await cloneBitbucketRepository(applicationEntity);
} else if (application.sourceType === "git") {
command += await cloneGitRepository(applicationEntity);
}
let exists = false;
if (serverId) {
const result = await execAsyncRemote(serverId, checkCommand);
exists = result.stdout.trim() === "exists";
await execAsyncRemote(serverId, command);
} else {
const result = await execAsync(checkCommand);
exists = result.stdout.trim() === "exists";
}
// Setup SSH if needed
let sshSetup = "";
if (sshKeyId) {
const sshKey = await findSSHKeyById(sshKeyId);
const temporalKeyPath = "/tmp/patch_repo_id_rsa";
sshSetup = `
echo "${sshKey.privateKey}" > ${temporalKeyPath};
chmod 600 ${temporalKeyPath};
export GIT_SSH_COMMAND="ssh -i ${temporalKeyPath} -o UserKnownHostsFile=${knownHostsPath} -o StrictHostKeyChecking=accept-new";
`;
}
if (!exists) {
// Clone the repo
const cloneCommand = `
set -e;
${sshSetup}
mkdir -p "${repoPath}";
git clone --branch ${gitBranch} --progress "${gitUrl}" "${repoPath}";
echo "Repository cloned successfully";
`;
try {
if (serverId) {
await execAsyncRemote(serverId, cloneCommand);
} else {
await execAsync(cloneCommand);
}
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Failed to clone repository: ${error}`,
});
}
} else {
// Repo exists - check if on correct branch and update
const updateCommand = `
set -e;
cd "${repoPath}";
${sshSetup}
# Fetch all updates including tags
git fetch origin --tags --force
# Checkout the target (branch or tag) - this handles switching branches/tags
git checkout --force "${gitBranch}"
# If it's a branch that corresponds to a remote branch, hard reset to match remote
# This ensures we pull the latest changes for that branch.
# If it's a tag, we are already at the correct commit after checkout.
if git rev-parse --verify "origin/${gitBranch}" >/dev/null 2>&1; then
git reset --hard "origin/${gitBranch}"
fi
echo "Updated repository to ${gitBranch}"
`;
try {
if (serverId) {
await execAsyncRemote(serverId, updateCommand);
} else {
await execAsync(updateCommand);
}
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Failed to update repository: ${error}`,
});
}
await execAsync(command);
}
return repoPath;
@@ -132,8 +87,6 @@ export const readPatchRepoDirectory = async (
// Use git ls-tree to get tracked files only
const command = `cd "${repoPath}" && git ls-tree -r --name-only HEAD`;
console.log("command", command);
let stdout: string;
try {
if (serverId) {

View File

@@ -11,8 +11,6 @@ import { and, eq, isNotNull } from "drizzle-orm";
export type Patch = typeof patch.$inferSelect;
// CRUD Operations
export const createPatch = async (input: typeof apiCreatePatch._type) => {
if (!input.applicationId && !input.composeId) {
throw new TRPCError({
@@ -25,9 +23,8 @@ export const createPatch = async (input: typeof apiCreatePatch._type) => {
.insert(patch)
.values({
...input,
content: input.content.endsWith("\n")
? input.content
: `${input.content}\n`,
content: input.content,
enabled: true,
})
.returning()
.then((value) => value[0]);
@@ -187,95 +184,3 @@ export const applyPatches = async ({
await execAsync(command);
}
};
interface GeneratePatchOptions {
codePath: string;
filePath: string;
newContent: string;
serverId?: string | null;
}
/**
* Generate a patch from modified file content using git diff
*/
export const generatePatch = async ({
codePath,
filePath,
newContent,
serverId,
}: GeneratePatchOptions): Promise<string> => {
const fullPath = join(codePath, filePath);
// Write new content to the file
const encodedContent = Buffer.from(newContent).toString("base64");
const writeCommand = `echo "${encodedContent}" | base64 -d > "${fullPath}"`;
if (serverId) {
await execAsyncRemote(serverId, writeCommand);
} else {
await execAsync(writeCommand);
}
// Generate diff
const diffCommand = `cd "${codePath}" && git diff -- "${filePath}"`;
let diffResult: string;
if (serverId) {
const result = await execAsyncRemote(serverId, diffCommand);
diffResult = result.stdout;
} else {
const result = await execAsync(diffCommand);
diffResult = result.stdout;
}
// Reset the file to original state
const resetCommand = `cd "${codePath}" && git checkout -- "${filePath}"`;
if (serverId) {
await execAsyncRemote(serverId, resetCommand);
} else {
await execAsync(resetCommand);
}
return diffResult;
};
interface ApplyPatchToContentOptions {
originalContent: string;
patchContent: string;
}
/**
* Apply a patch to content in memory (for preview purposes)
* Returns the patched content or throws an error if patch fails
*/
export const applyPatchToContent = async ({
originalContent,
patchContent,
}: ApplyPatchToContentOptions): Promise<string> => {
// Create temp files and apply patch
const tempDir = "/tmp/patch_preview_" + Date.now();
const tempFile = `${tempDir}/file`;
const patchFile = `${tempDir}/patch.diff`;
const encodedOriginal = Buffer.from(originalContent).toString("base64");
const encodedPatch = Buffer.from(patchContent).toString("base64");
const command = `
mkdir -p "${tempDir}";
echo "${encodedOriginal}" | base64 -d > "${tempFile}";
echo "${encodedPatch}" | base64 -d > "${patchFile}";
cd "${tempDir}" && patch -p0 < "${patchFile}" 2>/dev/null;
cat "${tempFile}";
rm -rf "${tempDir}";
`;
try {
const result = await execAsync(command);
return result.stdout;
} catch {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Failed to apply patch to content",
});
}
};

View File

@@ -86,6 +86,7 @@ interface CloneBitbucketRepository {
enableSubmodules: boolean;
serverId: string | null;
type?: "application" | "compose";
outputPathOverride?: string;
}
export const cloneBitbucketRepository = async ({
@@ -101,6 +102,7 @@ export const cloneBitbucketRepository = async ({
bitbucketId,
enableSubmodules,
serverId,
outputPathOverride,
} = entity;
const { COMPOSE_PATH, APPLICATIONS_PATH } = paths(!!serverId);
@@ -115,7 +117,7 @@ export const cloneBitbucketRepository = async ({
return command;
}
const basePath = type === "compose" ? COMPOSE_PATH : APPLICATIONS_PATH;
const outputPath = join(basePath, appName, "code");
const outputPath = outputPathOverride ?? join(basePath, appName, "code");
command += `rm -rf ${outputPath};`;
command += `mkdir -p ${outputPath};`;
const repoToUse = entity.bitbucketRepositorySlug || bitbucketRepository;

View File

@@ -14,6 +14,7 @@ interface CloneGitRepository {
enableSubmodules?: boolean;
serverId: string | null;
type?: "application" | "compose";
outputPathOverride?: string;
}
export const cloneGitRepository = async ({
@@ -28,6 +29,7 @@ export const cloneGitRepository = async ({
customGitSSHKeyId,
enableSubmodules,
serverId,
outputPathOverride,
} = entity;
const { SSH_PATH, COMPOSE_PATH, APPLICATIONS_PATH } = paths(!!serverId);
@@ -47,7 +49,7 @@ export const cloneGitRepository = async ({
`;
}
const basePath = type === "compose" ? COMPOSE_PATH : APPLICATIONS_PATH;
const outputPath = join(basePath, appName, "code");
const outputPath = outputPathOverride ?? join(basePath, appName, "code");
const knownHostsPath = path.join(SSH_PATH, "known_hosts");
if (!isHttpOrHttps(customGitUrl)) {

View File

@@ -130,6 +130,7 @@ interface CloneGiteaRepository {
enableSubmodules: boolean;
serverId: string | null;
type?: "application" | "compose";
outputPathOverride?: string;
}
export const cloneGiteaRepository = async ({
@@ -145,6 +146,7 @@ export const cloneGiteaRepository = async ({
giteaRepository,
enableSubmodules,
serverId,
outputPathOverride,
} = entity;
const { APPLICATIONS_PATH, COMPOSE_PATH } = paths(!!serverId);
@@ -162,7 +164,7 @@ export const cloneGiteaRepository = async ({
}
const basePath = type === "compose" ? COMPOSE_PATH : APPLICATIONS_PATH;
const outputPath = join(basePath, appName, "code");
const outputPath = outputPathOverride ?? join(basePath, appName, "code");
command += `rm -rf ${outputPath};`;
command += `mkdir -p ${outputPath};`;

View File

@@ -121,6 +121,7 @@ interface CloneGithubRepository {
type?: "application" | "compose";
enableSubmodules: boolean;
serverId: string | null;
outputPathOverride?: string;
}
export const cloneGithubRepository = async ({
type = "application",
@@ -136,6 +137,7 @@ export const cloneGithubRepository = async ({
githubId,
enableSubmodules,
serverId,
outputPathOverride,
} = entity;
const { APPLICATIONS_PATH, COMPOSE_PATH } = paths(!!serverId);
@@ -155,7 +157,7 @@ export const cloneGithubRepository = async ({
const githubProvider = await findGithubById(githubId);
const basePath = isCompose ? COMPOSE_PATH : APPLICATIONS_PATH;
const outputPath = join(basePath, appName, "code");
const outputPath = outputPathOverride ?? join(basePath, appName, "code");
const octokit = authGithub(githubProvider);
const token = await getGithubToken(octokit);
const repoclone = `github.com/${owner}/${repository}.git`;

View File

@@ -107,6 +107,7 @@ interface CloneGitlabRepository {
enableSubmodules: boolean;
serverId: string | null;
type?: "application" | "compose";
outputPathOverride?: string;
}
export const cloneGitlabRepository = async ({
@@ -121,6 +122,7 @@ export const cloneGitlabRepository = async ({
gitlabPathNamespace,
enableSubmodules,
serverId,
outputPathOverride,
} = entity;
const { COMPOSE_PATH, APPLICATIONS_PATH } = paths(!!serverId);
@@ -141,7 +143,7 @@ export const cloneGitlabRepository = async ({
}
const basePath = type === "compose" ? COMPOSE_PATH : APPLICATIONS_PATH;
const outputPath = join(basePath, appName, "code");
const outputPath = outputPathOverride ?? join(basePath, appName, "code");
command += `rm -rf ${outputPath};`;
command += `mkdir -p ${outputPath};`;
const repoClone = getGitlabRepoClone(gitlab, gitlabPathNamespace);