Author SHA1 Message Date
KenzoandClaude Opus 5 88ad16c86f fix(backend): Publish repariert - spawn git ENOENT und kaputter Image-Build
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/pr/woodpecker Pipeline was successful
ci/woodpecker/pull_request_closed/woodpecker Pipeline was successful
Publish schlug bei jedem Versuch mit "spawn git ENOENT" fehl, obwohl git im
Container installiert ist. Node meldet ENOENT auch dann, wenn das cwd des
Kindprozesses nicht existiert: initialize() setzte simple-git auf den
Workspace, loeschte diesen per rm -rf und startete den Clone anschliessend
aus dem geloeschten Verzeichnis heraus.

Nebeneffekt davon: da Uploads unter GIT_WORKSPACE_DIR/public/images liegen,
hat jeder fehlgeschlagene Publish die hochgeladenen Bilder mitgeloescht.

git.service.ts:
- Clone laeuft aus dem Elternverzeichnis statt aus dem geloeschten Ziel
- Re-Clone nur noch wenn kein brauchbares Repo vorhanden ist
- Uploads werden ueber den Re-Clone hinweg gesichert (Repo-Stand gewinnt)
- commitAndPush scheitert nicht mehr, wenn es nichts zu committen gibt
- reset() nur bei echtem Repo, mit Ausnahme fuer public/images
- Token wird in Fehlermeldungen maskiert

Dockerfile:
- COPY von src/db/migrations entfernt. Das Verzeichnis war nie im Git und
  liess den Image-Build scheitern. Zur Laufzeit wird es nicht gebraucht,
  das Schema legt initDatabase() selbst an.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-11 09:40:29 +02:00
2 changed files with 74 additions and 16 deletions
+2 -1
View File
@@ -39,8 +39,9 @@ RUN npm ci --omit=dev || npm install --production && \
RUN apk del python3 make g++ vips-dev
# Copy built files from builder
# Hinweis: Drizzle-Migrationen werden nicht kopiert - das Schema wird zur
# Laufzeit von initDatabase() angelegt, src/db/migrations existiert nicht.
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/src/db/migrations ./dist/db/migrations
# Copy migration script and migrated images
COPY --from=builder /app/migrate-production.js ./migrate-production.js
+72 -15
View File
@@ -1,43 +1,84 @@
import simpleGit, { SimpleGit } from 'simple-git';
import { mkdir, rm } from 'fs/promises';
import { mkdir, rm, cp } from 'fs/promises';
import { existsSync } from 'fs';
import path from 'path';
import { env } from '../config/env.js';
export class GitService {
private git: SimpleGit;
private workspaceDir: string;
private parentDir: string;
private repoUrl: string;
private token: string;
constructor() {
this.workspaceDir = env.GIT_WORKSPACE_DIR;
this.parentDir = path.dirname(this.workspaceDir);
this.repoUrl = env.GIT_REPO_URL;
this.token = env.GIT_TOKEN;
this.git = simpleGit();
}
async initialize() {
// Ensure workspace directory exists
await mkdir(this.workspaceDir, { recursive: true });
// Elternverzeichnis muss existieren - simple-git startet git von dort aus
await mkdir(this.parentDir, { recursive: true });
// Add token to repo URL for authentication
const authenticatedUrl = this.repoUrl.replace(
'https://',
`https://oauth2:${this.token}@`
`https://oauth2:${encodeURIComponent(this.token)}@`
);
try {
// Check if repo already exists
await this.git.cwd(this.workspaceDir);
await this.git.status();
console.log('Repository already exists, pulling latest...');
await this.git.pull();
} catch {
// Clone if doesn't exist
let usable = false;
if (existsSync(path.join(this.workspaceDir, '.git'))) {
try {
this.git = simpleGit(this.workspaceDir);
await this.git.status();
console.log('Repository already exists, pulling latest...');
await this.git.pull();
usable = true;
} catch (error) {
// Token aus der Meldung entfernen - git gibt die Remote-URL mit aus
const msg = error instanceof Error ? error.message : String(error);
console.warn(
'Existing workspace unusable, re-cloning:',
msg.replace(/\/\/[^@\s/]*@/g, '//***@')
);
}
}
if (!usable) {
console.log('Cloning repository...');
// Hochgeladene Bilder liegen im Workspace und wuerden beim Loeschen
// verschwinden - vorher wegsichern, nach dem Clone zurueckspielen
const imagesDir = path.join(this.workspaceDir, 'public', 'images');
const backupDir = path.join(this.parentDir, '.workspace-images-backup');
const hasImages = existsSync(imagesDir);
await rm(backupDir, { recursive: true, force: true });
if (hasImages) {
await cp(imagesDir, backupDir, { recursive: true });
}
await rm(this.workspaceDir, { recursive: true, force: true });
// Aus dem existierenden Elternverzeichnis klonen, nicht aus dem
// soeben geloeschten Zielverzeichnis (sonst: spawn git ENOENT)
this.git = simpleGit(this.parentDir);
await this.git.clone(authenticatedUrl, this.workspaceDir);
await this.git.cwd(this.workspaceDir);
this.git = simpleGit(this.workspaceDir);
if (hasImages) {
// force: false -> was schon im Repo liegt, bleibt unangetastet
await cp(backupDir, imagesDir, {
recursive: true,
force: false,
errorOnExist: false,
});
await rm(backupDir, { recursive: true, force: true });
}
}
// Configure git user
@@ -47,6 +88,14 @@ export class GitService {
async commitAndPush(message: string): Promise<string> {
await this.git.add('.');
const status = await this.git.status();
if (status.isClean()) {
// Nichts zu committen - aktuellen Stand zurueckgeben statt zu scheitern
const current = await this.git.log({ maxCount: 1 });
return current.latest?.hash || '';
}
await this.git.commit(message);
await this.git.push('origin', 'main');
@@ -59,7 +108,15 @@ export class GitService {
}
async reset() {
await this.git.reset(['--hard', 'HEAD']);
await this.git.clean('f', ['-d']);
// Nur zuruecksetzen wenn wirklich ein Repo da ist - sonst laeuft git
// im Prozess-Arbeitsverzeichnis und raeumt dort auf
if (!existsSync(path.join(this.workspaceDir, '.git'))) {
return;
}
const git = simpleGit(this.workspaceDir);
await git.reset(['--hard', 'HEAD']);
// Uploads ausnehmen - die sind noch nicht committed und waeren sonst weg
await git.clean('f', ['-d', '-e', 'public/images']);
}
}