diff --git a/.gitattributes b/.gitattributes index b214903c..b53d643a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -18,16 +18,14 @@ docker-compose.yml linguist-language=Dockerfile # HTML DIR /cmd/findergen/frontend/** linguist-vendored -/finder-template-generator-ssg/md-linter-plugin.ts linguist-vendored -/finder-template-generator-ssg/vite-pluginbuild-stats.ts linguist-vendored -/finder-template-generator-ssg/pages-ssg-plugin.ts linguist-vendored +# /finder-template-generator-ssg/md-linter-plugin.ts linguist-vendored +# /finder-template-generator-ssg/vite-pluginbuild-stats.ts linguist-vendored +# /finder-template-generator-ssg/pages-ssg-plugin.ts linguist-vendored -/finder-template-generator-ssg/src/pages.d.ts linguist-vendored -/finder-template-generator-ssg/src/jsx-runtime.ts linguist-vendored -/finder-template-generator-ssg/src/jsx.d.ts linguist-vendored +# /finder-template-generator-ssg/src/pages.d.ts linguist-vendored +# /finder-template-generator-ssg/src/jsx-runtime.ts linguist-vendored +# /finder-template-generator-ssg/src/jsx.d.ts linguist-vendored -/finder-template-generator-ssg/src/templates.js linguist-vendored - -/finder-template-generator-ssg/public/install.sh linguist-vendored +/npm/site/src/templates.js linguist-vendored /.obsidian/** linguist-vendored diff --git a/.github/workflows/deploywebpage.yml b/.github/workflows/deploywebpage.yml index c7f4df75..a10db3b4 100644 --- a/.github/workflows/deploywebpage.yml +++ b/.github/workflows/deploywebpage.yml @@ -24,20 +24,20 @@ jobs: with: node-version: 22 cache: npm - cache-dependency-path: finder-template-generator-ssg/package-lock.json + cache-dependency-path: npm/site/package-lock.json - name: Install dependencies run: npm ci - working-directory: finder-template-generator-ssg + working-directory: npm/site - name: Build static site run: npm run build:static - working-directory: finder-template-generator-ssg + working-directory: npm/site - name: Upload Pages artifact uses: actions/upload-pages-artifact@v4 with: - path: ./finder-template-generator-ssg/dist-static + path: ./npm/site/dist-static deploy: needs: build diff --git a/internal/bt/gitrepo/gitrepo.go b/internal/bt/gitrepo/gitrepo.go index f2ad5224..35df566f 100644 --- a/internal/bt/gitrepo/gitrepo.go +++ b/internal/bt/gitrepo/gitrepo.go @@ -404,6 +404,10 @@ func restoreZip(archive, destination string) error { if err := os.MkdirAll(destination, 0755); err != nil { return err } + rootAbs, err := filepath.Abs(destination) + if err != nil { + return err + } for _, file := range reader.File { if file.Name == "" { return fmt.Errorf("unsicherer ZIP-Pfad erkannt: %s", file.Name) @@ -417,13 +421,16 @@ func restoreZip(archive, destination string) error { filepath.VolumeName(entryPath) != "" { return fmt.Errorf("unsicherer ZIP-Pfad erkannt: %s", file.Name) } - target := filepath.Join(destination, entryPath) + target := filepath.Join(rootAbs, entryPath) resolved, err := filepath.Abs(target) if err != nil { return err } - root, _ := filepath.Abs(destination) - if resolved != root && !strings.HasPrefix(resolved, root+string(os.PathSeparator)) { + rel, err := filepath.Rel(rootAbs, resolved) + if err != nil { + return err + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) { return fmt.Errorf("unsicherer ZIP-Pfad erkannt: %s", file.Name) } if file.Name == "gitpack.json" { diff --git a/npm/finder-lib/src/index.ts b/npm/finder-lib/src/index.ts index 898cb2b8..7f5ff86b 100644 --- a/npm/finder-lib/src/index.ts +++ b/npm/finder-lib/src/index.ts @@ -129,11 +129,13 @@ export namespace versions { export interface Size { min?: number; max?: number; - min_size_type?: string; - max_size_type?: string; + min_size_type?: SizeType; + max_size_type?: SizeType; } export type Existence = "required" | "forbidden" | "optional"; + + export type SizeType = "B" | "KB" | "MB" | "GB"; } /// Version v0.3.16 @@ -169,8 +171,8 @@ export namespace versions { export interface Size { min?: number; max?: number; - min_size_type?: string; - max_size_type?: string; + min_size_type?: SizeType; + max_size_type?: SizeType; } export interface Checksums { @@ -179,6 +181,8 @@ export namespace versions { } export type Existence = "required" | "forbidden" | "optional"; + + export type SizeType = "B" | "KB" | "MB" | "GB"; } /// Version v0.3.17 @@ -215,8 +219,8 @@ export namespace versions { export interface Size { min?: number; max?: number; - min_size_type?: string; - max_size_type?: string; + min_size_type?: SizeType; + max_size_type?: SizeType; } export interface Checksums { @@ -225,5 +229,7 @@ export namespace versions { } export type Existence = "required" | "forbidden" | "optional"; + + export type SizeType = "B" | "KB" | "MB" | "GB"; } } diff --git a/npm/site/public/install.sh b/npm/site/public/install.sh deleted file mode 100644 index 61504b95..00000000 --- a/npm/site/public/install.sh +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env bash - -set -e - -REPO="shadowdara/finder" -INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}" - -detect_os() { - case "$(uname -s)" in - Linux*) echo "linux" ;; - Darwin*) echo "darwin" ;; - MINGW*|MSYS*|CYGWIN*) echo "windows" ;; - *) - echo "Unsupported OS: $(uname -s)" >&2 - exit 1 - ;; - esac -} - -detect_arch() { - case "$(uname -m)" in - x86_64|amd64) echo "amd64" ;; - arm64|aarch64) echo "arm64" ;; - *) - echo "Unsupported architecture: $(uname -m)" >&2 - exit 1 - ;; - esac -} - -OS="$(detect_os)" -ARCH="$(detect_arch)" - -echo "Detecting platform..." -echo " OS: $OS" -echo " Arch: $ARCH" - -VERSION="$( - curl -fsSL \ - "https://api.github.com/repos/${REPO}/releases/latest" | - grep '"tag_name":' | - sed -E 's/.*"([^"]+)".*/\1/' -)" - -if [ -z "$VERSION" ]; then - echo "Could not determine latest version." >&2 - exit 1 -fi - -echo " Version: $VERSION" - -ARCHIVE="finder_${VERSION}_${OS}_${ARCH}" - -if [ "$OS" = "windows" ]; then - ARCHIVE="${ARCHIVE}.zip" -else - ARCHIVE="${ARCHIVE}.tar.gz" -fi - -URL="https://github.com/${REPO}/releases/download/${VERSION}/${ARCHIVE}" - -TMP="$(mktemp -d)" -trap 'rm -rf "$TMP"' EXIT - -echo "Downloading:" -echo " $URL" - -curl -fL "$URL" -o "$TMP/archive" - -mkdir -p "$INSTALL_DIR" - -if [ "$OS" = "windows" ]; then - unzip -q "$TMP/archive" -d "$TMP/package" -else - mkdir "$TMP/package" - tar -xzf "$TMP/archive" -C "$TMP/package" -fi - -if [ "$OS" = "windows" ]; then - cp "$TMP/package/finder.exe" "$INSTALL_DIR/finder.exe" -else - install -m 755 "$TMP/package/finder" "$INSTALL_DIR/finder" -fi - -echo -echo "Finder installed successfully!" -echo -echo " $INSTALL_DIR/finder" - -if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then - echo "export PATH=\"\$PATH:$INSTALL_DIR\"" >> "$HOME/.bashrc" - export PATH="$PATH:$INSTALL_DIR" -fi - -source ~/.bashrc diff --git a/npm/site/src/configeditor/editor.tsx b/npm/site/src/configeditor/editor.tsx index d2ed7635..84845011 100644 --- a/npm/site/src/configeditor/editor.tsx +++ b/npm/site/src/configeditor/editor.tsx @@ -118,15 +118,26 @@ export function setupConfigEditor( function setPath(obj: any, path: string, value: unknown) { const parts = path.split("."); const blockedKeys = new Set(["__proto__", "constructor", "prototype"]); - if (parts.some((part) => blockedKeys.has(part))) return; + if (parts.length === 0 || parts.some((part) => blockedKeys.has(part))) return; - let cur = obj; + let cur: any = obj; for (let i = 0; i < parts.length - 1; i++) { const p = parts[i]; - if (typeof cur[p] !== "object" || cur[p] === null) cur[p] = {}; + if (blockedKeys.has(p)) return; + if (typeof cur !== "object" || cur === null) return; + + if (!Object.prototype.hasOwnProperty.call(cur, p)) { + cur[p] = Object.create(null); + } else if (typeof cur[p] !== "object" || cur[p] === null) { + cur[p] = Object.create(null); + } cur = cur[p]; } - cur[parts[parts.length - 1]] = value; + + const last = parts[parts.length - 1]; + if (blockedKeys.has(last)) return; + if (typeof cur !== "object" || cur === null) return; + cur[last] = value; } function readField(input: any): unknown { diff --git a/pub/db/git/README.md b/pub/db/git/README.md index df6cd20f..dc1e987f 100644 --- a/pub/db/git/README.md +++ b/pub/db/git/README.md @@ -1,7 +1,7 @@ # pub/db/git — gitdb Go-Bibliothek zum Scannen von Git-Repositories über die installierte -Git-CLI und zum Export nach **SQLite** oder **JSON**. +Git-CLI und zum Export nach **SQLite**, **SQL-Dump** oder **JSON**. ## Voraussetzungen @@ -24,6 +24,7 @@ func main() { report, err := gitdb.Export(gitdb.ExportOptions{ RepoPath: "/pfad/zum/repo", SQLitePath: "export.db", // Optional + SQLPath: "dump.sql", // Optional: SQL-Dump JSONDir: "./json-export", // Optional WithBlobs: true, // Blob-Inhalte mit exportieren LimitCommits: 100, // 0 = unbegrenzt @@ -45,6 +46,7 @@ func main() { | `Export(ExportOptions)` | Scannt Repo + schreibt alle angegebenen Ziele | | `Snapshot(ExportOptions)` | Scannt Repo, liefert `*RepoData` ohne Export | | `ExportSQLite(path, data)` | Schreibt `*RepoData` in eine SQLite-DB | +| `ExportSQL(path, data)` | Schreibt `*RepoData` als SQL-Dump (`.sql`) | | `ExportJSON(dir, data)` | Schreibt `*RepoData` als JSON-Dateien | | `ReadBlob(dbPath, hash)` | Liest einen dekomprimierten Blob aus der DB | | `GitVersion()` | Liefert die installierte Git-Version | @@ -55,15 +57,14 @@ func main() { type ExportOptions struct { RepoPath string // Pfad zum Git-Repository (leer = aktuelles Verzeichnis) SQLitePath string // Ziel-Datei für SQLite-Export + SQLPath string // Ziel-Datei für SQL-Dump (*.sql) JSONDir string // Ziel-Verzeichnis für JSON-Export WithBlobs bool // Blob-Inhalte mit exportieren LimitCommits int // Max. Anzahl Commits (0 = alle) } ``` -## Datenmodell - -### Tabellen (SQLite) +## Datenmodell & SQL-Dump) | Tabelle | Beschreibung | | -------------- | ------------------------------------------ | @@ -74,6 +75,14 @@ type ExportOptions struct { | `tree_entries` | Alle Datei-Einträge pro Commit | | `blobs` | Blob-Objekte (Inhalte gzip-komprimiert) | +Der SQL-Dump (`ExportSQL`) enthält dasselbe Schema wie die SQLite-Tabelle – +als `CREATE TABLE`- und `INSERT`-Statements, gekapselt in `BEGIN`/`COMMIT`. +Blob-Inhalte werden wie in SQLite gzip-komprimiert als hex-Literale +(`X'...'`) geschrieben. Der Dump ist direkt in SQLite importierbar +(`sqlite3 db.sqlite < dump.sql`). +| `tree_entries` | Alle Datei-Einträge pro Commit | +| `blobs` | Blob-Objekte (Inhalte gzip-komprimiert) | + ### JSON-Dateien ``` @@ -108,13 +117,23 @@ report, err := gitdb.Export(gitdb.ExportOptions{ ### Nur JSON, alle Referenzen +```` + +### Nur SQL-Dump + +```go +report, err := gitdb.Export(gitdb.ExportOptions{ + RepoPath: ".", + SQLPath: "./dump.sql", + WithBlobs: true, +}) ```go report, err := gitdb.Export(gitdb.ExportOptions{ RepoPath: ".", JSONDir: "./export", WithBlobs: true, }) -``` +```` ### Blobs aus SQLite lesen diff --git a/pub/db/git/doc.go b/pub/db/git/doc.go index 006e4105..efb173e6 100644 --- a/pub/db/git/doc.go +++ b/pub/db/git/doc.go @@ -1,6 +1,6 @@ // Package gitdb ist eine Go-Bibliothek zum Scannen von Git-Repositories // über die auf dem System installierte Git-CLI und zum Export der -// gescannten Daten nach SQLite oder JSON. +// gescannten Daten nach SQLite, SQL-Dump oder JSON. // // # Überblick // @@ -25,6 +25,7 @@ // RepoPath: "./mein-repo", // SQLitePath: "repo.db", // JSONDir: "./repo-export", +// SQLPath: "repo.sql", // WithBlobs: true, // } // meta, err := gitdb.Export(opts) @@ -40,6 +41,9 @@ // refs, commits, parents, author, committer, tree_entries und blobs. // Blob-Inhalte können auf Wunsch inline (gzip-komprimiert) gespeichert // oder nur als Metadaten erfasst werden. +// SQL-Dump (ExportSQL): Eine einzelne .sql-Datei mit demselben Schema, +// als CREATE TABLE- und INSERT-Statements (BEGIN/COMMIT). Direkt in +// SQLite importierbar. // // JSON (ExportJSON): Eine Menge von JSON-Dateien im Ausgabeverzeichnis: // diff --git a/pub/db/git/gitdb_test.go b/pub/db/git/gitdb_test.go index ddf249c6..b4c0e474 100644 --- a/pub/db/git/gitdb_test.go +++ b/pub/db/git/gitdb_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" ) @@ -125,6 +126,34 @@ func TestGzipBytesRoundTrip(t *testing.T) { } } +func TestSQLStr(t *testing.T) { + cases := []struct{ in, want string }{ + {"", "NULL"}, + {"abc", "'abc'"}, + {"O'Brien", "'O''Brien'"}, + {"a'b'c", "'a''b''c'"}, + {"name with spaces", "'name with spaces'"}, + } + for _, tc := range cases { + if got := sqlStr(tc.in); got != tc.want { + t.Errorf("sqlStr(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestSQLBlob(t *testing.T) { + if got := sqlBlob(nil); got != "NULL" { + t.Errorf("sqlBlob(nil) = %q, want NULL", got) + } + if got := sqlBlob([]byte{}); got != "NULL" { + t.Errorf("sqlBlob(empty) = %q, want NULL", got) + } + // 0x00 0x01 0xFF → "0001ff" + if got := sqlBlob([]byte{0x00, 0x01, 0xFF}); got != "X'0001ff'" { + t.Errorf("sqlBlob = %q, want X'0001ff'", got) + } +} + func TestCommitHashes(t *testing.T) { commits := []Commit{ {Hash: "abc123"}, @@ -390,6 +419,106 @@ func TestExportBothSQLiteAndJSON(t *testing.T) { } } +func TestExportSQL(t *testing.T) { + if _, err := GitVersion(); err != nil { + t.Skip("git nicht installiert") + } + + dir := t.TempDir() + sqlPath := filepath.Join(dir, "export.sql") + + opts := ExportOptions{ + RepoPath: "c:/Users/dara/Documents/GitHub/finder", + SQLPath: sqlPath, + WithBlobs: true, + LimitCommits: 2, + } + report, err := Export(opts) + if err != nil { + t.Fatalf("Export: %v", err) + } + if report.SQLPath == "" { + t.Error("Report.SQLPath sollte gesetzt sein") + } + + // Datei muss existieren und nicht leer sein + fi, err := os.Stat(sqlPath) + if err != nil { + t.Fatalf("SQL-Datei existiert nicht: %v", err) + } + if fi.Size() == 0 { + t.Fatal("SQL-Datei ist leer") + } + + content, err := os.ReadFile(sqlPath) + if err != nil { + t.Fatalf("SQL-Datei lesen: %v", err) + } + s := string(content) + + // Struktur prüfen + for _, want := range []string{ + "BEGIN;", "COMMIT;", + "CREATE TABLE IF NOT EXISTS meta", + "CREATE TABLE IF NOT EXISTS refs", + "CREATE TABLE IF NOT EXISTS commits", + "CREATE TABLE IF NOT EXISTS parents", + "CREATE TABLE IF NOT EXISTS tree_entries", + "CREATE TABLE IF NOT EXISTS blobs", + "INSERT INTO commits", + "INSERT INTO refs", + } { + if !strings.Contains(s, want) { + t.Errorf("SQL-Dump enthält nicht %q", want) + } + } + + // SQLite-kompatibel: Dump in eine frische DB einspielen und Tabellen zählen + db, err := sql.Open("sqlite", filepath.Join(dir, "restored.db")) + if err != nil { + t.Fatalf("DB öffnen: %v", err) + } + defer db.Close() + + if _, err := db.Exec(s); err != nil { + t.Fatalf("SQL-Dump in SQLite einspielen: %v", err) + } + for _, tbl := range []string{"meta", "refs", "commits", "parents", "tree_entries", "blobs"} { + var count int + if err := db.QueryRow("SELECT COUNT(*) FROM " + tbl).Scan(&count); err != nil { + t.Errorf("Tabelle %s: %v", tbl, err) + continue + } + t.Logf("Tabelle %s: %d Zeilen (restored)", tbl, count) + } +} + +func TestExportAllTargets(t *testing.T) { + if _, err := GitVersion(); err != nil { + t.Skip("git nicht installiert") + } + + dir := t.TempDir() + opts := ExportOptions{ + RepoPath: "c:/Users/dara/Documents/GitHub/finder", + SQLitePath: filepath.Join(dir, "test.db"), + SQLPath: filepath.Join(dir, "dump.sql"), + JSONDir: filepath.Join(dir, "json"), + WithBlobs: true, + LimitCommits: 1, + } + report, err := Export(opts) + if err != nil { + t.Fatalf("Export: %v", err) + } + if report.SQLitePath == "" || report.SQLPath == "" || report.JSONDir == "" { + t.Error("alle drei Pfade sollten gesetzt sein") + } + if _, err := os.Stat(report.SQLPath); err != nil { + t.Errorf("SQL-Dump fehlt: %v", err) + } +} + func TestExportNoTarget(t *testing.T) { opts := ExportOptions{RepoPath: "."} _, err := Export(opts) diff --git a/pub/db/git/model.go b/pub/db/git/model.go index 36003791..d94fbc5f 100644 --- a/pub/db/git/model.go +++ b/pub/db/git/model.go @@ -18,6 +18,10 @@ type ExportOptions struct { // Dateinamen erzeugt bzw. überschrieben. SQLitePath string + // SQLPath: wenn nicht leer, wird ein SQL-Dump (CREATE TABLE + INSERT) + // in diese .sql-Datei geschrieben. + SQLPath string + // JSONDir: wenn nicht leer, werden die JSON-Dateien in dieses // Verzeichnis geschrieben (wird bei Bedarf angelegt). JSONDir string @@ -98,14 +102,15 @@ type Report struct { ByteSize int64 `json:"byte_size"` // Summe der Blob-Größen Elapsed string `json:"elapsed"` // Dauer als String SQLitePath string `json:"sqlite_path,omitempty"` + SQLPath string `json:"sql_path,omitempty"` JSONDir string `json:"json_dir,omitempty"` } // Export extrahiert das Repository und schreibt alle angegebenen Ziele. // mindestens eines von SQLitePath/JSONDir muss gesetzt sein. func Export(opts ExportOptions) (*Report, error) { - if opts.SQLitePath == "" && opts.JSONDir == "" { - return nil, fmt.Errorf("gitdb: Export: weder SQLitePath noch JSONDir gesetzt") + if opts.SQLitePath == "" && opts.JSONDir == "" && opts.SQLPath == "" { + return nil, fmt.Errorf("gitdb: Export: weder SQLitePath, SQLPath noch JSONDir gesetzt") } start := time.Now() @@ -135,6 +140,13 @@ func Export(opts ExportOptions) (*Report, error) { rep.SQLitePath = opts.SQLitePath } + if opts.SQLPath != "" { + if err := ExportSQL(opts.SQLPath, data); err != nil { + return nil, err + } + rep.SQLPath = opts.SQLPath + } + if opts.JSONDir != "" { if err := ExportJSON(opts.JSONDir, data); err != nil { return nil, err diff --git a/pub/db/git/sqldump.go b/pub/db/git/sqldump.go new file mode 100644 index 00000000..9af48516 --- /dev/null +++ b/pub/db/git/sqldump.go @@ -0,0 +1,164 @@ +package gitdb + +import ( + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// ExportSQL schreibt den Snapshot als SQL-Dump in eine einzelne .sql-Datei. +// +// Das Schema entspricht dem von ExportSQLite: meta, refs, commits, parents, +// tree_entries und blobs werden als CREATE TABLE- und INSERT-Statements +// ausgegeben. Die Datei ist in BEGIN/COMMIT gekapselt und SQLite-kompatibel. +// +// Blob-Inhalte werden – sofern vorhanden (WithBlobs) – gzip-komprimiert und +// als hex-kodierte Literale (X'...') geschrieben, identisch zur SQLite- +// Ablage in sqlite.go. Ohne WithBlobs bleibt die Spalte data NULL. +func ExportSQL(path string, data *RepoData) error { + if path == "" { + return fmt.Errorf("gitdb: ExportSQL: leerer Pfad") + } + if dir := filepath.Dir(path); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("gitdb: ExportSQL: Verzeichnis: %w", err) + } + } + + var b strings.Builder + + // Kopf + b.WriteString("-- ============================================\n") + b.WriteString("-- Finder gitdb SQL-Dump\n") + fmt.Fprintf(&b, "-- Erzeugt: %s\n", time.Now().UTC().Format(time.RFC3339)) + fmt.Fprintf(&b, "-- Repository: %s\n", data.Meta.Path) + fmt.Fprintf(&b, "-- Commits: %d | Refs: %d | Bäume: %d | Blobs: %d\n\n", + len(data.Commits), len(data.Refs), len(data.Trees), len(data.Blobs)) + b.WriteString("-- ============================================\n\n") + + b.WriteString("BEGIN;\n\n") + + // Schema (identisch zu ExportSQLite, inkl. Indizes) + b.WriteString(schema) + b.WriteString("\n") + + // ----- meta ----- + b.WriteString("-- --------------------------------------------\n") + b.WriteString("-- meta\n") + b.WriteString("-- --------------------------------------------\n\n") + metaKeys := []string{"path", "remote_url", "branch", "git_version", "hash_algo", "is_bare", "exported_at"} + metaVals := map[string]string{ + "path": data.Meta.Path, + "remote_url": data.Meta.RemoteURL, + "branch": data.Meta.Branch, + "git_version": data.Meta.GitVersion, + "hash_algo": data.Meta.HashAlgo, + "is_bare": strconv.FormatBool(data.Meta.IsBare), + "exported_at": time.Now().UTC().Format(time.RFC3339), + } + for _, k := range metaKeys { + fmt.Fprintf(&b, "INSERT INTO meta (key, value) VALUES (%s, %s);\n", + sqlStr(k), sqlStr(metaVals[k])) + } + b.WriteString("\n") + + // ----- refs ----- + b.WriteString("-- --------------------------------------------\n") + b.WriteString("-- refs\n") + b.WriteString("-- --------------------------------------------\n\n") + for _, r := range data.Refs { + fmt.Fprintf(&b, "INSERT INTO refs (name, target, type) VALUES (%s, %s, %s);\n", + sqlStr(r.Name), sqlStr(r.Target), sqlStr(r.Type)) + } + b.WriteString("\n") + + // ----- commits ----- + b.WriteString("-- --------------------------------------------\n") + b.WriteString("-- commits\n") + b.WriteString("-- --------------------------------------------\n\n") + for _, c := range data.Commits { + dirty := 0 + if c.Dirty { + dirty = 1 + } + fmt.Fprintf(&b, + "INSERT INTO commits (hash, author_name, author_email, author_when, committer_name, committer_email, committer_when, message, tree, changed_files, insertions, deletions, dirty) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %d, %d, %d, %d);\n", + sqlStr(c.Hash), sqlStr(c.Author.Name), sqlStr(c.Author.Email), sqlStr(c.Author.When), + sqlStr(c.Committer.Name), sqlStr(c.Committer.Email), sqlStr(c.Committer.When), + sqlStr(c.Message), sqlStr(c.Tree), + c.ChangedFiles, c.Insertions, c.Deletions, dirty) + } + b.WriteString("\n") + + // ----- parents ----- + b.WriteString("-- --------------------------------------------\n") + b.WriteString("-- parents\n") + b.WriteString("-- --------------------------------------------\n\n") + for _, c := range data.Commits { + for i, p := range c.Parents { + fmt.Fprintf(&b, "INSERT INTO parents (commit_hash, parent_hash, ordinal) VALUES (%s, %s, %d);\n", + sqlStr(c.Hash), sqlStr(p), i) + } + } + b.WriteString("\n") + + // ----- tree_entries ----- + b.WriteString("-- --------------------------------------------\n") + b.WriteString("-- tree_entries\n") + b.WriteString("-- --------------------------------------------\n\n") + for _, e := range data.Trees { + fmt.Fprintf(&b, "INSERT INTO tree_entries (commit_hash, path, mode, type, object_hash, size) VALUES (%s, %s, %s, %s, %s, %d);\n", + sqlStr(e.CommitHash), sqlStr(e.Path), sqlStr(e.Mode), sqlStr(e.Type), sqlStr(e.ObjectHash), e.Size) + } + b.WriteString("\n") + + // ----- blobs ----- + b.WriteString("-- --------------------------------------------\n") + b.WriteString("-- blobs (data gzip-komprimiert als X'...')\n") + b.WriteString("-- --------------------------------------------\n\n") + for _, bl := range data.Blobs { + isText := 0 + if bl.IsText { + isText = 1 + } + blobLit := "NULL" // ohne WithBlobs keine Inhalte + if bl.Data != nil { + gz, err := gzipBytes(bl.Data) + if err != nil { + return fmt.Errorf("gitdb: ExportSQL: gzip für %s: %w", bl.Hash, err) + } + blobLit = sqlBlob(gz) + } + fmt.Fprintf(&b, "INSERT INTO blobs (hash, size, data, is_text) VALUES (%s, %d, %s, %d);\n", + sqlStr(bl.Hash), bl.Size, blobLit, isText) + } + b.WriteString("\n") + + b.WriteString("COMMIT;\n") + + if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil { + return fmt.Errorf("gitdb: ExportSQL: Schreiben: %w", err) + } + return nil +} + +// sqlStr formatiert einen String als SQL-Literal ('...', einfache Quotes +// werden verdoppelt). Leere Strings werden als NULL geschrieben. +func sqlStr(v string) string { + if v == "" { + return "NULL" + } + return "'" + strings.ReplaceAll(v, "'", "''") + "'" +} + +// sqlBlob formatiert Bytes als hex-kodiertes BLOB-Literal (X'...'). +func sqlBlob(data []byte) string { + if len(data) == 0 { + return "NULL" + } + return "X'" + hex.EncodeToString(data) + "'" +} \ No newline at end of file