From 58727f5d896bf53b1dbfdc030aaf2b9bff236b78 Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 11 Feb 2026 13:15:26 +0530 Subject: [PATCH 1/5] feat: Implementated Git status display & Credential Seperation --- bridge/src/handlers/gitHandlers.ts | 290 ++++++++++++++++ bridge/src/handlers/projectHandlers.ts | 69 ++++ bridge/src/jsonRpcHandler.ts | 34 ++ bridge/src/services/gitService.ts | 463 +++++++++++++++++++++++++ bridge/src/services/projectStore.ts | 83 +++++ src/components/common/GitStatusBar.tsx | 379 ++++++++++++++++++++ src/hooks/useGitQueries.ts | 139 ++++++++ src/hooks/useProjectQueries.ts | 14 + src/pages/DatabaseDetails.tsx | 32 +- src/services/bridgeApi.ts | 141 ++++++++ src/types/git.ts | 70 ++++ 11 files changed, 1705 insertions(+), 9 deletions(-) create mode 100644 bridge/src/handlers/gitHandlers.ts create mode 100644 bridge/src/services/gitService.ts create mode 100644 src/components/common/GitStatusBar.tsx create mode 100644 src/hooks/useGitQueries.ts create mode 100644 src/types/git.ts diff --git a/bridge/src/handlers/gitHandlers.ts b/bridge/src/handlers/gitHandlers.ts new file mode 100644 index 0000000..5de207c --- /dev/null +++ b/bridge/src/handlers/gitHandlers.ts @@ -0,0 +1,290 @@ +import { Rpc } from "../types"; +import { Logger } from "pino"; +import { gitServiceInstance, GitService } from "../services/gitService"; + +/** + * RPC handlers for git operations. + * + * Methods: + * git.status — repo status (branch, dirty, ahead/behind) + * git.init — initialize a new repo + * git.changes — list changed files + * git.stage — stage files + * git.stageAll — stage everything + * git.unstage — unstage files + * git.commit — commit staged changes + * git.log — recent commit history + * git.branches — list branches + * git.createBranch — create + checkout new branch + * git.checkout — switch branch + * git.discard — discard file changes + * git.stash — stash changes + * git.stashPop — pop latest stash + * git.diff — get diff output + * git.ensureIgnore — write/update .gitignore + */ +export class GitHandlers { + constructor( + private rpc: Rpc, + private logger: Logger, + private gitService: GitService = gitServiceInstance + ) { } + + // ---- Helpers ---- + + private requireDir(params: any, id: number | string): string | null { + const dir = params?.dir || params?.path || params?.cwd; + if (!dir) { + this.rpc.sendError(id, { + code: "BAD_REQUEST", + message: "Missing 'dir' parameter (project directory path)", + }); + return null; + } + return dir; + } + + // ---- Handlers ---- + + async handleStatus(params: any, id: number | string) { + const dir = this.requireDir(params, id); + if (!dir) return; + try { + const status = await this.gitService.getStatus(dir); + this.rpc.sendResponse(id, { ok: true, data: status }); + } catch (e: any) { + this.logger?.error({ e }, "git.status failed"); + this.rpc.sendError(id, { code: "GIT_ERROR", message: String(e.message || e) }); + } + } + + async handleInit(params: any, id: number | string) { + const dir = this.requireDir(params, id); + if (!dir) return; + try { + await this.gitService.init(dir, params?.defaultBranch || "main"); + // Also set up .gitignore + await this.gitService.ensureGitignore(dir); + const status = await this.gitService.getStatus(dir); + this.rpc.sendResponse(id, { ok: true, data: status }); + } catch (e: any) { + this.logger?.error({ e }, "git.init failed"); + this.rpc.sendError(id, { code: "GIT_ERROR", message: String(e.message || e) }); + } + } + + async handleChanges(params: any, id: number | string) { + const dir = this.requireDir(params, id); + if (!dir) return; + try { + const changes = await this.gitService.getChangedFiles(dir); + this.rpc.sendResponse(id, { ok: true, data: changes }); + } catch (e: any) { + this.logger?.error({ e }, "git.changes failed"); + this.rpc.sendError(id, { code: "GIT_ERROR", message: String(e.message || e) }); + } + } + + async handleStage(params: any, id: number | string) { + const dir = this.requireDir(params, id); + if (!dir) return; + try { + const files: string[] = params?.files; + if (!files?.length) { + return this.rpc.sendError(id, { + code: "BAD_REQUEST", + message: "Missing 'files' array", + }); + } + await this.gitService.stageFiles(dir, files); + this.rpc.sendResponse(id, { ok: true, data: null }); + } catch (e: any) { + this.logger?.error({ e }, "git.stage failed"); + this.rpc.sendError(id, { code: "GIT_ERROR", message: String(e.message || e) }); + } + } + + async handleStageAll(params: any, id: number | string) { + const dir = this.requireDir(params, id); + if (!dir) return; + try { + await this.gitService.stageAll(dir); + this.rpc.sendResponse(id, { ok: true, data: null }); + } catch (e: any) { + this.logger?.error({ e }, "git.stageAll failed"); + this.rpc.sendError(id, { code: "GIT_ERROR", message: String(e.message || e) }); + } + } + + async handleUnstage(params: any, id: number | string) { + const dir = this.requireDir(params, id); + if (!dir) return; + try { + const files: string[] = params?.files; + if (!files?.length) { + return this.rpc.sendError(id, { + code: "BAD_REQUEST", + message: "Missing 'files' array", + }); + } + await this.gitService.unstageFiles(dir, files); + this.rpc.sendResponse(id, { ok: true, data: null }); + } catch (e: any) { + this.logger?.error({ e }, "git.unstage failed"); + this.rpc.sendError(id, { code: "GIT_ERROR", message: String(e.message || e) }); + } + } + + async handleCommit(params: any, id: number | string) { + const dir = this.requireDir(params, id); + if (!dir) return; + try { + const message = params?.message; + if (!message) { + return this.rpc.sendError(id, { + code: "BAD_REQUEST", + message: "Missing 'message' parameter", + }); + } + const hash = await this.gitService.commit(dir, message); + this.rpc.sendResponse(id, { ok: true, data: { hash } }); + } catch (e: any) { + this.logger?.error({ e }, "git.commit failed"); + this.rpc.sendError(id, { code: "GIT_ERROR", message: String(e.message || e) }); + } + } + + async handleLog(params: any, id: number | string) { + const dir = this.requireDir(params, id); + if (!dir) return; + try { + const count = params?.count ?? 20; + const entries = await this.gitService.log(dir, count); + this.rpc.sendResponse(id, { ok: true, data: entries }); + } catch (e: any) { + this.logger?.error({ e }, "git.log failed"); + this.rpc.sendError(id, { code: "GIT_ERROR", message: String(e.message || e) }); + } + } + + async handleBranches(params: any, id: number | string) { + const dir = this.requireDir(params, id); + if (!dir) return; + try { + const branches = await this.gitService.listBranches(dir); + this.rpc.sendResponse(id, { ok: true, data: branches }); + } catch (e: any) { + this.logger?.error({ e }, "git.branches failed"); + this.rpc.sendError(id, { code: "GIT_ERROR", message: String(e.message || e) }); + } + } + + async handleCreateBranch(params: any, id: number | string) { + const dir = this.requireDir(params, id); + if (!dir) return; + try { + const name = params?.name; + if (!name) { + return this.rpc.sendError(id, { + code: "BAD_REQUEST", + message: "Missing 'name' parameter", + }); + } + await this.gitService.createBranch(dir, name); + this.rpc.sendResponse(id, { ok: true, data: { branch: name } }); + } catch (e: any) { + this.logger?.error({ e }, "git.createBranch failed"); + this.rpc.sendError(id, { code: "GIT_ERROR", message: String(e.message || e) }); + } + } + + async handleCheckout(params: any, id: number | string) { + const dir = this.requireDir(params, id); + if (!dir) return; + try { + const name = params?.name; + if (!name) { + return this.rpc.sendError(id, { + code: "BAD_REQUEST", + message: "Missing 'name' parameter", + }); + } + await this.gitService.checkoutBranch(dir, name); + this.rpc.sendResponse(id, { ok: true, data: { branch: name } }); + } catch (e: any) { + this.logger?.error({ e }, "git.checkout failed"); + this.rpc.sendError(id, { code: "GIT_ERROR", message: String(e.message || e) }); + } + } + + async handleDiscard(params: any, id: number | string) { + const dir = this.requireDir(params, id); + if (!dir) return; + try { + const files: string[] = params?.files; + if (!files?.length) { + return this.rpc.sendError(id, { + code: "BAD_REQUEST", + message: "Missing 'files' array", + }); + } + await this.gitService.discardChanges(dir, files); + this.rpc.sendResponse(id, { ok: true, data: null }); + } catch (e: any) { + this.logger?.error({ e }, "git.discard failed"); + this.rpc.sendError(id, { code: "GIT_ERROR", message: String(e.message || e) }); + } + } + + async handleStash(params: any, id: number | string) { + const dir = this.requireDir(params, id); + if (!dir) return; + try { + await this.gitService.stash(dir, params?.message); + this.rpc.sendResponse(id, { ok: true, data: null }); + } catch (e: any) { + this.logger?.error({ e }, "git.stash failed"); + this.rpc.sendError(id, { code: "GIT_ERROR", message: String(e.message || e) }); + } + } + + async handleStashPop(params: any, id: number | string) { + const dir = this.requireDir(params, id); + if (!dir) return; + try { + await this.gitService.stashPop(dir); + this.rpc.sendResponse(id, { ok: true, data: null }); + } catch (e: any) { + this.logger?.error({ e }, "git.stashPop failed"); + this.rpc.sendError(id, { code: "GIT_ERROR", message: String(e.message || e) }); + } + } + + async handleDiff(params: any, id: number | string) { + const dir = this.requireDir(params, id); + if (!dir) return; + try { + const diff = await this.gitService.diff( + dir, + params?.file, + params?.staged === true + ); + this.rpc.sendResponse(id, { ok: true, data: { diff } }); + } catch (e: any) { + this.logger?.error({ e }, "git.diff failed"); + this.rpc.sendError(id, { code: "GIT_ERROR", message: String(e.message || e) }); + } + } + + async handleEnsureIgnore(params: any, id: number | string) { + const dir = this.requireDir(params, id); + if (!dir) return; + try { + const modified = await this.gitService.ensureGitignore(dir); + this.rpc.sendResponse(id, { ok: true, data: { modified } }); + } catch (e: any) { + this.logger?.error({ e }, "git.ensureIgnore failed"); + this.rpc.sendError(id, { code: "GIT_ERROR", message: String(e.message || e) }); + } + } +} diff --git a/bridge/src/handlers/projectHandlers.ts b/bridge/src/handlers/projectHandlers.ts index 0858c05..4941e27 100644 --- a/bridge/src/handlers/projectHandlers.ts +++ b/bridge/src/handlers/projectHandlers.ts @@ -1,6 +1,7 @@ import { Rpc } from "../types"; import { Logger } from "pino"; import { projectStoreInstance } from "../services/projectStore"; +import { getProjectDir } from "../utils/config"; /** * RPC handlers for project CRUD and sub-resource operations. @@ -326,4 +327,72 @@ export class ProjectHandlers { this.rpc.sendError(id, { code: "IO_ERROR", message: String(e) }); } } + + async handleGetProjectDir(params: any, id: number | string) { + try { + const { projectId } = params || {}; + if (!projectId) { + return this.rpc.sendError(id, { + code: "BAD_REQUEST", + message: "Missing projectId", + }); + } + const dir = getProjectDir(projectId); + this.rpc.sendResponse(id, { ok: true, data: { dir } }); + } catch (e: any) { + this.logger?.error({ e }, "project.getDir failed"); + this.rpc.sendError(id, { code: "IO_ERROR", message: String(e) }); + } + } + + async handleGetLocalConfig(params: any, id: number | string) { + try { + const { projectId } = params || {}; + if (!projectId) { + return this.rpc.sendError(id, { + code: "BAD_REQUEST", + message: "Missing projectId", + }); + } + const config = await projectStoreInstance.getLocalConfig(projectId); + this.rpc.sendResponse(id, { ok: true, data: config }); + } catch (e: any) { + this.logger?.error({ e }, "project.getLocalConfig failed"); + this.rpc.sendError(id, { code: "IO_ERROR", message: String(e) }); + } + } + + async handleSaveLocalConfig(params: any, id: number | string) { + try { + const { projectId, config } = params || {}; + if (!projectId) { + return this.rpc.sendError(id, { + code: "BAD_REQUEST", + message: "Missing projectId", + }); + } + const saved = await projectStoreInstance.saveLocalConfig(projectId, config || {}); + this.rpc.sendResponse(id, { ok: true, data: saved }); + } catch (e: any) { + this.logger?.error({ e }, "project.saveLocalConfig failed"); + this.rpc.sendError(id, { code: "IO_ERROR", message: String(e) }); + } + } + + async handleEnsureGitignore(params: any, id: number | string) { + try { + const { projectId } = params || {}; + if (!projectId) { + return this.rpc.sendError(id, { + code: "BAD_REQUEST", + message: "Missing projectId", + }); + } + const modified = await projectStoreInstance.ensureGitignore(projectId); + this.rpc.sendResponse(id, { ok: true, data: { modified } }); + } catch (e: any) { + this.logger?.error({ e }, "project.ensureGitignore failed"); + this.rpc.sendError(id, { code: "IO_ERROR", message: String(e) }); + } + } } diff --git a/bridge/src/jsonRpcHandler.ts b/bridge/src/jsonRpcHandler.ts index fb33bcc..52371df 100644 --- a/bridge/src/jsonRpcHandler.ts +++ b/bridge/src/jsonRpcHandler.ts @@ -8,6 +8,7 @@ import { SessionHandlers } from "./handlers/sessionHandlers"; import { StatsHandlers } from "./handlers/statsHandlers"; import { MigrationHandlers } from "./handlers/migrationHandlers"; import { ProjectHandlers } from "./handlers/projectHandlers"; +import { GitHandlers } from "./handlers/gitHandlers"; import { discoveryService } from "./services/discoveryService"; import { Logger } from "pino"; @@ -54,6 +55,7 @@ export function registerDbHandlers( queryExecutor ); const projectHandlers = new ProjectHandlers(rpc, logger); + const gitHandlers = new GitHandlers(rpc, logger); // ========================================== // SESSION MANAGEMENT HANDLERS @@ -218,6 +220,38 @@ export function registerDbHandlers( rpcRegister("project.export", (p, id) => projectHandlers.handleExportProject(p, id) ); + rpcRegister("project.getDir", (p, id) => + projectHandlers.handleGetProjectDir(p, id) + ); + rpcRegister("project.getLocalConfig", (p, id) => + projectHandlers.handleGetLocalConfig(p, id) + ); + rpcRegister("project.saveLocalConfig", (p, id) => + projectHandlers.handleSaveLocalConfig(p, id) + ); + rpcRegister("project.ensureGitignore", (p, id) => + projectHandlers.handleEnsureGitignore(p, id) + ); + + // ========================================== + // GIT HANDLERS + // ========================================== + rpcRegister("git.status", (p, id) => gitHandlers.handleStatus(p, id)); + rpcRegister("git.init", (p, id) => gitHandlers.handleInit(p, id)); + rpcRegister("git.changes", (p, id) => gitHandlers.handleChanges(p, id)); + rpcRegister("git.stage", (p, id) => gitHandlers.handleStage(p, id)); + rpcRegister("git.stageAll", (p, id) => gitHandlers.handleStageAll(p, id)); + rpcRegister("git.unstage", (p, id) => gitHandlers.handleUnstage(p, id)); + rpcRegister("git.commit", (p, id) => gitHandlers.handleCommit(p, id)); + rpcRegister("git.log", (p, id) => gitHandlers.handleLog(p, id)); + rpcRegister("git.branches", (p, id) => gitHandlers.handleBranches(p, id)); + rpcRegister("git.createBranch", (p, id) => gitHandlers.handleCreateBranch(p, id)); + rpcRegister("git.checkout", (p, id) => gitHandlers.handleCheckout(p, id)); + rpcRegister("git.discard", (p, id) => gitHandlers.handleDiscard(p, id)); + rpcRegister("git.stash", (p, id) => gitHandlers.handleStash(p, id)); + rpcRegister("git.stashPop", (p, id) => gitHandlers.handleStashPop(p, id)); + rpcRegister("git.diff", (p, id) => gitHandlers.handleDiff(p, id)); + rpcRegister("git.ensureIgnore", (p, id) => gitHandlers.handleEnsureIgnore(p, id)); // ========================================== // DATABASE DISCOVERY HANDLERS diff --git a/bridge/src/services/gitService.ts b/bridge/src/services/gitService.ts new file mode 100644 index 0000000..a27d243 --- /dev/null +++ b/bridge/src/services/gitService.ts @@ -0,0 +1,463 @@ +// ---------------------------- +// services/gitService.ts +// ---------------------------- +// +// Lightweight git integration that shells out to `git` CLI. +// No npm dependency required — just needs git on PATH. + +import { execFile } from "child_process"; +import { promisify } from "util"; +import path from "path"; +import fsSync from "fs"; + +const execFileAsync = promisify(execFile); + +export interface GitStatus { + /** Whether the directory is inside a git repository */ + isGitRepo: boolean; + + /** Current branch name (e.g. "main", "feature/auth") */ + branch: string | null; + + /** Short commit hash of HEAD */ + headCommit: string | null; + + /** Whether there are uncommitted changes (staged or unstaged) */ + isDirty: boolean; + + /** Number of files with staged changes */ + stagedCount: number; + + /** Number of files with unstaged changes */ + unstagedCount: number; + + /** Number of untracked files */ + untrackedCount: number; + + /** Number of commits ahead of upstream (null if no upstream) */ + ahead: number | null; + + /** Number of commits behind upstream (null if no upstream) */ + behind: number | null; + + /** Remote tracking branch (e.g. "origin/main") */ + upstream: string | null; +} + +export interface GitFileChange { + /** Relative file path */ + path: string; + + /** Git status code: M=modified, A=added, D=deleted, ?=untracked, R=renamed */ + status: string; + + /** Whether this change is staged */ + staged: boolean; +} + +export interface GitLogEntry { + /** Short commit hash */ + hash: string; + + /** Full commit hash */ + fullHash: string; + + /** Author name */ + author: string; + + /** Commit date as ISO string */ + date: string; + + /** First line of commit message */ + subject: string; +} + +export interface GitBranchInfo { + /** Branch name */ + name: string; + + /** Is this the current branch? */ + current: boolean; + + /** Remote tracking branch (null for local-only branches) */ + upstream: string | null; +} + +export class GitService { + /** + * Run a git command in a specific directory. + * Returns stdout. Throws on non-zero exit. + */ + private async git(cwd: string, ...args: string[]): Promise { + try { + const { stdout } = await execFileAsync("git", args, { + cwd, + maxBuffer: 10 * 1024 * 1024, // 10 MB + timeout: 30_000, + windowsHide: true, + }); + return stdout.trimEnd(); + } catch (err: any) { + // Git returns exit code 128 for "not a git repo" etc. + if (err.code === "ENOENT") { + throw new Error("Git is not installed or not on PATH"); + } + throw err; + } + } + + /** + * Check if git is available on this machine + */ + async isGitInstalled(): Promise { + try { + await execFileAsync("git", ["--version"], { + timeout: 5000, + windowsHide: true, + }); + return true; + } catch { + return false; + } + } + + /** + * Check if a directory is inside a git repository + */ + async isRepo(dir: string): Promise { + try { + await this.git(dir, "rev-parse", "--is-inside-work-tree"); + return true; + } catch { + return false; + } + } + + /** + * Get the root directory of the git repository + */ + async getRepoRoot(dir: string): Promise { + return this.git(dir, "rev-parse", "--show-toplevel"); + } + + /** + * Initialize a new git repository + */ + async init(dir: string, defaultBranch = "main"): Promise { + await this.git(dir, "init", "-b", defaultBranch); + } + + /** + * Get comprehensive git status for a directory + */ + async getStatus(dir: string): Promise { + const isGitRepo = await this.isRepo(dir); + + if (!isGitRepo) { + return { + isGitRepo: false, + branch: null, + headCommit: null, + isDirty: false, + stagedCount: 0, + unstagedCount: 0, + untrackedCount: 0, + ahead: null, + behind: null, + upstream: null, + }; + } + + // Get branch + upstream + ahead/behind in one call + let branch: string | null = null; + let headCommit: string | null = null; + let upstream: string | null = null; + let ahead: number | null = null; + let behind: number | null = null; + + try { + // --porcelain=v2 --branch gives structured branch info + const branchOutput = await this.git( + dir, + "status", + "--porcelain=v2", + "--branch" + ); + + for (const line of branchOutput.split("\n")) { + if (line.startsWith("# branch.head ")) { + branch = line.slice("# branch.head ".length); + } else if (line.startsWith("# branch.oid ")) { + headCommit = line.slice("# branch.oid ".length).slice(0, 8); + } else if (line.startsWith("# branch.upstream ")) { + upstream = line.slice("# branch.upstream ".length); + } else if (line.startsWith("# branch.ab ")) { + const match = line.match(/\+(\d+) -(\d+)/); + if (match) { + ahead = parseInt(match[1], 10); + behind = parseInt(match[2], 10); + } + } + } + } catch { + // HEAD might be unborn (initial commit) + branch = "(no commits)"; + } + + // Get file-level status + let stagedCount = 0; + let unstagedCount = 0; + let untrackedCount = 0; + + try { + const statusOutput = await this.git( + dir, + "status", + "--porcelain=v1", + "-uall" + ); + + if (statusOutput) { + for (const line of statusOutput.split("\n")) { + if (!line) continue; + const x = line[0]; // staged status + const y = line[1]; // unstaged status + + if (x === "?" && y === "?") { + untrackedCount++; + } else { + if (x !== " " && x !== "?") stagedCount++; + if (y !== " " && y !== "?") unstagedCount++; + } + } + } + } catch { + // Ignore — might be empty repo + } + + return { + isGitRepo: true, + branch, + headCommit, + isDirty: stagedCount > 0 || unstagedCount > 0 || untrackedCount > 0, + stagedCount, + unstagedCount, + untrackedCount, + ahead, + behind, + upstream, + }; + } + + /** + * Get list of changed files with their status + */ + async getChangedFiles(dir: string): Promise { + const output = await this.git(dir, "status", "--porcelain=v1", "-uall"); + if (!output) return []; + + const changes: GitFileChange[] = []; + + for (const line of output.split("\n")) { + if (!line || line.length < 4) continue; + const x = line[0]; // index (staged) + const y = line[1]; // working tree + const filePath = line.slice(3); + + // Staged change + if (x !== " " && x !== "?") { + changes.push({ path: filePath, status: x, staged: true }); + } + // Unstaged change + if (y !== " " && y !== "?") { + changes.push({ path: filePath, status: y, staged: false }); + } + // Untracked + if (x === "?" && y === "?") { + changes.push({ path: filePath, status: "?", staged: false }); + } + } + + return changes; + } + + /** + * Stage files for commit + */ + async stageFiles(dir: string, files: string[]): Promise { + if (files.length === 0) return; + await this.git(dir, "add", "--", ...files); + } + + /** + * Stage all changes + */ + async stageAll(dir: string): Promise { + await this.git(dir, "add", "-A"); + } + + /** + * Unstage files + */ + async unstageFiles(dir: string, files: string[]): Promise { + if (files.length === 0) return; + await this.git(dir, "reset", "HEAD", "--", ...files); + } + + /** + * Commit staged changes + */ + async commit(dir: string, message: string): Promise { + const output = await this.git(dir, "commit", "-m", message); + // Extract short hash from output like "[main abc1234] message" + const match = output.match(/\[[\w/.-]+ ([a-f0-9]+)\]/); + return match?.[1] ?? ""; + } + + /** + * Get recent commit log + */ + async log(dir: string, count = 20): Promise { + try { + const SEP = "<>"; + const format = ["%h", "%H", "%an", "%aI", "%s"].join(SEP); + const output = await this.git( + dir, + "log", + `--max-count=${count}`, + `--format=${format}` + ); + + if (!output) return []; + + return output.split("\n").map((line) => { + const [hash, fullHash, author, date, subject] = line.split(SEP); + return { hash, fullHash, author, date, subject }; + }); + } catch { + return []; // No commits yet + } + } + + /** + * List branches + */ + async listBranches(dir: string): Promise { + try { + const output = await this.git( + dir, + "for-each-ref", + "--format=%(refname:short)%09%(HEAD)%09%(upstream:short)", + "refs/heads/" + ); + + if (!output) return []; + + return output.split("\n").map((line) => { + const [name, head, upstream] = line.split("\t"); + return { + name, + current: head === "*", + upstream: upstream || null, + }; + }); + } catch { + return []; + } + } + + /** + * Create and checkout a new branch + */ + async createBranch(dir: string, name: string): Promise { + await this.git(dir, "checkout", "-b", name); + } + + /** + * Checkout an existing branch + */ + async checkoutBranch(dir: string, name: string): Promise { + await this.git(dir, "checkout", name); + } + + /** + * Discard unstaged changes in a file + */ + async discardChanges(dir: string, files: string[]): Promise { + if (files.length === 0) return; + await this.git(dir, "checkout", "--", ...files); + } + + /** + * Stash all changes + */ + async stash(dir: string, message?: string): Promise { + const args = ["stash", "push", "-u"]; + if (message) args.push("-m", message); + await this.git(dir, ...args); + } + + /** + * Pop the latest stash + */ + async stashPop(dir: string): Promise { + await this.git(dir, "stash", "pop"); + } + + /** + * Get diff for a specific file (or all files) + */ + async diff(dir: string, file?: string, staged = false): Promise { + const args = ["diff"]; + if (staged) args.push("--staged"); + if (file) args.push("--", file); + return this.git(dir, ...args); + } + + /** + * Generate a .gitignore file suitable for RelWave projects + */ + generateGitignore(): string { + return [ + "# RelWave - auto-generated", + "# Connection credentials (NEVER commit these)", + "relwave.local.json", + ".credentials", + "", + "# OS files", + ".DS_Store", + "Thumbs.db", + "", + "# Editor", + ".vscode/", + ".idea/", + "*.swp", + "*.swo", + "", + ].join("\n"); + } + + /** + * Write a .gitignore if it doesn't already exist in the repo + */ + async ensureGitignore(dir: string): Promise { + const gi = path.join(dir, ".gitignore"); + if (fsSync.existsSync(gi)) { + // Append our rules if the file exists but doesn't contain them + const existing = fsSync.readFileSync(gi, "utf-8"); + if (!existing.includes("relwave.local.json")) { + fsSync.appendFileSync( + gi, + "\n\n" + this.generateGitignore(), + "utf-8" + ); + return true; // modified + } + return false; // already has our rules + } + fsSync.writeFileSync(gi, this.generateGitignore(), "utf-8"); + return true; // created + } +} + +export const gitServiceInstance = new GitService(); diff --git a/bridge/src/services/projectStore.ts b/bridge/src/services/projectStore.ts index 115091a..e20ee80 100644 --- a/bridge/src/services/projectStore.ts +++ b/bridge/src/services/projectStore.ts @@ -98,6 +98,21 @@ export type ProjectSummary = Pick< "id" | "name" | "description" | "engine" | "databaseId" | "createdAt" | "updatedAt" >; +/** + * Local (git-ignored) configuration for a project. + * Contains per-developer settings that should NOT be committed. + */ +export type LocalConfig = { + /** Override connection URL (developer-specific) */ + connectionUrl?: string; + + /** Environment label (dev / staging / prod) */ + environment?: string; + + /** Any developer-specific notes */ + notes?: string; +}; + type ProjectIndex = { @@ -108,6 +123,7 @@ type ProjectIndex = { const PROJECT_FILES = { metadata: "relwave.json", + localConfig: "relwave.local.json", schema: path.join("schema", "schema.json"), erDiagram: path.join("diagrams", "er.json"), queries: path.join("queries", "queries.json"), @@ -275,6 +291,12 @@ export class ProjectStore { this.writeJSON(this.projectFile(id, PROJECT_FILES.queries), emptyQueries), ]); + // Create git-safe scaffolding + await this.ensureGitignore(id); + // Create empty local config (will be gitignored) + const emptyLocal: LocalConfig = {}; + await this.writeJSON(this.projectFile(id, PROJECT_FILES.localConfig), emptyLocal); + // Update global index const index = await this.loadIndex(); index.projects.push({ @@ -506,6 +528,67 @@ export class ProjectStore { return { metadata, schema, erDiagram, queries }; } + + // ========================================== + // Local Config (git-ignored) + // ========================================== + + /** + * Read the local (git-ignored) config for a project + */ + async getLocalConfig(projectId: string): Promise { + return this.readJSON( + this.projectFile(projectId, PROJECT_FILES.localConfig) + ); + } + + /** + * Write/update the local config + */ + async saveLocalConfig(projectId: string, config: LocalConfig): Promise { + await this.writeJSON( + this.projectFile(projectId, PROJECT_FILES.localConfig), + config + ); + return config; + } + + // ========================================== + // .gitignore management + // ========================================== + + /** + * Ensure a .gitignore file exists in the project directory + * with rules to exclude local credentials and caches. + */ + async ensureGitignore(projectId: string): Promise { + const dir = this.projectDir(projectId); + const giPath = path.join(dir, ".gitignore"); + + const rules = [ + "# RelWave — auto-generated", + "# Local config (connection credentials, environment overrides)", + "relwave.local.json", + "", + "# OS / Editor", + ".DS_Store", + "Thumbs.db", + "", + ].join("\n"); + + if (fsSync.existsSync(giPath)) { + const existing = await fs.readFile(giPath, "utf-8"); + if (existing.includes("relwave.local.json")) { + return false; // already has our rules + } + // Append to existing + await fs.writeFile(giPath, existing + "\n\n" + rules, "utf-8"); + return true; + } + + await fs.writeFile(giPath, rules, "utf-8"); + return true; + } } // Singleton instance diff --git a/src/components/common/GitStatusBar.tsx b/src/components/common/GitStatusBar.tsx new file mode 100644 index 0000000..10d1b29 --- /dev/null +++ b/src/components/common/GitStatusBar.tsx @@ -0,0 +1,379 @@ +import { useState } from "react"; +import { + GitBranch, + GitCommitHorizontal, + ArrowUp, + ArrowDown, + Circle, + Plus, + Check, + ChevronDown, + RotateCcw, + FolderGit2, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; + +import { + useGitStatus, + useGitBranches, + useGitInit, + useGitStageAll, + useGitCommit, + useGitCheckout, + useGitCreateBranch, +} from "@/hooks/useGitQueries"; +import { toast } from "sonner"; +import type { GitBranchInfo } from "@/types/git"; + +interface GitStatusBarProps { + /** + * The directory to check git status for. + * Typically the project files directory from the bridge config. + */ + projectDir: string | null | undefined; +} + +export default function GitStatusBar({ projectDir }: GitStatusBarProps) { + const { data: status, isLoading } = useGitStatus(projectDir); + const { data: branches } = useGitBranches( + status?.isGitRepo ? projectDir : undefined + ); + + const initMutation = useGitInit(projectDir); + const stageAllMutation = useGitStageAll(projectDir); + const commitMutation = useGitCommit(projectDir); + const checkoutMutation = useGitCheckout(projectDir); + const createBranchMutation = useGitCreateBranch(projectDir); + + const [commitDialogOpen, setCommitDialogOpen] = useState(false); + const [commitMessage, setCommitMessage] = useState(""); + const [branchDialogOpen, setBranchDialogOpen] = useState(false); + const [newBranchName, setNewBranchName] = useState(""); + + // --- Not a git repo: show init button --- + if (!projectDir) return null; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!status?.isGitRepo) { + return ( + + + + + +

This project is not version-controlled. Click to init a git repo.

+
+
+ ); + } + + // --- Active repo: show branch + status --- + const totalChanges = status.stagedCount + status.unstagedCount + status.untrackedCount; + + const handleQuickCommit = async () => { + if (!commitMessage.trim()) return; + try { + // Stage all first + await stageAllMutation.mutateAsync(); + // Then commit + const result = await commitMutation.mutateAsync(commitMessage.trim()); + toast.success(`Committed as ${result.hash}`); + setCommitMessage(""); + setCommitDialogOpen(false); + } catch (e: any) { + toast.error("Commit failed: " + e.message); + } + }; + + const handleCreateBranch = async () => { + if (!newBranchName.trim()) return; + try { + await createBranchMutation.mutateAsync(newBranchName.trim()); + toast.success(`Switched to branch ${newBranchName.trim()}`); + setNewBranchName(""); + setBranchDialogOpen(false); + } catch (e: any) { + toast.error("Create branch failed: " + e.message); + } + }; + + const handleCheckout = async (name: string) => { + try { + await checkoutMutation.mutateAsync(name); + toast.success(`Switched to ${name}`); + } catch (e: any) { + toast.error("Checkout failed: " + e.message); + } + }; + + return ( + <> +
+ {/* Branch selector */} + + + + + + {branches?.map((b: GitBranchInfo) => ( + { + if (!b.current) handleCheckout(b.name); + }} + className="font-mono text-xs" + > +
+ {b.current ? ( + + ) : ( +
+ )} + {b.name} + {b.upstream && ( + + {b.upstream} + + )} +
+ + ))} + + setBranchDialogOpen(true)}> + + New Branch... + + + + + {/* Sync indicators: ahead / behind */} + {status.ahead != null && status.ahead > 0 && ( + + + + + {status.ahead} + + + +

{status.ahead} commit{status.ahead > 1 ? "s" : ""} ahead of {status.upstream}

+
+
+ )} + {status.behind != null && status.behind > 0 && ( + + + + + {status.behind} + + + +

{status.behind} commit{status.behind > 1 ? "s" : ""} behind {status.upstream}

+
+
+ )} + + {/* Dirty indicator + quick commit */} + {status.isDirty && ( + + + + + +
+ {status.stagedCount > 0 &&

{status.stagedCount} staged

} + {status.unstagedCount > 0 &&

{status.unstagedCount} modified

} + {status.untrackedCount > 0 &&

{status.untrackedCount} untracked

} +

Click to commit

+
+
+
+ )} + + {/* Clean indicator */} + {!status.isDirty && status.headCommit && ( + + + + + {status.headCommit} + + + +

Working tree clean — HEAD at {status.headCommit}

+
+
+ )} +
+ + {/* Quick Commit Dialog */} + + + + + + Commit Changes + + +
+
+ {status.stagedCount + status.unstagedCount + status.untrackedCount} file(s) will be staged and committed. +
+ setCommitMessage(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleQuickCommit(); + } + }} + autoFocus + /> +
+ + + + +
+
+ + {/* New Branch Dialog */} + + + + + + Create Branch + + +
+
+ New branch will be created from current HEAD ({status.headCommit || "initial"}). +
+ setNewBranchName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleCreateBranch(); + } + }} + autoFocus + /> +
+ + + + +
+
+ + ); +} diff --git a/src/hooks/useGitQueries.ts b/src/hooks/useGitQueries.ts new file mode 100644 index 0000000..6783d94 --- /dev/null +++ b/src/hooks/useGitQueries.ts @@ -0,0 +1,139 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { bridgeApi } from "@/services/bridgeApi"; +import { isBridgeReady } from "@/services/bridgeClient"; +import type { GitStatus, GitFileChange, GitLogEntry, GitBranchInfo } from "@/types/git"; + +export const gitKeys = { + status: (dir: string) => ["git", "status", dir] as const, + changes: (dir: string) => ["git", "changes", dir] as const, + log: (dir: string) => ["git", "log", dir] as const, + branches: (dir: string) => ["git", "branches", dir] as const, +}; + +const STALE = { + status: 10_000, // 10s — polled frequently + changes: 15_000, // 15s + log: 60_000, // 1 min + branches: 60_000, // 1 min +}; + +export function useGitStatus(dir: string | null | undefined) { + const queryClient = useQueryClient(); + const bridgeReady = + queryClient.getQueryData(["bridge-ready"]) ?? isBridgeReady(); + + return useQuery({ + queryKey: gitKeys.status(dir ?? ""), + queryFn: () => bridgeApi.gitStatus(dir!), + enabled: !!dir && bridgeReady, + staleTime: STALE.status, + refetchInterval: 15_000, // poll every 15s for live status + refetchIntervalInBackground: false, + }); +} + +export function useGitChanges(dir: string | null | undefined) { + const queryClient = useQueryClient(); + const bridgeReady = + queryClient.getQueryData(["bridge-ready"]) ?? isBridgeReady(); + + return useQuery({ + queryKey: gitKeys.changes(dir ?? ""), + queryFn: () => bridgeApi.gitChanges(dir!), + enabled: !!dir && bridgeReady, + staleTime: STALE.changes, + }); +} + +export function useGitLog(dir: string | null | undefined, count = 20) { + const queryClient = useQueryClient(); + const bridgeReady = + queryClient.getQueryData(["bridge-ready"]) ?? isBridgeReady(); + + return useQuery({ + queryKey: gitKeys.log(dir ?? ""), + queryFn: () => bridgeApi.gitLog(dir!, count), + enabled: !!dir && bridgeReady, + staleTime: STALE.log, + }); +} + +export function useGitBranches(dir: string | null | undefined) { + const queryClient = useQueryClient(); + const bridgeReady = + queryClient.getQueryData(["bridge-ready"]) ?? isBridgeReady(); + + return useQuery({ + queryKey: gitKeys.branches(dir ?? ""), + queryFn: () => bridgeApi.gitBranches(dir!), + enabled: !!dir && bridgeReady, + staleTime: STALE.branches, + }); +} + +function useInvalidateGit(dir: string | null | undefined) { + const queryClient = useQueryClient(); + return () => { + if (!dir) return; + queryClient.invalidateQueries({ queryKey: gitKeys.status(dir) }); + queryClient.invalidateQueries({ queryKey: gitKeys.changes(dir) }); + queryClient.invalidateQueries({ queryKey: gitKeys.log(dir) }); + queryClient.invalidateQueries({ queryKey: gitKeys.branches(dir) }); + }; +} + +export function useGitInit(dir: string | null | undefined) { + const invalidate = useInvalidateGit(dir); + return useMutation({ + mutationFn: () => bridgeApi.gitInit(dir!), + onSuccess: invalidate, + }); +} + +export function useGitStageAll(dir: string | null | undefined) { + const invalidate = useInvalidateGit(dir); + return useMutation({ + mutationFn: () => bridgeApi.gitStageAll(dir!), + onSuccess: invalidate, + }); +} + +export function useGitCommit(dir: string | null | undefined) { + const invalidate = useInvalidateGit(dir); + return useMutation({ + mutationFn: (message: string) => bridgeApi.gitCommit(dir!, message), + onSuccess: invalidate, + }); +} + +export function useGitCheckout(dir: string | null | undefined) { + const invalidate = useInvalidateGit(dir); + return useMutation({ + mutationFn: (branchName: string) => bridgeApi.gitCheckout(dir!, branchName), + onSuccess: invalidate, + }); +} + +export function useGitCreateBranch(dir: string | null | undefined) { + const invalidate = useInvalidateGit(dir); + return useMutation({ + mutationFn: (name: string) => bridgeApi.gitCreateBranch(dir!, name), + onSuccess: invalidate, + }); +} + +export function useGitStash(dir: string | null | undefined) { + const invalidate = useInvalidateGit(dir); + return useMutation({ + mutationFn: (message?: string) => bridgeApi.gitStash(dir!, message), + onSuccess: invalidate, + }); +} + +export function useGitStashPop(dir: string | null | undefined) { + const invalidate = useInvalidateGit(dir); + return useMutation({ + mutationFn: () => bridgeApi.gitStashPop(dir!), + onSuccess: invalidate, + }); +} diff --git a/src/hooks/useProjectQueries.ts b/src/hooks/useProjectQueries.ts index 777078b..5cb64b0 100644 --- a/src/hooks/useProjectQueries.ts +++ b/src/hooks/useProjectQueries.ts @@ -19,6 +19,7 @@ export const projectKeys = { erDiagram: (id: string) => ["projects", id, "erDiagram"] as const, queries: (id: string) => ["projects", id, "queries"] as const, export: (id: string) => ["projects", id, "export"] as const, + dir: (id: string) => ["projects", id, "dir"] as const, }; // ============================================ @@ -73,6 +74,19 @@ export function useProjectByDatabaseId(databaseId: string | undefined) { }); } +// ============================================ +// Get Project Filesystem Directory +// ============================================ +export function useProjectDir(projectId: string | null | undefined) { + return useQuery({ + queryKey: projectKeys.dir(projectId!), + queryFn: () => bridgeApi.getProjectDir(projectId!), + staleTime: Infinity, // path never changes for a given project + gcTime: 30 * 60 * 1000, + enabled: !!projectId, + }); +} + // ============================================ // Create Project // ============================================ diff --git a/src/pages/DatabaseDetails.tsx b/src/pages/DatabaseDetails.tsx index 21860a4..63d8207 100644 --- a/src/pages/DatabaseDetails.tsx +++ b/src/pages/DatabaseDetails.tsx @@ -14,6 +14,7 @@ import { useDatabaseDetails } from "@/hooks/useDatabaseDetails"; import { useMigrations, useFullSchema } from "@/hooks/useDbQueries"; import { useExport } from "@/hooks/useExport"; import { useProjectSync } from "@/hooks/useProjectSync"; +import { useProjectDir } from "@/hooks/useProjectQueries"; import BridgeLoader from "@/components/feedback/BridgeLoader"; import { Spinner } from "@/components/ui/spinner"; import VerticalIconBar, { PanelType } from "@/components/common/VerticalIconBar"; @@ -32,6 +33,7 @@ import SQLWorkspacePanel from "@/components/workspace/SQLWorkspacePanel"; import QueryBuilderPanel from "@/components/query-builder/QueryBuilderPanel"; import SchemaExplorerPanel from "@/components/schema-explorer/SchemaExplorerPanel"; import ERDiagramPanel from "@/components/er-diagram/ERDiagramPanel"; +import GitStatusBar from "@/components/common/GitStatusBar"; const DatabaseDetail = () => { const { id: dbId } = useParams<{ id: string }>(); @@ -96,6 +98,7 @@ const DatabaseDetail = () => { // and auto-saves to the linked project's JSON files in the background. const { data: schemaData } = useFullSchema(dbId); const { projectId } = useProjectSync(dbId, schemaData ?? undefined); + const { data: projectDir } = useProjectDir(projectId); if (bridgeLoading || bridgeReady === undefined) { return ; @@ -402,16 +405,27 @@ const DatabaseDetail = () => { }; return ( -
- +
+
+ -
- {renderPanelContent()} -
+
+ {renderPanelContent()} +
+
+ + {/* Bottom status bar with git info */} +
+ +
+ + {databaseName || "Database"} + +
{/* Migrations Panel */} { + try { + if (!projectId) return null; + const result = await bridgeRequest("project.getDir", { projectId }); + return result?.data?.dir || null; + } catch (error: any) { + console.error("Failed to get project dir:", error); + return null; + } + } + + // ------------------------------------ + // 8. GIT OPERATIONS (git.*) + // ------------------------------------ + + /** + * Get git repository status for a directory + */ + async gitStatus(dir: string): Promise { + const result = await bridgeRequest("git.status", { dir }); + return result?.data; + } + + /** + * Initialize a new git repo in the given directory + */ + async gitInit(dir: string, defaultBranch = "main"): Promise { + const result = await bridgeRequest("git.init", { dir, defaultBranch }); + return result?.data; + } + + /** + * Get list of changed files + */ + async gitChanges(dir: string): Promise { + const result = await bridgeRequest("git.changes", { dir }); + return result?.data || []; + } + + /** + * Stage specific files + */ + async gitStage(dir: string, files: string[]): Promise { + await bridgeRequest("git.stage", { dir, files }); + } + + /** + * Stage all changes + */ + async gitStageAll(dir: string): Promise { + await bridgeRequest("git.stageAll", { dir }); + } + + /** + * Unstage specific files + */ + async gitUnstage(dir: string, files: string[]): Promise { + await bridgeRequest("git.unstage", { dir, files }); + } + + /** + * Commit staged changes + */ + async gitCommit(dir: string, message: string): Promise<{ hash: string }> { + const result = await bridgeRequest("git.commit", { dir, message }); + return result?.data; + } + + /** + * Get recent commit history + */ + async gitLog(dir: string, count = 20): Promise { + const result = await bridgeRequest("git.log", { dir, count }); + return result?.data || []; + } + + /** + * List all branches + */ + async gitBranches(dir: string): Promise { + const result = await bridgeRequest("git.branches", { dir }); + return result?.data || []; + } + + /** + * Create and checkout a new branch + */ + async gitCreateBranch(dir: string, name: string): Promise<{ branch: string }> { + const result = await bridgeRequest("git.createBranch", { dir, name }); + return result?.data; + } + + /** + * Checkout an existing branch + */ + async gitCheckout(dir: string, name: string): Promise<{ branch: string }> { + const result = await bridgeRequest("git.checkout", { dir, name }); + return result?.data; + } + + /** + * Discard unstaged changes for specific files + */ + async gitDiscard(dir: string, files: string[]): Promise { + await bridgeRequest("git.discard", { dir, files }); + } + + /** + * Stash all changes + */ + async gitStash(dir: string, message?: string): Promise { + await bridgeRequest("git.stash", { dir, message }); + } + + /** + * Pop latest stash + */ + async gitStashPop(dir: string): Promise { + await bridgeRequest("git.stashPop", { dir }); + } + + /** + * Get diff for a file (or all files) + */ + async gitDiff(dir: string, file?: string, staged = false): Promise { + const result = await bridgeRequest("git.diff", { dir, file, staged }); + return result?.data?.diff || ""; + } + + /** + * Ensure .gitignore has RelWave rules + */ + async gitEnsureIgnore(dir: string): Promise<{ modified: boolean }> { + const result = await bridgeRequest("git.ensureIgnore", { dir }); + return result?.data; + } } // Export singleton instance diff --git a/src/types/git.ts b/src/types/git.ts new file mode 100644 index 0000000..491c752 --- /dev/null +++ b/src/types/git.ts @@ -0,0 +1,70 @@ +export interface GitStatus { + /** Whether the directory is inside a git repository */ + isGitRepo: boolean; + + /** Current branch name (e.g. "main", "feature/auth") */ + branch: string | null; + + /** Short commit hash of HEAD */ + headCommit: string | null; + + /** Whether there are uncommitted changes */ + isDirty: boolean; + + /** Number of staged files */ + stagedCount: number; + + /** Number of unstaged modified files */ + unstagedCount: number; + + /** Number of untracked files */ + untrackedCount: number; + + /** Commits ahead of upstream (null = no upstream) */ + ahead: number | null; + + /** Commits behind upstream (null = no upstream) */ + behind: number | null; + + /** Remote tracking branch (e.g. "origin/main") */ + upstream: string | null; +} + +export interface GitFileChange { + /** Relative file path */ + path: string; + + /** Git status code: M=modified, A=added, D=deleted, ?=untracked, R=renamed */ + status: string; + + /** Whether this change is staged */ + staged: boolean; +} + +export interface GitLogEntry { + /** Short commit hash */ + hash: string; + + /** Full commit hash */ + fullHash: string; + + /** Author name */ + author: string; + + /** Commit date as ISO string */ + date: string; + + /** First line of commit message */ + subject: string; +} + +export interface GitBranchInfo { + /** Branch name */ + name: string; + + /** Is this the current branch? */ + current: boolean; + + /** Remote tracking branch */ + upstream: string | null; +} From 09311fbcfbeb95a1a051d30aff0b67506131121b Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 11 Feb 2026 14:31:52 +0530 Subject: [PATCH 2/5] feat(schema-diff): implement schema diff functionality - Added SchemaDiffHandlers to handle RPC calls for schema diffing and file history. - Introduced SchemaDiffService to compute structured schema diffs between two schema snapshots. - Created SchemaDiffPanel component to display schema differences in the UI. - Implemented useSchemaDiff and useSchemaFileHistory hooks for fetching schema diff and history data. - Updated bridgeApi to include methods for schema diff and file history retrieval. - Enhanced VerticalIconBar to include a new panel for schema diff. - Added types for schema diff results and history in schemaDiff.ts. - Implemented gitService methods for retrieving file content at specific refs and listing file commit history. --- bridge/src/handlers/schemaDiffHandlers.ts | 173 +++++++ bridge/src/jsonRpcHandler.ts | 8 + bridge/src/services/gitService.ts | 38 ++ bridge/src/services/schemaDiffService.ts | 348 ++++++++++++++ src/components/common/VerticalIconBar.tsx | 5 +- .../schema-diff/SchemaDiffPanel.tsx | 446 ++++++++++++++++++ src/hooks/useSchemaDiff.ts | 49 ++ src/pages/DatabaseDetails.tsx | 3 + src/services/bridgeApi.ts | 29 ++ src/types/schemaDiff.ts | 73 +++ 10 files changed, 1170 insertions(+), 2 deletions(-) create mode 100644 bridge/src/handlers/schemaDiffHandlers.ts create mode 100644 bridge/src/services/schemaDiffService.ts create mode 100644 src/components/schema-diff/SchemaDiffPanel.tsx create mode 100644 src/hooks/useSchemaDiff.ts create mode 100644 src/types/schemaDiff.ts diff --git a/bridge/src/handlers/schemaDiffHandlers.ts b/bridge/src/handlers/schemaDiffHandlers.ts new file mode 100644 index 0000000..2d6f253 --- /dev/null +++ b/bridge/src/handlers/schemaDiffHandlers.ts @@ -0,0 +1,173 @@ +import { Rpc } from "../types"; +import { Logger } from "pino"; +import { gitServiceInstance, GitService } from "../services/gitService"; +import { + schemaDiffServiceInstance, + SchemaDiffService, +} from "../services/schemaDiffService"; +import { + projectStoreInstance, + ProjectStore, + SchemaFile, +} from "../services/projectStore"; +import { getProjectDir } from "../utils/config"; +import path from "path"; + +/** + * RPC handlers for schema diffing. + * + * Methods: + * schema.diff — diff working tree vs HEAD (or any two refs) + * schema.fileHistory — commit history for schema.json + */ +export class SchemaDiffHandlers { + constructor( + private rpc: Rpc, + private logger: Logger, + private git: GitService = gitServiceInstance, + private differ: SchemaDiffService = schemaDiffServiceInstance, + private store: ProjectStore = projectStoreInstance + ) { } + + /** + * schema.diff + * + * params: + * projectId — required + * fromRef — git ref for "before" (default: "HEAD") + * toRef — git ref for "after" (default: null = working tree) + */ + async handleDiff(params: any, id: number | string) { + try { + const { projectId, fromRef = "HEAD", toRef } = params || {}; + if (!projectId) { + return this.rpc.sendError(id, { + code: "BAD_REQUEST", + message: "Missing projectId", + }); + } + + const dir = getProjectDir(projectId); + + // Check if this project is in a git repo + const isRepo = await this.git.isRepo(dir); + if (!isRepo) { + return this.rpc.sendResponse(id, { + ok: true, + data: { + isGitRepo: false, + diff: null, + message: "Project directory is not a git repository", + }, + }); + } + + // Get repo root so we can compute the relative path + const repoRoot = await this.git.getRepoRoot(dir); + const relSchemaPath = path + .relative(repoRoot, path.join(dir, "schema", "schema.json")) + .replace(/\\/g, "/"); + + // Read "before" schema from git ref + let beforeSchema: SchemaFile | null = null; + try { + const beforeRaw = await this.git.getFileAtRef( + repoRoot, + relSchemaPath, + fromRef + ); + if (beforeRaw) { + beforeSchema = JSON.parse(beforeRaw) as SchemaFile; + } + } catch { + // File may not exist at this ref — that's OK, treat as empty + } + + // Read "after" schema + let afterSchema: SchemaFile | null = null; + if (toRef) { + // Comparing two refs + try { + const afterRaw = await this.git.getFileAtRef( + repoRoot, + relSchemaPath, + toRef + ); + if (afterRaw) { + afterSchema = JSON.parse(afterRaw) as SchemaFile; + } + } catch { + // ok + } + } else { + // Compare against working tree (current file on disk) + afterSchema = await this.store.getSchema(projectId); + } + + // Compute diff + const diff = this.differ.diff(beforeSchema, afterSchema); + + this.rpc.sendResponse(id, { + ok: true, + data: { + isGitRepo: true, + diff, + fromRef, + toRef: toRef || "working tree", + }, + }); + } catch (e: any) { + this.logger?.error({ e }, "schema.diff failed"); + this.rpc.sendError(id, { + code: "DIFF_ERROR", + message: String(e.message || e), + }); + } + } + + /** + * schema.fileHistory + * + * params: + * projectId — required + * count — max entries (default 20) + */ + async handleFileHistory(params: any, id: number | string) { + try { + const { projectId, count = 20 } = params || {}; + if (!projectId) { + return this.rpc.sendError(id, { + code: "BAD_REQUEST", + message: "Missing projectId", + }); + } + + const dir = getProjectDir(projectId); + const isRepo = await this.git.isRepo(dir); + if (!isRepo) { + return this.rpc.sendResponse(id, { + ok: true, + data: { isGitRepo: false, entries: [] }, + }); + } + + const repoRoot = await this.git.getRepoRoot(dir); + const relSchemaPath = path + .relative(repoRoot, path.join(dir, "schema", "schema.json")) + .replace(/\\/g, "/"); + + const entries = await this.git.fileLog(repoRoot, relSchemaPath, count); + + this.rpc.sendResponse(id, { + ok: true, + data: { isGitRepo: true, entries }, + }); + } catch (e: any) { + this.logger?.error({ e }, "schema.fileHistory failed"); + this.rpc.sendError(id, { + code: "DIFF_ERROR", + message: String(e.message || e), + }); + } + } +} diff --git a/bridge/src/jsonRpcHandler.ts b/bridge/src/jsonRpcHandler.ts index 52371df..3b82d5e 100644 --- a/bridge/src/jsonRpcHandler.ts +++ b/bridge/src/jsonRpcHandler.ts @@ -9,6 +9,7 @@ import { StatsHandlers } from "./handlers/statsHandlers"; import { MigrationHandlers } from "./handlers/migrationHandlers"; import { ProjectHandlers } from "./handlers/projectHandlers"; import { GitHandlers } from "./handlers/gitHandlers"; +import { SchemaDiffHandlers } from "./handlers/schemaDiffHandlers"; import { discoveryService } from "./services/discoveryService"; import { Logger } from "pino"; @@ -56,6 +57,7 @@ export function registerDbHandlers( ); const projectHandlers = new ProjectHandlers(rpc, logger); const gitHandlers = new GitHandlers(rpc, logger); + const schemaDiffHandlers = new SchemaDiffHandlers(rpc, logger); // ========================================== // SESSION MANAGEMENT HANDLERS @@ -253,6 +255,12 @@ export function registerDbHandlers( rpcRegister("git.diff", (p, id) => gitHandlers.handleDiff(p, id)); rpcRegister("git.ensureIgnore", (p, id) => gitHandlers.handleEnsureIgnore(p, id)); + // ========================================== + // SCHEMA DIFF HANDLERS + // ========================================== + rpcRegister("schema.diff", (p, id) => schemaDiffHandlers.handleDiff(p, id)); + rpcRegister("schema.fileHistory", (p, id) => schemaDiffHandlers.handleFileHistory(p, id)); + // ========================================== // DATABASE DISCOVERY HANDLERS // ========================================== diff --git a/bridge/src/services/gitService.ts b/bridge/src/services/gitService.ts index a27d243..8b79344 100644 --- a/bridge/src/services/gitService.ts +++ b/bridge/src/services/gitService.ts @@ -414,6 +414,44 @@ export class GitService { return this.git(dir, ...args); } + /** + * Read a file's content at a given git ref (HEAD, branch, commit hash). + * Returns null if the file doesn't exist at that ref. + */ + async getFileAtRef(dir: string, filePath: string, ref = "HEAD"): Promise { + try { + return await this.git(dir, "show", `${ref}:${filePath}`); + } catch { + return null; // file doesn't exist at this ref + } + } + + /** + * List commits that touched a specific file + */ + async fileLog(dir: string, filePath: string, count = 20): Promise { + try { + const SEP = "<>"; + const format = ["%h", "%H", "%an", "%aI", "%s"].join(SEP); + const output = await this.git( + dir, + "log", + `--max-count=${count}`, + `--format=${format}`, + "--follow", + "--", + filePath + ); + if (!output) return []; + return output.split("\n").map((line) => { + const [hash, fullHash, author, date, subject] = line.split(SEP); + return { hash, fullHash, author, date, subject }; + }); + } catch { + return []; + } + } + /** * Generate a .gitignore file suitable for RelWave projects */ diff --git a/bridge/src/services/schemaDiffService.ts b/bridge/src/services/schemaDiffService.ts new file mode 100644 index 0000000..2c956ec --- /dev/null +++ b/bridge/src/services/schemaDiffService.ts @@ -0,0 +1,348 @@ +// ---------------------------- +// services/schemaDiffService.ts +// ---------------------------- +// +// Computes a structured diff between two schema snapshots. +// Used for diffing working tree vs HEAD, or any two refs. + +import { + SchemaFile, + SchemaSnapshot, + TableSnapshot, + ColumnSnapshot, +} from "./projectStore"; + +// ========================================== +// Diff Result Types +// ========================================== + +export type SchemaDiffResult = { + /** Overall summary */ + summary: DiffSummary; + + /** Per-schema diffs */ + schemas: SchemaDiff[]; +}; + +export type DiffSummary = { + schemasAdded: number; + schemasRemoved: number; + schemasModified: number; + tablesAdded: number; + tablesRemoved: number; + tablesModified: number; + columnsAdded: number; + columnsRemoved: number; + columnsModified: number; + hasChanges: boolean; +}; + +export type SchemaDiff = { + name: string; + status: "added" | "removed" | "modified" | "unchanged"; + tables: TableDiff[]; +}; + +export type TableDiff = { + name: string; + schema: string; + status: "added" | "removed" | "modified" | "unchanged"; + columns: ColumnDiff[]; +}; + +export type ColumnDiff = { + name: string; + status: "added" | "removed" | "modified" | "unchanged"; + /** Only for "modified" — what changed */ + changes?: ColumnChange[]; + /** Before state (for removed/modified) */ + before?: ColumnSnapshot; + /** After state (for added/modified) */ + after?: ColumnSnapshot; +}; + +export type ColumnChange = { + field: string; + before: string; + after: string; +}; + +// ========================================== +// Diff Engine +// ========================================== + +export class SchemaDiffService { + /** + * Compare two SchemaFile objects and return a structured diff. + * Either can be null (e.g., initial commit has no HEAD version). + */ + diff( + before: SchemaFile | null, + after: SchemaFile | null + ): SchemaDiffResult { + const beforeSchemas = before?.schemas ?? []; + const afterSchemas = after?.schemas ?? []; + + const beforeMap = new Map(beforeSchemas.map((s) => [s.name, s])); + const afterMap = new Map(afterSchemas.map((s) => [s.name, s])); + + const allSchemaNames = new Set([ + ...beforeMap.keys(), + ...afterMap.keys(), + ]); + + const schemas: SchemaDiff[] = []; + const summary: DiffSummary = { + schemasAdded: 0, + schemasRemoved: 0, + schemasModified: 0, + tablesAdded: 0, + tablesRemoved: 0, + tablesModified: 0, + columnsAdded: 0, + columnsRemoved: 0, + columnsModified: 0, + hasChanges: false, + }; + + for (const name of allSchemaNames) { + const bSchema = beforeMap.get(name); + const aSchema = afterMap.get(name); + + if (!bSchema && aSchema) { + // Schema added + summary.schemasAdded++; + const tables = aSchema.tables.map((t) => + this.tableAsAdded(name, t) + ); + summary.tablesAdded += tables.length; + for (const t of tables) summary.columnsAdded += t.columns.length; + + schemas.push({ name, status: "added", tables }); + } else if (bSchema && !aSchema) { + // Schema removed + summary.schemasRemoved++; + const tables = bSchema.tables.map((t) => + this.tableAsRemoved(name, t) + ); + summary.tablesRemoved += tables.length; + for (const t of tables) summary.columnsRemoved += t.columns.length; + + schemas.push({ name, status: "removed", tables }); + } else if (bSchema && aSchema) { + // Schema exists in both — diff tables + const tableDiffs = this.diffTables(name, bSchema, aSchema, summary); + const hasTableChanges = tableDiffs.some( + (t) => t.status !== "unchanged" + ); + + if (hasTableChanges) summary.schemasModified++; + + schemas.push({ + name, + status: hasTableChanges ? "modified" : "unchanged", + tables: tableDiffs, + }); + } + } + + summary.hasChanges = + summary.schemasAdded > 0 || + summary.schemasRemoved > 0 || + summary.schemasModified > 0; + + // Sort: changed items first + schemas.sort((a, b) => { + const order = { removed: 0, added: 1, modified: 2, unchanged: 3 }; + return order[a.status] - order[b.status]; + }); + + return { summary, schemas }; + } + + // ---- Table diffing ---- + + private diffTables( + schemaName: string, + before: SchemaSnapshot, + after: SchemaSnapshot, + summary: DiffSummary + ): TableDiff[] { + const bMap = new Map(before.tables.map((t) => [t.name, t])); + const aMap = new Map(after.tables.map((t) => [t.name, t])); + + const allTableNames = new Set([...bMap.keys(), ...aMap.keys()]); + const results: TableDiff[] = []; + + for (const name of allTableNames) { + const bTable = bMap.get(name); + const aTable = aMap.get(name); + + if (!bTable && aTable) { + summary.tablesAdded++; + const diff = this.tableAsAdded(schemaName, aTable); + summary.columnsAdded += diff.columns.length; + results.push(diff); + } else if (bTable && !aTable) { + summary.tablesRemoved++; + const diff = this.tableAsRemoved(schemaName, bTable); + summary.columnsRemoved += diff.columns.length; + results.push(diff); + } else if (bTable && aTable) { + const columns = this.diffColumns(bTable, aTable); + const hasColChanges = columns.some( + (c) => c.status !== "unchanged" + ); + + if (hasColChanges) summary.tablesModified++; + for (const c of columns) { + if (c.status === "added") summary.columnsAdded++; + if (c.status === "removed") summary.columnsRemoved++; + if (c.status === "modified") summary.columnsModified++; + } + + results.push({ + name, + schema: schemaName, + status: hasColChanges ? "modified" : "unchanged", + columns, + }); + } + } + + // Sort: changed items first + results.sort((a, b) => { + const order = { removed: 0, added: 1, modified: 2, unchanged: 3 }; + return order[a.status] - order[b.status]; + }); + + return results; + } + + // ---- Column diffing ---- + + private diffColumns( + before: TableSnapshot, + after: TableSnapshot + ): ColumnDiff[] { + const bMap = new Map(before.columns.map((c) => [c.name, c])); + const aMap = new Map(after.columns.map((c) => [c.name, c])); + + const allColumnNames = new Set([...bMap.keys(), ...aMap.keys()]); + const results: ColumnDiff[] = []; + + for (const name of allColumnNames) { + const bCol = bMap.get(name); + const aCol = aMap.get(name); + + if (!bCol && aCol) { + results.push({ name, status: "added", after: aCol }); + } else if (bCol && !aCol) { + results.push({ name, status: "removed", before: bCol }); + } else if (bCol && aCol) { + const changes = this.diffColumnProps(bCol, aCol); + if (changes.length > 0) { + results.push({ + name, + status: "modified", + changes, + before: bCol, + after: aCol, + }); + } else { + results.push({ name, status: "unchanged" }); + } + } + } + + // Sort: changed items first + results.sort((a, b) => { + const order = { removed: 0, added: 1, modified: 2, unchanged: 3 }; + return order[a.status] - order[b.status]; + }); + + return results; + } + + private diffColumnProps( + before: ColumnSnapshot, + after: ColumnSnapshot + ): ColumnChange[] { + const changes: ColumnChange[] = []; + + if (before.type !== after.type) { + changes.push({ + field: "type", + before: before.type, + after: after.type, + }); + } + if (before.nullable !== after.nullable) { + changes.push({ + field: "nullable", + before: String(before.nullable), + after: String(after.nullable), + }); + } + if (before.isPrimaryKey !== after.isPrimaryKey) { + changes.push({ + field: "primaryKey", + before: String(before.isPrimaryKey), + after: String(after.isPrimaryKey), + }); + } + if (before.isForeignKey !== after.isForeignKey) { + changes.push({ + field: "foreignKey", + before: String(before.isForeignKey), + after: String(after.isForeignKey), + }); + } + if (before.isUnique !== after.isUnique) { + changes.push({ + field: "unique", + before: String(before.isUnique), + after: String(after.isUnique), + }); + } + if (before.defaultValue !== after.defaultValue) { + changes.push({ + field: "default", + before: before.defaultValue ?? "null", + after: after.defaultValue ?? "null", + }); + } + + return changes; + } + + // ---- Helpers ---- + + private tableAsAdded(schemaName: string, table: TableSnapshot): TableDiff { + return { + name: table.name, + schema: schemaName, + status: "added", + columns: table.columns.map((c) => ({ + name: c.name, + status: "added" as const, + after: c, + })), + }; + } + + private tableAsRemoved(schemaName: string, table: TableSnapshot): TableDiff { + return { + name: table.name, + schema: schemaName, + status: "removed", + columns: table.columns.map((c) => ({ + name: c.name, + status: "removed" as const, + before: c, + })), + }; + } +} + +export const schemaDiffServiceInstance = new SchemaDiffService(); diff --git a/src/components/common/VerticalIconBar.tsx b/src/components/common/VerticalIconBar.tsx index bd11cfe..8a811eb 100644 --- a/src/components/common/VerticalIconBar.tsx +++ b/src/components/common/VerticalIconBar.tsx @@ -1,4 +1,4 @@ -import { Home, Database, Search, GitBranch, Settings, Layers, Terminal, FolderOpen } from 'lucide-react'; +import { Home, Database, Search, GitBranch, GitCompareArrows, Settings, Layers, Terminal, FolderOpen } from 'lucide-react'; import { Link, useLocation } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { @@ -7,7 +7,7 @@ import { TooltipTrigger, } from '@/components/ui/tooltip'; -export type PanelType = 'data' | 'sql-workspace' | 'query-builder' | 'schema-explorer' | 'er-diagram'; +export type PanelType = 'data' | 'sql-workspace' | 'query-builder' | 'schema-explorer' | 'er-diagram' | 'schema-diff'; interface VerticalIconBarProps { dbId?: string; @@ -38,6 +38,7 @@ export default function VerticalIconBar({ dbId, activePanel, onPanelChange }: Ve { icon: Search, label: 'Query Builder', panel: 'query-builder' }, { icon: GitBranch, label: 'Schema Explorer', panel: 'schema-explorer' }, { icon: Database, label: 'ER Diagram', panel: 'er-diagram' }, + { icon: GitCompareArrows, label: 'Schema Diff', panel: 'schema-diff' }, ] : []; return ( diff --git a/src/components/schema-diff/SchemaDiffPanel.tsx b/src/components/schema-diff/SchemaDiffPanel.tsx new file mode 100644 index 0000000..f195aab --- /dev/null +++ b/src/components/schema-diff/SchemaDiffPanel.tsx @@ -0,0 +1,446 @@ +import { useState } from "react"; +import { + GitCompareArrows, + ChevronRight, + ChevronDown, + Plus, + Minus, + Pencil, + Table2, + Columns3, + Database, + RefreshCw, + History, + AlertCircle, + FolderGit2, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Spinner } from "@/components/ui/spinner"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import { useSchemaDiff, useSchemaFileHistory } from "@/hooks/useSchemaDiff"; +import type { + DiffStatus, + SchemaDiff, + TableDiff, + ColumnDiff, + ColumnChange, + SchemaDiffResult, +} from "@/types/schemaDiff"; + +// ─── Helpers ───────────────────────────────────────────────── + +const statusColor: Record = { + added: "text-emerald-500", + removed: "text-red-500", + modified: "text-amber-500", + unchanged: "text-muted-foreground/60", +}; + +const statusBg: Record = { + added: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20", + removed: "bg-red-500/10 text-red-500 border-red-500/20", + modified: "bg-amber-500/10 text-amber-500 border-amber-500/20", + unchanged: "bg-muted/30 text-muted-foreground border-border/30", +}; + +const StatusIcon = ({ status }: { status: DiffStatus }) => { + switch (status) { + case "added": + return ; + case "removed": + return ; + case "modified": + return ; + default: + return null; + } +}; + +// ─── Props ─────────────────────────────────────────────────── + +interface SchemaDiffPanelProps { + projectId?: string | null; +} + +// ─── Component ─────────────────────────────────────────────── + +export default function SchemaDiffPanel({ projectId }: SchemaDiffPanelProps) { + const [fromRef, setFromRef] = useState("HEAD"); + const [hideUnchanged, setHideUnchanged] = useState(true); + + const { + data: diffResponse, + isLoading, + isFetching, + refetch, + } = useSchemaDiff(projectId ?? undefined, fromRef); + + const { data: historyResponse } = useSchemaFileHistory( + projectId ?? undefined, + ); + + // ── Not a git repo ────────────────────────────────────────── + if (!isLoading && diffResponse && !diffResponse.isGitRepo) { + return ( +
+ +

+ This project is not inside a Git repository. Initialize Git from the + status bar to start tracking schema changes. +

+
+ ); + } + + // ── Loading ───────────────────────────────────────────────── + if (isLoading) { + return ( +
+ +
+ ); + } + + // ── Error / null diff (no commits yet) ────────────────────── + if (!diffResponse?.diff) { + return ( +
+ +

+ {diffResponse?.message ?? + "No schema snapshots found yet. Save a schema to start tracking changes."} +

+
+ ); + } + + const diff = diffResponse.diff; + const history = historyResponse?.entries ?? []; + + return ( +
+ {/* Header */} +
+
+
+ +

Schema Diff

+ {isFetching && } +
+
+ + + + + + Refresh diff + +
+
+ + {/* Ref selector row */} +
+ Compare + + → working tree +
+
+ + {/* Summary bar */} + + + {/* Diff tree */} + +
+ {diff.schemas + .filter((s) => !hideUnchanged || s.status !== "unchanged") + .map((schema) => ( + + ))} + {diff.schemas.filter( + (s) => !hideUnchanged || s.status !== "unchanged", + ).length === 0 && ( +
+ No schema changes detected. +
+ )} +
+
+
+ ); +} + +// ─── Summary Bar ───────────────────────────────────────────── + +function SummaryBar({ diff }: { diff: SchemaDiffResult }) { + const s = diff.summary; + if (!s.hasChanges) { + return ( +
+ Schema is up to date — no changes from the committed version. +
+ ); + } + return ( +
+ {s.tablesAdded > 0 && ( + + +{s.tablesAdded} table{s.tablesAdded > 1 ? "s" : ""} + + )} + {s.tablesRemoved > 0 && ( + + -{s.tablesRemoved} table{s.tablesRemoved > 1 ? "s" : ""} + + )} + {s.tablesModified > 0 && ( + + ~{s.tablesModified} table{s.tablesModified > 1 ? "s" : ""} modified + + )} + {s.columnsAdded > 0 && ( + + +{s.columnsAdded} column{s.columnsAdded > 1 ? "s" : ""} + + )} + {s.columnsRemoved > 0 && ( + + -{s.columnsRemoved} column{s.columnsRemoved > 1 ? "s" : ""} + + )} + {s.columnsModified > 0 && ( + + ~{s.columnsModified} column{s.columnsModified > 1 ? "s" : ""} modified + + )} +
+ ); +} + +// ─── Schema Node ───────────────────────────────────────────── + +function SchemaNode({ + schema, + hideUnchanged, +}: { + schema: SchemaDiff; + hideUnchanged: boolean; +}) { + const [open, setOpen] = useState(schema.status !== "unchanged"); + const visibleTables = schema.tables.filter( + (t) => !hideUnchanged || t.status !== "unchanged", + ); + + return ( +
+ + {open && ( +
+ {visibleTables.map((table) => ( + + ))} +
+ )} +
+ ); +} + +// ─── Table Node ────────────────────────────────────────────── + +function TableNode({ + table, + hideUnchanged, +}: { + table: TableDiff; + hideUnchanged: boolean; +}) { + const [open, setOpen] = useState(table.status !== "unchanged"); + const visibleCols = table.columns.filter( + (c) => !hideUnchanged || c.status !== "unchanged", + ); + + return ( +
+ + {open && visibleCols.length > 0 && ( +
+ {visibleCols.map((col) => ( + + ))} +
+ )} +
+ ); +} + +// ─── Column Node ───────────────────────────────────────────── + +function ColumnNode({ column }: { column: ColumnDiff }) { + const [open, setOpen] = useState(false); + const hasDetails = column.changes && column.changes.length > 0; + + return ( +
+ + {open && hasDetails && ( +
+ {column.changes!.map((change) => ( + + ))} +
+ )} +
+ ); +} + +// ─── Change Row ────────────────────────────────────────────── + +function ChangeRow({ change }: { change: ColumnChange }) { + return ( +
+ + {change.field} + + + {change.before} + + + {change.after} +
+ ); +} diff --git a/src/hooks/useSchemaDiff.ts b/src/hooks/useSchemaDiff.ts new file mode 100644 index 0000000..7e37d60 --- /dev/null +++ b/src/hooks/useSchemaDiff.ts @@ -0,0 +1,49 @@ +import { useQuery } from "@tanstack/react-query"; +import { bridgeApi } from "@/services/bridgeApi"; +import type { SchemaDiffResponse, SchemaFileHistoryResponse } from "@/types/schemaDiff"; + +// ─── Query Keys ────────────────────────────────────────────── +export const schemaDiffKeys = { + all: ["schemaDiff"] as const, + diff: (projectId: string, fromRef?: string, toRef?: string) => + [...schemaDiffKeys.all, "diff", projectId, fromRef ?? "HEAD", toRef ?? "working"] as const, + history: (projectId: string) => + [...schemaDiffKeys.all, "history", projectId] as const, +}; + +// ─── Hooks ─────────────────────────────────────────────────── + +/** + * Fetch a structural schema diff between two git refs. + * Defaults to HEAD → working tree. + */ +export function useSchemaDiff( + projectId: string | undefined, + fromRef = "HEAD", + toRef?: string, + enabled = true, +) { + return useQuery({ + queryKey: schemaDiffKeys.diff(projectId ?? "", fromRef, toRef), + queryFn: () => bridgeApi.schemaDiff(projectId!, fromRef, toRef), + enabled: !!projectId && enabled, + staleTime: 30_000, // re-fetch after 30 s + refetchInterval: 60_000, // auto-poll every 60 s + }); +} + +/** + * Fetch the commit history for the project's schema.json file. + */ +export function useSchemaFileHistory( + projectId: string | undefined, + count = 20, + enabled = true, +) { + return useQuery({ + queryKey: schemaDiffKeys.history(projectId ?? ""), + queryFn: () => bridgeApi.schemaFileHistory(projectId!, count), + enabled: !!projectId && enabled, + staleTime: 60_000, + }); +} diff --git a/src/pages/DatabaseDetails.tsx b/src/pages/DatabaseDetails.tsx index 63d8207..b6be4f8 100644 --- a/src/pages/DatabaseDetails.tsx +++ b/src/pages/DatabaseDetails.tsx @@ -33,6 +33,7 @@ import SQLWorkspacePanel from "@/components/workspace/SQLWorkspacePanel"; import QueryBuilderPanel from "@/components/query-builder/QueryBuilderPanel"; import SchemaExplorerPanel from "@/components/schema-explorer/SchemaExplorerPanel"; import ERDiagramPanel from "@/components/er-diagram/ERDiagramPanel"; +import SchemaDiffPanel from "@/components/schema-diff/SchemaDiffPanel"; import GitStatusBar from "@/components/common/GitStatusBar"; const DatabaseDetail = () => { @@ -150,6 +151,8 @@ const DatabaseDetail = () => { return ; case 'er-diagram': return ; + case 'schema-diff': + return ; case 'data': default: return ( diff --git a/src/services/bridgeApi.ts b/src/services/bridgeApi.ts index 2807712..f63aafd 100644 --- a/src/services/bridgeApi.ts +++ b/src/services/bridgeApi.ts @@ -1,6 +1,7 @@ import { AddDatabaseParams, ConnectionTestResult, CreateTableColumn, DatabaseConnection, DatabaseSchemaDetails, DatabaseStats, DiscoveredDatabase, RunQueryParams, TableRow, UpdateDatabaseParams } from "@/types/database"; import { ProjectSummary, ProjectMetadata, CreateProjectParams, UpdateProjectParams, SchemaFile, SchemaSnapshot, ERDiagramFile, ERNode, QueriesFile, SavedQuery, ProjectExport } from "@/types/project"; import { GitStatus, GitFileChange, GitLogEntry, GitBranchInfo } from "@/types/git"; +import { SchemaDiffResponse, SchemaFileHistoryResponse } from "@/types/schemaDiff"; import { bridgeRequest } from "./bridgeClient"; @@ -1164,6 +1165,34 @@ class BridgeApiService { const result = await bridgeRequest("git.ensureIgnore", { dir }); return result?.data; } + + // ------------------------------------ + // 9. SCHEMA DIFF (schema.*) + // ------------------------------------ + + /** + * Compute structured schema diff between two git refs. + * Default: HEAD vs working tree. + */ + async schemaDiff( + projectId: string, + fromRef = "HEAD", + toRef?: string + ): Promise { + const result = await bridgeRequest("schema.diff", { projectId, fromRef, toRef }); + return result?.data; + } + + /** + * Get commit history for a project's schema.json file. + */ + async schemaFileHistory( + projectId: string, + count = 20 + ): Promise { + const result = await bridgeRequest("schema.fileHistory", { projectId, count }); + return result?.data; + } } // Export singleton instance diff --git a/src/types/schemaDiff.ts b/src/types/schemaDiff.ts new file mode 100644 index 0000000..713c7e1 --- /dev/null +++ b/src/types/schemaDiff.ts @@ -0,0 +1,73 @@ +// ========================================== +// Schema Diff Types — Frontend +// ========================================== + +import type { ColumnSnapshot } from "@/types/project"; + +export interface SchemaDiffResult { + summary: DiffSummary; + schemas: SchemaDiff[]; +} + +export interface DiffSummary { + schemasAdded: number; + schemasRemoved: number; + schemasModified: number; + tablesAdded: number; + tablesRemoved: number; + tablesModified: number; + columnsAdded: number; + columnsRemoved: number; + columnsModified: number; + hasChanges: boolean; +} + +export type DiffStatus = "added" | "removed" | "modified" | "unchanged"; + +export interface SchemaDiff { + name: string; + status: DiffStatus; + tables: TableDiff[]; +} + +export interface TableDiff { + name: string; + schema: string; + status: DiffStatus; + columns: ColumnDiff[]; +} + +export interface ColumnDiff { + name: string; + status: DiffStatus; + changes?: ColumnChange[]; + before?: ColumnSnapshot; + after?: ColumnSnapshot; +} + +export interface ColumnChange { + field: string; + before: string; + after: string; +} + +export interface SchemaDiffResponse { + isGitRepo: boolean; + diff: SchemaDiffResult | null; + fromRef?: string; + toRef?: string; + message?: string; +} + +export interface SchemaFileHistoryResponse { + isGitRepo: boolean; + entries: SchemaFileHistoryEntry[]; +} + +export interface SchemaFileHistoryEntry { + hash: string; + fullHash: string; + author: string; + date: string; + subject: string; +} From 0e4b94e1e798f2e6ad1d521f1a2754602f08c152 Mon Sep 17 00:00:00 2001 From: Yash Date: Wed, 11 Feb 2026 15:38:37 +0530 Subject: [PATCH 3/5] feat: add environment switcher and migration timeline panel - Implemented EnvironmentSwitcher component for managing environment mappings. - Added MigrationTimelinePanel component to display schema migration history. - Updated VerticalIconBar to include Migration Timeline option. - Enhanced Git workflow hooks to support environment configuration and conflict detection. - Integrated new components into DatabaseDetails page for improved user experience. - Added necessary API endpoints in bridgeApi for timeline and environment management. - Created types for Git workflow, including timeline entries and environment mappings. --- bridge/src/handlers/gitWorkflowHandlers.ts | 191 ++++++++ bridge/src/jsonRpcHandler.ts | 21 + .../src/services/conflictDetectionService.ts | 307 +++++++++++++ bridge/src/services/environmentService.ts | 206 +++++++++ bridge/src/services/gitService.ts | 115 +++++ .../src/services/migrationTimelineService.ts | 266 +++++++++++ src/components/common/EnvironmentSwitcher.tsx | 275 ++++++++++++ src/components/common/VerticalIconBar.tsx | 5 +- .../MigrationTimelinePanel.tsx | 422 ++++++++++++++++++ src/hooks/useGitQueries.ts | 1 + src/hooks/useGitWorkflow.ts | 185 ++++++++ src/pages/DatabaseDetails.tsx | 5 + src/services/bridgeApi.ts | 132 ++++++ src/types/gitWorkflow.ts | 84 ++++ 14 files changed, 2213 insertions(+), 2 deletions(-) create mode 100644 bridge/src/handlers/gitWorkflowHandlers.ts create mode 100644 bridge/src/services/conflictDetectionService.ts create mode 100644 bridge/src/services/environmentService.ts create mode 100644 bridge/src/services/migrationTimelineService.ts create mode 100644 src/components/common/EnvironmentSwitcher.tsx create mode 100644 src/components/migration-timeline/MigrationTimelinePanel.tsx create mode 100644 src/hooks/useGitWorkflow.ts create mode 100644 src/types/gitWorkflow.ts diff --git a/bridge/src/handlers/gitWorkflowHandlers.ts b/bridge/src/handlers/gitWorkflowHandlers.ts new file mode 100644 index 0000000..c785bf5 --- /dev/null +++ b/bridge/src/handlers/gitWorkflowHandlers.ts @@ -0,0 +1,191 @@ +import { Rpc } from "../types"; +import { Logger } from "pino"; +import { + MigrationTimelineService, + migrationTimelineServiceInstance, +} from "../services/migrationTimelineService"; +import { + EnvironmentService, + environmentServiceInstance, +} from "../services/environmentService"; +import { + ConflictDetectionService, + conflictDetectionServiceInstance, +} from "../services/conflictDetectionService"; + +/** + * P2 RPC handlers for migration timeline, environment management, + * and schema conflict detection. + * + * Methods: + * timeline.list — migration timeline from git history + * timeline.commitSummary — change summary for a single commit + * timeline.autoCommit — auto-commit schema snapshot + * + * env.getConfig — read branch-environment mappings + * env.saveConfig — replace full environment config + * env.setMapping — upsert a single branch mapping + * env.removeMapping — delete a branch mapping + * env.resolve — resolve current environment for project + * + * conflict.detect — detect schema conflicts with target branch + */ +export class GitWorkflowHandlers { + constructor( + private rpc: Rpc, + private logger: Logger, + private timeline: MigrationTimelineService = migrationTimelineServiceInstance, + private env: EnvironmentService = environmentServiceInstance, + private conflicts: ConflictDetectionService = conflictDetectionServiceInstance, + ) { } + + // ========================================== + // TIMELINE + // ========================================== + + async handleTimelineList(params: any, id: number | string) { + try { + const { projectId, count = 50 } = params || {}; + if (!projectId) { + return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing projectId" }); + } + + const entries = await this.timeline.getTimeline(projectId, count); + this.rpc.sendResponse(id, { ok: true, data: { entries } }); + } catch (e: any) { + this.logger?.error({ e }, "timeline.list failed"); + this.rpc.sendError(id, { code: "TIMELINE_ERROR", message: String(e.message || e) }); + } + } + + async handleCommitSummary(params: any, id: number | string) { + try { + const { projectId, commitHash } = params || {}; + if (!projectId || !commitHash) { + return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing projectId or commitHash" }); + } + + const summary = await this.timeline.getCommitSummary(projectId, commitHash); + this.rpc.sendResponse(id, { ok: true, data: { summary } }); + } catch (e: any) { + this.logger?.error({ e }, "timeline.commitSummary failed"); + this.rpc.sendError(id, { code: "TIMELINE_ERROR", message: String(e.message || e) }); + } + } + + async handleAutoCommit(params: any, id: number | string) { + try { + const { projectId, message, tag } = params || {}; + if (!projectId) { + return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing projectId" }); + } + + const result = await this.timeline.autoCommitSchema(projectId, { + message, + tag, + }); + this.rpc.sendResponse(id, { ok: true, data: result }); + } catch (e: any) { + this.logger?.error({ e }, "timeline.autoCommit failed"); + this.rpc.sendError(id, { code: "COMMIT_ERROR", message: String(e.message || e) }); + } + } + + // ========================================== + // ENVIRONMENT + // ========================================== + + async handleEnvGetConfig(params: any, id: number | string) { + try { + const { projectId } = params || {}; + if (!projectId) { + return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing projectId" }); + } + + const config = await this.env.getConfig(projectId); + this.rpc.sendResponse(id, { ok: true, data: config }); + } catch (e: any) { + this.logger?.error({ e }, "env.getConfig failed"); + this.rpc.sendError(id, { code: "ENV_ERROR", message: String(e.message || e) }); + } + } + + async handleEnvSaveConfig(params: any, id: number | string) { + try { + const { projectId, config } = params || {}; + if (!projectId || !config) { + return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing projectId or config" }); + } + + const saved = await this.env.saveConfig(projectId, config); + this.rpc.sendResponse(id, { ok: true, data: saved }); + } catch (e: any) { + this.logger?.error({ e }, "env.saveConfig failed"); + this.rpc.sendError(id, { code: "ENV_ERROR", message: String(e.message || e) }); + } + } + + async handleEnvSetMapping(params: any, id: number | string) { + try { + const { projectId, mapping } = params || {}; + if (!projectId || !mapping) { + return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing projectId or mapping" }); + } + + const config = await this.env.setMapping(projectId, mapping); + this.rpc.sendResponse(id, { ok: true, data: config }); + } catch (e: any) { + this.logger?.error({ e }, "env.setMapping failed"); + this.rpc.sendError(id, { code: "ENV_ERROR", message: String(e.message || e) }); + } + } + + async handleEnvRemoveMapping(params: any, id: number | string) { + try { + const { projectId, branch } = params || {}; + if (!projectId || !branch) { + return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing projectId or branch" }); + } + + const config = await this.env.removeMapping(projectId, branch); + this.rpc.sendResponse(id, { ok: true, data: config }); + } catch (e: any) { + this.logger?.error({ e }, "env.removeMapping failed"); + this.rpc.sendError(id, { code: "ENV_ERROR", message: String(e.message || e) }); + } + } + + async handleEnvResolve(params: any, id: number | string) { + try { + const { projectId } = params || {}; + if (!projectId) { + return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing projectId" }); + } + + const resolved = await this.env.resolve(projectId); + this.rpc.sendResponse(id, { ok: true, data: resolved }); + } catch (e: any) { + this.logger?.error({ e }, "env.resolve failed"); + this.rpc.sendError(id, { code: "ENV_ERROR", message: String(e.message || e) }); + } + } + + // ========================================== + // CONFLICT DETECTION + // ========================================== + + async handleConflictDetect(params: any, id: number | string) { + try { + const { projectId, targetBranch = "main" } = params || {}; + if (!projectId) { + return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing projectId" }); + } + + const report = await this.conflicts.detectConflicts(projectId, targetBranch); + this.rpc.sendResponse(id, { ok: true, data: report }); + } catch (e: any) { + this.logger?.error({ e }, "conflict.detect failed"); + this.rpc.sendError(id, { code: "CONFLICT_ERROR", message: String(e.message || e) }); + } + } +} diff --git a/bridge/src/jsonRpcHandler.ts b/bridge/src/jsonRpcHandler.ts index 3b82d5e..de10011 100644 --- a/bridge/src/jsonRpcHandler.ts +++ b/bridge/src/jsonRpcHandler.ts @@ -10,6 +10,7 @@ import { MigrationHandlers } from "./handlers/migrationHandlers"; import { ProjectHandlers } from "./handlers/projectHandlers"; import { GitHandlers } from "./handlers/gitHandlers"; import { SchemaDiffHandlers } from "./handlers/schemaDiffHandlers"; +import { GitWorkflowHandlers } from "./handlers/gitWorkflowHandlers"; import { discoveryService } from "./services/discoveryService"; import { Logger } from "pino"; @@ -58,6 +59,7 @@ export function registerDbHandlers( const projectHandlers = new ProjectHandlers(rpc, logger); const gitHandlers = new GitHandlers(rpc, logger); const schemaDiffHandlers = new SchemaDiffHandlers(rpc, logger); + const gitWorkflowHandlers = new GitWorkflowHandlers(rpc, logger); // ========================================== // SESSION MANAGEMENT HANDLERS @@ -261,6 +263,25 @@ export function registerDbHandlers( rpcRegister("schema.diff", (p, id) => schemaDiffHandlers.handleDiff(p, id)); rpcRegister("schema.fileHistory", (p, id) => schemaDiffHandlers.handleFileHistory(p, id)); + // ========================================== + // GIT WORKFLOW HANDLERS (P2) + // ========================================== + + // Timeline + rpcRegister("timeline.list", (p, id) => gitWorkflowHandlers.handleTimelineList(p, id)); + rpcRegister("timeline.commitSummary", (p, id) => gitWorkflowHandlers.handleCommitSummary(p, id)); + rpcRegister("timeline.autoCommit", (p, id) => gitWorkflowHandlers.handleAutoCommit(p, id)); + + // Environment + rpcRegister("env.getConfig", (p, id) => gitWorkflowHandlers.handleEnvGetConfig(p, id)); + rpcRegister("env.saveConfig", (p, id) => gitWorkflowHandlers.handleEnvSaveConfig(p, id)); + rpcRegister("env.setMapping", (p, id) => gitWorkflowHandlers.handleEnvSetMapping(p, id)); + rpcRegister("env.removeMapping", (p, id) => gitWorkflowHandlers.handleEnvRemoveMapping(p, id)); + rpcRegister("env.resolve", (p, id) => gitWorkflowHandlers.handleEnvResolve(p, id)); + + // Conflict Detection + rpcRegister("conflict.detect", (p, id) => gitWorkflowHandlers.handleConflictDetect(p, id)); + // ========================================== // DATABASE DISCOVERY HANDLERS // ========================================== diff --git a/bridge/src/services/conflictDetectionService.ts b/bridge/src/services/conflictDetectionService.ts new file mode 100644 index 0000000..5412187 --- /dev/null +++ b/bridge/src/services/conflictDetectionService.ts @@ -0,0 +1,307 @@ +// ============================================================ +// services/conflictDetectionService.ts +// ============================================================ +// +// Detects schema conflicts between the current branch and another +// branch (typically "main" or the upstream target). +// +// Works by: +// 1. Finding the merge-base (common ancestor) +// 2. Reading schema.json at merge-base, current branch, and target branch +// 3. Computing structural diffs for both sides +// 4. Identifying tables/columns modified by BOTH sides (= conflicts) + +import path from "path"; +import { GitService, gitServiceInstance } from "./gitService"; +import { + SchemaDiffService, + schemaDiffServiceInstance, +} from "./schemaDiffService"; +import { + ProjectStore, + projectStoreInstance, + SchemaFile, +} from "./projectStore"; +import { getProjectDir } from "../utils/config"; + +// ─── Types ─────────────────────────────────────────────────── + +export type ConflictSeverity = "high" | "medium" | "low"; + +export interface SchemaConflict { + /** Which table has a conflict */ + table: string; + /** Schema the table belongs to */ + schema: string; + /** What kind of conflict */ + type: "both-modified" | "modified-deleted" | "both-added"; + /** Severity assessment */ + severity: ConflictSeverity; + /** Human-readable description */ + description: string; + /** Columns involved (for both-modified) */ + columns?: ConflictingColumn[]; +} + +export interface ConflictingColumn { + name: string; + /** What changed on the current branch */ + oursChange: string; + /** What changed on the target branch */ + theirsChange: string; +} + +export interface ConflictReport { + /** Branch we're comparing FROM (current) */ + currentBranch: string | null; + /** Branch we're comparing TO (target) */ + targetBranch: string; + /** Common ancestor commit */ + mergeBase: string | null; + /** File-level conflicts detected by git merge-tree */ + fileConflicts: string[]; + /** Structural schema conflicts */ + schemaConflicts: SchemaConflict[]; + /** Whether schema.json itself has a git-level conflict */ + hasSchemaFileConflict: boolean; + /** Total number of conflicting tables */ + conflictCount: number; + /** Quick summary */ + summary: string; +} + +// ─── Service ───────────────────────────────────────────────── + +export class ConflictDetectionService { + constructor( + private git: GitService = gitServiceInstance, + private differ: SchemaDiffService = schemaDiffServiceInstance, + private store: ProjectStore = projectStoreInstance, + ) { } + + /** + * Detect schema conflicts between the current branch and a target branch. + */ + async detectConflicts( + projectId: string, + targetBranch: string, + ): Promise { + const dir = getProjectDir(projectId); + if (!(await this.git.isRepo(dir))) { + return this.emptyReport(null, targetBranch); + } + + const repoRoot = await this.git.getRepoRoot(dir); + const relSchemaPath = path + .relative(repoRoot, path.join(dir, "schema", "schema.json")) + .replace(/\\/g, "/"); + + // Get current branch + const status = await this.git.getStatus(dir); + const currentBranch = status.branch; + + // Find merge-base + const currentRef = currentBranch ?? "HEAD"; + const mergeBase = await this.git.mergeBase(repoRoot, currentRef, targetBranch); + + if (!mergeBase) { + return { + ...this.emptyReport(currentBranch, targetBranch), + summary: "No common ancestor found between branches", + }; + } + + // Check git-level file conflicts + const fileConflicts = await this.git.dryMerge(repoRoot, targetBranch); + const hasSchemaFileConflict = fileConflicts.some( + (f) => f.endsWith("schema.json") || f === relSchemaPath, + ); + + // Read schema at three points: merge-base, ours (current), theirs (target) + const [baseSchema, oursSchema, theirsSchema] = await Promise.all([ + this.readSchemaAt(repoRoot, relSchemaPath, mergeBase), + this.readSchemaAt(repoRoot, relSchemaPath, currentRef), + this.readSchemaAt(repoRoot, relSchemaPath, targetBranch), + ]); + + // Compute diffs from merge-base to each branch + const oursDiff = this.differ.diff(baseSchema, oursSchema); + const theirsDiff = this.differ.diff(baseSchema, theirsSchema); + + // Find structural conflicts + const schemaConflicts = this.findConflicts(oursDiff, theirsDiff); + + // Build summary + const conflictCount = schemaConflicts.length; + let summary: string; + if (conflictCount === 0 && !hasSchemaFileConflict) { + summary = "No schema conflicts detected — safe to merge"; + } else if (hasSchemaFileConflict && conflictCount === 0) { + summary = "Git detects a text-level conflict in schema.json but no structural conflicts"; + } else { + const high = schemaConflicts.filter((c) => c.severity === "high").length; + const med = schemaConflicts.filter((c) => c.severity === "medium").length; + summary = `${conflictCount} conflicting table${conflictCount > 1 ? "s" : ""}`; + if (high) summary += ` (${high} high severity)`; + else if (med) summary += ` (${med} medium severity)`; + } + + return { + currentBranch, + targetBranch, + mergeBase: mergeBase.slice(0, 8), + fileConflicts, + schemaConflicts, + hasSchemaFileConflict, + conflictCount, + summary, + }; + } + + // ── Private ───────────────────────────────────────────── + + private async readSchemaAt( + repoRoot: string, + relPath: string, + ref: string, + ): Promise { + try { + const raw = await this.git.getFileAtRef(repoRoot, relPath, ref); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } + } + + /** + * Compare two diffs (ours vs theirs, both from the same merge-base) + * and identify tables/columns changed by BOTH sides. + */ + private findConflicts( + oursDiff: ReturnType, + theirsDiff: ReturnType, + ): SchemaConflict[] { + const conflicts: SchemaConflict[] = []; + + // Build lookup: "schema.table" → status for theirs + const theirsTableMap = new Map(); + const theirsColMap = new Map>(); + + for (const s of theirsDiff.schemas) { + for (const t of s.tables) { + const key = `${s.name}.${t.name}`; + if (t.status !== "unchanged") { + theirsTableMap.set(key, t.status); + } + const changedCols = new Set(); + for (const c of t.columns) { + if (c.status !== "unchanged") { + changedCols.add(c.name); + } + } + if (changedCols.size > 0) { + theirsColMap.set(key, changedCols); + } + } + } + + // Walk ours and check for overlaps + for (const s of oursDiff.schemas) { + for (const t of s.tables) { + const key = `${s.name}.${t.name}`; + + if (t.status === "unchanged") continue; + + const theirsStatus = theirsTableMap.get(key); + if (!theirsStatus) continue; // only we changed it => no conflict + + // Both sides changed this table + if (t.status === "removed" && theirsStatus === "modified") { + conflicts.push({ + table: t.name, + schema: s.name, + type: "modified-deleted", + severity: "high", + description: `Table "${t.name}" was deleted on current branch but modified on ${key}`, + }); + } else if (t.status === "modified" && theirsStatus === "removed") { + conflicts.push({ + table: t.name, + schema: s.name, + type: "modified-deleted", + severity: "high", + description: `Table "${t.name}" was modified on current branch but deleted on target`, + }); + } else if (t.status === "added" && theirsStatus === "added") { + conflicts.push({ + table: t.name, + schema: s.name, + type: "both-added", + severity: "medium", + description: `Table "${t.name}" was added on both branches — definitions may differ`, + }); + } else if (t.status === "modified" && theirsStatus === "modified") { + // Check column-level overlap + const theirsCols = theirsColMap.get(key); + const conflictingColumns: ConflictingColumn[] = []; + + for (const c of t.columns) { + if (c.status === "unchanged") continue; + if (theirsCols?.has(c.name)) { + conflictingColumns.push({ + name: c.name, + oursChange: c.status, + theirsChange: "modified", + }); + } + } + + if (conflictingColumns.length > 0) { + conflicts.push({ + table: t.name, + schema: s.name, + type: "both-modified", + severity: "high", + description: `${conflictingColumns.length} column${conflictingColumns.length > 1 ? "s" : ""} modified on both branches`, + columns: conflictingColumns, + }); + } else { + // Different columns changed — lower risk + conflicts.push({ + table: t.name, + schema: s.name, + type: "both-modified", + severity: "low", + description: `Table "${t.name}" modified on both branches but different columns affected`, + }); + } + } + } + } + + // Sort by severity (high first) + const severityOrder: Record = { + high: 0, + medium: 1, + low: 2, + }; + conflicts.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]); + + return conflicts; + } + + private emptyReport(currentBranch: string | null, targetBranch: string): ConflictReport { + return { + currentBranch, + targetBranch, + mergeBase: null, + fileConflicts: [], + schemaConflicts: [], + hasSchemaFileConflict: false, + conflictCount: 0, + summary: "Not a git repository", + }; + } +} + +export const conflictDetectionServiceInstance = new ConflictDetectionService(); diff --git a/bridge/src/services/environmentService.ts b/bridge/src/services/environmentService.ts new file mode 100644 index 0000000..73f975a --- /dev/null +++ b/bridge/src/services/environmentService.ts @@ -0,0 +1,206 @@ +// ============================================================ +// services/environmentService.ts +// ============================================================ +// +// Maps git branches → database environments (dev / staging / prod). +// +// Persisted in the project's `relwave.json` under an `environments` +// key so the mapping is committed with the project and shared +// across team members. +// +// Per-developer connection overrides live in `relwave.local.json` +// (git-ignored) so credentials never leak. + +import { + ProjectStore, + projectStoreInstance, + ProjectMetadata, + LocalConfig, +} from "./projectStore"; +import { GitService, gitServiceInstance } from "./gitService"; +import { getProjectDir } from "../utils/config"; +import path from "path"; +import fs from "fs/promises"; +import fsSync from "fs"; + +// ─── Types ─────────────────────────────────────────────────── + +/** One entry in the branch → environment mapping */ +export interface EnvironmentMapping { + /** Git branch name (exact match, e.g. "main", "develop") */ + branch: string; + /** Labelled environment name */ + environment: string; + /** Optional connection URL for this environment (shared) */ + connectionUrl?: string; + /** Is this the production environment? (extra protection) */ + isProduction?: boolean; +} + +/** Full environment configuration stored in relwave.json (committed) */ +export interface EnvironmentConfig { + /** Ordered list of branch → environment mappings */ + mappings: EnvironmentMapping[]; + /** Default environment label when branch doesn't match any mapping */ + defaultEnvironment?: string; +} + +/** Runtime-resolved environment for the current branch */ +export interface ResolvedEnvironment { + /** Current git branch */ + branch: string | null; + /** Resolved environment label */ + environment: string; + /** Whether this branch is mapped to production */ + isProduction: boolean; + /** Best connection URL (local override > mapping > default) */ + connectionUrl: string | null; + /** Source of the connection URL */ + connectionSource: "local" | "mapping" | "database" | "none"; +} + +// ─── Service ───────────────────────────────────────────────── + +export class EnvironmentService { + constructor( + private store: ProjectStore = projectStoreInstance, + private git: GitService = gitServiceInstance, + ) { } + + // ── Read / Write environment config ─────────────────────── + + /** + * Get the environment config from relwave.json + */ + async getConfig(projectId: string): Promise { + const meta = await this.readMetadataRaw(projectId); + return (meta as any)?.environments ?? { mappings: [] }; + } + + /** + * Save environment config back to relwave.json + */ + async saveConfig( + projectId: string, + config: EnvironmentConfig, + ): Promise { + const meta = await this.readMetadataRaw(projectId); + if (!meta) throw new Error(`Project ${projectId} not found`); + + (meta as any).environments = config; + meta.updatedAt = new Date().toISOString(); + + await this.writeMetadataRaw(projectId, meta); + return config; + } + + // ── Single-mapping CRUD ─────────────────────────────────── + + /** + * Add or update a branch → environment mapping + */ + async setMapping( + projectId: string, + mapping: EnvironmentMapping, + ): Promise { + const config = await this.getConfig(projectId); + const idx = config.mappings.findIndex((m) => m.branch === mapping.branch); + if (idx >= 0) { + config.mappings[idx] = mapping; + } else { + config.mappings.push(mapping); + } + return this.saveConfig(projectId, config); + } + + /** + * Remove a branch mapping + */ + async removeMapping( + projectId: string, + branch: string, + ): Promise { + const config = await this.getConfig(projectId); + config.mappings = config.mappings.filter((m) => m.branch !== branch); + return this.saveConfig(projectId, config); + } + + // ── Resolution ──────────────────────────────────────────── + + /** + * Resolve the current environment based on the active git branch. + * Priority for connection URL: local override > mapping > database default. + */ + async resolve(projectId: string): Promise { + const dir = getProjectDir(projectId); + const isRepo = await this.git.isRepo(dir); + + // Get current branch + let branch: string | null = null; + if (isRepo) { + const status = await this.git.getStatus(dir); + branch = status.branch; + } + + const config = await this.getConfig(projectId); + const localConfig = await this.store.getLocalConfig(projectId); + + // Find matching mapping + const mapping = branch + ? config.mappings.find((m) => m.branch === branch) + : undefined; + + const environment = + mapping?.environment ?? + config.defaultEnvironment ?? + "development"; + + const isProduction = mapping?.isProduction ?? false; + + // Resolve connection URL with priority chain + let connectionUrl: string | null = null; + let connectionSource: ResolvedEnvironment["connectionSource"] = "none"; + + if (localConfig?.connectionUrl) { + connectionUrl = localConfig.connectionUrl; + connectionSource = "local"; + } else if (mapping?.connectionUrl) { + connectionUrl = mapping.connectionUrl; + connectionSource = "mapping"; + } + + return { + branch, + environment, + isProduction, + connectionUrl, + connectionSource, + }; + } + + // ── Private helpers ─────────────────────────────────────── + + /** + * Read relwave.json as raw object (to preserve extra fields like `environments`) + */ + private async readMetadataRaw(projectId: string): Promise<(ProjectMetadata & { environments?: EnvironmentConfig }) | null> { + const filePath = path.join(getProjectDir(projectId), "relwave.json"); + try { + if (!fsSync.existsSync(filePath)) return null; + const raw = await fs.readFile(filePath, "utf-8"); + return JSON.parse(raw); + } catch { + return null; + } + } + + /** + * Write relwave.json preserving all fields + */ + private async writeMetadataRaw(projectId: string, data: any): Promise { + const filePath = path.join(getProjectDir(projectId), "relwave.json"); + await fs.writeFile(filePath, JSON.stringify(data, null, 2), "utf-8"); + } +} + +export const environmentServiceInstance = new EnvironmentService(); diff --git a/bridge/src/services/gitService.ts b/bridge/src/services/gitService.ts index 8b79344..c486c14 100644 --- a/bridge/src/services/gitService.ts +++ b/bridge/src/services/gitService.ts @@ -140,6 +140,18 @@ export class GitService { return this.git(dir, "rev-parse", "--show-toplevel"); } + /** + * Resolve a ref (tag, branch, HEAD~1, etc.) to a full commit hash. + * Returns null if the ref cannot be resolved. + */ + async resolveRef(dir: string, ref: string): Promise { + try { + return await this.git(dir, "rev-list", "-n1", ref); + } catch { + return null; + } + } + /** * Initialize a new git repository */ @@ -496,6 +508,109 @@ export class GitService { fsSync.writeFileSync(gi, this.generateGitignore(), "utf-8"); return true; // created } + + // ========================================== + // Tags + // ========================================== + + /** + * Create an annotated tag at the current HEAD (or a given ref) + */ + async createTag(dir: string, tagName: string, message?: string, ref?: string): Promise { + const args = ["tag"]; + if (message) { + args.push("-a", tagName, "-m", message); + } else { + args.push(tagName); + } + if (ref) args.push(ref); + await this.git(dir, ...args); + } + + /** + * Delete a tag + */ + async deleteTag(dir: string, tagName: string): Promise { + await this.git(dir, "tag", "-d", tagName); + } + + /** + * List tags with optional pattern filter. + * Returns tag names sorted by creation date (newest first). + */ + async listTags(dir: string, pattern?: string): Promise { + try { + const args = ["tag", "-l", "--sort=-creatordate"]; + if (pattern) args.push(pattern); + const output = await this.git(dir, ...args); + if (!output) return []; + return output.split("\n").filter(Boolean); + } catch { + return []; + } + } + + /** + * Get the message of an annotated tag + */ + async getTagMessage(dir: string, tagName: string): Promise { + try { + return await this.git(dir, "tag", "-l", "-n99", tagName); + } catch { + return null; + } + } + + // ========================================== + // Merge / Conflict detection + // ========================================== + + /** + * Get the merge-base (common ancestor commit) between two refs. + * Returns full hash, or null if no common ancestor. + */ + async mergeBase(dir: string, refA: string, refB: string): Promise { + try { + const output = await this.git(dir, "merge-base", refA, refB); + return output || null; + } catch { + return null; + } + } + + /** + * Check if merging `source` into the current branch would produce conflicts, + * without actually modifying the working tree. + * Returns list of conflicting file paths, or empty if clean. + */ + async dryMerge(dir: string, source: string): Promise { + try { + // Try to merge in-memory (index only) + await this.git(dir, "merge-tree", "--write-tree", "--no-messages", "HEAD", source); + return []; // clean merge + } catch (err: any) { + // merge-tree exits non-zero when there are conflicts and lists them + const output: string = err.stdout ?? err.message ?? ""; + const conflicts: string[] = []; + for (const line of output.split("\n")) { + // merge-tree outputs "CONFLICT (content): ..." lines + if (line.startsWith("CONFLICT")) { + const match = line.match(/Merge conflict in (.+)/); + if (match) conflicts.push(match[1].trim()); + } + } + return conflicts.length > 0 ? conflicts : ["(unknown conflict)"]; + } + } + + /** + * Stage-and-commit specific files in one go (for auto-commit workflows). + * Returns the short commit hash. + */ + async commitFiles(dir: string, files: string[], message: string): Promise { + await this.git(dir, "add", "--", ...files); + return this.commit(dir, message); + } } export const gitServiceInstance = new GitService(); diff --git a/bridge/src/services/migrationTimelineService.ts b/bridge/src/services/migrationTimelineService.ts new file mode 100644 index 0000000..4318b35 --- /dev/null +++ b/bridge/src/services/migrationTimelineService.ts @@ -0,0 +1,266 @@ +// ============================================================ +// services/migrationTimelineService.ts +// ============================================================ +// +// Provides a "migration timeline" built from the git history of +// schema.json. Each commit that touches the schema file is one +// entry in the timeline. Entries may optionally carry a tag +// (e.g. "v1.2.0" or "relwave/migration/20260101-add-users"). +// +// Auto-commit: When the frontend saves a schema snapshot we +// can stage + commit + tag the change automatically so that +// every schema mutation is a discrete, revertable git commit. + +import path from "path"; +import { + GitService, + gitServiceInstance, + GitLogEntry, +} from "./gitService"; +import { + SchemaDiffService, + schemaDiffServiceInstance, +} from "./schemaDiffService"; +import { + ProjectStore, + projectStoreInstance, + SchemaFile, +} from "./projectStore"; +import { getProjectDir } from "../utils/config"; + +// ─── Types ─────────────────────────────────────────────────── + +export interface TimelineEntry { + /** Short commit hash */ + hash: string; + /** Full commit hash */ + fullHash: string; + /** Commit author */ + author: string; + /** Commit date ISO string */ + date: string; + /** Commit subject line */ + subject: string; + /** Tags on this commit (may be empty) */ + tags: string[]; + /** Whether this commit was an auto-commit by RelWave */ + isAutoCommit: boolean; + /** Quick schema change summary for this commit (optional — computed lazily) */ + summary?: TimelineChangeSummary; +} + +export interface TimelineChangeSummary { + tablesAdded: number; + tablesRemoved: number; + tablesModified: number; + columnsAdded: number; + columnsRemoved: number; + columnsModified: number; +} + +export interface AutoCommitResult { + /** Short hash of the new commit */ + hash: string; + /** Tag name if one was created */ + tag: string | null; + /** Commit message used */ + message: string; +} + +// Tag prefix used by RelWave auto-commits +const TAG_PREFIX = "relwave/schema/"; +const AUTO_COMMIT_PREFIX = "[relwave] "; + +// ─── Service ───────────────────────────────────────────────── + +export class MigrationTimelineService { + constructor( + private git: GitService = gitServiceInstance, + private differ: SchemaDiffService = schemaDiffServiceInstance, + private store: ProjectStore = projectStoreInstance, + ) { } + + /** + * Build the full migration timeline from the git log of schema.json. + * Each entry = one commit that changed the schema file. + */ + async getTimeline(projectId: string, count = 50): Promise { + const dir = getProjectDir(projectId); + if (!(await this.git.isRepo(dir))) return []; + + const repoRoot = await this.git.getRepoRoot(dir); + const relSchemaPath = path + .relative(repoRoot, path.join(dir, "schema", "schema.json")) + .replace(/\\/g, "/"); + + // Get commits that touched schema.json + const commits = await this.git.fileLog(repoRoot, relSchemaPath, count); + if (commits.length === 0) return []; + + // Get all tags in the repo (prefixed with our namespace) + const allTags = await this.git.listTags(repoRoot, `${TAG_PREFIX}*`); + + // For each tag, resolve to a commit hash so we can associate + const tagsByCommit = new Map(); + for (const tag of allTags) { + try { + // rev-parse dereferences the tag to its commit + const hash = await this.resolveTagToCommit(repoRoot, tag); + if (hash) { + const existing = tagsByCommit.get(hash) ?? []; + existing.push(tag); + tagsByCommit.set(hash, existing); + } + } catch { + // skip unresolvable tags + } + } + + return commits.map((c) => ({ + hash: c.hash, + fullHash: c.fullHash, + author: c.author, + date: c.date, + subject: c.subject, + tags: tagsByCommit.get(c.fullHash) ?? [], + isAutoCommit: c.subject.startsWith(AUTO_COMMIT_PREFIX), + })); + } + + /** + * Get a detailed change summary for a specific commit by diffing it + * against its parent. + */ + async getCommitSummary( + projectId: string, + commitHash: string, + ): Promise { + const dir = getProjectDir(projectId); + if (!(await this.git.isRepo(dir))) return null; + + const repoRoot = await this.git.getRepoRoot(dir); + const relSchemaPath = path + .relative(repoRoot, path.join(dir, "schema", "schema.json")) + .replace(/\\/g, "/"); + + // Read schema at this commit and its parent + let afterRaw: string | null = null; + let beforeRaw: string | null = null; + + try { + afterRaw = await this.git.getFileAtRef(repoRoot, relSchemaPath, commitHash); + } catch { /* file may not exist */ } + + try { + beforeRaw = await this.git.getFileAtRef(repoRoot, relSchemaPath, `${commitHash}~1`); + } catch { /* parent may not exist (first commit) */ } + + const before: SchemaFile | null = beforeRaw ? JSON.parse(beforeRaw) : null; + const after: SchemaFile | null = afterRaw ? JSON.parse(afterRaw) : null; + + const diff = this.differ.diff(before, after); + + return { + tablesAdded: diff.summary.tablesAdded, + tablesRemoved: diff.summary.tablesRemoved, + tablesModified: diff.summary.tablesModified, + columnsAdded: diff.summary.columnsAdded, + columnsRemoved: diff.summary.columnsRemoved, + columnsModified: diff.summary.columnsModified, + }; + } + + /** + * Auto-commit the current schema snapshot with an optional tag. + * + * Flow: + * 1. Stage schema/schema.json + * 2. Commit with a descriptive message + * 3. Optionally create an annotated tag + */ + async autoCommitSchema( + projectId: string, + options?: { + message?: string; + tag?: string; + }, + ): Promise { + const dir = getProjectDir(projectId); + if (!(await this.git.isRepo(dir))) { + throw new Error("Project directory is not a git repository"); + } + + const repoRoot = await this.git.getRepoRoot(dir); + const relSchemaPath = path + .relative(repoRoot, path.join(dir, "schema", "schema.json")) + .replace(/\\/g, "/"); + + // Build a descriptive commit message + let message = options?.message ?? ""; + if (!message) { + // Generate from diff summary + const summary = await this.getWorkingTreeSummary(projectId); + message = `${AUTO_COMMIT_PREFIX}schema update`; + if (summary) { + const parts: string[] = []; + if (summary.tablesAdded) parts.push(`+${summary.tablesAdded} table${summary.tablesAdded > 1 ? "s" : ""}`); + if (summary.tablesRemoved) parts.push(`-${summary.tablesRemoved} table${summary.tablesRemoved > 1 ? "s" : ""}`); + if (summary.tablesModified) parts.push(`~${summary.tablesModified} table${summary.tablesModified > 1 ? "s" : ""}`); + if (parts.length) message = `${AUTO_COMMIT_PREFIX}${parts.join(", ")}`; + } + } + + const hash = await this.git.commitFiles(repoRoot, [relSchemaPath], message); + + // Create tag if requested + let tagName: string | null = null; + if (options?.tag) { + tagName = options.tag.startsWith(TAG_PREFIX) + ? options.tag + : `${TAG_PREFIX}${options.tag}`; + await this.git.createTag(repoRoot, tagName, message); + } + + return { hash, tag: tagName, message }; + } + + /** + * Get a change summary for the working tree vs HEAD + */ + private async getWorkingTreeSummary( + projectId: string, + ): Promise { + const dir = getProjectDir(projectId); + const repoRoot = await this.git.getRepoRoot(dir); + const relSchemaPath = path + .relative(repoRoot, path.join(dir, "schema", "schema.json")) + .replace(/\\/g, "/"); + + let beforeRaw: string | null = null; + try { + beforeRaw = await this.git.getFileAtRef(repoRoot, relSchemaPath, "HEAD"); + } catch { /* no HEAD version */ } + + const before: SchemaFile | null = beforeRaw ? JSON.parse(beforeRaw) : null; + const after = await this.store.getSchema(projectId); + + const diff = this.differ.diff(before, after); + return { + tablesAdded: diff.summary.tablesAdded, + tablesRemoved: diff.summary.tablesRemoved, + tablesModified: diff.summary.tablesModified, + columnsAdded: diff.summary.columnsAdded, + columnsRemoved: diff.summary.columnsRemoved, + columnsModified: diff.summary.columnsModified, + }; + } + + /** + * Resolve a tag name to its commit hash + */ + private async resolveTagToCommit(repoRoot: string, tag: string): Promise { + return this.git.resolveRef(repoRoot, tag); + } +} + +export const migrationTimelineServiceInstance = new MigrationTimelineService(); diff --git a/src/components/common/EnvironmentSwitcher.tsx b/src/components/common/EnvironmentSwitcher.tsx new file mode 100644 index 0000000..ec260a0 --- /dev/null +++ b/src/components/common/EnvironmentSwitcher.tsx @@ -0,0 +1,275 @@ +import { useState } from "react"; +import { + Globe, + Shield, + ShieldAlert, + Plus, + Trash2, + Settings2, + Check, + Loader2, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; +import { + useResolvedEnv, + useEnvConfig, + useSetEnvMapping, + useRemoveEnvMapping, +} from "@/hooks/useGitWorkflow"; +import type { EnvironmentMapping } from "@/types/gitWorkflow"; +import { toast } from "sonner"; + +// ─── Helpers ───────────────────────────────────────────────── + +const envBadgeColor: Record = { + production: "bg-red-500/15 text-red-500 border-red-500/30", + staging: "bg-amber-500/15 text-amber-500 border-amber-500/30", + development: "bg-emerald-500/15 text-emerald-500 border-emerald-500/30", + testing: "bg-blue-500/15 text-blue-500 border-blue-500/30", +}; + +function getEnvColor(env: string): string { + const lower = env.toLowerCase(); + return envBadgeColor[lower] ?? "bg-muted text-muted-foreground border-border/30"; +} + +// ─── Status Bar Pill ───────────────────────────────────────── + +interface EnvironmentSwitcherProps { + projectId?: string | null; +} + +/** + * Compact environment indicator for the bottom status bar. + * Shows the resolved environment; clicking opens configuration. + */ +export default function EnvironmentSwitcher({ projectId }: EnvironmentSwitcherProps) { + const [configOpen, setConfigOpen] = useState(false); + + const { data: resolved } = useResolvedEnv(projectId ?? undefined); + + if (!projectId || !resolved) return null; + + const envLabel = resolved.environment; + const isProd = resolved.isProduction; + + return ( + <> + + + + + +

+ Environment: {envLabel} + {resolved.branch && ( + <> + {" "} + (branch: {resolved.branch}) + + )} +

+

Click to configure

+
+
+ + + + ); +} + +// ─── Configuration Dialog ──────────────────────────────────── + +function EnvironmentConfigDialog({ + projectId, + open, + onOpenChange, +}: { + projectId: string; + open: boolean; + onOpenChange: (v: boolean) => void; +}) { + const { data: config } = useEnvConfig(projectId); + const setMappingMut = useSetEnvMapping(); + const removeMappingMut = useRemoveEnvMapping(); + + const [newBranch, setNewBranch] = useState(""); + const [newEnv, setNewEnv] = useState(""); + const [newIsProd, setNewIsProd] = useState(false); + + const mappings = config?.mappings ?? []; + + const handleAdd = async () => { + if (!newBranch.trim() || !newEnv.trim()) return; + try { + await setMappingMut.mutateAsync({ + projectId, + mapping: { + branch: newBranch.trim(), + environment: newEnv.trim(), + isProduction: newIsProd, + }, + }); + toast.success(`Mapped ${newBranch} → ${newEnv}`); + setNewBranch(""); + setNewEnv(""); + setNewIsProd(false); + } catch (err: any) { + toast.error(err.message || "Failed to save mapping"); + } + }; + + const handleRemove = async (branch: string) => { + try { + await removeMappingMut.mutateAsync({ projectId, branch }); + toast.success(`Removed mapping for ${branch}`); + } catch (err: any) { + toast.error(err.message || "Failed to remove mapping"); + } + }; + + return ( + + + + + + Environment Mappings + + + Map git branches to database environments. When you switch branches, + the environment label and connection automatically update. + + + + {/* Existing mappings */} +
+ {mappings.length === 0 ? ( +

+ No mappings configured yet +

+ ) : ( + mappings.map((m) => ( +
+ + {m.branch} + + + + {m.isProduction && } + {m.environment} + + +
+ )) + )} +
+ + {/* Add new mapping */} +
+

Add mapping

+
+ setNewBranch(e.target.value)} + className="h-7 text-xs flex-1" + /> + + setNewEnv(e.target.value)} + className="h-7 text-xs flex-1" + /> +
+
+ + +
+
+
+
+ ); +} diff --git a/src/components/common/VerticalIconBar.tsx b/src/components/common/VerticalIconBar.tsx index 8a811eb..2ecc601 100644 --- a/src/components/common/VerticalIconBar.tsx +++ b/src/components/common/VerticalIconBar.tsx @@ -1,4 +1,4 @@ -import { Home, Database, Search, GitBranch, GitCompareArrows, Settings, Layers, Terminal, FolderOpen } from 'lucide-react'; +import { Home, Database, Search, GitBranch, GitCompareArrows, History, Settings, Layers, Terminal, FolderOpen } from 'lucide-react'; import { Link, useLocation } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { @@ -7,7 +7,7 @@ import { TooltipTrigger, } from '@/components/ui/tooltip'; -export type PanelType = 'data' | 'sql-workspace' | 'query-builder' | 'schema-explorer' | 'er-diagram' | 'schema-diff'; +export type PanelType = 'data' | 'sql-workspace' | 'query-builder' | 'schema-explorer' | 'er-diagram' | 'schema-diff' | 'migration-timeline'; interface VerticalIconBarProps { dbId?: string; @@ -39,6 +39,7 @@ export default function VerticalIconBar({ dbId, activePanel, onPanelChange }: Ve { icon: GitBranch, label: 'Schema Explorer', panel: 'schema-explorer' }, { icon: Database, label: 'ER Diagram', panel: 'er-diagram' }, { icon: GitCompareArrows, label: 'Schema Diff', panel: 'schema-diff' }, + { icon: History, label: 'Migration Timeline', panel: 'migration-timeline' }, ] : []; return ( diff --git a/src/components/migration-timeline/MigrationTimelinePanel.tsx b/src/components/migration-timeline/MigrationTimelinePanel.tsx new file mode 100644 index 0000000..66f966c --- /dev/null +++ b/src/components/migration-timeline/MigrationTimelinePanel.tsx @@ -0,0 +1,422 @@ +import { useState } from "react"; +import { + History, + GitCommitHorizontal, + Tag, + Plus, + Minus, + Pencil, + ChevronDown, + ChevronRight, + RefreshCw, + BookmarkPlus, + AlertTriangle, + CheckCircle2, + Loader2, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Spinner } from "@/components/ui/spinner"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import { + useTimeline, + useCommitSummary, + useAutoCommit, + useConflictDetection, +} from "@/hooks/useGitWorkflow"; +import { useSchemaDiff } from "@/hooks/useSchemaDiff"; +import type { TimelineEntry, ConflictReport } from "@/types/gitWorkflow"; +import { toast } from "sonner"; + +// ─── Helpers ───────────────────────────────────────────────── + +function relativeTime(iso: string): string { + const now = Date.now(); + const then = new Date(iso).getTime(); + const diff = now - then; + const mins = Math.floor(diff / 60000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + return new Date(iso).toLocaleDateString(); +} + +function severityColor(s: string) { + if (s === "high") return "bg-red-500/10 text-red-500 border-red-500/20"; + if (s === "medium") return "bg-amber-500/10 text-amber-500 border-amber-500/20"; + return "bg-blue-500/10 text-blue-500 border-blue-500/20"; +} + +// ─── Sub-components ────────────────────────────────────────── + +function CommitNode({ + entry, + projectId, + isFirst, +}: { + entry: TimelineEntry; + projectId: string; + isFirst: boolean; +}) { + const [expanded, setExpanded] = useState(false); + const { data: summaryData } = useCommitSummary( + expanded ? projectId : undefined, + expanded ? entry.fullHash : undefined, + ); + const summary = summaryData?.summary; + + return ( +
+ {/* Timeline line */} +
+ + {/* Dot */} +
+ + {/* Content */} +
setExpanded(!expanded)} + > +
+
+

+ {entry.subject} +

+
+ {entry.hash} + · + {entry.author} + · + {relativeTime(entry.date)} +
+
+ {expanded ? ( + + ) : ( + + )} +
+ + {/* Tags */} + {entry.tags.length > 0 && ( +
+ {entry.tags.map((t) => ( + + + {t.replace("relwave/schema/", "")} + + ))} +
+ )} +
+ + {/* Expanded: change summary */} + {expanded && ( +
+ {!summary ? ( +
+ + Loading changes… +
+ ) : ( +
+ {summary.tablesAdded > 0 && ( + + + {summary.tablesAdded} table{summary.tablesAdded > 1 ? "s" : ""} + + )} + {summary.tablesRemoved > 0 && ( + + + {summary.tablesRemoved} table{summary.tablesRemoved > 1 ? "s" : ""} + + )} + {summary.tablesModified > 0 && ( + + + {summary.tablesModified} table{summary.tablesModified > 1 ? "s" : ""} + + )} + {summary.columnsAdded > 0 && ( + + +{summary.columnsAdded} col{summary.columnsAdded > 1 ? "s" : ""} + + )} + {summary.columnsRemoved > 0 && ( + + -{summary.columnsRemoved} col{summary.columnsRemoved > 1 ? "s" : ""} + + )} + {summary.columnsModified > 0 && ( + + ~{summary.columnsModified} col{summary.columnsModified > 1 ? "s" : ""} + + )} + {summary.tablesAdded === 0 && + summary.tablesRemoved === 0 && + summary.tablesModified === 0 && ( + No structural changes + )} +
+ )} +
+ )} +
+ ); +} + +function ConflictBanner({ + report, + isLoading, +}: { + report: ConflictReport | undefined; + isLoading: boolean; +}) { + if (isLoading) return null; + if (!report || report.conflictCount === 0) { + return ( +
+ + No schema conflicts with main +
+ ); + } + + return ( +
+
+ + {report.summary} +
+
+ {report.schemaConflicts.map((c, i) => ( + + {c.schema}.{c.table} + + ))} +
+
+ ); +} + +// ─── Main Panel ────────────────────────────────────────────── + +interface MigrationTimelinePanelProps { + projectId?: string | null; +} + +export default function MigrationTimelinePanel({ + projectId, +}: MigrationTimelinePanelProps) { + const [autoCommitOpen, setAutoCommitOpen] = useState(false); + const [commitMsg, setCommitMsg] = useState(""); + const [commitTag, setCommitTag] = useState(""); + + const { + data: timelineData, + isLoading, + isFetching, + refetch, + } = useTimeline(projectId ?? undefined); + + const { data: conflictReport, isLoading: conflictsLoading } = + useConflictDetection(projectId ?? undefined, "main"); + + const { data: diffResp } = useSchemaDiff(projectId ?? undefined); + const hasPendingChanges = diffResp?.diff?.summary?.hasChanges ?? false; + + const autoCommitMut = useAutoCommit(); + + const entries = timelineData?.entries ?? []; + + // ── Empty / loading states ────────────────────────── + if (isLoading) { + return ( +
+ +
+ ); + } + + const handleAutoCommit = async () => { + if (!projectId) return; + try { + const result = await autoCommitMut.mutateAsync({ + projectId, + message: commitMsg || undefined, + tag: commitTag || undefined, + }); + toast.success(`Schema committed: ${result.hash}`); + setAutoCommitOpen(false); + setCommitMsg(""); + setCommitTag(""); + } catch (err: any) { + toast.error(err.message || "Auto-commit failed"); + } + }; + + return ( +
+ {/* Header */} +
+
+
+ +

Migration Timeline

+ {isFetching && } +
+
+ {hasPendingChanges && ( + + + + + +

Commit current schema as a snapshot

+
+
+ )} + +
+
+
+ + {/* Conflict banner */} + + + {/* Pending changes indicator */} + {hasPendingChanges && ( +
+ + Schema has uncommitted changes +
+ )} + + {/* Timeline */} + +
+ {entries.length === 0 ? ( +
+ +

No schema commits yet

+

+ Save a schema to create the first timeline entry +

+
+ ) : ( + entries.map((entry, i) => ( + + )) + )} +
+
+ + {/* Auto-commit dialog */} + + + + Snapshot Schema + + Commit the current schema.json as a versioned snapshot. + + +
+
+ + setCommitMsg(e.target.value)} + className="h-8 text-xs" + /> +
+
+ + setCommitTag(e.target.value)} + className="h-8 text-xs" + /> +
+
+ + + +
+
+
+ ); +} diff --git a/src/hooks/useGitQueries.ts b/src/hooks/useGitQueries.ts index 6783d94..b396bd1 100644 --- a/src/hooks/useGitQueries.ts +++ b/src/hooks/useGitQueries.ts @@ -4,6 +4,7 @@ import { isBridgeReady } from "@/services/bridgeClient"; import type { GitStatus, GitFileChange, GitLogEntry, GitBranchInfo } from "@/types/git"; export const gitKeys = { + all: ["git"] as const, status: (dir: string) => ["git", "status", dir] as const, changes: (dir: string) => ["git", "changes", dir] as const, log: (dir: string) => ["git", "log", dir] as const, diff --git a/src/hooks/useGitWorkflow.ts b/src/hooks/useGitWorkflow.ts new file mode 100644 index 0000000..76852ef --- /dev/null +++ b/src/hooks/useGitWorkflow.ts @@ -0,0 +1,185 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { bridgeApi } from "@/services/bridgeApi"; +import type { + TimelineEntry, + TimelineChangeSummary, + AutoCommitResult, + EnvironmentConfig, + EnvironmentMapping, + ResolvedEnvironment, + ConflictReport, +} from "@/types/gitWorkflow"; +import { gitKeys } from "./useGitQueries"; +import { schemaDiffKeys } from "./useSchemaDiff"; + +// ─── Query Keys ────────────────────────────────────────────── + +export const timelineKeys = { + all: ["timeline"] as const, + list: (projectId: string) => [...timelineKeys.all, "list", projectId] as const, + commitSummary: (projectId: string, hash: string) => + [...timelineKeys.all, "summary", projectId, hash] as const, +}; + +export const envKeys = { + all: ["env"] as const, + config: (projectId: string) => [...envKeys.all, "config", projectId] as const, + resolved: (projectId: string) => [...envKeys.all, "resolved", projectId] as const, +}; + +export const conflictKeys = { + all: ["conflict"] as const, + detect: (projectId: string, targetBranch: string) => + [...conflictKeys.all, "detect", projectId, targetBranch] as const, +}; + +// ─── Timeline Hooks ────────────────────────────────────────── + +/** + * Fetch the migration timeline — commits that changed schema.json + */ +export function useTimeline(projectId: string | undefined, count = 50) { + return useQuery<{ entries: TimelineEntry[] }>({ + queryKey: timelineKeys.list(projectId ?? ""), + queryFn: () => bridgeApi.timelineList(projectId!, count), + enabled: !!projectId, + staleTime: 30_000, + }); +} + +/** + * Fetch the change summary for a specific commit in the timeline + */ +export function useCommitSummary( + projectId: string | undefined, + commitHash: string | undefined, +) { + return useQuery<{ summary: TimelineChangeSummary | null }>({ + queryKey: timelineKeys.commitSummary(projectId ?? "", commitHash ?? ""), + queryFn: () => bridgeApi.timelineCommitSummary(projectId!, commitHash!), + enabled: !!projectId && !!commitHash, + staleTime: Infinity, // commit summaries never change + }); +} + +/** + * Auto-commit the current schema snapshot (mutation) + */ +export function useAutoCommit() { + const qc = useQueryClient(); + return useMutation< + AutoCommitResult, + Error, + { projectId: string; message?: string; tag?: string } + >({ + mutationFn: ({ projectId, message, tag }) => + bridgeApi.timelineAutoCommit(projectId, { message, tag }), + onSuccess: (_data, vars) => { + qc.invalidateQueries({ queryKey: timelineKeys.list(vars.projectId) }); + qc.invalidateQueries({ queryKey: schemaDiffKeys.all }); + qc.invalidateQueries({ queryKey: gitKeys.all }); + }, + }); +} + +// ─── Environment Hooks ─────────────────────────────────────── + +/** + * Fetch the environment config (branch → environment mappings) + */ +export function useEnvConfig(projectId: string | undefined) { + return useQuery({ + queryKey: envKeys.config(projectId ?? ""), + queryFn: () => bridgeApi.envGetConfig(projectId!), + enabled: !!projectId, + staleTime: 60_000, + }); +} + +/** + * Resolve the current environment based on git branch + */ +export function useResolvedEnv(projectId: string | undefined) { + return useQuery({ + queryKey: envKeys.resolved(projectId ?? ""), + queryFn: () => bridgeApi.envResolve(projectId!), + enabled: !!projectId, + staleTime: 15_000, + refetchInterval: 30_000, // auto-re-resolve as branch may change + }); +} + +/** + * Save the full environment config (mutation) + */ +export function useSaveEnvConfig() { + const qc = useQueryClient(); + return useMutation< + EnvironmentConfig, + Error, + { projectId: string; config: EnvironmentConfig } + >({ + mutationFn: ({ projectId, config }) => + bridgeApi.envSaveConfig(projectId, config), + onSuccess: (_data, vars) => { + qc.invalidateQueries({ queryKey: envKeys.config(vars.projectId) }); + qc.invalidateQueries({ queryKey: envKeys.resolved(vars.projectId) }); + }, + }); +} + +/** + * Add or update a single environment mapping (mutation) + */ +export function useSetEnvMapping() { + const qc = useQueryClient(); + return useMutation< + EnvironmentConfig, + Error, + { projectId: string; mapping: EnvironmentMapping } + >({ + mutationFn: ({ projectId, mapping }) => + bridgeApi.envSetMapping(projectId, mapping), + onSuccess: (_data, vars) => { + qc.invalidateQueries({ queryKey: envKeys.config(vars.projectId) }); + qc.invalidateQueries({ queryKey: envKeys.resolved(vars.projectId) }); + }, + }); +} + +/** + * Remove a branch mapping (mutation) + */ +export function useRemoveEnvMapping() { + const qc = useQueryClient(); + return useMutation< + EnvironmentConfig, + Error, + { projectId: string; branch: string } + >({ + mutationFn: ({ projectId, branch }) => + bridgeApi.envRemoveMapping(projectId, branch), + onSuccess: (_data, vars) => { + qc.invalidateQueries({ queryKey: envKeys.config(vars.projectId) }); + qc.invalidateQueries({ queryKey: envKeys.resolved(vars.projectId) }); + }, + }); +} + +// ─── Conflict Detection Hooks ──────────────────────────────── + +/** + * Detect schema conflicts between current branch and target + */ +export function useConflictDetection( + projectId: string | undefined, + targetBranch = "main", + enabled = true, +) { + return useQuery({ + queryKey: conflictKeys.detect(projectId ?? "", targetBranch), + queryFn: () => bridgeApi.conflictDetect(projectId!, targetBranch), + enabled: !!projectId && enabled, + staleTime: 60_000, + }); +} diff --git a/src/pages/DatabaseDetails.tsx b/src/pages/DatabaseDetails.tsx index b6be4f8..850b5a0 100644 --- a/src/pages/DatabaseDetails.tsx +++ b/src/pages/DatabaseDetails.tsx @@ -34,7 +34,9 @@ import QueryBuilderPanel from "@/components/query-builder/QueryBuilderPanel"; import SchemaExplorerPanel from "@/components/schema-explorer/SchemaExplorerPanel"; import ERDiagramPanel from "@/components/er-diagram/ERDiagramPanel"; import SchemaDiffPanel from "@/components/schema-diff/SchemaDiffPanel"; +import MigrationTimelinePanel from "@/components/migration-timeline/MigrationTimelinePanel"; import GitStatusBar from "@/components/common/GitStatusBar"; +import EnvironmentSwitcher from "@/components/common/EnvironmentSwitcher"; const DatabaseDetail = () => { const { id: dbId } = useParams<{ id: string }>(); @@ -153,6 +155,8 @@ const DatabaseDetail = () => { return ; case 'schema-diff': return ; + case 'migration-timeline': + return ; case 'data': default: return ( @@ -424,6 +428,7 @@ const DatabaseDetail = () => { {/* Bottom status bar with git info */}
+
{databaseName || "Database"} diff --git a/src/services/bridgeApi.ts b/src/services/bridgeApi.ts index f63aafd..4726c1a 100644 --- a/src/services/bridgeApi.ts +++ b/src/services/bridgeApi.ts @@ -2,6 +2,15 @@ import { AddDatabaseParams, ConnectionTestResult, CreateTableColumn, DatabaseCon import { ProjectSummary, ProjectMetadata, CreateProjectParams, UpdateProjectParams, SchemaFile, SchemaSnapshot, ERDiagramFile, ERNode, QueriesFile, SavedQuery, ProjectExport } from "@/types/project"; import { GitStatus, GitFileChange, GitLogEntry, GitBranchInfo } from "@/types/git"; import { SchemaDiffResponse, SchemaFileHistoryResponse } from "@/types/schemaDiff"; +import { + TimelineEntry, + TimelineChangeSummary, + AutoCommitResult, + EnvironmentConfig, + EnvironmentMapping, + ResolvedEnvironment, + ConflictReport, +} from "@/types/gitWorkflow"; import { bridgeRequest } from "./bridgeClient"; @@ -1193,6 +1202,129 @@ class BridgeApiService { const result = await bridgeRequest("schema.fileHistory", { projectId, count }); return result?.data; } + + // ------------------------------------ + // 10. MIGRATION TIMELINE (timeline.*) + // ------------------------------------ + + /** + * Get the migration timeline (commits that changed schema.json) + */ + async timelineList( + projectId: string, + count = 50 + ): Promise<{ entries: TimelineEntry[] }> { + const result = await bridgeRequest("timeline.list", { projectId, count }); + return result?.data; + } + + /** + * Get change summary for a specific commit in the timeline + */ + async timelineCommitSummary( + projectId: string, + commitHash: string + ): Promise<{ summary: TimelineChangeSummary | null }> { + const result = await bridgeRequest("timeline.commitSummary", { + projectId, + commitHash, + }); + return result?.data; + } + + /** + * Auto-commit the current schema snapshot with optional tag + */ + async timelineAutoCommit( + projectId: string, + options?: { message?: string; tag?: string } + ): Promise { + const result = await bridgeRequest("timeline.autoCommit", { + projectId, + ...options, + }); + return result?.data; + } + + // ------------------------------------ + // 11. ENVIRONMENT (env.*) + // ------------------------------------ + + /** + * Get environment config (branch → environment mappings) + */ + async envGetConfig(projectId: string): Promise { + const result = await bridgeRequest("env.getConfig", { projectId }); + return result?.data; + } + + /** + * Replace the full environment config + */ + async envSaveConfig( + projectId: string, + config: EnvironmentConfig + ): Promise { + const result = await bridgeRequest("env.saveConfig", { + projectId, + config, + }); + return result?.data; + } + + /** + * Add or update a single branch → environment mapping + */ + async envSetMapping( + projectId: string, + mapping: EnvironmentMapping + ): Promise { + const result = await bridgeRequest("env.setMapping", { + projectId, + mapping, + }); + return result?.data; + } + + /** + * Remove a branch mapping + */ + async envRemoveMapping( + projectId: string, + branch: string + ): Promise { + const result = await bridgeRequest("env.removeMapping", { + projectId, + branch, + }); + return result?.data; + } + + /** + * Resolve the current environment (based on active git branch) + */ + async envResolve(projectId: string): Promise { + const result = await bridgeRequest("env.resolve", { projectId }); + return result?.data; + } + + // ------------------------------------ + // 12. CONFLICT DETECTION (conflict.*) + // ------------------------------------ + + /** + * Detect schema conflicts between current branch and a target + */ + async conflictDetect( + projectId: string, + targetBranch = "main" + ): Promise { + const result = await bridgeRequest("conflict.detect", { + projectId, + targetBranch, + }); + return result?.data; + } } // Export singleton instance diff --git a/src/types/gitWorkflow.ts b/src/types/gitWorkflow.ts new file mode 100644 index 0000000..229eab8 --- /dev/null +++ b/src/types/gitWorkflow.ts @@ -0,0 +1,84 @@ +// ========================================== +// Git Workflow Types — Frontend (P2) +// ========================================== +// Migration timeline, environment mapping, conflict detection + +// ─── Timeline ──────────────────────────────────────────────── + +export interface TimelineEntry { + hash: string; + fullHash: string; + author: string; + date: string; + subject: string; + tags: string[]; + isAutoCommit: boolean; + summary?: TimelineChangeSummary; +} + +export interface TimelineChangeSummary { + tablesAdded: number; + tablesRemoved: number; + tablesModified: number; + columnsAdded: number; + columnsRemoved: number; + columnsModified: number; +} + +export interface AutoCommitResult { + hash: string; + tag: string | null; + message: string; +} + +// ─── Environment ───────────────────────────────────────────── + +export interface EnvironmentMapping { + branch: string; + environment: string; + connectionUrl?: string; + isProduction?: boolean; +} + +export interface EnvironmentConfig { + mappings: EnvironmentMapping[]; + defaultEnvironment?: string; +} + +export interface ResolvedEnvironment { + branch: string | null; + environment: string; + isProduction: boolean; + connectionUrl: string | null; + connectionSource: "local" | "mapping" | "database" | "none"; +} + +// ─── Conflict Detection ────────────────────────────────────── + +export type ConflictSeverity = "high" | "medium" | "low"; + +export interface SchemaConflict { + table: string; + schema: string; + type: "both-modified" | "modified-deleted" | "both-added"; + severity: ConflictSeverity; + description: string; + columns?: ConflictingColumn[]; +} + +export interface ConflictingColumn { + name: string; + oursChange: string; + theirsChange: string; +} + +export interface ConflictReport { + currentBranch: string | null; + targetBranch: string; + mergeBase: string | null; + fileConflicts: string[]; + schemaConflicts: SchemaConflict[]; + hasSchemaFileConflict: boolean; + conflictCount: number; + summary: string; +} From 89ae1818ea38e9944039e844af39f982f0c48e56 Mon Sep 17 00:00:00 2001 From: Yash Date: Sun, 15 Feb 2026 21:18:19 +0530 Subject: [PATCH 4/5] refactor: remove SchemaDiffPanel and related hooks, introduce useGitAdvanced for Git operations - Deleted SchemaDiffPanel component and its associated hooks (useSchemaDiff, useSchemaFileHistory). - Added useGitAdvanced hook for managing Git remotes, push/pull/fetch operations, and revert functionality. - Updated bridgeApi service to include new Git-related methods for remote management and push/pull operations. - Removed unused gitWorkflow types and schemaDiff types. - Updated DatabaseDetails page to integrate GitStatusPanel instead of SchemaDiffPanel and MigrationTimelinePanel. --- bridge/src/handlers/gitAdvancedHandlers.ts | 201 +++++++ bridge/src/handlers/gitWorkflowHandlers.ts | 191 ------- bridge/src/handlers/schemaDiffHandlers.ts | 173 ------ bridge/src/jsonRpcHandler.ts | 38 +- .../src/services/conflictDetectionService.ts | 307 ----------- bridge/src/services/environmentService.ts | 206 ------- bridge/src/services/gitService.ts | 422 ++++++++++++++ .../src/services/migrationTimelineService.ts | 266 --------- bridge/src/services/projectStore.ts | 11 + bridge/src/services/schemaDiffService.ts | 348 ------------ bridge/src/types/index.ts | 2 +- src/components/common/EnvironmentSwitcher.tsx | 275 ---------- src/components/common/GitStatusBar.tsx | 177 +++++- src/components/common/RemoteConfigDialog.tsx | 266 +++++++++ src/components/common/VerticalIconBar.tsx | 7 +- src/components/git/GitStatusPanel.tsx | 513 ++++++++++++++++++ .../MigrationTimelinePanel.tsx | 422 -------------- .../schema-diff/SchemaDiffPanel.tsx | 446 --------------- src/hooks/useGitAdvanced.ts | 147 +++++ src/hooks/useGitWorkflow.ts | 185 ------- src/hooks/useSchemaDiff.ts | 49 -- src/pages/DatabaseDetails.tsx | 11 +- src/services/bridgeApi.ts | 194 ++----- src/types/git.ts | 18 + src/types/gitWorkflow.ts | 84 --- src/types/schemaDiff.ts | 73 --- 26 files changed, 1814 insertions(+), 3218 deletions(-) create mode 100644 bridge/src/handlers/gitAdvancedHandlers.ts delete mode 100644 bridge/src/handlers/gitWorkflowHandlers.ts delete mode 100644 bridge/src/handlers/schemaDiffHandlers.ts delete mode 100644 bridge/src/services/conflictDetectionService.ts delete mode 100644 bridge/src/services/environmentService.ts delete mode 100644 bridge/src/services/migrationTimelineService.ts delete mode 100644 bridge/src/services/schemaDiffService.ts delete mode 100644 src/components/common/EnvironmentSwitcher.tsx create mode 100644 src/components/common/RemoteConfigDialog.tsx create mode 100644 src/components/git/GitStatusPanel.tsx delete mode 100644 src/components/migration-timeline/MigrationTimelinePanel.tsx delete mode 100644 src/components/schema-diff/SchemaDiffPanel.tsx create mode 100644 src/hooks/useGitAdvanced.ts delete mode 100644 src/hooks/useGitWorkflow.ts delete mode 100644 src/hooks/useSchemaDiff.ts delete mode 100644 src/types/gitWorkflow.ts delete mode 100644 src/types/schemaDiff.ts diff --git a/bridge/src/handlers/gitAdvancedHandlers.ts b/bridge/src/handlers/gitAdvancedHandlers.ts new file mode 100644 index 0000000..6daae6b --- /dev/null +++ b/bridge/src/handlers/gitAdvancedHandlers.ts @@ -0,0 +1,201 @@ +// ---------------------------- +// handlers/gitAdvancedHandlers.ts +// ---------------------------- +// +// RPC handlers for: Remote management, push/pull/fetch, revert. + +import { Rpc } from "../types"; +import { GitService, gitServiceInstance } from "../services/gitService"; +import { Logger } from "pino"; + +export class GitAdvancedHandlers { + constructor( + private rpc: Rpc, + private logger?: Logger, + private gitService: GitService = gitServiceInstance + ) { } + + private requireDir(params: any, id: number | string): string | null { + const dir = params?.dir || params?.path || params?.cwd; + if (!dir) { + this.rpc.sendError(id, { + code: "BAD_REQUEST", + message: "Missing 'dir' parameter (project directory path)", + }); + return null; + } + return dir; + } + + // ========================================== + // REMOTE MANAGEMENT + // ========================================== + + async handleRemoteList(params: any, id: number | string) { + const dir = this.requireDir(params, id); + if (!dir) return; + try { + const remotes = await this.gitService.remoteList(dir); + this.rpc.sendResponse(id, { ok: true, data: remotes }); + } catch (e: any) { + this.logger?.error({ e }, "git.remoteList failed"); + this.rpc.sendError(id, { code: "GIT_ERROR", message: String(e.message || e) }); + } + } + + async handleRemoteAdd(params: any, id: number | string) { + const dir = this.requireDir(params, id); + if (!dir) return; + try { + const name = params?.name; + const url = params?.url; + if (!name || !url) { + return this.rpc.sendError(id, { + code: "BAD_REQUEST", + message: "Missing 'name' and/or 'url' parameters", + }); + } + await this.gitService.remoteAdd(dir, name, url); + this.rpc.sendResponse(id, { ok: true, data: null }); + } catch (e: any) { + this.logger?.error({ e }, "git.remoteAdd failed"); + this.rpc.sendError(id, { code: "GIT_ERROR", message: String(e.message || e) }); + } + } + + async handleRemoteRemove(params: any, id: number | string) { + const dir = this.requireDir(params, id); + if (!dir) return; + try { + const name = params?.name; + if (!name) { + return this.rpc.sendError(id, { + code: "BAD_REQUEST", + message: "Missing 'name' parameter", + }); + } + await this.gitService.remoteRemove(dir, name); + this.rpc.sendResponse(id, { ok: true, data: null }); + } catch (e: any) { + this.logger?.error({ e }, "git.remoteRemove failed"); + this.rpc.sendError(id, { code: "GIT_ERROR", message: String(e.message || e) }); + } + } + + async handleRemoteGetUrl(params: any, id: number | string) { + const dir = this.requireDir(params, id); + if (!dir) return; + try { + const url = await this.gitService.remoteGetUrl(dir, params?.name || "origin"); + this.rpc.sendResponse(id, { ok: true, data: { url } }); + } catch (e: any) { + this.logger?.error({ e }, "git.remoteGetUrl failed"); + this.rpc.sendError(id, { code: "GIT_ERROR", message: String(e.message || e) }); + } + } + + async handleRemoteSetUrl(params: any, id: number | string) { + const dir = this.requireDir(params, id); + if (!dir) return; + try { + const name = params?.name; + const url = params?.url; + if (!name || !url) { + return this.rpc.sendError(id, { + code: "BAD_REQUEST", + message: "Missing 'name' and/or 'url' parameters", + }); + } + await this.gitService.remoteSetUrl(dir, name, url); + this.rpc.sendResponse(id, { ok: true, data: null }); + } catch (e: any) { + this.logger?.error({ e }, "git.remoteSetUrl failed"); + this.rpc.sendError(id, { code: "GIT_ERROR", message: String(e.message || e) }); + } + } + + // ========================================== + // PUSH / PULL / FETCH + // ========================================== + + async handlePush(params: any, id: number | string) { + const dir = this.requireDir(params, id); + if (!dir) return; + try { + const output = await this.gitService.push( + dir, + params?.remote || "origin", + params?.branch, + { + force: params?.force === true, + setUpstream: params?.setUpstream === true, + } + ); + this.rpc.sendResponse(id, { ok: true, data: { output } }); + } catch (e: any) { + this.logger?.error({ e }, "git.push failed"); + this.rpc.sendError(id, { code: "GIT_ERROR", message: String(e.message || e) }); + } + } + + async handlePull(params: any, id: number | string) { + const dir = this.requireDir(params, id); + if (!dir) return; + try { + const output = await this.gitService.pull( + dir, + params?.remote || "origin", + params?.branch, + { rebase: params?.rebase === true } + ); + this.rpc.sendResponse(id, { ok: true, data: { output } }); + } catch (e: any) { + this.logger?.error({ e }, "git.pull failed"); + this.rpc.sendError(id, { code: "GIT_ERROR", message: String(e.message || e) }); + } + } + + async handleFetch(params: any, id: number | string) { + const dir = this.requireDir(params, id); + if (!dir) return; + try { + const output = await this.gitService.fetch( + dir, + params?.remote, + { + prune: params?.prune === true, + all: params?.all === true, + } + ); + this.rpc.sendResponse(id, { ok: true, data: { output } }); + } catch (e: any) { + this.logger?.error({ e }, "git.fetch failed"); + this.rpc.sendError(id, { code: "GIT_ERROR", message: String(e.message || e) }); + } + } + + // ========================================== + // REVERT (Rollback to Previous Commit) + // ========================================== + + async handleRevert(params: any, id: number | string) { + const dir = this.requireDir(params, id); + if (!dir) return; + try { + const hash = params?.hash || params?.commitHash; + if (!hash) { + return this.rpc.sendError(id, { + code: "BAD_REQUEST", + message: "Missing 'hash' parameter", + }); + } + const output = await this.gitService.revert(dir, hash, { + noCommit: params?.noCommit === true, + }); + this.rpc.sendResponse(id, { ok: true, data: { output } }); + } catch (e: any) { + this.logger?.error({ e }, "git.revert failed"); + this.rpc.sendError(id, { code: "GIT_ERROR", message: String(e.message || e) }); + } + } +} diff --git a/bridge/src/handlers/gitWorkflowHandlers.ts b/bridge/src/handlers/gitWorkflowHandlers.ts deleted file mode 100644 index c785bf5..0000000 --- a/bridge/src/handlers/gitWorkflowHandlers.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { Rpc } from "../types"; -import { Logger } from "pino"; -import { - MigrationTimelineService, - migrationTimelineServiceInstance, -} from "../services/migrationTimelineService"; -import { - EnvironmentService, - environmentServiceInstance, -} from "../services/environmentService"; -import { - ConflictDetectionService, - conflictDetectionServiceInstance, -} from "../services/conflictDetectionService"; - -/** - * P2 RPC handlers for migration timeline, environment management, - * and schema conflict detection. - * - * Methods: - * timeline.list — migration timeline from git history - * timeline.commitSummary — change summary for a single commit - * timeline.autoCommit — auto-commit schema snapshot - * - * env.getConfig — read branch-environment mappings - * env.saveConfig — replace full environment config - * env.setMapping — upsert a single branch mapping - * env.removeMapping — delete a branch mapping - * env.resolve — resolve current environment for project - * - * conflict.detect — detect schema conflicts with target branch - */ -export class GitWorkflowHandlers { - constructor( - private rpc: Rpc, - private logger: Logger, - private timeline: MigrationTimelineService = migrationTimelineServiceInstance, - private env: EnvironmentService = environmentServiceInstance, - private conflicts: ConflictDetectionService = conflictDetectionServiceInstance, - ) { } - - // ========================================== - // TIMELINE - // ========================================== - - async handleTimelineList(params: any, id: number | string) { - try { - const { projectId, count = 50 } = params || {}; - if (!projectId) { - return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing projectId" }); - } - - const entries = await this.timeline.getTimeline(projectId, count); - this.rpc.sendResponse(id, { ok: true, data: { entries } }); - } catch (e: any) { - this.logger?.error({ e }, "timeline.list failed"); - this.rpc.sendError(id, { code: "TIMELINE_ERROR", message: String(e.message || e) }); - } - } - - async handleCommitSummary(params: any, id: number | string) { - try { - const { projectId, commitHash } = params || {}; - if (!projectId || !commitHash) { - return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing projectId or commitHash" }); - } - - const summary = await this.timeline.getCommitSummary(projectId, commitHash); - this.rpc.sendResponse(id, { ok: true, data: { summary } }); - } catch (e: any) { - this.logger?.error({ e }, "timeline.commitSummary failed"); - this.rpc.sendError(id, { code: "TIMELINE_ERROR", message: String(e.message || e) }); - } - } - - async handleAutoCommit(params: any, id: number | string) { - try { - const { projectId, message, tag } = params || {}; - if (!projectId) { - return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing projectId" }); - } - - const result = await this.timeline.autoCommitSchema(projectId, { - message, - tag, - }); - this.rpc.sendResponse(id, { ok: true, data: result }); - } catch (e: any) { - this.logger?.error({ e }, "timeline.autoCommit failed"); - this.rpc.sendError(id, { code: "COMMIT_ERROR", message: String(e.message || e) }); - } - } - - // ========================================== - // ENVIRONMENT - // ========================================== - - async handleEnvGetConfig(params: any, id: number | string) { - try { - const { projectId } = params || {}; - if (!projectId) { - return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing projectId" }); - } - - const config = await this.env.getConfig(projectId); - this.rpc.sendResponse(id, { ok: true, data: config }); - } catch (e: any) { - this.logger?.error({ e }, "env.getConfig failed"); - this.rpc.sendError(id, { code: "ENV_ERROR", message: String(e.message || e) }); - } - } - - async handleEnvSaveConfig(params: any, id: number | string) { - try { - const { projectId, config } = params || {}; - if (!projectId || !config) { - return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing projectId or config" }); - } - - const saved = await this.env.saveConfig(projectId, config); - this.rpc.sendResponse(id, { ok: true, data: saved }); - } catch (e: any) { - this.logger?.error({ e }, "env.saveConfig failed"); - this.rpc.sendError(id, { code: "ENV_ERROR", message: String(e.message || e) }); - } - } - - async handleEnvSetMapping(params: any, id: number | string) { - try { - const { projectId, mapping } = params || {}; - if (!projectId || !mapping) { - return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing projectId or mapping" }); - } - - const config = await this.env.setMapping(projectId, mapping); - this.rpc.sendResponse(id, { ok: true, data: config }); - } catch (e: any) { - this.logger?.error({ e }, "env.setMapping failed"); - this.rpc.sendError(id, { code: "ENV_ERROR", message: String(e.message || e) }); - } - } - - async handleEnvRemoveMapping(params: any, id: number | string) { - try { - const { projectId, branch } = params || {}; - if (!projectId || !branch) { - return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing projectId or branch" }); - } - - const config = await this.env.removeMapping(projectId, branch); - this.rpc.sendResponse(id, { ok: true, data: config }); - } catch (e: any) { - this.logger?.error({ e }, "env.removeMapping failed"); - this.rpc.sendError(id, { code: "ENV_ERROR", message: String(e.message || e) }); - } - } - - async handleEnvResolve(params: any, id: number | string) { - try { - const { projectId } = params || {}; - if (!projectId) { - return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing projectId" }); - } - - const resolved = await this.env.resolve(projectId); - this.rpc.sendResponse(id, { ok: true, data: resolved }); - } catch (e: any) { - this.logger?.error({ e }, "env.resolve failed"); - this.rpc.sendError(id, { code: "ENV_ERROR", message: String(e.message || e) }); - } - } - - // ========================================== - // CONFLICT DETECTION - // ========================================== - - async handleConflictDetect(params: any, id: number | string) { - try { - const { projectId, targetBranch = "main" } = params || {}; - if (!projectId) { - return this.rpc.sendError(id, { code: "BAD_REQUEST", message: "Missing projectId" }); - } - - const report = await this.conflicts.detectConflicts(projectId, targetBranch); - this.rpc.sendResponse(id, { ok: true, data: report }); - } catch (e: any) { - this.logger?.error({ e }, "conflict.detect failed"); - this.rpc.sendError(id, { code: "CONFLICT_ERROR", message: String(e.message || e) }); - } - } -} diff --git a/bridge/src/handlers/schemaDiffHandlers.ts b/bridge/src/handlers/schemaDiffHandlers.ts deleted file mode 100644 index 2d6f253..0000000 --- a/bridge/src/handlers/schemaDiffHandlers.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { Rpc } from "../types"; -import { Logger } from "pino"; -import { gitServiceInstance, GitService } from "../services/gitService"; -import { - schemaDiffServiceInstance, - SchemaDiffService, -} from "../services/schemaDiffService"; -import { - projectStoreInstance, - ProjectStore, - SchemaFile, -} from "../services/projectStore"; -import { getProjectDir } from "../utils/config"; -import path from "path"; - -/** - * RPC handlers for schema diffing. - * - * Methods: - * schema.diff — diff working tree vs HEAD (or any two refs) - * schema.fileHistory — commit history for schema.json - */ -export class SchemaDiffHandlers { - constructor( - private rpc: Rpc, - private logger: Logger, - private git: GitService = gitServiceInstance, - private differ: SchemaDiffService = schemaDiffServiceInstance, - private store: ProjectStore = projectStoreInstance - ) { } - - /** - * schema.diff - * - * params: - * projectId — required - * fromRef — git ref for "before" (default: "HEAD") - * toRef — git ref for "after" (default: null = working tree) - */ - async handleDiff(params: any, id: number | string) { - try { - const { projectId, fromRef = "HEAD", toRef } = params || {}; - if (!projectId) { - return this.rpc.sendError(id, { - code: "BAD_REQUEST", - message: "Missing projectId", - }); - } - - const dir = getProjectDir(projectId); - - // Check if this project is in a git repo - const isRepo = await this.git.isRepo(dir); - if (!isRepo) { - return this.rpc.sendResponse(id, { - ok: true, - data: { - isGitRepo: false, - diff: null, - message: "Project directory is not a git repository", - }, - }); - } - - // Get repo root so we can compute the relative path - const repoRoot = await this.git.getRepoRoot(dir); - const relSchemaPath = path - .relative(repoRoot, path.join(dir, "schema", "schema.json")) - .replace(/\\/g, "/"); - - // Read "before" schema from git ref - let beforeSchema: SchemaFile | null = null; - try { - const beforeRaw = await this.git.getFileAtRef( - repoRoot, - relSchemaPath, - fromRef - ); - if (beforeRaw) { - beforeSchema = JSON.parse(beforeRaw) as SchemaFile; - } - } catch { - // File may not exist at this ref — that's OK, treat as empty - } - - // Read "after" schema - let afterSchema: SchemaFile | null = null; - if (toRef) { - // Comparing two refs - try { - const afterRaw = await this.git.getFileAtRef( - repoRoot, - relSchemaPath, - toRef - ); - if (afterRaw) { - afterSchema = JSON.parse(afterRaw) as SchemaFile; - } - } catch { - // ok - } - } else { - // Compare against working tree (current file on disk) - afterSchema = await this.store.getSchema(projectId); - } - - // Compute diff - const diff = this.differ.diff(beforeSchema, afterSchema); - - this.rpc.sendResponse(id, { - ok: true, - data: { - isGitRepo: true, - diff, - fromRef, - toRef: toRef || "working tree", - }, - }); - } catch (e: any) { - this.logger?.error({ e }, "schema.diff failed"); - this.rpc.sendError(id, { - code: "DIFF_ERROR", - message: String(e.message || e), - }); - } - } - - /** - * schema.fileHistory - * - * params: - * projectId — required - * count — max entries (default 20) - */ - async handleFileHistory(params: any, id: number | string) { - try { - const { projectId, count = 20 } = params || {}; - if (!projectId) { - return this.rpc.sendError(id, { - code: "BAD_REQUEST", - message: "Missing projectId", - }); - } - - const dir = getProjectDir(projectId); - const isRepo = await this.git.isRepo(dir); - if (!isRepo) { - return this.rpc.sendResponse(id, { - ok: true, - data: { isGitRepo: false, entries: [] }, - }); - } - - const repoRoot = await this.git.getRepoRoot(dir); - const relSchemaPath = path - .relative(repoRoot, path.join(dir, "schema", "schema.json")) - .replace(/\\/g, "/"); - - const entries = await this.git.fileLog(repoRoot, relSchemaPath, count); - - this.rpc.sendResponse(id, { - ok: true, - data: { isGitRepo: true, entries }, - }); - } catch (e: any) { - this.logger?.error({ e }, "schema.fileHistory failed"); - this.rpc.sendError(id, { - code: "DIFF_ERROR", - message: String(e.message || e), - }); - } - } -} diff --git a/bridge/src/jsonRpcHandler.ts b/bridge/src/jsonRpcHandler.ts index de10011..cb11087 100644 --- a/bridge/src/jsonRpcHandler.ts +++ b/bridge/src/jsonRpcHandler.ts @@ -9,8 +9,7 @@ import { StatsHandlers } from "./handlers/statsHandlers"; import { MigrationHandlers } from "./handlers/migrationHandlers"; import { ProjectHandlers } from "./handlers/projectHandlers"; import { GitHandlers } from "./handlers/gitHandlers"; -import { SchemaDiffHandlers } from "./handlers/schemaDiffHandlers"; -import { GitWorkflowHandlers } from "./handlers/gitWorkflowHandlers"; +import { GitAdvancedHandlers } from "./handlers/gitAdvancedHandlers"; import { discoveryService } from "./services/discoveryService"; import { Logger } from "pino"; @@ -58,8 +57,7 @@ export function registerDbHandlers( ); const projectHandlers = new ProjectHandlers(rpc, logger); const gitHandlers = new GitHandlers(rpc, logger); - const schemaDiffHandlers = new SchemaDiffHandlers(rpc, logger); - const gitWorkflowHandlers = new GitWorkflowHandlers(rpc, logger); + const gitAdvancedHandlers = new GitAdvancedHandlers(rpc, logger); // ========================================== // SESSION MANAGEMENT HANDLERS @@ -258,29 +256,23 @@ export function registerDbHandlers( rpcRegister("git.ensureIgnore", (p, id) => gitHandlers.handleEnsureIgnore(p, id)); // ========================================== - // SCHEMA DIFF HANDLERS + // GIT ADVANCED HANDLERS // ========================================== - rpcRegister("schema.diff", (p, id) => schemaDiffHandlers.handleDiff(p, id)); - rpcRegister("schema.fileHistory", (p, id) => schemaDiffHandlers.handleFileHistory(p, id)); - // ========================================== - // GIT WORKFLOW HANDLERS (P2) - // ========================================== - - // Timeline - rpcRegister("timeline.list", (p, id) => gitWorkflowHandlers.handleTimelineList(p, id)); - rpcRegister("timeline.commitSummary", (p, id) => gitWorkflowHandlers.handleCommitSummary(p, id)); - rpcRegister("timeline.autoCommit", (p, id) => gitWorkflowHandlers.handleAutoCommit(p, id)); + // Remote management + rpcRegister("git.remoteList", (p, id) => gitAdvancedHandlers.handleRemoteList(p, id)); + rpcRegister("git.remoteAdd", (p, id) => gitAdvancedHandlers.handleRemoteAdd(p, id)); + rpcRegister("git.remoteRemove", (p, id) => gitAdvancedHandlers.handleRemoteRemove(p, id)); + rpcRegister("git.remoteGetUrl", (p, id) => gitAdvancedHandlers.handleRemoteGetUrl(p, id)); + rpcRegister("git.remoteSetUrl", (p, id) => gitAdvancedHandlers.handleRemoteSetUrl(p, id)); - // Environment - rpcRegister("env.getConfig", (p, id) => gitWorkflowHandlers.handleEnvGetConfig(p, id)); - rpcRegister("env.saveConfig", (p, id) => gitWorkflowHandlers.handleEnvSaveConfig(p, id)); - rpcRegister("env.setMapping", (p, id) => gitWorkflowHandlers.handleEnvSetMapping(p, id)); - rpcRegister("env.removeMapping", (p, id) => gitWorkflowHandlers.handleEnvRemoveMapping(p, id)); - rpcRegister("env.resolve", (p, id) => gitWorkflowHandlers.handleEnvResolve(p, id)); + // Push / Pull / Fetch + rpcRegister("git.push", (p, id) => gitAdvancedHandlers.handlePush(p, id)); + rpcRegister("git.pull", (p, id) => gitAdvancedHandlers.handlePull(p, id)); + rpcRegister("git.fetch", (p, id) => gitAdvancedHandlers.handleFetch(p, id)); - // Conflict Detection - rpcRegister("conflict.detect", (p, id) => gitWorkflowHandlers.handleConflictDetect(p, id)); + // Rollback + rpcRegister("git.revert", (p, id) => gitAdvancedHandlers.handleRevert(p, id)); // ========================================== // DATABASE DISCOVERY HANDLERS diff --git a/bridge/src/services/conflictDetectionService.ts b/bridge/src/services/conflictDetectionService.ts deleted file mode 100644 index 5412187..0000000 --- a/bridge/src/services/conflictDetectionService.ts +++ /dev/null @@ -1,307 +0,0 @@ -// ============================================================ -// services/conflictDetectionService.ts -// ============================================================ -// -// Detects schema conflicts between the current branch and another -// branch (typically "main" or the upstream target). -// -// Works by: -// 1. Finding the merge-base (common ancestor) -// 2. Reading schema.json at merge-base, current branch, and target branch -// 3. Computing structural diffs for both sides -// 4. Identifying tables/columns modified by BOTH sides (= conflicts) - -import path from "path"; -import { GitService, gitServiceInstance } from "./gitService"; -import { - SchemaDiffService, - schemaDiffServiceInstance, -} from "./schemaDiffService"; -import { - ProjectStore, - projectStoreInstance, - SchemaFile, -} from "./projectStore"; -import { getProjectDir } from "../utils/config"; - -// ─── Types ─────────────────────────────────────────────────── - -export type ConflictSeverity = "high" | "medium" | "low"; - -export interface SchemaConflict { - /** Which table has a conflict */ - table: string; - /** Schema the table belongs to */ - schema: string; - /** What kind of conflict */ - type: "both-modified" | "modified-deleted" | "both-added"; - /** Severity assessment */ - severity: ConflictSeverity; - /** Human-readable description */ - description: string; - /** Columns involved (for both-modified) */ - columns?: ConflictingColumn[]; -} - -export interface ConflictingColumn { - name: string; - /** What changed on the current branch */ - oursChange: string; - /** What changed on the target branch */ - theirsChange: string; -} - -export interface ConflictReport { - /** Branch we're comparing FROM (current) */ - currentBranch: string | null; - /** Branch we're comparing TO (target) */ - targetBranch: string; - /** Common ancestor commit */ - mergeBase: string | null; - /** File-level conflicts detected by git merge-tree */ - fileConflicts: string[]; - /** Structural schema conflicts */ - schemaConflicts: SchemaConflict[]; - /** Whether schema.json itself has a git-level conflict */ - hasSchemaFileConflict: boolean; - /** Total number of conflicting tables */ - conflictCount: number; - /** Quick summary */ - summary: string; -} - -// ─── Service ───────────────────────────────────────────────── - -export class ConflictDetectionService { - constructor( - private git: GitService = gitServiceInstance, - private differ: SchemaDiffService = schemaDiffServiceInstance, - private store: ProjectStore = projectStoreInstance, - ) { } - - /** - * Detect schema conflicts between the current branch and a target branch. - */ - async detectConflicts( - projectId: string, - targetBranch: string, - ): Promise { - const dir = getProjectDir(projectId); - if (!(await this.git.isRepo(dir))) { - return this.emptyReport(null, targetBranch); - } - - const repoRoot = await this.git.getRepoRoot(dir); - const relSchemaPath = path - .relative(repoRoot, path.join(dir, "schema", "schema.json")) - .replace(/\\/g, "/"); - - // Get current branch - const status = await this.git.getStatus(dir); - const currentBranch = status.branch; - - // Find merge-base - const currentRef = currentBranch ?? "HEAD"; - const mergeBase = await this.git.mergeBase(repoRoot, currentRef, targetBranch); - - if (!mergeBase) { - return { - ...this.emptyReport(currentBranch, targetBranch), - summary: "No common ancestor found between branches", - }; - } - - // Check git-level file conflicts - const fileConflicts = await this.git.dryMerge(repoRoot, targetBranch); - const hasSchemaFileConflict = fileConflicts.some( - (f) => f.endsWith("schema.json") || f === relSchemaPath, - ); - - // Read schema at three points: merge-base, ours (current), theirs (target) - const [baseSchema, oursSchema, theirsSchema] = await Promise.all([ - this.readSchemaAt(repoRoot, relSchemaPath, mergeBase), - this.readSchemaAt(repoRoot, relSchemaPath, currentRef), - this.readSchemaAt(repoRoot, relSchemaPath, targetBranch), - ]); - - // Compute diffs from merge-base to each branch - const oursDiff = this.differ.diff(baseSchema, oursSchema); - const theirsDiff = this.differ.diff(baseSchema, theirsSchema); - - // Find structural conflicts - const schemaConflicts = this.findConflicts(oursDiff, theirsDiff); - - // Build summary - const conflictCount = schemaConflicts.length; - let summary: string; - if (conflictCount === 0 && !hasSchemaFileConflict) { - summary = "No schema conflicts detected — safe to merge"; - } else if (hasSchemaFileConflict && conflictCount === 0) { - summary = "Git detects a text-level conflict in schema.json but no structural conflicts"; - } else { - const high = schemaConflicts.filter((c) => c.severity === "high").length; - const med = schemaConflicts.filter((c) => c.severity === "medium").length; - summary = `${conflictCount} conflicting table${conflictCount > 1 ? "s" : ""}`; - if (high) summary += ` (${high} high severity)`; - else if (med) summary += ` (${med} medium severity)`; - } - - return { - currentBranch, - targetBranch, - mergeBase: mergeBase.slice(0, 8), - fileConflicts, - schemaConflicts, - hasSchemaFileConflict, - conflictCount, - summary, - }; - } - - // ── Private ───────────────────────────────────────────── - - private async readSchemaAt( - repoRoot: string, - relPath: string, - ref: string, - ): Promise { - try { - const raw = await this.git.getFileAtRef(repoRoot, relPath, ref); - return raw ? JSON.parse(raw) : null; - } catch { - return null; - } - } - - /** - * Compare two diffs (ours vs theirs, both from the same merge-base) - * and identify tables/columns changed by BOTH sides. - */ - private findConflicts( - oursDiff: ReturnType, - theirsDiff: ReturnType, - ): SchemaConflict[] { - const conflicts: SchemaConflict[] = []; - - // Build lookup: "schema.table" → status for theirs - const theirsTableMap = new Map(); - const theirsColMap = new Map>(); - - for (const s of theirsDiff.schemas) { - for (const t of s.tables) { - const key = `${s.name}.${t.name}`; - if (t.status !== "unchanged") { - theirsTableMap.set(key, t.status); - } - const changedCols = new Set(); - for (const c of t.columns) { - if (c.status !== "unchanged") { - changedCols.add(c.name); - } - } - if (changedCols.size > 0) { - theirsColMap.set(key, changedCols); - } - } - } - - // Walk ours and check for overlaps - for (const s of oursDiff.schemas) { - for (const t of s.tables) { - const key = `${s.name}.${t.name}`; - - if (t.status === "unchanged") continue; - - const theirsStatus = theirsTableMap.get(key); - if (!theirsStatus) continue; // only we changed it => no conflict - - // Both sides changed this table - if (t.status === "removed" && theirsStatus === "modified") { - conflicts.push({ - table: t.name, - schema: s.name, - type: "modified-deleted", - severity: "high", - description: `Table "${t.name}" was deleted on current branch but modified on ${key}`, - }); - } else if (t.status === "modified" && theirsStatus === "removed") { - conflicts.push({ - table: t.name, - schema: s.name, - type: "modified-deleted", - severity: "high", - description: `Table "${t.name}" was modified on current branch but deleted on target`, - }); - } else if (t.status === "added" && theirsStatus === "added") { - conflicts.push({ - table: t.name, - schema: s.name, - type: "both-added", - severity: "medium", - description: `Table "${t.name}" was added on both branches — definitions may differ`, - }); - } else if (t.status === "modified" && theirsStatus === "modified") { - // Check column-level overlap - const theirsCols = theirsColMap.get(key); - const conflictingColumns: ConflictingColumn[] = []; - - for (const c of t.columns) { - if (c.status === "unchanged") continue; - if (theirsCols?.has(c.name)) { - conflictingColumns.push({ - name: c.name, - oursChange: c.status, - theirsChange: "modified", - }); - } - } - - if (conflictingColumns.length > 0) { - conflicts.push({ - table: t.name, - schema: s.name, - type: "both-modified", - severity: "high", - description: `${conflictingColumns.length} column${conflictingColumns.length > 1 ? "s" : ""} modified on both branches`, - columns: conflictingColumns, - }); - } else { - // Different columns changed — lower risk - conflicts.push({ - table: t.name, - schema: s.name, - type: "both-modified", - severity: "low", - description: `Table "${t.name}" modified on both branches but different columns affected`, - }); - } - } - } - } - - // Sort by severity (high first) - const severityOrder: Record = { - high: 0, - medium: 1, - low: 2, - }; - conflicts.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]); - - return conflicts; - } - - private emptyReport(currentBranch: string | null, targetBranch: string): ConflictReport { - return { - currentBranch, - targetBranch, - mergeBase: null, - fileConflicts: [], - schemaConflicts: [], - hasSchemaFileConflict: false, - conflictCount: 0, - summary: "Not a git repository", - }; - } -} - -export const conflictDetectionServiceInstance = new ConflictDetectionService(); diff --git a/bridge/src/services/environmentService.ts b/bridge/src/services/environmentService.ts deleted file mode 100644 index 73f975a..0000000 --- a/bridge/src/services/environmentService.ts +++ /dev/null @@ -1,206 +0,0 @@ -// ============================================================ -// services/environmentService.ts -// ============================================================ -// -// Maps git branches → database environments (dev / staging / prod). -// -// Persisted in the project's `relwave.json` under an `environments` -// key so the mapping is committed with the project and shared -// across team members. -// -// Per-developer connection overrides live in `relwave.local.json` -// (git-ignored) so credentials never leak. - -import { - ProjectStore, - projectStoreInstance, - ProjectMetadata, - LocalConfig, -} from "./projectStore"; -import { GitService, gitServiceInstance } from "./gitService"; -import { getProjectDir } from "../utils/config"; -import path from "path"; -import fs from "fs/promises"; -import fsSync from "fs"; - -// ─── Types ─────────────────────────────────────────────────── - -/** One entry in the branch → environment mapping */ -export interface EnvironmentMapping { - /** Git branch name (exact match, e.g. "main", "develop") */ - branch: string; - /** Labelled environment name */ - environment: string; - /** Optional connection URL for this environment (shared) */ - connectionUrl?: string; - /** Is this the production environment? (extra protection) */ - isProduction?: boolean; -} - -/** Full environment configuration stored in relwave.json (committed) */ -export interface EnvironmentConfig { - /** Ordered list of branch → environment mappings */ - mappings: EnvironmentMapping[]; - /** Default environment label when branch doesn't match any mapping */ - defaultEnvironment?: string; -} - -/** Runtime-resolved environment for the current branch */ -export interface ResolvedEnvironment { - /** Current git branch */ - branch: string | null; - /** Resolved environment label */ - environment: string; - /** Whether this branch is mapped to production */ - isProduction: boolean; - /** Best connection URL (local override > mapping > default) */ - connectionUrl: string | null; - /** Source of the connection URL */ - connectionSource: "local" | "mapping" | "database" | "none"; -} - -// ─── Service ───────────────────────────────────────────────── - -export class EnvironmentService { - constructor( - private store: ProjectStore = projectStoreInstance, - private git: GitService = gitServiceInstance, - ) { } - - // ── Read / Write environment config ─────────────────────── - - /** - * Get the environment config from relwave.json - */ - async getConfig(projectId: string): Promise { - const meta = await this.readMetadataRaw(projectId); - return (meta as any)?.environments ?? { mappings: [] }; - } - - /** - * Save environment config back to relwave.json - */ - async saveConfig( - projectId: string, - config: EnvironmentConfig, - ): Promise { - const meta = await this.readMetadataRaw(projectId); - if (!meta) throw new Error(`Project ${projectId} not found`); - - (meta as any).environments = config; - meta.updatedAt = new Date().toISOString(); - - await this.writeMetadataRaw(projectId, meta); - return config; - } - - // ── Single-mapping CRUD ─────────────────────────────────── - - /** - * Add or update a branch → environment mapping - */ - async setMapping( - projectId: string, - mapping: EnvironmentMapping, - ): Promise { - const config = await this.getConfig(projectId); - const idx = config.mappings.findIndex((m) => m.branch === mapping.branch); - if (idx >= 0) { - config.mappings[idx] = mapping; - } else { - config.mappings.push(mapping); - } - return this.saveConfig(projectId, config); - } - - /** - * Remove a branch mapping - */ - async removeMapping( - projectId: string, - branch: string, - ): Promise { - const config = await this.getConfig(projectId); - config.mappings = config.mappings.filter((m) => m.branch !== branch); - return this.saveConfig(projectId, config); - } - - // ── Resolution ──────────────────────────────────────────── - - /** - * Resolve the current environment based on the active git branch. - * Priority for connection URL: local override > mapping > database default. - */ - async resolve(projectId: string): Promise { - const dir = getProjectDir(projectId); - const isRepo = await this.git.isRepo(dir); - - // Get current branch - let branch: string | null = null; - if (isRepo) { - const status = await this.git.getStatus(dir); - branch = status.branch; - } - - const config = await this.getConfig(projectId); - const localConfig = await this.store.getLocalConfig(projectId); - - // Find matching mapping - const mapping = branch - ? config.mappings.find((m) => m.branch === branch) - : undefined; - - const environment = - mapping?.environment ?? - config.defaultEnvironment ?? - "development"; - - const isProduction = mapping?.isProduction ?? false; - - // Resolve connection URL with priority chain - let connectionUrl: string | null = null; - let connectionSource: ResolvedEnvironment["connectionSource"] = "none"; - - if (localConfig?.connectionUrl) { - connectionUrl = localConfig.connectionUrl; - connectionSource = "local"; - } else if (mapping?.connectionUrl) { - connectionUrl = mapping.connectionUrl; - connectionSource = "mapping"; - } - - return { - branch, - environment, - isProduction, - connectionUrl, - connectionSource, - }; - } - - // ── Private helpers ─────────────────────────────────────── - - /** - * Read relwave.json as raw object (to preserve extra fields like `environments`) - */ - private async readMetadataRaw(projectId: string): Promise<(ProjectMetadata & { environments?: EnvironmentConfig }) | null> { - const filePath = path.join(getProjectDir(projectId), "relwave.json"); - try { - if (!fsSync.existsSync(filePath)) return null; - const raw = await fs.readFile(filePath, "utf-8"); - return JSON.parse(raw); - } catch { - return null; - } - } - - /** - * Write relwave.json preserving all fields - */ - private async writeMetadataRaw(projectId: string, data: any): Promise { - const filePath = path.join(getProjectDir(projectId), "relwave.json"); - await fs.writeFile(filePath, JSON.stringify(data, null, 2), "utf-8"); - } -} - -export const environmentServiceInstance = new EnvironmentService(); diff --git a/bridge/src/services/gitService.ts b/bridge/src/services/gitService.ts index c486c14..3c559da 100644 --- a/bridge/src/services/gitService.ts +++ b/bridge/src/services/gitService.ts @@ -611,6 +611,428 @@ export class GitService { await this.git(dir, "add", "--", ...files); return this.commit(dir, message); } + + // ========================================== + // Remote Management (P3) + // ========================================== + + /** + * List all remotes with their fetch/push URLs. + */ + async remoteList(dir: string): Promise<{ name: string; fetchUrl: string; pushUrl: string }[]> { + try { + const output = await this.git(dir, "remote", "-v"); + if (!output) return []; + + const map = new Map(); + for (const line of output.split("\n")) { + const match = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/); + if (!match) continue; + const [, name, url, type] = match; + if (!map.has(name)) map.set(name, { fetchUrl: "", pushUrl: "" }); + const entry = map.get(name)!; + if (type === "fetch") entry.fetchUrl = url; + else entry.pushUrl = url; + } + + return Array.from(map.entries()).map(([name, urls]) => ({ name, ...urls })); + } catch { + return []; + } + } + + /** + * Add a named remote + */ + async remoteAdd(dir: string, name: string, url: string): Promise { + await this.git(dir, "remote", "add", name, url); + } + + /** + * Remove a named remote + */ + async remoteRemove(dir: string, name: string): Promise { + await this.git(dir, "remote", "remove", name); + } + + /** + * Get the URL of a remote + */ + async remoteGetUrl(dir: string, name = "origin"): Promise { + try { + return await this.git(dir, "remote", "get-url", name); + } catch { + return null; + } + } + + /** + * Change the URL of an existing remote + */ + async remoteSetUrl(dir: string, name: string, url: string): Promise { + await this.git(dir, "remote", "set-url", name, url); + } + + // ========================================== + // Push / Pull / Fetch (P3) + // ========================================== + + /** + * Push commits to a remote. + * Returns push output text. + */ + async push( + dir: string, + remote = "origin", + branch?: string, + options?: { force?: boolean; setUpstream?: boolean } + ): Promise { + const args = ["push"]; + if (options?.force) args.push("--force-with-lease"); + if (options?.setUpstream) args.push("--set-upstream"); + args.push(remote); + if (branch) args.push(branch); + return this.git(dir, ...args); + } + + /** + * Pull from a remote. + * Returns pull output text. + */ + async pull( + dir: string, + remote = "origin", + branch?: string, + options?: { rebase?: boolean } + ): Promise { + const args = ["pull"]; + if (options?.rebase) args.push("--rebase"); + args.push(remote); + if (branch) args.push(branch); + return this.git(dir, ...args); + } + + /** + * Fetch from a remote (or all remotes). + */ + async fetch( + dir: string, + remote?: string, + options?: { prune?: boolean; all?: boolean } + ): Promise { + const args = ["fetch"]; + if (options?.prune) args.push("--prune"); + if (options?.all || !remote) { + args.push("--all"); + } else { + args.push(remote); + } + return this.git(dir, ...args); + } + + // ========================================== + // Merge & Rebase (P3) + // ========================================== + + /** + * Merge a branch into the current branch. + * Returns merge output. Throws on conflict. + */ + async merge( + dir: string, + branch: string, + options?: { noFF?: boolean; squash?: boolean; message?: string } + ): Promise { + const args = ["merge"]; + if (options?.noFF) args.push("--no-ff"); + if (options?.squash) args.push("--squash"); + if (options?.message) args.push("-m", options.message); + args.push(branch); + return this.git(dir, ...args); + } + + /** + * Abort an in-progress merge + */ + async abortMerge(dir: string): Promise { + await this.git(dir, "merge", "--abort"); + } + + /** + * Rebase current branch onto target + */ + async rebase(dir: string, onto: string): Promise { + return this.git(dir, "rebase", onto); + } + + /** + * Abort an in-progress rebase + */ + async abortRebase(dir: string): Promise { + await this.git(dir, "rebase", "--abort"); + } + + /** + * Continue a rebase after resolving conflicts + */ + async continueRebase(dir: string): Promise { + return this.git(dir, "rebase", "--continue"); + } + + // ========================================== + // History & Reversal (P3) + // ========================================== + + /** + * Revert a specific commit (creates a new commit that undoes the changes) + */ + async revert(dir: string, commitHash: string, options?: { noCommit?: boolean }): Promise { + const args = ["revert"]; + if (options?.noCommit) args.push("--no-commit"); + args.push(commitHash); + return this.git(dir, ...args); + } + + /** + * Cherry-pick a commit from another branch + */ + async cherryPick(dir: string, commitHash: string, options?: { noCommit?: boolean }): Promise { + const args = ["cherry-pick"]; + if (options?.noCommit) args.push("--no-commit"); + args.push(commitHash); + return this.git(dir, ...args); + } + + /** + * Get line-by-line blame for a file. + * Returns array of blame entries. + */ + async blame(dir: string, filePath: string): Promise<{ + hash: string; + author: string; + date: string; + lineNumber: number; + content: string; + }[]> { + try { + const output = await this.git( + dir, + "blame", + "--porcelain", + "--", + filePath + ); + if (!output) return []; + + const entries: { hash: string; author: string; date: string; lineNumber: number; content: string }[] = []; + const lines = output.split("\n"); + let i = 0; + while (i < lines.length) { + const header = lines[i]; + const headerMatch = header.match(/^([0-9a-f]{40})\s+\d+\s+(\d+)/); + if (!headerMatch) { i++; continue; } + const hash = headerMatch[1].slice(0, 8); + const lineNumber = parseInt(headerMatch[2], 10); + let author = ""; + let date = ""; + i++; + // Read header fields until content line starting with \t + while (i < lines.length && !lines[i].startsWith("\t")) { + if (lines[i].startsWith("author ")) author = lines[i].slice(7); + if (lines[i].startsWith("author-time ")) { + const ts = parseInt(lines[i].slice(12), 10); + date = new Date(ts * 1000).toISOString(); + } + i++; + } + const content = i < lines.length ? lines[i].slice(1) : ""; + entries.push({ hash, author, date, lineNumber, content }); + i++; + } + return entries; + } catch { + return []; + } + } + + /** + * Show a file at a specific ref (alias for getFileAtRef for consistency) + */ + async show(dir: string, ref: string, filePath: string): Promise { + return this.getFileAtRef(dir, filePath, ref); + } + + // ========================================== + // Stash Management (P3) + // ========================================== + + /** + * List all stash entries + */ + async stashList(dir: string): Promise<{ index: number; message: string; date: string }[]> { + try { + const SEP = "<>"; + const output = await this.git( + dir, + "stash", + "list", + `--format=%gd${SEP}%s${SEP}%aI` + ); + if (!output) return []; + + return output.split("\n").filter(Boolean).map((line) => { + const [ref, message, date] = line.split(SEP); + const indexMatch = ref.match(/\{(\d+)\}/); + return { + index: indexMatch ? parseInt(indexMatch[1], 10) : 0, + message: message || ref, + date: date || "", + }; + }); + } catch { + return []; + } + } + + /** + * Apply a specific stash entry (without removing it from the stash list) + */ + async stashApply(dir: string, index = 0): Promise { + await this.git(dir, "stash", "apply", `stash@{${index}}`); + } + + /** + * Drop a specific stash entry + */ + async stashDrop(dir: string, index = 0): Promise { + await this.git(dir, "stash", "drop", `stash@{${index}}`); + } + + /** + * Clear all stash entries + */ + async stashClear(dir: string): Promise { + await this.git(dir, "stash", "clear"); + } + + // ========================================== + // Clone (P3) + // ========================================== + + /** + * Clone a repository. Returns the path of the cloned directory. + */ + async clone(url: string, dest: string, branch?: string): Promise { + const args = ["clone"]; + if (branch) args.push("-b", branch); + args.push(url, dest); + // cwd doesn't matter for clone, use dest's parent + const parent = path.dirname(dest); + await this.git(parent, ...args); + return dest; + } + + // ========================================== + // Conflict Resolution Helpers (P3) + // ========================================== + + /** + * Check if there is a merge or rebase in progress + */ + async getMergeState(dir: string): Promise<{ + mergeInProgress: boolean; + rebaseInProgress: boolean; + conflictedFiles: string[]; + }> { + let mergeInProgress = false; + let rebaseInProgress = false; + + try { + const gitDir = await this.git(dir, "rev-parse", "--git-dir"); + const absGitDir = path.resolve(dir, gitDir); + mergeInProgress = fsSync.existsSync(path.join(absGitDir, "MERGE_HEAD")); + rebaseInProgress = + fsSync.existsSync(path.join(absGitDir, "rebase-merge")) || + fsSync.existsSync(path.join(absGitDir, "rebase-apply")); + } catch { + // Not a repo or other error + } + + // Get list of conflicted files + const conflictedFiles: string[] = []; + try { + const output = await this.git(dir, "diff", "--name-only", "--diff-filter=U"); + if (output) { + conflictedFiles.push(...output.split("\n").filter(Boolean)); + } + } catch { + // Ignore + } + + return { mergeInProgress, rebaseInProgress, conflictedFiles }; + } + + /** + * Mark conflicted files as resolved (stage them) + */ + async markResolved(dir: string, files: string[]): Promise { + if (files.length === 0) return; + await this.git(dir, "add", "--", ...files); + } + + // ========================================== + // Protection & Safety (P3) + // ========================================== + + /** + * Get the list of configured protected branch patterns. + * By convention, reads from .relwave-protected-branches in the repo root. + * Returns ["main", "production"] by default if the file doesn't exist. + */ + getProtectedBranches(dir: string): string[] { + try { + const filePath = path.join(dir, ".relwave-protected-branches"); + if (fsSync.existsSync(filePath)) { + return fsSync + .readFileSync(filePath, "utf-8") + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + } + } catch { + // Ignore + } + return ["main", "production"]; + } + + /** + * Check if a branch name matches any protected pattern + */ + isProtectedBranch(dir: string, branch: string): boolean { + const patterns = this.getProtectedBranches(dir); + return patterns.some((p) => { + if (p.includes("*")) { + const regex = new RegExp("^" + p.replace(/\*/g, ".*") + "$"); + return regex.test(branch); + } + return p === branch; + }); + } + + /** + * Delete a local branch (prevent deletion of the current branch) + */ + async deleteBranch(dir: string, name: string, force = false): Promise { + const flag = force ? "-D" : "-d"; + await this.git(dir, "branch", flag, name); + } + + /** + * Rename the current branch + */ + async renameBranch(dir: string, newName: string): Promise { + await this.git(dir, "branch", "-m", newName); + } } export const gitServiceInstance = new GitService(); diff --git a/bridge/src/services/migrationTimelineService.ts b/bridge/src/services/migrationTimelineService.ts deleted file mode 100644 index 4318b35..0000000 --- a/bridge/src/services/migrationTimelineService.ts +++ /dev/null @@ -1,266 +0,0 @@ -// ============================================================ -// services/migrationTimelineService.ts -// ============================================================ -// -// Provides a "migration timeline" built from the git history of -// schema.json. Each commit that touches the schema file is one -// entry in the timeline. Entries may optionally carry a tag -// (e.g. "v1.2.0" or "relwave/migration/20260101-add-users"). -// -// Auto-commit: When the frontend saves a schema snapshot we -// can stage + commit + tag the change automatically so that -// every schema mutation is a discrete, revertable git commit. - -import path from "path"; -import { - GitService, - gitServiceInstance, - GitLogEntry, -} from "./gitService"; -import { - SchemaDiffService, - schemaDiffServiceInstance, -} from "./schemaDiffService"; -import { - ProjectStore, - projectStoreInstance, - SchemaFile, -} from "./projectStore"; -import { getProjectDir } from "../utils/config"; - -// ─── Types ─────────────────────────────────────────────────── - -export interface TimelineEntry { - /** Short commit hash */ - hash: string; - /** Full commit hash */ - fullHash: string; - /** Commit author */ - author: string; - /** Commit date ISO string */ - date: string; - /** Commit subject line */ - subject: string; - /** Tags on this commit (may be empty) */ - tags: string[]; - /** Whether this commit was an auto-commit by RelWave */ - isAutoCommit: boolean; - /** Quick schema change summary for this commit (optional — computed lazily) */ - summary?: TimelineChangeSummary; -} - -export interface TimelineChangeSummary { - tablesAdded: number; - tablesRemoved: number; - tablesModified: number; - columnsAdded: number; - columnsRemoved: number; - columnsModified: number; -} - -export interface AutoCommitResult { - /** Short hash of the new commit */ - hash: string; - /** Tag name if one was created */ - tag: string | null; - /** Commit message used */ - message: string; -} - -// Tag prefix used by RelWave auto-commits -const TAG_PREFIX = "relwave/schema/"; -const AUTO_COMMIT_PREFIX = "[relwave] "; - -// ─── Service ───────────────────────────────────────────────── - -export class MigrationTimelineService { - constructor( - private git: GitService = gitServiceInstance, - private differ: SchemaDiffService = schemaDiffServiceInstance, - private store: ProjectStore = projectStoreInstance, - ) { } - - /** - * Build the full migration timeline from the git log of schema.json. - * Each entry = one commit that changed the schema file. - */ - async getTimeline(projectId: string, count = 50): Promise { - const dir = getProjectDir(projectId); - if (!(await this.git.isRepo(dir))) return []; - - const repoRoot = await this.git.getRepoRoot(dir); - const relSchemaPath = path - .relative(repoRoot, path.join(dir, "schema", "schema.json")) - .replace(/\\/g, "/"); - - // Get commits that touched schema.json - const commits = await this.git.fileLog(repoRoot, relSchemaPath, count); - if (commits.length === 0) return []; - - // Get all tags in the repo (prefixed with our namespace) - const allTags = await this.git.listTags(repoRoot, `${TAG_PREFIX}*`); - - // For each tag, resolve to a commit hash so we can associate - const tagsByCommit = new Map(); - for (const tag of allTags) { - try { - // rev-parse dereferences the tag to its commit - const hash = await this.resolveTagToCommit(repoRoot, tag); - if (hash) { - const existing = tagsByCommit.get(hash) ?? []; - existing.push(tag); - tagsByCommit.set(hash, existing); - } - } catch { - // skip unresolvable tags - } - } - - return commits.map((c) => ({ - hash: c.hash, - fullHash: c.fullHash, - author: c.author, - date: c.date, - subject: c.subject, - tags: tagsByCommit.get(c.fullHash) ?? [], - isAutoCommit: c.subject.startsWith(AUTO_COMMIT_PREFIX), - })); - } - - /** - * Get a detailed change summary for a specific commit by diffing it - * against its parent. - */ - async getCommitSummary( - projectId: string, - commitHash: string, - ): Promise { - const dir = getProjectDir(projectId); - if (!(await this.git.isRepo(dir))) return null; - - const repoRoot = await this.git.getRepoRoot(dir); - const relSchemaPath = path - .relative(repoRoot, path.join(dir, "schema", "schema.json")) - .replace(/\\/g, "/"); - - // Read schema at this commit and its parent - let afterRaw: string | null = null; - let beforeRaw: string | null = null; - - try { - afterRaw = await this.git.getFileAtRef(repoRoot, relSchemaPath, commitHash); - } catch { /* file may not exist */ } - - try { - beforeRaw = await this.git.getFileAtRef(repoRoot, relSchemaPath, `${commitHash}~1`); - } catch { /* parent may not exist (first commit) */ } - - const before: SchemaFile | null = beforeRaw ? JSON.parse(beforeRaw) : null; - const after: SchemaFile | null = afterRaw ? JSON.parse(afterRaw) : null; - - const diff = this.differ.diff(before, after); - - return { - tablesAdded: diff.summary.tablesAdded, - tablesRemoved: diff.summary.tablesRemoved, - tablesModified: diff.summary.tablesModified, - columnsAdded: diff.summary.columnsAdded, - columnsRemoved: diff.summary.columnsRemoved, - columnsModified: diff.summary.columnsModified, - }; - } - - /** - * Auto-commit the current schema snapshot with an optional tag. - * - * Flow: - * 1. Stage schema/schema.json - * 2. Commit with a descriptive message - * 3. Optionally create an annotated tag - */ - async autoCommitSchema( - projectId: string, - options?: { - message?: string; - tag?: string; - }, - ): Promise { - const dir = getProjectDir(projectId); - if (!(await this.git.isRepo(dir))) { - throw new Error("Project directory is not a git repository"); - } - - const repoRoot = await this.git.getRepoRoot(dir); - const relSchemaPath = path - .relative(repoRoot, path.join(dir, "schema", "schema.json")) - .replace(/\\/g, "/"); - - // Build a descriptive commit message - let message = options?.message ?? ""; - if (!message) { - // Generate from diff summary - const summary = await this.getWorkingTreeSummary(projectId); - message = `${AUTO_COMMIT_PREFIX}schema update`; - if (summary) { - const parts: string[] = []; - if (summary.tablesAdded) parts.push(`+${summary.tablesAdded} table${summary.tablesAdded > 1 ? "s" : ""}`); - if (summary.tablesRemoved) parts.push(`-${summary.tablesRemoved} table${summary.tablesRemoved > 1 ? "s" : ""}`); - if (summary.tablesModified) parts.push(`~${summary.tablesModified} table${summary.tablesModified > 1 ? "s" : ""}`); - if (parts.length) message = `${AUTO_COMMIT_PREFIX}${parts.join(", ")}`; - } - } - - const hash = await this.git.commitFiles(repoRoot, [relSchemaPath], message); - - // Create tag if requested - let tagName: string | null = null; - if (options?.tag) { - tagName = options.tag.startsWith(TAG_PREFIX) - ? options.tag - : `${TAG_PREFIX}${options.tag}`; - await this.git.createTag(repoRoot, tagName, message); - } - - return { hash, tag: tagName, message }; - } - - /** - * Get a change summary for the working tree vs HEAD - */ - private async getWorkingTreeSummary( - projectId: string, - ): Promise { - const dir = getProjectDir(projectId); - const repoRoot = await this.git.getRepoRoot(dir); - const relSchemaPath = path - .relative(repoRoot, path.join(dir, "schema", "schema.json")) - .replace(/\\/g, "/"); - - let beforeRaw: string | null = null; - try { - beforeRaw = await this.git.getFileAtRef(repoRoot, relSchemaPath, "HEAD"); - } catch { /* no HEAD version */ } - - const before: SchemaFile | null = beforeRaw ? JSON.parse(beforeRaw) : null; - const after = await this.store.getSchema(projectId); - - const diff = this.differ.diff(before, after); - return { - tablesAdded: diff.summary.tablesAdded, - tablesRemoved: diff.summary.tablesRemoved, - tablesModified: diff.summary.tablesModified, - columnsAdded: diff.summary.columnsAdded, - columnsRemoved: diff.summary.columnsRemoved, - columnsModified: diff.summary.columnsModified, - }; - } - - /** - * Resolve a tag name to its commit hash - */ - private async resolveTagToCommit(repoRoot: string, tag: string): Promise { - return this.git.resolveRef(repoRoot, tag); - } -} - -export const migrationTimelineServiceInstance = new MigrationTimelineService(); diff --git a/bridge/src/services/projectStore.ts b/bridge/src/services/projectStore.ts index e20ee80..f67beeb 100644 --- a/bridge/src/services/projectStore.ts +++ b/bridge/src/services/projectStore.ts @@ -388,6 +388,17 @@ export class ProjectStore { const meta = await this.getProject(projectId); if (!meta) throw new Error(`Project ${projectId} not found`); + // Read existing file and skip write if schema data is identical + // (avoids cachedAt churn that creates phantom git changes) + const existing = await this.getSchema(projectId); + if (existing) { + const oldData = JSON.stringify(existing.schemas); + const newData = JSON.stringify(schemas); + if (oldData === newData) { + return existing; // nothing changed — keep old cachedAt + } + } + const now = new Date().toISOString(); const file: SchemaFile = { version: 1, diff --git a/bridge/src/services/schemaDiffService.ts b/bridge/src/services/schemaDiffService.ts deleted file mode 100644 index 2c956ec..0000000 --- a/bridge/src/services/schemaDiffService.ts +++ /dev/null @@ -1,348 +0,0 @@ -// ---------------------------- -// services/schemaDiffService.ts -// ---------------------------- -// -// Computes a structured diff between two schema snapshots. -// Used for diffing working tree vs HEAD, or any two refs. - -import { - SchemaFile, - SchemaSnapshot, - TableSnapshot, - ColumnSnapshot, -} from "./projectStore"; - -// ========================================== -// Diff Result Types -// ========================================== - -export type SchemaDiffResult = { - /** Overall summary */ - summary: DiffSummary; - - /** Per-schema diffs */ - schemas: SchemaDiff[]; -}; - -export type DiffSummary = { - schemasAdded: number; - schemasRemoved: number; - schemasModified: number; - tablesAdded: number; - tablesRemoved: number; - tablesModified: number; - columnsAdded: number; - columnsRemoved: number; - columnsModified: number; - hasChanges: boolean; -}; - -export type SchemaDiff = { - name: string; - status: "added" | "removed" | "modified" | "unchanged"; - tables: TableDiff[]; -}; - -export type TableDiff = { - name: string; - schema: string; - status: "added" | "removed" | "modified" | "unchanged"; - columns: ColumnDiff[]; -}; - -export type ColumnDiff = { - name: string; - status: "added" | "removed" | "modified" | "unchanged"; - /** Only for "modified" — what changed */ - changes?: ColumnChange[]; - /** Before state (for removed/modified) */ - before?: ColumnSnapshot; - /** After state (for added/modified) */ - after?: ColumnSnapshot; -}; - -export type ColumnChange = { - field: string; - before: string; - after: string; -}; - -// ========================================== -// Diff Engine -// ========================================== - -export class SchemaDiffService { - /** - * Compare two SchemaFile objects and return a structured diff. - * Either can be null (e.g., initial commit has no HEAD version). - */ - diff( - before: SchemaFile | null, - after: SchemaFile | null - ): SchemaDiffResult { - const beforeSchemas = before?.schemas ?? []; - const afterSchemas = after?.schemas ?? []; - - const beforeMap = new Map(beforeSchemas.map((s) => [s.name, s])); - const afterMap = new Map(afterSchemas.map((s) => [s.name, s])); - - const allSchemaNames = new Set([ - ...beforeMap.keys(), - ...afterMap.keys(), - ]); - - const schemas: SchemaDiff[] = []; - const summary: DiffSummary = { - schemasAdded: 0, - schemasRemoved: 0, - schemasModified: 0, - tablesAdded: 0, - tablesRemoved: 0, - tablesModified: 0, - columnsAdded: 0, - columnsRemoved: 0, - columnsModified: 0, - hasChanges: false, - }; - - for (const name of allSchemaNames) { - const bSchema = beforeMap.get(name); - const aSchema = afterMap.get(name); - - if (!bSchema && aSchema) { - // Schema added - summary.schemasAdded++; - const tables = aSchema.tables.map((t) => - this.tableAsAdded(name, t) - ); - summary.tablesAdded += tables.length; - for (const t of tables) summary.columnsAdded += t.columns.length; - - schemas.push({ name, status: "added", tables }); - } else if (bSchema && !aSchema) { - // Schema removed - summary.schemasRemoved++; - const tables = bSchema.tables.map((t) => - this.tableAsRemoved(name, t) - ); - summary.tablesRemoved += tables.length; - for (const t of tables) summary.columnsRemoved += t.columns.length; - - schemas.push({ name, status: "removed", tables }); - } else if (bSchema && aSchema) { - // Schema exists in both — diff tables - const tableDiffs = this.diffTables(name, bSchema, aSchema, summary); - const hasTableChanges = tableDiffs.some( - (t) => t.status !== "unchanged" - ); - - if (hasTableChanges) summary.schemasModified++; - - schemas.push({ - name, - status: hasTableChanges ? "modified" : "unchanged", - tables: tableDiffs, - }); - } - } - - summary.hasChanges = - summary.schemasAdded > 0 || - summary.schemasRemoved > 0 || - summary.schemasModified > 0; - - // Sort: changed items first - schemas.sort((a, b) => { - const order = { removed: 0, added: 1, modified: 2, unchanged: 3 }; - return order[a.status] - order[b.status]; - }); - - return { summary, schemas }; - } - - // ---- Table diffing ---- - - private diffTables( - schemaName: string, - before: SchemaSnapshot, - after: SchemaSnapshot, - summary: DiffSummary - ): TableDiff[] { - const bMap = new Map(before.tables.map((t) => [t.name, t])); - const aMap = new Map(after.tables.map((t) => [t.name, t])); - - const allTableNames = new Set([...bMap.keys(), ...aMap.keys()]); - const results: TableDiff[] = []; - - for (const name of allTableNames) { - const bTable = bMap.get(name); - const aTable = aMap.get(name); - - if (!bTable && aTable) { - summary.tablesAdded++; - const diff = this.tableAsAdded(schemaName, aTable); - summary.columnsAdded += diff.columns.length; - results.push(diff); - } else if (bTable && !aTable) { - summary.tablesRemoved++; - const diff = this.tableAsRemoved(schemaName, bTable); - summary.columnsRemoved += diff.columns.length; - results.push(diff); - } else if (bTable && aTable) { - const columns = this.diffColumns(bTable, aTable); - const hasColChanges = columns.some( - (c) => c.status !== "unchanged" - ); - - if (hasColChanges) summary.tablesModified++; - for (const c of columns) { - if (c.status === "added") summary.columnsAdded++; - if (c.status === "removed") summary.columnsRemoved++; - if (c.status === "modified") summary.columnsModified++; - } - - results.push({ - name, - schema: schemaName, - status: hasColChanges ? "modified" : "unchanged", - columns, - }); - } - } - - // Sort: changed items first - results.sort((a, b) => { - const order = { removed: 0, added: 1, modified: 2, unchanged: 3 }; - return order[a.status] - order[b.status]; - }); - - return results; - } - - // ---- Column diffing ---- - - private diffColumns( - before: TableSnapshot, - after: TableSnapshot - ): ColumnDiff[] { - const bMap = new Map(before.columns.map((c) => [c.name, c])); - const aMap = new Map(after.columns.map((c) => [c.name, c])); - - const allColumnNames = new Set([...bMap.keys(), ...aMap.keys()]); - const results: ColumnDiff[] = []; - - for (const name of allColumnNames) { - const bCol = bMap.get(name); - const aCol = aMap.get(name); - - if (!bCol && aCol) { - results.push({ name, status: "added", after: aCol }); - } else if (bCol && !aCol) { - results.push({ name, status: "removed", before: bCol }); - } else if (bCol && aCol) { - const changes = this.diffColumnProps(bCol, aCol); - if (changes.length > 0) { - results.push({ - name, - status: "modified", - changes, - before: bCol, - after: aCol, - }); - } else { - results.push({ name, status: "unchanged" }); - } - } - } - - // Sort: changed items first - results.sort((a, b) => { - const order = { removed: 0, added: 1, modified: 2, unchanged: 3 }; - return order[a.status] - order[b.status]; - }); - - return results; - } - - private diffColumnProps( - before: ColumnSnapshot, - after: ColumnSnapshot - ): ColumnChange[] { - const changes: ColumnChange[] = []; - - if (before.type !== after.type) { - changes.push({ - field: "type", - before: before.type, - after: after.type, - }); - } - if (before.nullable !== after.nullable) { - changes.push({ - field: "nullable", - before: String(before.nullable), - after: String(after.nullable), - }); - } - if (before.isPrimaryKey !== after.isPrimaryKey) { - changes.push({ - field: "primaryKey", - before: String(before.isPrimaryKey), - after: String(after.isPrimaryKey), - }); - } - if (before.isForeignKey !== after.isForeignKey) { - changes.push({ - field: "foreignKey", - before: String(before.isForeignKey), - after: String(after.isForeignKey), - }); - } - if (before.isUnique !== after.isUnique) { - changes.push({ - field: "unique", - before: String(before.isUnique), - after: String(after.isUnique), - }); - } - if (before.defaultValue !== after.defaultValue) { - changes.push({ - field: "default", - before: before.defaultValue ?? "null", - after: after.defaultValue ?? "null", - }); - } - - return changes; - } - - // ---- Helpers ---- - - private tableAsAdded(schemaName: string, table: TableSnapshot): TableDiff { - return { - name: table.name, - schema: schemaName, - status: "added", - columns: table.columns.map((c) => ({ - name: c.name, - status: "added" as const, - after: c, - })), - }; - } - - private tableAsRemoved(schemaName: string, table: TableSnapshot): TableDiff { - return { - name: table.name, - schema: schemaName, - status: "removed", - columns: table.columns.map((c) => ({ - name: c.name, - status: "removed" as const, - before: c, - })), - }; - } -} - -export const schemaDiffServiceInstance = new SchemaDiffService(); diff --git a/bridge/src/types/index.ts b/bridge/src/types/index.ts index d9ba93f..4081bd2 100644 --- a/bridge/src/types/index.ts +++ b/bridge/src/types/index.ts @@ -20,7 +20,7 @@ export enum DBType { export type Rpc = { sendResponse: (id: number | string, payload: any) => void; - sendError: (id: number | string, err: { code?: string; message: string }) => void; + sendError: (id: number | string, err: { code?: string; message: string; details?: any }) => void; sendNotification?: (method: string, params?: any) => void; }; diff --git a/src/components/common/EnvironmentSwitcher.tsx b/src/components/common/EnvironmentSwitcher.tsx deleted file mode 100644 index ec260a0..0000000 --- a/src/components/common/EnvironmentSwitcher.tsx +++ /dev/null @@ -1,275 +0,0 @@ -import { useState } from "react"; -import { - Globe, - Shield, - ShieldAlert, - Plus, - Trash2, - Settings2, - Check, - Loader2, -} from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { Input } from "@/components/ui/input"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "@/components/ui/dialog"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Switch } from "@/components/ui/switch"; -import { cn } from "@/lib/utils"; -import { - useResolvedEnv, - useEnvConfig, - useSetEnvMapping, - useRemoveEnvMapping, -} from "@/hooks/useGitWorkflow"; -import type { EnvironmentMapping } from "@/types/gitWorkflow"; -import { toast } from "sonner"; - -// ─── Helpers ───────────────────────────────────────────────── - -const envBadgeColor: Record = { - production: "bg-red-500/15 text-red-500 border-red-500/30", - staging: "bg-amber-500/15 text-amber-500 border-amber-500/30", - development: "bg-emerald-500/15 text-emerald-500 border-emerald-500/30", - testing: "bg-blue-500/15 text-blue-500 border-blue-500/30", -}; - -function getEnvColor(env: string): string { - const lower = env.toLowerCase(); - return envBadgeColor[lower] ?? "bg-muted text-muted-foreground border-border/30"; -} - -// ─── Status Bar Pill ───────────────────────────────────────── - -interface EnvironmentSwitcherProps { - projectId?: string | null; -} - -/** - * Compact environment indicator for the bottom status bar. - * Shows the resolved environment; clicking opens configuration. - */ -export default function EnvironmentSwitcher({ projectId }: EnvironmentSwitcherProps) { - const [configOpen, setConfigOpen] = useState(false); - - const { data: resolved } = useResolvedEnv(projectId ?? undefined); - - if (!projectId || !resolved) return null; - - const envLabel = resolved.environment; - const isProd = resolved.isProduction; - - return ( - <> - - - - - -

- Environment: {envLabel} - {resolved.branch && ( - <> - {" "} - (branch: {resolved.branch}) - - )} -

-

Click to configure

-
-
- - - - ); -} - -// ─── Configuration Dialog ──────────────────────────────────── - -function EnvironmentConfigDialog({ - projectId, - open, - onOpenChange, -}: { - projectId: string; - open: boolean; - onOpenChange: (v: boolean) => void; -}) { - const { data: config } = useEnvConfig(projectId); - const setMappingMut = useSetEnvMapping(); - const removeMappingMut = useRemoveEnvMapping(); - - const [newBranch, setNewBranch] = useState(""); - const [newEnv, setNewEnv] = useState(""); - const [newIsProd, setNewIsProd] = useState(false); - - const mappings = config?.mappings ?? []; - - const handleAdd = async () => { - if (!newBranch.trim() || !newEnv.trim()) return; - try { - await setMappingMut.mutateAsync({ - projectId, - mapping: { - branch: newBranch.trim(), - environment: newEnv.trim(), - isProduction: newIsProd, - }, - }); - toast.success(`Mapped ${newBranch} → ${newEnv}`); - setNewBranch(""); - setNewEnv(""); - setNewIsProd(false); - } catch (err: any) { - toast.error(err.message || "Failed to save mapping"); - } - }; - - const handleRemove = async (branch: string) => { - try { - await removeMappingMut.mutateAsync({ projectId, branch }); - toast.success(`Removed mapping for ${branch}`); - } catch (err: any) { - toast.error(err.message || "Failed to remove mapping"); - } - }; - - return ( - - - - - - Environment Mappings - - - Map git branches to database environments. When you switch branches, - the environment label and connection automatically update. - - - - {/* Existing mappings */} -
- {mappings.length === 0 ? ( -

- No mappings configured yet -

- ) : ( - mappings.map((m) => ( -
- - {m.branch} - - - - {m.isProduction && } - {m.environment} - - -
- )) - )} -
- - {/* Add new mapping */} -
-

Add mapping

-
- setNewBranch(e.target.value)} - className="h-7 text-xs flex-1" - /> - - setNewEnv(e.target.value)} - className="h-7 text-xs flex-1" - /> -
-
- - -
-
-
-
- ); -} diff --git a/src/components/common/GitStatusBar.tsx b/src/components/common/GitStatusBar.tsx index 10d1b29..44916ea 100644 --- a/src/components/common/GitStatusBar.tsx +++ b/src/components/common/GitStatusBar.tsx @@ -8,8 +8,11 @@ import { Plus, Check, ChevronDown, - RotateCcw, FolderGit2, + Globe, + CloudUpload, + CloudDownload, + RefreshCw, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -43,14 +46,17 @@ import { useGitCheckout, useGitCreateBranch, } from "@/hooks/useGitQueries"; +import { + useGitPush, + useGitPull, + useGitFetch, + useGitRemotes, +} from "@/hooks/useGitAdvanced"; import { toast } from "sonner"; import type { GitBranchInfo } from "@/types/git"; +import RemoteConfigDialog from "./RemoteConfigDialog"; interface GitStatusBarProps { - /** - * The directory to check git status for. - * Typically the project files directory from the bridge config. - */ projectDir: string | null | undefined; } @@ -70,8 +76,17 @@ export default function GitStatusBar({ projectDir }: GitStatusBarProps) { const [commitMessage, setCommitMessage] = useState(""); const [branchDialogOpen, setBranchDialogOpen] = useState(false); const [newBranchName, setNewBranchName] = useState(""); + const [remoteDialogOpen, setRemoteDialogOpen] = useState(false); + + const pushMutation = useGitPush(projectDir); + const pullMutation = useGitPull(projectDir); + const fetchMutation = useGitFetch(projectDir); + const { data: remotes } = useGitRemotes( + status?.isGitRepo ? projectDir : undefined + ); + + const hasRemote = remotes && remotes.length > 0; - // --- Not a git repo: show init button --- if (!projectDir) return null; if (isLoading) { @@ -113,15 +128,12 @@ export default function GitStatusBar({ projectDir }: GitStatusBarProps) { ); } - // --- Active repo: show branch + status --- const totalChanges = status.stagedCount + status.unstagedCount + status.untrackedCount; const handleQuickCommit = async () => { if (!commitMessage.trim()) return; try { - // Stage all first await stageAllMutation.mutateAsync(); - // Then commit const result = await commitMutation.mutateAsync(commitMessage.trim()); toast.success(`Committed as ${result.hash}`); setCommitMessage(""); @@ -152,6 +164,37 @@ export default function GitStatusBar({ projectDir }: GitStatusBarProps) { } }; + const handlePush = async () => { + try { + const needsUpstream = !status?.upstream; + await pushMutation.mutateAsync({ + setUpstream: needsUpstream, + branch: needsUpstream ? (status?.branch ?? undefined) : undefined, + }); + toast.success("Pushed successfully"); + } catch (e: any) { + toast.error("Push failed: " + e.message); + } + }; + + const handlePull = async () => { + try { + await pullMutation.mutateAsync(); + toast.success("Pulled successfully"); + } catch (e: any) { + toast.error("Pull failed: " + e.message); + } + }; + + const handleFetch = async () => { + try { + await fetchMutation.mutateAsync({ all: true, prune: true }); + toast.success("Fetched from all remotes"); + } catch (e: any) { + toast.error("Fetch failed: " + e.message); + } + }; + return ( <>
@@ -197,33 +240,120 @@ export default function GitStatusBar({ projectDir }: GitStatusBarProps) { New Branch... + {hasRemote && ( + <> + + + + Push + + + + Pull + + + + Fetch All + + + )} + + setRemoteDialogOpen(true)}> + + Manage Remotes... + - {/* Sync indicators: ahead / behind */} - {status.ahead != null && status.ahead > 0 && ( + {/* Ahead indicator — click to push */} + {hasRemote && status.ahead != null && status.ahead > 0 && ( - - + -

{status.ahead} commit{status.ahead > 1 ? "s" : ""} ahead of {status.upstream}

+

Push {status.ahead} commit{status.ahead > 1 ? "s" : ""} to {status.upstream}

)} - {status.behind != null && status.behind > 0 && ( + + {/* Behind indicator — click to pull */} + {hasRemote && status.behind != null && status.behind > 0 && ( - - + -

{status.behind} commit{status.behind > 1 ? "s" : ""} behind {status.upstream}

+

Pull {status.behind} commit{status.behind > 1 ? "s" : ""} from {status.upstream}

+
+
+ )} + + {/* Sync button when up to date */} + {hasRemote && (status.ahead === 0 || status.ahead == null) && (status.behind === 0 || status.behind == null) && ( + + + + + +

Fetch from all remotes

+
+
+ )} + + {/* No remote — show add remote button */} + {!hasRemote && ( + + + + + +

Add a remote to enable push/pull

)} @@ -374,6 +504,13 @@ export default function GitStatusBar({ projectDir }: GitStatusBarProps) { + + {/* Remote Config Dialog */} + ); } diff --git a/src/components/common/RemoteConfigDialog.tsx b/src/components/common/RemoteConfigDialog.tsx new file mode 100644 index 0000000..11da2f7 --- /dev/null +++ b/src/components/common/RemoteConfigDialog.tsx @@ -0,0 +1,266 @@ +import { useState } from "react"; +import { + Globe, + Plus, + Trash2, + Pencil, + Check, + X, + Copy, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; +import { + useGitRemotes, + useGitRemoteAdd, + useGitRemoteRemove, + useGitRemoteSetUrl, +} from "@/hooks/useGitAdvanced"; +import { toast } from "sonner"; +import type { GitRemoteInfo } from "@/types/git"; + +interface RemoteConfigDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + projectDir: string | null | undefined; +} + +export default function RemoteConfigDialog({ + open, + onOpenChange, + projectDir, +}: RemoteConfigDialogProps) { + const { data: remotes, isLoading } = useGitRemotes(open ? projectDir : undefined); + const addMutation = useGitRemoteAdd(projectDir); + const removeMutation = useGitRemoteRemove(projectDir); + const setUrlMutation = useGitRemoteSetUrl(projectDir); + + const [addMode, setAddMode] = useState(false); + const [newName, setNewName] = useState("origin"); + const [newUrl, setNewUrl] = useState(""); + + // Editing state + const [editingRemote, setEditingRemote] = useState(null); + const [editUrl, setEditUrl] = useState(""); + + const handleAdd = async () => { + if (!newName.trim() || !newUrl.trim()) return; + try { + await addMutation.mutateAsync({ name: newName.trim(), url: newUrl.trim() }); + toast.success(`Remote '${newName.trim()}' added`); + setNewName("origin"); + setNewUrl(""); + setAddMode(false); + } catch (e: any) { + toast.error("Failed to add remote: " + e.message); + } + }; + + const handleRemove = async (name: string) => { + try { + await removeMutation.mutateAsync(name); + toast.success(`Remote '${name}' removed`); + } catch (e: any) { + toast.error("Failed to remove remote: " + e.message); + } + }; + + const handleUpdateUrl = async (name: string) => { + if (!editUrl.trim()) return; + try { + await setUrlMutation.mutateAsync({ name, url: editUrl.trim() }); + toast.success(`Remote '${name}' URL updated`); + setEditingRemote(null); + setEditUrl(""); + } catch (e: any) { + toast.error("Failed to update URL: " + e.message); + } + }; + + return ( + + + + + + Remote Repositories + + + +
+ {isLoading && ( +
+ +
+ )} + + {!isLoading && (!remotes || remotes.length === 0) && !addMode && ( +
+ +

No remotes configured.

+

+ Add a remote to push and pull changes. +

+
+ )} + + {remotes?.map((r: GitRemoteInfo) => ( +
+
+ + {r.name} + +
+ + + +
+
+ + {editingRemote === r.name ? ( +
+ setEditUrl(e.target.value)} + placeholder="https://github.com/user/repo.git" + className="h-7 text-xs font-mono" + onKeyDown={(e) => { + if (e.key === "Enter") handleUpdateUrl(r.name); + if (e.key === "Escape") setEditingRemote(null); + }} + autoFocus + /> + + +
+ ) : ( +

+ {r.pushUrl || r.fetchUrl} +

+ )} +
+ ))} + + {/* Add new remote form */} + {addMode && ( +
+ setNewName(e.target.value)} + placeholder="Remote name (e.g. origin)" + className="h-7 text-xs font-mono" + autoFocus + /> + setNewUrl(e.target.value)} + placeholder="https://github.com/user/repo.git" + className="h-7 text-xs font-mono" + onKeyDown={(e) => { + if (e.key === "Enter") handleAdd(); + if (e.key === "Escape") setAddMode(false); + }} + /> +
+ + +
+
+ )} +
+ + + {!addMode && ( + + )} + + +
+
+ ); +} diff --git a/src/components/common/VerticalIconBar.tsx b/src/components/common/VerticalIconBar.tsx index 2ecc601..3441ea8 100644 --- a/src/components/common/VerticalIconBar.tsx +++ b/src/components/common/VerticalIconBar.tsx @@ -1,4 +1,4 @@ -import { Home, Database, Search, GitBranch, GitCompareArrows, History, Settings, Layers, Terminal, FolderOpen } from 'lucide-react'; +import { Home, Database, Search, GitBranch, GitCommitHorizontal, Settings, Layers, Terminal, FolderOpen } from 'lucide-react'; import { Link, useLocation } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { @@ -7,7 +7,7 @@ import { TooltipTrigger, } from '@/components/ui/tooltip'; -export type PanelType = 'data' | 'sql-workspace' | 'query-builder' | 'schema-explorer' | 'er-diagram' | 'schema-diff' | 'migration-timeline'; +export type PanelType = 'data' | 'sql-workspace' | 'query-builder' | 'schema-explorer' | 'er-diagram' | 'git-status'; interface VerticalIconBarProps { dbId?: string; @@ -38,8 +38,7 @@ export default function VerticalIconBar({ dbId, activePanel, onPanelChange }: Ve { icon: Search, label: 'Query Builder', panel: 'query-builder' }, { icon: GitBranch, label: 'Schema Explorer', panel: 'schema-explorer' }, { icon: Database, label: 'ER Diagram', panel: 'er-diagram' }, - { icon: GitCompareArrows, label: 'Schema Diff', panel: 'schema-diff' }, - { icon: History, label: 'Migration Timeline', panel: 'migration-timeline' }, + { icon: GitCommitHorizontal, label: 'Git Status', panel: 'git-status' }, ] : []; return ( diff --git a/src/components/git/GitStatusPanel.tsx b/src/components/git/GitStatusPanel.tsx new file mode 100644 index 0000000..019858b --- /dev/null +++ b/src/components/git/GitStatusPanel.tsx @@ -0,0 +1,513 @@ +import { useState } from "react"; +import { + GitBranch, + GitCommitHorizontal, + FileEdit, + FilePlus2, + FileX2, + FileQuestion, + FileDiff, + Clock, + ArrowUp, + ArrowDown, + ChevronRight, + Eye, + RotateCcw, + User, +} from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + useGitStatus, + useGitChanges, + useGitLog, + useGitBranches, +} from "@/hooks/useGitQueries"; +import { useGitRevert } from "@/hooks/useGitAdvanced"; +import { bridgeApi } from "@/services/bridgeApi"; +import { Spinner } from "@/components/ui/spinner"; +import { toast } from "sonner"; +import type { GitFileChange, GitLogEntry } from "@/types/git"; + +// ─── Helpers ────────────────────────────────────────── + +function statusIcon(status: string, staged: boolean) { + const color = staged ? "text-green-500" : "text-yellow-500"; + switch (status) { + case "M": + return ; + case "A": + return ; + case "D": + return ; + case "R": + return ; + case "?": + return ; + default: + return ; + } +} + +function statusLabel(status: string) { + switch (status) { + case "M": + return "Modified"; + case "A": + return "Added"; + case "D": + return "Deleted"; + case "R": + return "Renamed"; + case "?": + return "Untracked"; + case "C": + return "Copied"; + case "U": + return "Unmerged"; + default: + return status; + } +} + +function timeAgo(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + if (days < 30) return `${days}d ago`; + return new Date(dateStr).toLocaleDateString(); +} + +// ─── Component ──────────────────────────────────────── + +interface GitStatusPanelProps { + projectDir: string | null | undefined; +} + +export default function GitStatusPanel({ projectDir }: GitStatusPanelProps) { + const { data: status, isLoading: statusLoading } = useGitStatus(projectDir); + const { data: changes } = useGitChanges( + status?.isGitRepo ? projectDir : undefined + ); + const { data: log } = useGitLog( + status?.isGitRepo ? projectDir : undefined, + 50 + ); + const { data: branches } = useGitBranches( + status?.isGitRepo ? projectDir : undefined + ); + const revertMutation = useGitRevert(projectDir); + + const [diffDialogOpen, setDiffDialogOpen] = useState(false); + const [diffContent, setDiffContent] = useState(""); + const [diffFile, setDiffFile] = useState(""); + const [diffLoading, setDiffLoading] = useState(false); + + if (!projectDir) { + return ( +
+ No project directory available. +
+ ); + } + + if (statusLoading) { + return ( +
+ + Loading git status… +
+ ); + } + + if (!status?.isGitRepo) { + return ( +
+ + Not a git repository. Initialize git from the status bar below. +
+ ); + } + + // Split changes into staged / unstaged + const staged = (changes ?? []).filter((c) => c.staged); + const unstaged = (changes ?? []).filter((c) => !c.staged); + + const viewDiff = async (file: string, isStaged: boolean) => { + setDiffFile(file); + setDiffLoading(true); + setDiffDialogOpen(true); + try { + const diff = await bridgeApi.gitDiff(projectDir!, file, isStaged); + setDiffContent(diff || "(no diff available)"); + } catch { + setDiffContent("Failed to load diff."); + } finally { + setDiffLoading(false); + } + }; + + const handleRevert = (hash: string, subject: string) => { + revertMutation.mutate( + { hash }, + { + onSuccess: () => toast.success(`Reverted: ${subject}`), + onError: (err: any) => + toast.error(`Revert failed: ${err?.message ?? "Unknown error"}`), + } + ); + }; + + return ( +
+ {/* Header */} +
+
+
+ +

Git Status

+ + {status.branch ?? "HEAD"} + + {status.headCommit && ( + + {status.headCommit} + + )} +
+
+ {status.ahead != null && status.ahead > 0 && ( + + + {status.ahead} + + )} + {status.behind != null && status.behind > 0 && ( + + + {status.behind} + + )} + {status.upstream && ( + {status.upstream} + )} +
+
+
+ + {/* Tabs */} + + + + + Changes + {(changes?.length ?? 0) > 0 && ( + + {changes!.length} + + )} + + + + History + + + + Branches + {branches && ( + + {branches.length} + + )} + + + + {/* ── Changes Tab ─────────────────────────── */} + + + {(!changes || changes.length === 0) ? ( +
+ + Working tree clean +
+ ) : ( +
+ {/* Staged */} + {staged.length > 0 && ( +
+

+ Staged Changes + + {staged.length} + +

+
+ {staged.map((f) => ( + viewDiff(f.path, true)} + /> + ))} +
+
+ )} + + {/* Unstaged */} + {unstaged.length > 0 && ( +
+

+ Unstaged Changes + + {unstaged.length} + +

+
+ {unstaged.map((f) => ( + viewDiff(f.path, false)} + /> + ))} +
+
+ )} +
+ )} +
+
+ + {/* ── History Tab ─────────────────────────── */} + + + {(!log || log.length === 0) ? ( +
+ + No commits yet +
+ ) : ( +
+ {log.map((entry, idx) => ( + handleRevert(entry.hash, entry.subject)} + reverting={revertMutation.isPending} + /> + ))} +
+ )} +
+
+ + {/* ── Branches Tab ────────────────────────── */} + + + {(!branches || branches.length === 0) ? ( +
+ + No branches +
+ ) : ( +
+ {branches.map((b) => ( +
+ + {b.name} + {b.current && ( + + current + + )} + {b.upstream && ( + + → {b.upstream} + + )} +
+ ))} +
+ )} +
+
+
+ + {/* Diff Dialog */} + + + + + + {diffFile} + + + + {diffLoading ? ( +
+ +
+ ) : ( +
+                                {diffContent.split("\n").map((line, i) => {
+                                    let color = "text-foreground/80";
+                                    if (line.startsWith("+") && !line.startsWith("+++"))
+                                        color = "text-green-500";
+                                    else if (line.startsWith("-") && !line.startsWith("---"))
+                                        color = "text-red-500";
+                                    else if (line.startsWith("@@"))
+                                        color = "text-blue-400";
+                                    return (
+                                        
+ {line} +
+ ); + })} +
+ )} +
+
+
+
+ ); +} + +// ─── File Row Sub-Component ─────────────────────────── + +function FileRow({ + file, + onViewDiff, +}: { + file: GitFileChange; + onViewDiff: () => void; +}) { + // Extract filename from path + const parts = file.path.split("/"); + const fileName = parts.pop() ?? file.path; + const dir = parts.length > 0 ? parts.join("/") + "/" : ""; + + return ( +
+ {statusIcon(file.status, file.staged)} +
+ {fileName} + {dir && ( + + {dir} + + )} +
+ + {statusLabel(file.status)} + + + + + + View Diff + +
+ ); +} + +// ─── Commit Row Sub-Component ───────────────────────── + +function CommitRow({ + entry, + isLatest, + onRevert, + reverting, +}: { + entry: GitLogEntry; + isLatest: boolean; + onRevert: () => void; + reverting: boolean; +}) { + return ( +
+ {/* Timeline dot */} +
+
+
+ +
+
+ + {entry.subject} + + + + + + + Rollback to this commit + + +
+
+ {entry.hash} + + + {entry.author} + + + + {timeAgo(entry.date)} + +
+
+
+ ); +} diff --git a/src/components/migration-timeline/MigrationTimelinePanel.tsx b/src/components/migration-timeline/MigrationTimelinePanel.tsx deleted file mode 100644 index 66f966c..0000000 --- a/src/components/migration-timeline/MigrationTimelinePanel.tsx +++ /dev/null @@ -1,422 +0,0 @@ -import { useState } from "react"; -import { - History, - GitCommitHorizontal, - Tag, - Plus, - Minus, - Pencil, - ChevronDown, - ChevronRight, - RefreshCw, - BookmarkPlus, - AlertTriangle, - CheckCircle2, - Loader2, -} from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Spinner } from "@/components/ui/spinner"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { cn } from "@/lib/utils"; -import { - useTimeline, - useCommitSummary, - useAutoCommit, - useConflictDetection, -} from "@/hooks/useGitWorkflow"; -import { useSchemaDiff } from "@/hooks/useSchemaDiff"; -import type { TimelineEntry, ConflictReport } from "@/types/gitWorkflow"; -import { toast } from "sonner"; - -// ─── Helpers ───────────────────────────────────────────────── - -function relativeTime(iso: string): string { - const now = Date.now(); - const then = new Date(iso).getTime(); - const diff = now - then; - const mins = Math.floor(diff / 60000); - if (mins < 1) return "just now"; - if (mins < 60) return `${mins}m ago`; - const hours = Math.floor(mins / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - if (days < 30) return `${days}d ago`; - return new Date(iso).toLocaleDateString(); -} - -function severityColor(s: string) { - if (s === "high") return "bg-red-500/10 text-red-500 border-red-500/20"; - if (s === "medium") return "bg-amber-500/10 text-amber-500 border-amber-500/20"; - return "bg-blue-500/10 text-blue-500 border-blue-500/20"; -} - -// ─── Sub-components ────────────────────────────────────────── - -function CommitNode({ - entry, - projectId, - isFirst, -}: { - entry: TimelineEntry; - projectId: string; - isFirst: boolean; -}) { - const [expanded, setExpanded] = useState(false); - const { data: summaryData } = useCommitSummary( - expanded ? projectId : undefined, - expanded ? entry.fullHash : undefined, - ); - const summary = summaryData?.summary; - - return ( -
- {/* Timeline line */} -
- - {/* Dot */} -
- - {/* Content */} -
setExpanded(!expanded)} - > -
-
-

- {entry.subject} -

-
- {entry.hash} - · - {entry.author} - · - {relativeTime(entry.date)} -
-
- {expanded ? ( - - ) : ( - - )} -
- - {/* Tags */} - {entry.tags.length > 0 && ( -
- {entry.tags.map((t) => ( - - - {t.replace("relwave/schema/", "")} - - ))} -
- )} -
- - {/* Expanded: change summary */} - {expanded && ( -
- {!summary ? ( -
- - Loading changes… -
- ) : ( -
- {summary.tablesAdded > 0 && ( - - - {summary.tablesAdded} table{summary.tablesAdded > 1 ? "s" : ""} - - )} - {summary.tablesRemoved > 0 && ( - - - {summary.tablesRemoved} table{summary.tablesRemoved > 1 ? "s" : ""} - - )} - {summary.tablesModified > 0 && ( - - - {summary.tablesModified} table{summary.tablesModified > 1 ? "s" : ""} - - )} - {summary.columnsAdded > 0 && ( - - +{summary.columnsAdded} col{summary.columnsAdded > 1 ? "s" : ""} - - )} - {summary.columnsRemoved > 0 && ( - - -{summary.columnsRemoved} col{summary.columnsRemoved > 1 ? "s" : ""} - - )} - {summary.columnsModified > 0 && ( - - ~{summary.columnsModified} col{summary.columnsModified > 1 ? "s" : ""} - - )} - {summary.tablesAdded === 0 && - summary.tablesRemoved === 0 && - summary.tablesModified === 0 && ( - No structural changes - )} -
- )} -
- )} -
- ); -} - -function ConflictBanner({ - report, - isLoading, -}: { - report: ConflictReport | undefined; - isLoading: boolean; -}) { - if (isLoading) return null; - if (!report || report.conflictCount === 0) { - return ( -
- - No schema conflicts with main -
- ); - } - - return ( -
-
- - {report.summary} -
-
- {report.schemaConflicts.map((c, i) => ( - - {c.schema}.{c.table} - - ))} -
-
- ); -} - -// ─── Main Panel ────────────────────────────────────────────── - -interface MigrationTimelinePanelProps { - projectId?: string | null; -} - -export default function MigrationTimelinePanel({ - projectId, -}: MigrationTimelinePanelProps) { - const [autoCommitOpen, setAutoCommitOpen] = useState(false); - const [commitMsg, setCommitMsg] = useState(""); - const [commitTag, setCommitTag] = useState(""); - - const { - data: timelineData, - isLoading, - isFetching, - refetch, - } = useTimeline(projectId ?? undefined); - - const { data: conflictReport, isLoading: conflictsLoading } = - useConflictDetection(projectId ?? undefined, "main"); - - const { data: diffResp } = useSchemaDiff(projectId ?? undefined); - const hasPendingChanges = diffResp?.diff?.summary?.hasChanges ?? false; - - const autoCommitMut = useAutoCommit(); - - const entries = timelineData?.entries ?? []; - - // ── Empty / loading states ────────────────────────── - if (isLoading) { - return ( -
- -
- ); - } - - const handleAutoCommit = async () => { - if (!projectId) return; - try { - const result = await autoCommitMut.mutateAsync({ - projectId, - message: commitMsg || undefined, - tag: commitTag || undefined, - }); - toast.success(`Schema committed: ${result.hash}`); - setAutoCommitOpen(false); - setCommitMsg(""); - setCommitTag(""); - } catch (err: any) { - toast.error(err.message || "Auto-commit failed"); - } - }; - - return ( -
- {/* Header */} -
-
-
- -

Migration Timeline

- {isFetching && } -
-
- {hasPendingChanges && ( - - - - - -

Commit current schema as a snapshot

-
-
- )} - -
-
-
- - {/* Conflict banner */} - - - {/* Pending changes indicator */} - {hasPendingChanges && ( -
- - Schema has uncommitted changes -
- )} - - {/* Timeline */} - -
- {entries.length === 0 ? ( -
- -

No schema commits yet

-

- Save a schema to create the first timeline entry -

-
- ) : ( - entries.map((entry, i) => ( - - )) - )} -
-
- - {/* Auto-commit dialog */} - - - - Snapshot Schema - - Commit the current schema.json as a versioned snapshot. - - -
-
- - setCommitMsg(e.target.value)} - className="h-8 text-xs" - /> -
-
- - setCommitTag(e.target.value)} - className="h-8 text-xs" - /> -
-
- - - -
-
-
- ); -} diff --git a/src/components/schema-diff/SchemaDiffPanel.tsx b/src/components/schema-diff/SchemaDiffPanel.tsx deleted file mode 100644 index f195aab..0000000 --- a/src/components/schema-diff/SchemaDiffPanel.tsx +++ /dev/null @@ -1,446 +0,0 @@ -import { useState } from "react"; -import { - GitCompareArrows, - ChevronRight, - ChevronDown, - Plus, - Minus, - Pencil, - Table2, - Columns3, - Database, - RefreshCw, - History, - AlertCircle, - FolderGit2, -} from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Spinner } from "@/components/ui/spinner"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { cn } from "@/lib/utils"; -import { useSchemaDiff, useSchemaFileHistory } from "@/hooks/useSchemaDiff"; -import type { - DiffStatus, - SchemaDiff, - TableDiff, - ColumnDiff, - ColumnChange, - SchemaDiffResult, -} from "@/types/schemaDiff"; - -// ─── Helpers ───────────────────────────────────────────────── - -const statusColor: Record = { - added: "text-emerald-500", - removed: "text-red-500", - modified: "text-amber-500", - unchanged: "text-muted-foreground/60", -}; - -const statusBg: Record = { - added: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20", - removed: "bg-red-500/10 text-red-500 border-red-500/20", - modified: "bg-amber-500/10 text-amber-500 border-amber-500/20", - unchanged: "bg-muted/30 text-muted-foreground border-border/30", -}; - -const StatusIcon = ({ status }: { status: DiffStatus }) => { - switch (status) { - case "added": - return ; - case "removed": - return ; - case "modified": - return ; - default: - return null; - } -}; - -// ─── Props ─────────────────────────────────────────────────── - -interface SchemaDiffPanelProps { - projectId?: string | null; -} - -// ─── Component ─────────────────────────────────────────────── - -export default function SchemaDiffPanel({ projectId }: SchemaDiffPanelProps) { - const [fromRef, setFromRef] = useState("HEAD"); - const [hideUnchanged, setHideUnchanged] = useState(true); - - const { - data: diffResponse, - isLoading, - isFetching, - refetch, - } = useSchemaDiff(projectId ?? undefined, fromRef); - - const { data: historyResponse } = useSchemaFileHistory( - projectId ?? undefined, - ); - - // ── Not a git repo ────────────────────────────────────────── - if (!isLoading && diffResponse && !diffResponse.isGitRepo) { - return ( -
- -

- This project is not inside a Git repository. Initialize Git from the - status bar to start tracking schema changes. -

-
- ); - } - - // ── Loading ───────────────────────────────────────────────── - if (isLoading) { - return ( -
- -
- ); - } - - // ── Error / null diff (no commits yet) ────────────────────── - if (!diffResponse?.diff) { - return ( -
- -

- {diffResponse?.message ?? - "No schema snapshots found yet. Save a schema to start tracking changes."} -

-
- ); - } - - const diff = diffResponse.diff; - const history = historyResponse?.entries ?? []; - - return ( -
- {/* Header */} -
-
-
- -

Schema Diff

- {isFetching && } -
-
- - - - - - Refresh diff - -
-
- - {/* Ref selector row */} -
- Compare - - → working tree -
-
- - {/* Summary bar */} - - - {/* Diff tree */} - -
- {diff.schemas - .filter((s) => !hideUnchanged || s.status !== "unchanged") - .map((schema) => ( - - ))} - {diff.schemas.filter( - (s) => !hideUnchanged || s.status !== "unchanged", - ).length === 0 && ( -
- No schema changes detected. -
- )} -
-
-
- ); -} - -// ─── Summary Bar ───────────────────────────────────────────── - -function SummaryBar({ diff }: { diff: SchemaDiffResult }) { - const s = diff.summary; - if (!s.hasChanges) { - return ( -
- Schema is up to date — no changes from the committed version. -
- ); - } - return ( -
- {s.tablesAdded > 0 && ( - - +{s.tablesAdded} table{s.tablesAdded > 1 ? "s" : ""} - - )} - {s.tablesRemoved > 0 && ( - - -{s.tablesRemoved} table{s.tablesRemoved > 1 ? "s" : ""} - - )} - {s.tablesModified > 0 && ( - - ~{s.tablesModified} table{s.tablesModified > 1 ? "s" : ""} modified - - )} - {s.columnsAdded > 0 && ( - - +{s.columnsAdded} column{s.columnsAdded > 1 ? "s" : ""} - - )} - {s.columnsRemoved > 0 && ( - - -{s.columnsRemoved} column{s.columnsRemoved > 1 ? "s" : ""} - - )} - {s.columnsModified > 0 && ( - - ~{s.columnsModified} column{s.columnsModified > 1 ? "s" : ""} modified - - )} -
- ); -} - -// ─── Schema Node ───────────────────────────────────────────── - -function SchemaNode({ - schema, - hideUnchanged, -}: { - schema: SchemaDiff; - hideUnchanged: boolean; -}) { - const [open, setOpen] = useState(schema.status !== "unchanged"); - const visibleTables = schema.tables.filter( - (t) => !hideUnchanged || t.status !== "unchanged", - ); - - return ( -
- - {open && ( -
- {visibleTables.map((table) => ( - - ))} -
- )} -
- ); -} - -// ─── Table Node ────────────────────────────────────────────── - -function TableNode({ - table, - hideUnchanged, -}: { - table: TableDiff; - hideUnchanged: boolean; -}) { - const [open, setOpen] = useState(table.status !== "unchanged"); - const visibleCols = table.columns.filter( - (c) => !hideUnchanged || c.status !== "unchanged", - ); - - return ( -
- - {open && visibleCols.length > 0 && ( -
- {visibleCols.map((col) => ( - - ))} -
- )} -
- ); -} - -// ─── Column Node ───────────────────────────────────────────── - -function ColumnNode({ column }: { column: ColumnDiff }) { - const [open, setOpen] = useState(false); - const hasDetails = column.changes && column.changes.length > 0; - - return ( -
- - {open && hasDetails && ( -
- {column.changes!.map((change) => ( - - ))} -
- )} -
- ); -} - -// ─── Change Row ────────────────────────────────────────────── - -function ChangeRow({ change }: { change: ColumnChange }) { - return ( -
- - {change.field} - - - {change.before} - - - {change.after} -
- ); -} diff --git a/src/hooks/useGitAdvanced.ts b/src/hooks/useGitAdvanced.ts new file mode 100644 index 0000000..c1b56ee --- /dev/null +++ b/src/hooks/useGitAdvanced.ts @@ -0,0 +1,147 @@ +// ========================================== +// hooks/useGitAdvanced.ts — Remote, Push/Pull/Fetch, Revert hooks +// ========================================== + +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { bridgeApi } from "@/services/bridgeApi"; +import { isBridgeReady } from "@/services/bridgeClient"; +import { gitKeys } from "@/hooks/useGitQueries"; +import type { GitRemoteInfo, GitPushPullResult } from "@/types/git"; + +// ─── Query keys ────────────────────────────────────────────── + +export const gitAdvancedKeys = { + remotes: (dir: string) => ["git", "remotes", dir] as const, +}; + +const STALE = { + remotes: 120_000, // 2 min — rarely changes +}; + +// ─── Helpers ───────────────────────────────────────────────── + +function useBridgeEnabled() { + const qc = useQueryClient(); + return qc.getQueryData(["bridge-ready"]) ?? isBridgeReady(); +} + +function useInvalidateAll(dir: string | null | undefined) { + const queryClient = useQueryClient(); + return () => { + if (!dir) return; + queryClient.invalidateQueries({ queryKey: gitKeys.all }); + queryClient.invalidateQueries({ queryKey: gitAdvancedKeys.remotes(dir) }); + }; +} + +// ========================================== +// REMOTE QUERIES & MUTATIONS +// ========================================== + +export function useGitRemotes(dir: string | null | undefined) { + const ready = useBridgeEnabled(); + return useQuery({ + queryKey: gitAdvancedKeys.remotes(dir ?? ""), + queryFn: () => bridgeApi.gitRemoteList(dir!), + enabled: !!dir && ready, + staleTime: STALE.remotes, + }); +} + +export function useGitRemoteAdd(dir: string | null | undefined) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ name, url }: { name: string; url: string }) => + bridgeApi.gitRemoteAdd(dir!, name, url), + onSuccess: () => { + if (dir) qc.invalidateQueries({ queryKey: gitAdvancedKeys.remotes(dir) }); + }, + }); +} + +export function useGitRemoteRemove(dir: string | null | undefined) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (name: string) => bridgeApi.gitRemoteRemove(dir!, name), + onSuccess: () => { + if (dir) qc.invalidateQueries({ queryKey: gitAdvancedKeys.remotes(dir) }); + }, + }); +} + +export function useGitRemoteSetUrl(dir: string | null | undefined) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ name, url }: { name: string; url: string }) => + bridgeApi.gitRemoteSetUrl(dir!, name, url), + onSuccess: () => { + if (dir) qc.invalidateQueries({ queryKey: gitAdvancedKeys.remotes(dir) }); + }, + }); +} + +// ========================================== +// PUSH / PULL / FETCH +// ========================================== + +export function useGitPush(dir: string | null | undefined) { + const invalidate = useInvalidateAll(dir); + return useMutation({ + mutationFn: (opts) => + bridgeApi.gitPush( + dir!, + opts?.remote, + opts?.branch, + { force: opts?.force, setUpstream: opts?.setUpstream } + ), + onSuccess: invalidate, + }); +} + +export function useGitPull(dir: string | null | undefined) { + const invalidate = useInvalidateAll(dir); + return useMutation({ + mutationFn: (opts) => + bridgeApi.gitPull( + dir!, + opts?.remote, + opts?.branch, + { rebase: opts?.rebase } + ), + onSuccess: invalidate, + }); +} + +export function useGitFetch(dir: string | null | undefined) { + const invalidate = useInvalidateAll(dir); + return useMutation({ + mutationFn: (opts) => + bridgeApi.gitFetch(dir!, opts?.remote, { prune: opts?.prune, all: opts?.all }), + onSuccess: invalidate, + }); +} + +// ========================================== +// REVERT (Rollback to Previous Commit) +// ========================================== + +export function useGitRevert(dir: string | null | undefined) { + const invalidate = useInvalidateAll(dir); + return useMutation({ + mutationFn: (opts) => bridgeApi.gitRevert(dir!, opts.hash, opts.noCommit), + onSuccess: invalidate, + }); +} diff --git a/src/hooks/useGitWorkflow.ts b/src/hooks/useGitWorkflow.ts deleted file mode 100644 index 76852ef..0000000 --- a/src/hooks/useGitWorkflow.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { bridgeApi } from "@/services/bridgeApi"; -import type { - TimelineEntry, - TimelineChangeSummary, - AutoCommitResult, - EnvironmentConfig, - EnvironmentMapping, - ResolvedEnvironment, - ConflictReport, -} from "@/types/gitWorkflow"; -import { gitKeys } from "./useGitQueries"; -import { schemaDiffKeys } from "./useSchemaDiff"; - -// ─── Query Keys ────────────────────────────────────────────── - -export const timelineKeys = { - all: ["timeline"] as const, - list: (projectId: string) => [...timelineKeys.all, "list", projectId] as const, - commitSummary: (projectId: string, hash: string) => - [...timelineKeys.all, "summary", projectId, hash] as const, -}; - -export const envKeys = { - all: ["env"] as const, - config: (projectId: string) => [...envKeys.all, "config", projectId] as const, - resolved: (projectId: string) => [...envKeys.all, "resolved", projectId] as const, -}; - -export const conflictKeys = { - all: ["conflict"] as const, - detect: (projectId: string, targetBranch: string) => - [...conflictKeys.all, "detect", projectId, targetBranch] as const, -}; - -// ─── Timeline Hooks ────────────────────────────────────────── - -/** - * Fetch the migration timeline — commits that changed schema.json - */ -export function useTimeline(projectId: string | undefined, count = 50) { - return useQuery<{ entries: TimelineEntry[] }>({ - queryKey: timelineKeys.list(projectId ?? ""), - queryFn: () => bridgeApi.timelineList(projectId!, count), - enabled: !!projectId, - staleTime: 30_000, - }); -} - -/** - * Fetch the change summary for a specific commit in the timeline - */ -export function useCommitSummary( - projectId: string | undefined, - commitHash: string | undefined, -) { - return useQuery<{ summary: TimelineChangeSummary | null }>({ - queryKey: timelineKeys.commitSummary(projectId ?? "", commitHash ?? ""), - queryFn: () => bridgeApi.timelineCommitSummary(projectId!, commitHash!), - enabled: !!projectId && !!commitHash, - staleTime: Infinity, // commit summaries never change - }); -} - -/** - * Auto-commit the current schema snapshot (mutation) - */ -export function useAutoCommit() { - const qc = useQueryClient(); - return useMutation< - AutoCommitResult, - Error, - { projectId: string; message?: string; tag?: string } - >({ - mutationFn: ({ projectId, message, tag }) => - bridgeApi.timelineAutoCommit(projectId, { message, tag }), - onSuccess: (_data, vars) => { - qc.invalidateQueries({ queryKey: timelineKeys.list(vars.projectId) }); - qc.invalidateQueries({ queryKey: schemaDiffKeys.all }); - qc.invalidateQueries({ queryKey: gitKeys.all }); - }, - }); -} - -// ─── Environment Hooks ─────────────────────────────────────── - -/** - * Fetch the environment config (branch → environment mappings) - */ -export function useEnvConfig(projectId: string | undefined) { - return useQuery({ - queryKey: envKeys.config(projectId ?? ""), - queryFn: () => bridgeApi.envGetConfig(projectId!), - enabled: !!projectId, - staleTime: 60_000, - }); -} - -/** - * Resolve the current environment based on git branch - */ -export function useResolvedEnv(projectId: string | undefined) { - return useQuery({ - queryKey: envKeys.resolved(projectId ?? ""), - queryFn: () => bridgeApi.envResolve(projectId!), - enabled: !!projectId, - staleTime: 15_000, - refetchInterval: 30_000, // auto-re-resolve as branch may change - }); -} - -/** - * Save the full environment config (mutation) - */ -export function useSaveEnvConfig() { - const qc = useQueryClient(); - return useMutation< - EnvironmentConfig, - Error, - { projectId: string; config: EnvironmentConfig } - >({ - mutationFn: ({ projectId, config }) => - bridgeApi.envSaveConfig(projectId, config), - onSuccess: (_data, vars) => { - qc.invalidateQueries({ queryKey: envKeys.config(vars.projectId) }); - qc.invalidateQueries({ queryKey: envKeys.resolved(vars.projectId) }); - }, - }); -} - -/** - * Add or update a single environment mapping (mutation) - */ -export function useSetEnvMapping() { - const qc = useQueryClient(); - return useMutation< - EnvironmentConfig, - Error, - { projectId: string; mapping: EnvironmentMapping } - >({ - mutationFn: ({ projectId, mapping }) => - bridgeApi.envSetMapping(projectId, mapping), - onSuccess: (_data, vars) => { - qc.invalidateQueries({ queryKey: envKeys.config(vars.projectId) }); - qc.invalidateQueries({ queryKey: envKeys.resolved(vars.projectId) }); - }, - }); -} - -/** - * Remove a branch mapping (mutation) - */ -export function useRemoveEnvMapping() { - const qc = useQueryClient(); - return useMutation< - EnvironmentConfig, - Error, - { projectId: string; branch: string } - >({ - mutationFn: ({ projectId, branch }) => - bridgeApi.envRemoveMapping(projectId, branch), - onSuccess: (_data, vars) => { - qc.invalidateQueries({ queryKey: envKeys.config(vars.projectId) }); - qc.invalidateQueries({ queryKey: envKeys.resolved(vars.projectId) }); - }, - }); -} - -// ─── Conflict Detection Hooks ──────────────────────────────── - -/** - * Detect schema conflicts between current branch and target - */ -export function useConflictDetection( - projectId: string | undefined, - targetBranch = "main", - enabled = true, -) { - return useQuery({ - queryKey: conflictKeys.detect(projectId ?? "", targetBranch), - queryFn: () => bridgeApi.conflictDetect(projectId!, targetBranch), - enabled: !!projectId && enabled, - staleTime: 60_000, - }); -} diff --git a/src/hooks/useSchemaDiff.ts b/src/hooks/useSchemaDiff.ts deleted file mode 100644 index 7e37d60..0000000 --- a/src/hooks/useSchemaDiff.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { bridgeApi } from "@/services/bridgeApi"; -import type { SchemaDiffResponse, SchemaFileHistoryResponse } from "@/types/schemaDiff"; - -// ─── Query Keys ────────────────────────────────────────────── -export const schemaDiffKeys = { - all: ["schemaDiff"] as const, - diff: (projectId: string, fromRef?: string, toRef?: string) => - [...schemaDiffKeys.all, "diff", projectId, fromRef ?? "HEAD", toRef ?? "working"] as const, - history: (projectId: string) => - [...schemaDiffKeys.all, "history", projectId] as const, -}; - -// ─── Hooks ─────────────────────────────────────────────────── - -/** - * Fetch a structural schema diff between two git refs. - * Defaults to HEAD → working tree. - */ -export function useSchemaDiff( - projectId: string | undefined, - fromRef = "HEAD", - toRef?: string, - enabled = true, -) { - return useQuery({ - queryKey: schemaDiffKeys.diff(projectId ?? "", fromRef, toRef), - queryFn: () => bridgeApi.schemaDiff(projectId!, fromRef, toRef), - enabled: !!projectId && enabled, - staleTime: 30_000, // re-fetch after 30 s - refetchInterval: 60_000, // auto-poll every 60 s - }); -} - -/** - * Fetch the commit history for the project's schema.json file. - */ -export function useSchemaFileHistory( - projectId: string | undefined, - count = 20, - enabled = true, -) { - return useQuery({ - queryKey: schemaDiffKeys.history(projectId ?? ""), - queryFn: () => bridgeApi.schemaFileHistory(projectId!, count), - enabled: !!projectId && enabled, - staleTime: 60_000, - }); -} diff --git a/src/pages/DatabaseDetails.tsx b/src/pages/DatabaseDetails.tsx index 850b5a0..a2f86cf 100644 --- a/src/pages/DatabaseDetails.tsx +++ b/src/pages/DatabaseDetails.tsx @@ -33,10 +33,8 @@ import SQLWorkspacePanel from "@/components/workspace/SQLWorkspacePanel"; import QueryBuilderPanel from "@/components/query-builder/QueryBuilderPanel"; import SchemaExplorerPanel from "@/components/schema-explorer/SchemaExplorerPanel"; import ERDiagramPanel from "@/components/er-diagram/ERDiagramPanel"; -import SchemaDiffPanel from "@/components/schema-diff/SchemaDiffPanel"; -import MigrationTimelinePanel from "@/components/migration-timeline/MigrationTimelinePanel"; +import GitStatusPanel from "@/components/git/GitStatusPanel"; import GitStatusBar from "@/components/common/GitStatusBar"; -import EnvironmentSwitcher from "@/components/common/EnvironmentSwitcher"; const DatabaseDetail = () => { const { id: dbId } = useParams<{ id: string }>(); @@ -153,10 +151,8 @@ const DatabaseDetail = () => { return ; case 'er-diagram': return ; - case 'schema-diff': - return ; - case 'migration-timeline': - return ; + case 'git-status': + return ; case 'data': default: return ( @@ -428,7 +424,6 @@ const DatabaseDetail = () => { {/* Bottom status bar with git info */}
-
{databaseName || "Database"} diff --git a/src/services/bridgeApi.ts b/src/services/bridgeApi.ts index 4726c1a..8c9ccba 100644 --- a/src/services/bridgeApi.ts +++ b/src/services/bridgeApi.ts @@ -1,16 +1,6 @@ import { AddDatabaseParams, ConnectionTestResult, CreateTableColumn, DatabaseConnection, DatabaseSchemaDetails, DatabaseStats, DiscoveredDatabase, RunQueryParams, TableRow, UpdateDatabaseParams } from "@/types/database"; import { ProjectSummary, ProjectMetadata, CreateProjectParams, UpdateProjectParams, SchemaFile, SchemaSnapshot, ERDiagramFile, ERNode, QueriesFile, SavedQuery, ProjectExport } from "@/types/project"; -import { GitStatus, GitFileChange, GitLogEntry, GitBranchInfo } from "@/types/git"; -import { SchemaDiffResponse, SchemaFileHistoryResponse } from "@/types/schemaDiff"; -import { - TimelineEntry, - TimelineChangeSummary, - AutoCommitResult, - EnvironmentConfig, - EnvironmentMapping, - ResolvedEnvironment, - ConflictReport, -} from "@/types/gitWorkflow"; +import { GitStatus, GitFileChange, GitLogEntry, GitBranchInfo, GitRemoteInfo, GitPushPullResult } from "@/types/git"; import { bridgeRequest } from "./bridgeClient"; @@ -1179,151 +1169,81 @@ class BridgeApiService { // 9. SCHEMA DIFF (schema.*) // ------------------------------------ - /** - * Compute structured schema diff between two git refs. - * Default: HEAD vs working tree. - */ - async schemaDiff( - projectId: string, - fromRef = "HEAD", - toRef?: string - ): Promise { - const result = await bridgeRequest("schema.diff", { projectId, fromRef, toRef }); - return result?.data; - } - - /** - * Get commit history for a project's schema.json file. - */ - async schemaFileHistory( - projectId: string, - count = 20 - ): Promise { - const result = await bridgeRequest("schema.fileHistory", { projectId, count }); - return result?.data; - } - // ------------------------------------ - // 10. MIGRATION TIMELINE (timeline.*) + // 13. GIT REMOTE OPERATIONS // ------------------------------------ - /** - * Get the migration timeline (commits that changed schema.json) - */ - async timelineList( - projectId: string, - count = 50 - ): Promise<{ entries: TimelineEntry[] }> { - const result = await bridgeRequest("timeline.list", { projectId, count }); - return result?.data; + /** List all configured remotes */ + async gitRemoteList(dir: string): Promise { + const result = await bridgeRequest("git.remoteList", { dir }); + return result?.data || []; } - /** - * Get change summary for a specific commit in the timeline - */ - async timelineCommitSummary( - projectId: string, - commitHash: string - ): Promise<{ summary: TimelineChangeSummary | null }> { - const result = await bridgeRequest("timeline.commitSummary", { - projectId, - commitHash, - }); - return result?.data; + /** Add a named remote */ + async gitRemoteAdd(dir: string, name: string, url: string): Promise { + await bridgeRequest("git.remoteAdd", { dir, name, url }); } - /** - * Auto-commit the current schema snapshot with optional tag - */ - async timelineAutoCommit( - projectId: string, - options?: { message?: string; tag?: string } - ): Promise { - const result = await bridgeRequest("timeline.autoCommit", { - projectId, - ...options, - }); - return result?.data; + /** Remove a named remote */ + async gitRemoteRemove(dir: string, name: string): Promise { + await bridgeRequest("git.remoteRemove", { dir, name }); } - // ------------------------------------ - // 11. ENVIRONMENT (env.*) - // ------------------------------------ - - /** - * Get environment config (branch → environment mappings) - */ - async envGetConfig(projectId: string): Promise { - const result = await bridgeRequest("env.getConfig", { projectId }); - return result?.data; - } - - /** - * Replace the full environment config - */ - async envSaveConfig( - projectId: string, - config: EnvironmentConfig - ): Promise { - const result = await bridgeRequest("env.saveConfig", { - projectId, - config, - }); - return result?.data; + /** Get the URL of a remote */ + async gitRemoteGetUrl(dir: string, name = "origin"): Promise { + const result = await bridgeRequest("git.remoteGetUrl", { dir, name }); + return result?.data?.url || null; } - /** - * Add or update a single branch → environment mapping - */ - async envSetMapping( - projectId: string, - mapping: EnvironmentMapping - ): Promise { - const result = await bridgeRequest("env.setMapping", { - projectId, - mapping, - }); - return result?.data; + /** Change the URL of an existing remote */ + async gitRemoteSetUrl(dir: string, name: string, url: string): Promise { + await bridgeRequest("git.remoteSetUrl", { dir, name, url }); } - /** - * Remove a branch mapping - */ - async envRemoveMapping( - projectId: string, - branch: string - ): Promise { - const result = await bridgeRequest("env.removeMapping", { - projectId, - branch, - }); - return result?.data; - } + // ------------------------------------ + // 14. GIT PUSH / PULL / FETCH (P3) + // ------------------------------------ - /** - * Resolve the current environment (based on active git branch) - */ - async envResolve(projectId: string): Promise { - const result = await bridgeRequest("env.resolve", { projectId }); - return result?.data; + /** Push commits to a remote */ + async gitPush( + dir: string, + remote = "origin", + branch?: string, + options?: { force?: boolean; setUpstream?: boolean } + ): Promise { + const result = await bridgeRequest("git.push", { dir, remote, branch, ...options }); + return result?.data || { output: "" }; + } + + /** Pull from a remote */ + async gitPull( + dir: string, + remote = "origin", + branch?: string, + options?: { rebase?: boolean } + ): Promise { + const result = await bridgeRequest("git.pull", { dir, remote, branch, ...options }); + return result?.data || { output: "" }; + } + + /** Fetch from a remote (or all) */ + async gitFetch( + dir: string, + remote?: string, + options?: { prune?: boolean; all?: boolean } + ): Promise { + const result = await bridgeRequest("git.fetch", { dir, remote, ...options }); + return result?.data || { output: "" }; } // ------------------------------------ - // 12. CONFLICT DETECTION (conflict.*) + // 15. GIT REVERT (Rollback) // ------------------------------------ - /** - * Detect schema conflicts between current branch and a target - */ - async conflictDetect( - projectId: string, - targetBranch = "main" - ): Promise { - const result = await bridgeRequest("conflict.detect", { - projectId, - targetBranch, - }); - return result?.data; + /** Revert a specific commit */ + async gitRevert(dir: string, hash: string, noCommit = false): Promise { + const result = await bridgeRequest("git.revert", { dir, hash, noCommit }); + return result?.data || { output: "" }; } } diff --git a/src/types/git.ts b/src/types/git.ts index 491c752..22823ea 100644 --- a/src/types/git.ts +++ b/src/types/git.ts @@ -68,3 +68,21 @@ export interface GitBranchInfo { /** Remote tracking branch */ upstream: string | null; } + +// ========================================== +// P3 — Remote, Push/Pull +// ========================================== + +export interface GitRemoteInfo { + /** Remote name (e.g. "origin") */ + name: string; + /** Fetch URL */ + fetchUrl: string; + /** Push URL */ + pushUrl: string; +} + +export interface GitPushPullResult { + /** Command output text */ + output: string; +} diff --git a/src/types/gitWorkflow.ts b/src/types/gitWorkflow.ts deleted file mode 100644 index 229eab8..0000000 --- a/src/types/gitWorkflow.ts +++ /dev/null @@ -1,84 +0,0 @@ -// ========================================== -// Git Workflow Types — Frontend (P2) -// ========================================== -// Migration timeline, environment mapping, conflict detection - -// ─── Timeline ──────────────────────────────────────────────── - -export interface TimelineEntry { - hash: string; - fullHash: string; - author: string; - date: string; - subject: string; - tags: string[]; - isAutoCommit: boolean; - summary?: TimelineChangeSummary; -} - -export interface TimelineChangeSummary { - tablesAdded: number; - tablesRemoved: number; - tablesModified: number; - columnsAdded: number; - columnsRemoved: number; - columnsModified: number; -} - -export interface AutoCommitResult { - hash: string; - tag: string | null; - message: string; -} - -// ─── Environment ───────────────────────────────────────────── - -export interface EnvironmentMapping { - branch: string; - environment: string; - connectionUrl?: string; - isProduction?: boolean; -} - -export interface EnvironmentConfig { - mappings: EnvironmentMapping[]; - defaultEnvironment?: string; -} - -export interface ResolvedEnvironment { - branch: string | null; - environment: string; - isProduction: boolean; - connectionUrl: string | null; - connectionSource: "local" | "mapping" | "database" | "none"; -} - -// ─── Conflict Detection ────────────────────────────────────── - -export type ConflictSeverity = "high" | "medium" | "low"; - -export interface SchemaConflict { - table: string; - schema: string; - type: "both-modified" | "modified-deleted" | "both-added"; - severity: ConflictSeverity; - description: string; - columns?: ConflictingColumn[]; -} - -export interface ConflictingColumn { - name: string; - oursChange: string; - theirsChange: string; -} - -export interface ConflictReport { - currentBranch: string | null; - targetBranch: string; - mergeBase: string | null; - fileConflicts: string[]; - schemaConflicts: SchemaConflict[]; - hasSchemaFileConflict: boolean; - conflictCount: number; - summary: string; -} diff --git a/src/types/schemaDiff.ts b/src/types/schemaDiff.ts deleted file mode 100644 index 713c7e1..0000000 --- a/src/types/schemaDiff.ts +++ /dev/null @@ -1,73 +0,0 @@ -// ========================================== -// Schema Diff Types — Frontend -// ========================================== - -import type { ColumnSnapshot } from "@/types/project"; - -export interface SchemaDiffResult { - summary: DiffSummary; - schemas: SchemaDiff[]; -} - -export interface DiffSummary { - schemasAdded: number; - schemasRemoved: number; - schemasModified: number; - tablesAdded: number; - tablesRemoved: number; - tablesModified: number; - columnsAdded: number; - columnsRemoved: number; - columnsModified: number; - hasChanges: boolean; -} - -export type DiffStatus = "added" | "removed" | "modified" | "unchanged"; - -export interface SchemaDiff { - name: string; - status: DiffStatus; - tables: TableDiff[]; -} - -export interface TableDiff { - name: string; - schema: string; - status: DiffStatus; - columns: ColumnDiff[]; -} - -export interface ColumnDiff { - name: string; - status: DiffStatus; - changes?: ColumnChange[]; - before?: ColumnSnapshot; - after?: ColumnSnapshot; -} - -export interface ColumnChange { - field: string; - before: string; - after: string; -} - -export interface SchemaDiffResponse { - isGitRepo: boolean; - diff: SchemaDiffResult | null; - fromRef?: string; - toRef?: string; - message?: string; -} - -export interface SchemaFileHistoryResponse { - isGitRepo: boolean; - entries: SchemaFileHistoryEntry[]; -} - -export interface SchemaFileHistoryEntry { - hash: string; - fullHash: string; - author: string; - date: string; - subject: string; -} From c9e5c3f27b5abdb0b8024a9c87de212c6fe3feb6 Mon Sep 17 00:00:00 2001 From: Yash Date: Mon, 16 Feb 2026 07:30:57 +0530 Subject: [PATCH 5/5] implmented test cases --- bridge/__tests__/gitAdvancedHandlers.test.ts | 329 ++++++++++ bridge/__tests__/gitHandlers.test.ts | 504 +++++++++++++++ bridge/__tests__/gitService.test.ts | 608 +++++++++++++++++++ bridge/__tests__/projectStore.test.ts | 566 +++++++++++++++++ 4 files changed, 2007 insertions(+) create mode 100644 bridge/__tests__/gitAdvancedHandlers.test.ts create mode 100644 bridge/__tests__/gitHandlers.test.ts create mode 100644 bridge/__tests__/gitService.test.ts create mode 100644 bridge/__tests__/projectStore.test.ts diff --git a/bridge/__tests__/gitAdvancedHandlers.test.ts b/bridge/__tests__/gitAdvancedHandlers.test.ts new file mode 100644 index 0000000..281daf8 --- /dev/null +++ b/bridge/__tests__/gitAdvancedHandlers.test.ts @@ -0,0 +1,329 @@ +import { describe, expect, test, jest, beforeEach } from "@jest/globals"; +import { GitAdvancedHandlers } from "../src/handlers/gitAdvancedHandlers"; +import type { GitService } from "../src/services/gitService"; +import type { Rpc } from "../src/types"; + +// ─── Mock Factory ────────────────────────────────── + +function createMockRpc(): Rpc & { + _responses: any[]; + _errors: any[]; +} { + const responses: any[] = []; + const errors: any[] = []; + return { + sendResponse: jest.fn((id: number | string, payload: any) => { + responses.push({ id, payload }); + }), + sendError: jest.fn((id: number | string, err: any) => { + errors.push({ id, err }); + }), + _responses: responses, + _errors: errors, + }; +} + +function createMockGitService(): GitService { + return { + remoteList: jest.fn().mockResolvedValue([]), + remoteAdd: jest.fn().mockResolvedValue(undefined), + remoteRemove: jest.fn().mockResolvedValue(undefined), + remoteGetUrl: jest.fn().mockResolvedValue("https://github.com/test/repo.git"), + remoteSetUrl: jest.fn().mockResolvedValue(undefined), + push: jest.fn().mockResolvedValue("Everything up-to-date"), + pull: jest.fn().mockResolvedValue("Already up to date."), + fetch: jest.fn().mockResolvedValue(""), + revert: jest.fn().mockResolvedValue(""), + } as any; +} + +// ─── Tests ────────────────────────────────────────── + +let rpc: ReturnType; +let gitService: GitService; +let handlers: GitAdvancedHandlers; + +beforeEach(() => { + rpc = createMockRpc(); + gitService = createMockGitService(); + handlers = new GitAdvancedHandlers(rpc, undefined, gitService); +}); + +// ========================================== +// requireDir Validation +// ========================================== + +describe("GitAdvancedHandlers — requireDir validation", () => { + const handlerMethods = [ + "handleRemoteList", + "handleRemoteAdd", + "handleRemoteRemove", + "handleRemoteGetUrl", + "handleRemoteSetUrl", + "handlePush", + "handlePull", + "handleFetch", + "handleRevert", + ] as const; + + test("all handlers send BAD_REQUEST when dir is missing", async () => { + for (const method of handlerMethods) { + rpc = createMockRpc(); + gitService = createMockGitService(); + handlers = new GitAdvancedHandlers(rpc, undefined, gitService); + + await (handlers as any)[method]({}, 1); + expect(rpc.sendError).toHaveBeenCalledWith(1, { + code: "BAD_REQUEST", + message: expect.stringContaining("dir"), + }); + } + }); + + test("accepts dir, path, or cwd as directory param", async () => { + for (const key of ["dir", "path", "cwd"]) { + rpc = createMockRpc(); + gitService = createMockGitService(); + handlers = new GitAdvancedHandlers(rpc, undefined, gitService); + + await handlers.handleRemoteList({ [key]: "/repo" }, 1); + expect(rpc.sendResponse).toHaveBeenCalled(); + } + }); +}); + +// ========================================== +// REMOTE MANAGEMENT +// ========================================== + +describe("GitAdvancedHandlers — Remote Management", () => { + test("handleRemoteList returns remotes", async () => { + const mockRemotes = [ + { name: "origin", fetchUrl: "https://a.git", pushUrl: "https://a.git" }, + ]; + (gitService.remoteList as jest.Mock).mockResolvedValue(mockRemotes); + + await handlers.handleRemoteList({ dir: "/repo" }, 1); + expect(gitService.remoteList).toHaveBeenCalledWith("/repo"); + expect(rpc.sendResponse).toHaveBeenCalledWith(1, { + ok: true, + data: mockRemotes, + }); + }); + + test("handleRemoteAdd adds a remote", async () => { + await handlers.handleRemoteAdd( + { dir: "/repo", name: "upstream", url: "https://up.git" }, + 1 + ); + expect(gitService.remoteAdd).toHaveBeenCalledWith("/repo", "upstream", "https://up.git"); + expect(rpc.sendResponse).toHaveBeenCalledWith(1, { ok: true, data: null }); + }); + + test("handleRemoteAdd returns BAD_REQUEST when name missing", async () => { + await handlers.handleRemoteAdd({ dir: "/repo", url: "https://up.git" }, 1); + expect(rpc.sendError).toHaveBeenCalledWith(1, { + code: "BAD_REQUEST", + message: expect.stringContaining("name"), + }); + }); + + test("handleRemoteAdd returns BAD_REQUEST when url missing", async () => { + await handlers.handleRemoteAdd({ dir: "/repo", name: "origin" }, 1); + expect(rpc.sendError).toHaveBeenCalledWith(1, { + code: "BAD_REQUEST", + message: expect.stringContaining("url"), + }); + }); + + test("handleRemoteRemove removes a remote", async () => { + await handlers.handleRemoteRemove({ dir: "/repo", name: "origin" }, 1); + expect(gitService.remoteRemove).toHaveBeenCalledWith("/repo", "origin"); + expect(rpc.sendResponse).toHaveBeenCalledWith(1, { ok: true, data: null }); + }); + + test("handleRemoteRemove returns BAD_REQUEST when name missing", async () => { + await handlers.handleRemoteRemove({ dir: "/repo" }, 1); + expect(rpc.sendError).toHaveBeenCalledWith(1, { + code: "BAD_REQUEST", + message: expect.stringContaining("name"), + }); + }); + + test("handleRemoteGetUrl returns url", async () => { + await handlers.handleRemoteGetUrl({ dir: "/repo", name: "origin" }, 1); + expect(gitService.remoteGetUrl).toHaveBeenCalledWith("/repo", "origin"); + expect(rpc.sendResponse).toHaveBeenCalledWith(1, { + ok: true, + data: { url: "https://github.com/test/repo.git" }, + }); + }); + + test("handleRemoteGetUrl defaults to origin", async () => { + await handlers.handleRemoteGetUrl({ dir: "/repo" }, 1); + expect(gitService.remoteGetUrl).toHaveBeenCalledWith("/repo", "origin"); + }); + + test("handleRemoteSetUrl sets url", async () => { + await handlers.handleRemoteSetUrl( + { dir: "/repo", name: "origin", url: "https://new.git" }, + 1 + ); + expect(gitService.remoteSetUrl).toHaveBeenCalledWith("/repo", "origin", "https://new.git"); + expect(rpc.sendResponse).toHaveBeenCalledWith(1, { ok: true, data: null }); + }); + + test("handleRemoteSetUrl returns BAD_REQUEST when params missing", async () => { + await handlers.handleRemoteSetUrl({ dir: "/repo" }, 1); + expect(rpc.sendError).toHaveBeenCalledWith(1, { + code: "BAD_REQUEST", + message: expect.stringContaining("name"), + }); + }); +}); + +// ========================================== +// PUSH / PULL / FETCH +// ========================================== + +describe("GitAdvancedHandlers — Push / Pull / Fetch", () => { + test("handlePush pushes to remote", async () => { + await handlers.handlePush({ dir: "/repo" }, 1); + expect(gitService.push).toHaveBeenCalledWith("/repo", "origin", undefined, { + force: false, + setUpstream: false, + }); + expect(rpc.sendResponse).toHaveBeenCalledWith(1, { + ok: true, + data: { output: "Everything up-to-date" }, + }); + }); + + test("handlePush passes custom remote and branch", async () => { + await handlers.handlePush( + { dir: "/repo", remote: "upstream", branch: "main", force: true, setUpstream: true }, + 1 + ); + expect(gitService.push).toHaveBeenCalledWith("/repo", "upstream", "main", { + force: true, + setUpstream: true, + }); + }); + + test("handlePull pulls from remote", async () => { + await handlers.handlePull({ dir: "/repo" }, 1); + expect(gitService.pull).toHaveBeenCalledWith("/repo", "origin", undefined, { + rebase: false, + }); + expect(rpc.sendResponse).toHaveBeenCalledWith(1, { + ok: true, + data: { output: "Already up to date." }, + }); + }); + + test("handlePull passes rebase option", async () => { + await handlers.handlePull({ dir: "/repo", rebase: true }, 1); + expect(gitService.pull).toHaveBeenCalledWith("/repo", "origin", undefined, { + rebase: true, + }); + }); + + test("handleFetch fetches from remote", async () => { + await handlers.handleFetch({ dir: "/repo" }, 1); + expect(gitService.fetch).toHaveBeenCalledWith("/repo", undefined, { + prune: false, + all: false, + }); + }); + + test("handleFetch passes prune and all options", async () => { + await handlers.handleFetch({ dir: "/repo", prune: true, all: true }, 1); + expect(gitService.fetch).toHaveBeenCalledWith("/repo", undefined, { + prune: true, + all: true, + }); + }); +}); + +// ========================================== +// REVERT +// ========================================== + +describe("GitAdvancedHandlers — Revert", () => { + test("handleRevert reverts a commit by hash", async () => { + await handlers.handleRevert({ dir: "/repo", hash: "abc1234" }, 1); + expect(gitService.revert).toHaveBeenCalledWith("/repo", "abc1234", { + noCommit: false, + }); + expect(rpc.sendResponse).toHaveBeenCalledWith(1, { + ok: true, + data: { output: "" }, + }); + }); + + test("handleRevert accepts commitHash alias", async () => { + await handlers.handleRevert({ dir: "/repo", commitHash: "def5678" }, 1); + expect(gitService.revert).toHaveBeenCalledWith("/repo", "def5678", { + noCommit: false, + }); + }); + + test("handleRevert passes noCommit flag", async () => { + await handlers.handleRevert({ dir: "/repo", hash: "abc", noCommit: true }, 1); + expect(gitService.revert).toHaveBeenCalledWith("/repo", "abc", { + noCommit: true, + }); + }); + + test("handleRevert returns BAD_REQUEST when hash missing", async () => { + await handlers.handleRevert({ dir: "/repo" }, 1); + expect(rpc.sendError).toHaveBeenCalledWith(1, { + code: "BAD_REQUEST", + message: expect.stringContaining("hash"), + }); + }); +}); + +// ========================================== +// Error Forwarding +// ========================================== + +describe("GitAdvancedHandlers — Error Forwarding", () => { + test("all handlers forward service errors as GIT_ERROR", async () => { + const err = new Error("network failure"); + for (const key of Object.keys(gitService)) { + const val = (gitService as any)[key]; + if (typeof val?.mockRejectedValue === "function") { + val.mockRejectedValue(err); + } + } + + const testCases: [string, () => Promise][] = [ + ["remoteList", () => handlers.handleRemoteList({ dir: "/r" }, 1)], + [ + "remoteAdd", + () => handlers.handleRemoteAdd({ dir: "/r", name: "o", url: "u" }, 1), + ], + ["remoteRemove", () => handlers.handleRemoteRemove({ dir: "/r", name: "o" }, 1)], + ["remoteGetUrl", () => handlers.handleRemoteGetUrl({ dir: "/r" }, 1)], + [ + "remoteSetUrl", + () => handlers.handleRemoteSetUrl({ dir: "/r", name: "o", url: "u" }, 1), + ], + ["push", () => handlers.handlePush({ dir: "/r" }, 1)], + ["pull", () => handlers.handlePull({ dir: "/r" }, 1)], + ["fetch", () => handlers.handleFetch({ dir: "/r" }, 1)], + ["revert", () => handlers.handleRevert({ dir: "/r", hash: "abc" }, 1)], + ]; + + for (const [name, fn] of testCases) { + rpc = createMockRpc(); + handlers = new GitAdvancedHandlers(rpc, undefined, gitService); + await fn(); + expect(rpc.sendError).toHaveBeenCalledWith(1, { + code: "GIT_ERROR", + message: "network failure", + }); + } + }); +}); diff --git a/bridge/__tests__/gitHandlers.test.ts b/bridge/__tests__/gitHandlers.test.ts new file mode 100644 index 0000000..f9436b5 --- /dev/null +++ b/bridge/__tests__/gitHandlers.test.ts @@ -0,0 +1,504 @@ +import { describe, expect, test, jest, beforeEach } from "@jest/globals"; +import { GitHandlers } from "../src/handlers/gitHandlers"; +import type { GitService } from "../src/services/gitService"; +import type { Rpc } from "../src/types"; + +// ─── Mock Factory ────────────────────────────────── + +function createMockRpc(): Rpc & { + _responses: any[]; + _errors: any[]; +} { + const responses: any[] = []; + const errors: any[] = []; + return { + sendResponse: jest.fn((id: number | string, payload: any) => { + responses.push({ id, payload }); + }), + sendError: jest.fn((id: number | string, err: any) => { + errors.push({ id, err }); + }), + _responses: responses, + _errors: errors, + }; +} + +function createMockLogger(): any { + return { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + child: jest.fn().mockReturnThis(), + }; +} + +function createMockGitService(): GitService { + return { + isGitInstalled: jest.fn().mockResolvedValue(true), + isRepo: jest.fn().mockResolvedValue(true), + init: jest.fn().mockResolvedValue(undefined), + getRepoRoot: jest.fn().mockResolvedValue("/repo"), + getStatus: jest.fn().mockResolvedValue({ + isGitRepo: true, + branch: "main", + isDirty: false, + stagedCount: 0, + unstagedCount: 0, + untrackedCount: 0, + headCommit: "abc12345", + aheadBehind: { ahead: 0, behind: 0 }, + }), + getChangedFiles: jest.fn().mockResolvedValue([]), + stageFiles: jest.fn().mockResolvedValue(undefined), + stageAll: jest.fn().mockResolvedValue(undefined), + unstageFiles: jest.fn().mockResolvedValue(undefined), + commit: jest.fn().mockResolvedValue("abc1234"), + commitFiles: jest.fn().mockResolvedValue("abc1234"), + log: jest.fn().mockResolvedValue([]), + fileLog: jest.fn().mockResolvedValue([]), + listBranches: jest.fn().mockResolvedValue([]), + createBranch: jest.fn().mockResolvedValue(undefined), + checkoutBranch: jest.fn().mockResolvedValue(undefined), + discardChanges: jest.fn().mockResolvedValue(undefined), + stash: jest.fn().mockResolvedValue(undefined), + stashPop: jest.fn().mockResolvedValue(undefined), + diff: jest.fn().mockResolvedValue("diff output"), + ensureGitignore: jest.fn().mockResolvedValue(true), + generateGitignore: jest.fn().mockReturnValue("# gitignore"), + // Advanced methods (present on GitService but not used by GitHandlers) + resolveRef: jest.fn(), + getFileAtRef: jest.fn(), + show: jest.fn(), + push: jest.fn(), + pull: jest.fn(), + fetch: jest.fn(), + revert: jest.fn(), + remoteList: jest.fn(), + remoteAdd: jest.fn(), + remoteRemove: jest.fn(), + remoteGetUrl: jest.fn(), + remoteSetUrl: jest.fn(), + createTag: jest.fn(), + deleteTag: jest.fn(), + listTags: jest.fn(), + merge: jest.fn(), + abortMerge: jest.fn(), + rebase: jest.fn(), + cherryPick: jest.fn(), + blame: jest.fn(), + stashList: jest.fn(), + stashApply: jest.fn(), + stashDrop: jest.fn(), + stashClear: jest.fn(), + clone: jest.fn(), + dryMerge: jest.fn(), + getMergeState: jest.fn(), + markResolved: jest.fn(), + getProtectedBranches: jest.fn(), + isProtectedBranch: jest.fn(), + deleteBranch: jest.fn(), + renameBranch: jest.fn(), + } as any; +} + +// ─── Tests ────────────────────────────────────────── + +let rpc: ReturnType; +let logger: any; +let gitService: GitService; +let handlers: GitHandlers; + +beforeEach(() => { + rpc = createMockRpc(); + logger = createMockLogger(); + gitService = createMockGitService(); + handlers = new GitHandlers(rpc, logger, gitService); +}); + +// ========================================== +// requireDir Validation +// ========================================== + +describe("GitHandlers — requireDir validation", () => { + const handlerNames: [string, (p: any, id: number) => Promise][] = []; + + beforeEach(() => { + handlerNames.length = 0; + handlerNames.push( + ["handleStatus", (p, id) => handlers.handleStatus(p, id)], + ["handleInit", (p, id) => handlers.handleInit(p, id)], + ["handleChanges", (p, id) => handlers.handleChanges(p, id)], + ["handleStageAll", (p, id) => handlers.handleStageAll(p, id)], + ["handleLog", (p, id) => handlers.handleLog(p, id)], + ["handleBranches", (p, id) => handlers.handleBranches(p, id)], + ["handleDiff", (p, id) => handlers.handleDiff(p, id)], + ["handleEnsureIgnore", (p, id) => handlers.handleEnsureIgnore(p, id)], + ["handleStash", (p, id) => handlers.handleStash(p, id)], + ["handleStashPop", (p, id) => handlers.handleStashPop(p, id)] + ); + }); + + test("sends BAD_REQUEST when dir is missing", async () => { + for (const [name, fn] of handlerNames) { + rpc = createMockRpc(); + gitService = createMockGitService(); + handlers = new GitHandlers(rpc, logger, gitService); + + await fn({}, 1); + expect(rpc.sendError).toHaveBeenCalledWith(1, { + code: "BAD_REQUEST", + message: expect.stringContaining("dir"), + }); + } + }); + + test("accepts dir, path, or cwd as directory param", async () => { + for (const key of ["dir", "path", "cwd"]) { + rpc = createMockRpc(); + gitService = createMockGitService(); + handlers = new GitHandlers(rpc, logger, gitService); + + await handlers.handleStatus({ [key]: "/repo" }, 1); + expect(rpc.sendResponse).toHaveBeenCalled(); + } + }); +}); + +// ========================================== +// handleStatus +// ========================================== + +describe("GitHandlers — handleStatus", () => { + test("returns status data on success", async () => { + await handlers.handleStatus({ dir: "/repo" }, 1); + expect(gitService.getStatus).toHaveBeenCalledWith("/repo"); + expect(rpc.sendResponse).toHaveBeenCalledWith(1, { + ok: true, + data: expect.objectContaining({ isGitRepo: true, branch: "main" }), + }); + }); + + test("returns GIT_ERROR on failure", async () => { + (gitService.getStatus as jest.Mock).mockRejectedValue(new Error("git error")); + await handlers.handleStatus({ dir: "/repo" }, 1); + expect(rpc.sendError).toHaveBeenCalledWith(1, { + code: "GIT_ERROR", + message: "git error", + }); + }); +}); + +// ========================================== +// handleInit +// ========================================== + +describe("GitHandlers — handleInit", () => { + test("initializes repo, sets up gitignore, returns status", async () => { + await handlers.handleInit({ dir: "/repo" }, 1); + expect(gitService.init).toHaveBeenCalledWith("/repo", "main"); + expect(gitService.ensureGitignore).toHaveBeenCalledWith("/repo"); + expect(gitService.getStatus).toHaveBeenCalledWith("/repo"); + expect(rpc.sendResponse).toHaveBeenCalled(); + }); + + test("uses custom default branch", async () => { + await handlers.handleInit({ dir: "/repo", defaultBranch: "develop" }, 1); + expect(gitService.init).toHaveBeenCalledWith("/repo", "develop"); + }); +}); + +// ========================================== +// handleChanges +// ========================================== + +describe("GitHandlers — handleChanges", () => { + test("returns changed files array", async () => { + const mockChanges = [{ path: "file.txt", status: "M", staged: false }]; + (gitService.getChangedFiles as jest.Mock).mockResolvedValue(mockChanges); + + await handlers.handleChanges({ dir: "/repo" }, 1); + expect(rpc.sendResponse).toHaveBeenCalledWith(1, { + ok: true, + data: mockChanges, + }); + }); +}); + +// ========================================== +// handleStage +// ========================================== + +describe("GitHandlers — handleStage", () => { + test("stages specified files", async () => { + await handlers.handleStage({ dir: "/repo", files: ["a.txt", "b.txt"] }, 1); + expect(gitService.stageFiles).toHaveBeenCalledWith("/repo", ["a.txt", "b.txt"]); + expect(rpc.sendResponse).toHaveBeenCalledWith(1, { ok: true, data: null }); + }); + + test("returns BAD_REQUEST for missing files", async () => { + await handlers.handleStage({ dir: "/repo" }, 1); + expect(rpc.sendError).toHaveBeenCalledWith(1, { + code: "BAD_REQUEST", + message: expect.stringContaining("files"), + }); + }); + + test("returns BAD_REQUEST for empty files array", async () => { + await handlers.handleStage({ dir: "/repo", files: [] }, 1); + expect(rpc.sendError).toHaveBeenCalledWith(1, { + code: "BAD_REQUEST", + message: expect.stringContaining("files"), + }); + }); +}); + +// ========================================== +// handleStageAll +// ========================================== + +describe("GitHandlers — handleStageAll", () => { + test("stages all files", async () => { + await handlers.handleStageAll({ dir: "/repo" }, 1); + expect(gitService.stageAll).toHaveBeenCalledWith("/repo"); + expect(rpc.sendResponse).toHaveBeenCalledWith(1, { ok: true, data: null }); + }); +}); + +// ========================================== +// handleUnstage +// ========================================== + +describe("GitHandlers — handleUnstage", () => { + test("unstages specified files", async () => { + await handlers.handleUnstage({ dir: "/repo", files: ["a.txt"] }, 1); + expect(gitService.unstageFiles).toHaveBeenCalledWith("/repo", ["a.txt"]); + expect(rpc.sendResponse).toHaveBeenCalledWith(1, { ok: true, data: null }); + }); + + test("returns BAD_REQUEST for missing files", async () => { + await handlers.handleUnstage({ dir: "/repo" }, 1); + expect(rpc.sendError).toHaveBeenCalledWith(1, { + code: "BAD_REQUEST", + message: expect.stringContaining("files"), + }); + }); +}); + +// ========================================== +// handleCommit +// ========================================== + +describe("GitHandlers — handleCommit", () => { + test("commits with message and returns hash", async () => { + await handlers.handleCommit({ dir: "/repo", message: "feat: add X" }, 1); + expect(gitService.commit).toHaveBeenCalledWith("/repo", "feat: add X"); + expect(rpc.sendResponse).toHaveBeenCalledWith(1, { + ok: true, + data: { hash: "abc1234" }, + }); + }); + + test("returns BAD_REQUEST for missing message", async () => { + await handlers.handleCommit({ dir: "/repo" }, 1); + expect(rpc.sendError).toHaveBeenCalledWith(1, { + code: "BAD_REQUEST", + message: expect.stringContaining("message"), + }); + }); +}); + +// ========================================== +// handleLog +// ========================================== + +describe("GitHandlers — handleLog", () => { + test("returns log entries with default count", async () => { + const entries = [{ hash: "abc", subject: "test" }]; + (gitService.log as jest.Mock).mockResolvedValue(entries); + + await handlers.handleLog({ dir: "/repo" }, 1); + expect(gitService.log).toHaveBeenCalledWith("/repo", 20); + expect(rpc.sendResponse).toHaveBeenCalledWith(1, { ok: true, data: entries }); + }); + + test("respects custom count", async () => { + await handlers.handleLog({ dir: "/repo", count: 5 }, 1); + expect(gitService.log).toHaveBeenCalledWith("/repo", 5); + }); +}); + +// ========================================== +// handleBranches +// ========================================== + +describe("GitHandlers — handleBranches", () => { + test("returns branch list", async () => { + const branches = [{ name: "main", current: true }]; + (gitService.listBranches as jest.Mock).mockResolvedValue(branches); + + await handlers.handleBranches({ dir: "/repo" }, 1); + expect(rpc.sendResponse).toHaveBeenCalledWith(1, { ok: true, data: branches }); + }); +}); + +// ========================================== +// handleCreateBranch +// ========================================== + +describe("GitHandlers — handleCreateBranch", () => { + test("creates branch and returns name", async () => { + await handlers.handleCreateBranch({ dir: "/repo", name: "feature" }, 1); + expect(gitService.createBranch).toHaveBeenCalledWith("/repo", "feature"); + expect(rpc.sendResponse).toHaveBeenCalledWith(1, { + ok: true, + data: { branch: "feature" }, + }); + }); + + test("returns BAD_REQUEST for missing name", async () => { + await handlers.handleCreateBranch({ dir: "/repo" }, 1); + expect(rpc.sendError).toHaveBeenCalledWith(1, { + code: "BAD_REQUEST", + message: expect.stringContaining("name"), + }); + }); +}); + +// ========================================== +// handleCheckout +// ========================================== + +describe("GitHandlers — handleCheckout", () => { + test("checks out branch", async () => { + await handlers.handleCheckout({ dir: "/repo", name: "develop" }, 1); + expect(gitService.checkoutBranch).toHaveBeenCalledWith("/repo", "develop"); + expect(rpc.sendResponse).toHaveBeenCalledWith(1, { + ok: true, + data: { branch: "develop" }, + }); + }); + + test("returns BAD_REQUEST for missing name", async () => { + await handlers.handleCheckout({ dir: "/repo" }, 1); + expect(rpc.sendError).toHaveBeenCalledWith(1, { + code: "BAD_REQUEST", + message: expect.stringContaining("name"), + }); + }); +}); + +// ========================================== +// handleDiscard +// ========================================== + +describe("GitHandlers — handleDiscard", () => { + test("discards changes to specified files", async () => { + await handlers.handleDiscard({ dir: "/repo", files: ["f.txt"] }, 1); + expect(gitService.discardChanges).toHaveBeenCalledWith("/repo", ["f.txt"]); + expect(rpc.sendResponse).toHaveBeenCalledWith(1, { ok: true, data: null }); + }); + + test("returns BAD_REQUEST for missing files", async () => { + await handlers.handleDiscard({ dir: "/repo" }, 1); + expect(rpc.sendError).toHaveBeenCalledWith(1, { + code: "BAD_REQUEST", + message: expect.stringContaining("files"), + }); + }); +}); + +// ========================================== +// handleStash / handleStashPop +// ========================================== + +describe("GitHandlers — handleStash / handleStashPop", () => { + test("stash saves changes", async () => { + await handlers.handleStash({ dir: "/repo", message: "wip" }, 1); + expect(gitService.stash).toHaveBeenCalledWith("/repo", "wip"); + expect(rpc.sendResponse).toHaveBeenCalledWith(1, { ok: true, data: null }); + }); + + test("stashPop restores stash", async () => { + await handlers.handleStashPop({ dir: "/repo" }, 1); + expect(gitService.stashPop).toHaveBeenCalledWith("/repo"); + expect(rpc.sendResponse).toHaveBeenCalledWith(1, { ok: true, data: null }); + }); +}); + +// ========================================== +// handleDiff +// ========================================== + +describe("GitHandlers — handleDiff", () => { + test("returns diff output", async () => { + await handlers.handleDiff({ dir: "/repo", file: "readme.md" }, 1); + expect(gitService.diff).toHaveBeenCalledWith("/repo", "readme.md", false); + expect(rpc.sendResponse).toHaveBeenCalledWith(1, { + ok: true, + data: { diff: "diff output" }, + }); + }); + + test("passes staged flag", async () => { + await handlers.handleDiff({ dir: "/repo", file: "a.ts", staged: true }, 1); + expect(gitService.diff).toHaveBeenCalledWith("/repo", "a.ts", true); + }); + + test("works without file (repo-wide diff)", async () => { + await handlers.handleDiff({ dir: "/repo" }, 1); + expect(gitService.diff).toHaveBeenCalledWith("/repo", undefined, false); + }); +}); + +// ========================================== +// handleEnsureIgnore +// ========================================== + +describe("GitHandlers — handleEnsureIgnore", () => { + test("returns modified flag", async () => { + await handlers.handleEnsureIgnore({ dir: "/repo" }, 1); + expect(gitService.ensureGitignore).toHaveBeenCalledWith("/repo"); + expect(rpc.sendResponse).toHaveBeenCalledWith(1, { + ok: true, + data: { modified: true }, + }); + }); +}); + +// ========================================== +// Error Forwarding +// ========================================== + +describe("GitHandlers — Error Forwarding", () => { + test("all handlers forward errors as GIT_ERROR", async () => { + const errorMsg = "unexpected git failure"; + // Mock all service methods to reject + for (const key of Object.keys(gitService)) { + const val = (gitService as any)[key]; + if (typeof val?.mockRejectedValue === "function") { + val.mockRejectedValue(new Error(errorMsg)); + } + } + + const testCases: [string, () => Promise][] = [ + ["status", () => handlers.handleStatus({ dir: "/r" }, 1)], + ["changes", () => handlers.handleChanges({ dir: "/r" }, 1)], + ["stageAll", () => handlers.handleStageAll({ dir: "/r" }, 1)], + ["log", () => handlers.handleLog({ dir: "/r" }, 1)], + ["branches", () => handlers.handleBranches({ dir: "/r" }, 1)], + ["stashPop", () => handlers.handleStashPop({ dir: "/r" }, 1)], + ["ensureIgnore", () => handlers.handleEnsureIgnore({ dir: "/r" }, 1)], + ]; + + for (const [name, fn] of testCases) { + rpc = createMockRpc(); + handlers = new GitHandlers(rpc, logger, gitService); + await fn(); + expect(rpc.sendError).toHaveBeenCalledWith(1, { + code: "GIT_ERROR", + message: errorMsg, + }); + } + }); +}); diff --git a/bridge/__tests__/gitService.test.ts b/bridge/__tests__/gitService.test.ts new file mode 100644 index 0000000..119d3dd --- /dev/null +++ b/bridge/__tests__/gitService.test.ts @@ -0,0 +1,608 @@ +import { afterAll, beforeEach,beforeAll, describe, expect, test } from "@jest/globals"; +import { GitService } from "../src/services/gitService"; +import fs from "fs/promises"; +import fsSync from "fs"; +import path from "path"; +import os from "os"; +import { execFile } from "child_process"; +import { promisify } from "util"; + +const execFileAsync = promisify(execFile); + +// ─── Test Setup ────────────────────────────────────── + +const TEST_ROOT = path.join(os.tmpdir(), "git-service-test-" + Date.now()); +let repoDir: string; +let git: GitService; +let testCounter = 0; + +/** + * Helper: run raw git commands in a directory + */ +async function rawGit(cwd: string, ...args: string[]): Promise { + const { stdout } = await execFileAsync("git", args, { cwd, windowsHide: true }); + return stdout.trimEnd(); +} + +/** + * Helper: create a fresh temp repo for each test + */ +async function createTempRepo(): Promise { + testCounter++; + const dir = path.join(TEST_ROOT, `repo-${testCounter}`); + await fs.mkdir(dir, { recursive: true }); + await rawGit(dir, "init", "-b", "main"); + await rawGit(dir, "config", "user.email", "test@relwave.dev"); + await rawGit(dir, "config", "user.name", "Test User"); + return dir; +} + +/** + * Helper: create a file and commit it + */ +async function commitFile(dir: string, filename: string, content: string, message: string) { + await fs.writeFile(path.join(dir, filename), content, "utf-8"); + await rawGit(dir, "add", filename); + await rawGit(dir, "commit", "-m", message); +} + +beforeAll(async () => { + await fs.mkdir(TEST_ROOT, { recursive: true }); +}); + +afterAll(async () => { + if (fsSync.existsSync(TEST_ROOT)) { + await fs.rm(TEST_ROOT, { recursive: true, force: true }); + } +}); + +beforeEach(async () => { + git = new GitService(); + repoDir = await createTempRepo(); +}); + +// ========================================== +// Basic Repo Operations +// ========================================== + +describe("GitService — Basic Operations", () => { + test("isGitInstalled returns true", async () => { + const installed = await git.isGitInstalled(); + expect(installed).toBe(true); + }); + + test("isRepo returns true for initialized repo", async () => { + expect(await git.isRepo(repoDir)).toBe(true); + }); + + test("isRepo returns false for non-repo directory", async () => { + const plain = path.join(TEST_ROOT, "plain-" + Date.now()); + await fs.mkdir(plain, { recursive: true }); + expect(await git.isRepo(plain)).toBe(false); + }); + + test("init creates a new repository", async () => { + const dir = path.join(TEST_ROOT, "new-init-" + Date.now()); + await fs.mkdir(dir, { recursive: true }); + await git.init(dir, "main"); + expect(await git.isRepo(dir)).toBe(true); + }); + + test("getRepoRoot returns the repository root", async () => { + const root = await git.getRepoRoot(repoDir); + // Normalize path separators for cross-platform comparison + expect(path.normalize(root)).toBe(path.normalize(repoDir)); + }); +}); + +// ========================================== +// Status +// ========================================== + +describe("GitService — Status", () => { + test("returns clean status for empty repo", async () => { + const status = await git.getStatus(repoDir); + expect(status.isGitRepo).toBe(true); + expect(status.isDirty).toBe(false); + expect(status.stagedCount).toBe(0); + expect(status.unstagedCount).toBe(0); + expect(status.untrackedCount).toBe(0); + }); + + test("returns not-a-repo status for plain directory", async () => { + const dir = path.join(TEST_ROOT, "not-a-repo-" + Date.now()); + await fs.mkdir(dir, { recursive: true }); + + const status = await git.getStatus(dir); + expect(status.isGitRepo).toBe(false); + expect(status.branch).toBeNull(); + }); + + test("detects untracked files", async () => { + await fs.writeFile(path.join(repoDir, "newfile.txt"), "hello", "utf-8"); + const status = await git.getStatus(repoDir); + expect(status.isDirty).toBe(true); + expect(status.untrackedCount).toBe(1); + }); + + test("detects staged files", async () => { + await fs.writeFile(path.join(repoDir, "staged.txt"), "staged", "utf-8"); + await rawGit(repoDir, "add", "staged.txt"); + + const status = await git.getStatus(repoDir); + expect(status.stagedCount).toBe(1); + expect(status.isDirty).toBe(true); + }); + + test("detects unstaged modifications", async () => { + await commitFile(repoDir, "file.txt", "original", "initial"); + await fs.writeFile(path.join(repoDir, "file.txt"), "modified", "utf-8"); + + const status = await git.getStatus(repoDir); + expect(status.unstagedCount).toBe(1); + expect(status.isDirty).toBe(true); + }); + + test("returns branch name", async () => { + await commitFile(repoDir, "file.txt", "content", "first commit"); + const status = await git.getStatus(repoDir); + expect(status.branch).toBe("main"); + }); + + test("returns headCommit hash", async () => { + await commitFile(repoDir, "file.txt", "content", "first commit"); + const status = await git.getStatus(repoDir); + expect(status.headCommit).toBeDefined(); + expect(status.headCommit!.length).toBe(8); + }); +}); + +// ========================================== +// Changed Files +// ========================================== + +describe("GitService — Changed Files", () => { + test("returns empty for clean repo", async () => { + await commitFile(repoDir, "file.txt", "content", "initial"); + const changes = await git.getChangedFiles(repoDir); + expect(changes).toEqual([]); + }); + + test("detects untracked files", async () => { + await fs.writeFile(path.join(repoDir, "new.txt"), "new", "utf-8"); + const changes = await git.getChangedFiles(repoDir); + expect(changes).toHaveLength(1); + expect(changes[0].status).toBe("?"); + expect(changes[0].staged).toBe(false); + expect(changes[0].path).toBe("new.txt"); + }); + + test("detects staged modifications", async () => { + await commitFile(repoDir, "file.txt", "original", "init"); + await fs.writeFile(path.join(repoDir, "file.txt"), "changed", "utf-8"); + await rawGit(repoDir, "add", "file.txt"); + + const changes = await git.getChangedFiles(repoDir); + const staged = changes.filter((c) => c.staged); + expect(staged.length).toBeGreaterThanOrEqual(1); + expect(staged[0].status).toBe("M"); + }); + + test("detects deleted files", async () => { + await commitFile(repoDir, "file.txt", "content", "init"); + await fs.unlink(path.join(repoDir, "file.txt")); + + const changes = await git.getChangedFiles(repoDir); + const deleted = changes.filter((c) => c.status === "D"); + expect(deleted.length).toBe(1); + }); +}); + +// ========================================== +// Staging & Committing +// ========================================== + +describe("GitService — Stage & Commit", () => { + test("stageFiles stages specific files", async () => { + await fs.writeFile(path.join(repoDir, "a.txt"), "a", "utf-8"); + await fs.writeFile(path.join(repoDir, "b.txt"), "b", "utf-8"); + + await git.stageFiles(repoDir, ["a.txt"]); + + const status = await git.getStatus(repoDir); + expect(status.stagedCount).toBe(1); + expect(status.untrackedCount).toBe(1); + }); + + test("stageFiles is no-op for empty array", async () => { + // Should not throw + await expect(git.stageFiles(repoDir, [])).resolves.not.toThrow(); + }); + + test("stageAll stages everything", async () => { + await fs.writeFile(path.join(repoDir, "a.txt"), "a", "utf-8"); + await fs.writeFile(path.join(repoDir, "b.txt"), "b", "utf-8"); + + await git.stageAll(repoDir); + + const status = await git.getStatus(repoDir); + expect(status.stagedCount).toBe(2); + expect(status.untrackedCount).toBe(0); + }); + + test("commit returns a string (may be hash or empty)", async () => { + await fs.writeFile(path.join(repoDir, "file.txt"), "data", "utf-8"); + await git.stageAll(repoDir); + + const hash = await git.commit(repoDir, "test commit"); + expect(typeof hash).toBe("string"); + + // Verify the commit actually happened + const log = await git.log(repoDir, 1); + expect(log).toHaveLength(1); + expect(log[0].subject).toBe("test commit"); + }); + + test("unstageFiles removes files from staging", async () => { + await fs.writeFile(path.join(repoDir, "file.txt"), "data", "utf-8"); + await git.stageAll(repoDir); + expect((await git.getStatus(repoDir)).stagedCount).toBe(1); + + await git.unstageFiles(repoDir, ["file.txt"]); + // After unstaging a new file, it goes back to untracked + const status = await git.getStatus(repoDir); + expect(status.stagedCount).toBe(0); + }); + + test("commitFiles stages and commits in one call", async () => { + await commitFile(repoDir, "base.txt", "base", "initial"); // Need a first commit + await fs.writeFile(path.join(repoDir, "auto.txt"), "auto", "utf-8"); + + const hash = await git.commitFiles(repoDir, ["auto.txt"], "auto commit"); + expect(hash).toBeDefined(); + + const status = await git.getStatus(repoDir); + expect(status.isDirty).toBe(false); + }); +}); + +// ========================================== +// Log & History +// ========================================== + +describe("GitService — Log", () => { + test("returns empty log for fresh repo", async () => { + const entries = await git.log(repoDir); + expect(entries).toEqual([]); + }); + + test("returns commit entries", async () => { + await commitFile(repoDir, "a.txt", "a", "first commit"); + await commitFile(repoDir, "b.txt", "b", "second commit"); + + const entries = await git.log(repoDir); + expect(entries).toHaveLength(2); + expect(entries[0].subject).toBe("second commit"); + expect(entries[1].subject).toBe("first commit"); + }); + + test("entries have correct fields", async () => { + await commitFile(repoDir, "file.txt", "data", "test message"); + + const [entry] = await git.log(repoDir, 1); + expect(entry.hash).toBeDefined(); + expect(entry.fullHash).toBeDefined(); + expect(entry.author).toBe("Test User"); + expect(entry.date).toBeDefined(); + expect(entry.subject).toBe("test message"); + }); + + test("respects count limit", async () => { + for (let i = 0; i < 5; i++) { + await commitFile(repoDir, `f${i}.txt`, `${i}`, `commit ${i}`); + } + + const limited = await git.log(repoDir, 3); + expect(limited).toHaveLength(3); + }); + + test("fileLog returns commits for specific file", async () => { + await commitFile(repoDir, "a.txt", "v1", "commit a"); + await commitFile(repoDir, "b.txt", "v1", "commit b"); + await commitFile(repoDir, "a.txt", "v2", "update a"); + + const aLog = await git.fileLog(repoDir, "a.txt"); + expect(aLog).toHaveLength(2); + expect(aLog.map((e) => e.subject)).toEqual(["update a", "commit a"]); + }); +}); + +// ========================================== +// Branches +// ========================================== + +describe("GitService — Branches", () => { + beforeEach(async () => { + await commitFile(repoDir, "init.txt", "init", "initial commit"); + }); + + test("lists branches with current indicator", async () => { + const branches = await git.listBranches(repoDir); + expect(branches).toHaveLength(1); + expect(branches[0].name).toBe("main"); + expect(branches[0].current).toBe(true); + }); + + test("creates and lists new branches", async () => { + await git.createBranch(repoDir, "feature"); + + const branches = await git.listBranches(repoDir); + expect(branches).toHaveLength(2); + + const feature = branches.find((b) => b.name === "feature"); + expect(feature).toBeDefined(); + expect(feature!.current).toBe(true); // createBranch does checkout -b + }); + + test("checkout switches branches", async () => { + await git.createBranch(repoDir, "feature"); + await git.checkoutBranch(repoDir, "main"); + + const branches = await git.listBranches(repoDir); + const main = branches.find((b) => b.name === "main"); + expect(main!.current).toBe(true); + }); + + test("resolveRef returns commit hash", async () => { + const hash = await git.resolveRef(repoDir, "HEAD"); + expect(hash).toBeDefined(); + expect(hash!.length).toBe(40); + }); + + test("resolveRef returns null for invalid ref", async () => { + const hash = await git.resolveRef(repoDir, "nonexistent"); + expect(hash).toBeNull(); + }); +}); + +// ========================================== +// Discard & Stash +// ========================================== + +describe("GitService — Discard & Stash", () => { + beforeEach(async () => { + await commitFile(repoDir, "file.txt", "original", "initial"); + }); + + test("discardChanges restores file content", async () => { + await fs.writeFile(path.join(repoDir, "file.txt"), "modified", "utf-8"); + await git.discardChanges(repoDir, ["file.txt"]); + + const content = await fs.readFile(path.join(repoDir, "file.txt"), "utf-8"); + expect(content).toBe("original"); + }); + + test("stash saves and restores changes", async () => { + await fs.writeFile(path.join(repoDir, "file.txt"), "modified", "utf-8"); + await git.stash(repoDir, "wip changes"); + + // Working tree should be clean after stash + const status = await git.getStatus(repoDir); + expect(status.isDirty).toBe(false); + + // Pop restores + await git.stashPop(repoDir); + const content = await fs.readFile(path.join(repoDir, "file.txt"), "utf-8"); + expect(content).toBe("modified"); + }); +}); + +// ========================================== +// Diff +// ========================================== + +describe("GitService — Diff", () => { + beforeEach(async () => { + await commitFile(repoDir, "file.txt", "line1\nline2\n", "initial"); + }); + + test("diff shows unstaged changes", async () => { + await fs.writeFile(path.join(repoDir, "file.txt"), "line1\nline2\nline3\n", "utf-8"); + + const diff = await git.diff(repoDir, "file.txt"); + expect(diff).toContain("+line3"); + }); + + test("diff shows staged changes with --staged", async () => { + await fs.writeFile(path.join(repoDir, "file.txt"), "changed\n", "utf-8"); + await rawGit(repoDir, "add", "file.txt"); + + const diff = await git.diff(repoDir, "file.txt", true); + expect(diff).toContain("-line1"); + expect(diff).toContain("+changed"); + }); + + test("diff returns empty for no changes", async () => { + const diff = await git.diff(repoDir); + expect(diff).toBe(""); + }); +}); + +// ========================================== +// Gitignore +// ========================================== + +describe("GitService — Gitignore", () => { + test("generateGitignore returns rules containing relwave.local.json", () => { + const content = git.generateGitignore(); + expect(content).toContain("relwave.local.json"); + expect(content).toContain(".credentials"); + expect(content).toContain(".DS_Store"); + }); + + test("ensureGitignore creates new file", async () => { + const modified = await git.ensureGitignore(repoDir); + expect(modified).toBe(true); + + const content = await fs.readFile(path.join(repoDir, ".gitignore"), "utf-8"); + expect(content).toContain("relwave.local.json"); + }); + + test("ensureGitignore is idempotent", async () => { + await git.ensureGitignore(repoDir); + const secondCall = await git.ensureGitignore(repoDir); + expect(secondCall).toBe(false); + }); + + test("ensureGitignore appends to existing file", async () => { + await fs.writeFile(path.join(repoDir, ".gitignore"), "node_modules/\n", "utf-8"); + const modified = await git.ensureGitignore(repoDir); + expect(modified).toBe(true); + + const content = await fs.readFile(path.join(repoDir, ".gitignore"), "utf-8"); + expect(content).toContain("node_modules/"); + expect(content).toContain("relwave.local.json"); + }); +}); + +// ========================================== +// Remote Management +// ========================================== + +describe("GitService — Remote Management", () => { + beforeEach(async () => { + await commitFile(repoDir, "init.txt", "init", "initial"); + }); + + test("remoteList returns empty for no remotes", async () => { + const remotes = await git.remoteList(repoDir); + expect(remotes).toEqual([]); + }); + + test("remoteAdd and remoteList", async () => { + await git.remoteAdd(repoDir, "origin", "https://github.com/test/repo.git"); + + const remotes = await git.remoteList(repoDir); + expect(remotes).toHaveLength(1); + expect(remotes[0].name).toBe("origin"); + expect(remotes[0].fetchUrl).toBe("https://github.com/test/repo.git"); + expect(remotes[0].pushUrl).toBe("https://github.com/test/repo.git"); + }); + + test("remoteRemove removes a remote", async () => { + await git.remoteAdd(repoDir, "origin", "https://github.com/test/repo.git"); + await git.remoteRemove(repoDir, "origin"); + + const remotes = await git.remoteList(repoDir); + expect(remotes).toEqual([]); + }); + + test("remoteGetUrl returns URL", async () => { + await git.remoteAdd(repoDir, "origin", "https://github.com/test/repo.git"); + const url = await git.remoteGetUrl(repoDir, "origin"); + expect(url).toBe("https://github.com/test/repo.git"); + }); + + test("remoteGetUrl returns null for non-existent remote", async () => { + const url = await git.remoteGetUrl(repoDir, "nonexistent"); + expect(url).toBeNull(); + }); + + test("remoteSetUrl changes URL", async () => { + await git.remoteAdd(repoDir, "origin", "https://old.url/repo.git"); + await git.remoteSetUrl(repoDir, "origin", "https://new.url/repo.git"); + + const url = await git.remoteGetUrl(repoDir, "origin"); + expect(url).toBe("https://new.url/repo.git"); + }); + + test("multiple remotes", async () => { + await git.remoteAdd(repoDir, "origin", "https://github.com/main.git"); + await git.remoteAdd(repoDir, "upstream", "https://github.com/upstream.git"); + + const remotes = await git.remoteList(repoDir); + expect(remotes).toHaveLength(2); + expect(remotes.map((r) => r.name).sort()).toEqual(["origin", "upstream"]); + }); +}); + +// ========================================== +// Tags +// ========================================== + +describe("GitService — Tags", () => { + beforeEach(async () => { + await commitFile(repoDir, "init.txt", "init", "initial commit"); + }); + + test("createTag creates a lightweight tag", async () => { + await git.createTag(repoDir, "v1.0.0"); + const tags = await git.listTags(repoDir); + expect(tags).toContain("v1.0.0"); + }); + + test("createTag creates an annotated tag", async () => { + await git.createTag(repoDir, "v2.0.0", "Release 2.0"); + const tags = await git.listTags(repoDir); + expect(tags).toContain("v2.0.0"); + }); + + test("deleteTag removes a tag", async () => { + await git.createTag(repoDir, "v1.0.0"); + await git.deleteTag(repoDir, "v1.0.0"); + const tags = await git.listTags(repoDir); + expect(tags).not.toContain("v1.0.0"); + }); + + test("listTags returns all tags", async () => { + await git.createTag(repoDir, "v1.0.0"); + await commitFile(repoDir, "b.txt", "b", "second"); + await git.createTag(repoDir, "v2.0.0"); + + const tags = await git.listTags(repoDir); + expect(tags).toHaveLength(2); + expect(tags).toContain("v1.0.0"); + expect(tags).toContain("v2.0.0"); + }); +}); + +// ========================================== +// Revert +// ========================================== + +describe("GitService — Revert", () => { + test("revert creates a revert commit", async () => { + await commitFile(repoDir, "file.txt", "version1", "commit 1"); + await commitFile(repoDir, "file.txt", "version2", "commit 2"); + + const log = await git.log(repoDir, 1); + await git.revert(repoDir, log[0].hash); + + const afterLog = await git.log(repoDir); + expect(afterLog[0].subject).toContain("Revert"); + }); +}); + +// ========================================== +// File at Ref +// ========================================== + +describe("GitService — File at Ref", () => { + test("getFileAtRef returns file content at HEAD", async () => { + await commitFile(repoDir, "file.txt", "hello world", "add file"); + const content = await git.getFileAtRef(repoDir, "file.txt", "HEAD"); + expect(content).toBe("hello world"); + }); + + test("getFileAtRef returns null for missing file", async () => { + await commitFile(repoDir, "file.txt", "data", "init"); + const content = await git.getFileAtRef(repoDir, "nonexistent.txt", "HEAD"); + expect(content).toBeNull(); + }); + + test("show is alias for getFileAtRef", async () => { + await commitFile(repoDir, "file.txt", "show me", "add"); + const content = await git.show(repoDir, "HEAD", "file.txt"); + expect(content).toBe("show me"); + }); +}); diff --git a/bridge/__tests__/projectStore.test.ts b/bridge/__tests__/projectStore.test.ts new file mode 100644 index 0000000..b0a4dbd --- /dev/null +++ b/bridge/__tests__/projectStore.test.ts @@ -0,0 +1,566 @@ +import { afterEach, beforeEach, describe, expect, jest, test } from "@jest/globals"; +import { ProjectStore, ProjectMetadata, SchemaSnapshot } from "../src/services/projectStore"; +import fs from "fs/promises"; +import fsSync from "fs"; +import path from "path"; +import os from "os"; + +// ─── Test Setup ────────────────────────────────────── + +const TEST_ROOT = path.join(os.tmpdir(), "projectstore-test-" + Date.now()); +const PROJECTS_DIR = path.join(TEST_ROOT, "projects"); +const INDEX_FILE = path.join(PROJECTS_DIR, "index.json"); + +/** + * ProjectStore uses `getProjectDir()` from config.ts, which is hardcoded. + * We mock the config module to redirect to our temp folder. + */ +jest.mock("../src/utils/config", () => { + const original = jest.requireActual("../src/utils/config") as any; + const _path = require("path"); + const _os = require("os"); + const testProjects = _path.join(_os.tmpdir(), "projectstore-test-" + Date.now(), "projects"); + return { + ...original, + PROJECTS_FOLDER: testProjects, + PROJECTS_INDEX_FILE: _path.join(testProjects, "index.json"), + getProjectDir: (id: string) => _path.join(testProjects, id), + }; +}); + +/** + * Mock dbStoreInstance.getDB to avoid needing a real database store + */ +jest.mock("../src/services/dbStore", () => ({ + dbStoreInstance: { + getDB: jest.fn<() => Promise>().mockResolvedValue({ + id: "test-db-id", + name: "TestDB", + type: "POSTGRES", + host: "localhost", + port: 5432, + }), + }, + DBMeta: {}, +})); + +// After mocking, get the actual folder being used +import { PROJECTS_FOLDER, PROJECTS_INDEX_FILE, getProjectDir } from "../src/utils/config"; + +describe("ProjectStore", () => { + let store: ProjectStore; + + beforeEach(async () => { + // Clean & create test directory + if (fsSync.existsSync(PROJECTS_FOLDER)) { + await fs.rm(PROJECTS_FOLDER, { recursive: true, force: true }); + } + await fs.mkdir(PROJECTS_FOLDER, { recursive: true }); + + store = new ProjectStore(PROJECTS_FOLDER, PROJECTS_INDEX_FILE); + }); + + afterEach(async () => { + if (fsSync.existsSync(PROJECTS_FOLDER)) { + await fs.rm(PROJECTS_FOLDER, { recursive: true, force: true }); + } + }); + + // ========================================== + // Project CRUD + // ========================================== + + describe("CRUD Operations", () => { + test("should create a new project", async () => { + const project = await store.createProject({ + databaseId: "db-1", + name: "My Project", + description: "A test project", + }); + + expect(project).toBeDefined(); + expect(project.id).toBeDefined(); + expect(project.name).toBe("My Project"); + expect(project.description).toBe("A test project"); + expect(project.databaseId).toBe("db-1"); + expect(project.engine).toBe("POSTGRES"); // from mocked dbStore + expect(project.version).toBe(1); + expect(project.createdAt).toBeDefined(); + expect(project.updatedAt).toBeDefined(); + }); + + test("should create project directories", async () => { + const project = await store.createProject({ + databaseId: "db-1", + name: "DirTest", + }); + + const dir = getProjectDir(project.id); + expect(fsSync.existsSync(dir)).toBe(true); + expect(fsSync.existsSync(path.join(dir, "schema"))).toBe(true); + expect(fsSync.existsSync(path.join(dir, "diagrams"))).toBe(true); + expect(fsSync.existsSync(path.join(dir, "queries"))).toBe(true); + }); + + test("should create initial sub-files", async () => { + const project = await store.createProject({ + databaseId: "db-1", + name: "SubFileTest", + }); + + const dir = getProjectDir(project.id); + expect(fsSync.existsSync(path.join(dir, "relwave.json"))).toBe(true); + expect(fsSync.existsSync(path.join(dir, "relwave.local.json"))).toBe(true); + expect(fsSync.existsSync(path.join(dir, "schema", "schema.json"))).toBe(true); + expect(fsSync.existsSync(path.join(dir, "diagrams", "er.json"))).toBe(true); + expect(fsSync.existsSync(path.join(dir, "queries", "queries.json"))).toBe(true); + expect(fsSync.existsSync(path.join(dir, ".gitignore"))).toBe(true); + }); + + test("should get project by ID", async () => { + const created = await store.createProject({ + databaseId: "db-1", + name: "GetTest", + }); + + const found = await store.getProject(created.id); + expect(found).toBeDefined(); + expect(found!.id).toBe(created.id); + expect(found!.name).toBe("GetTest"); + }); + + test("should return null for non-existent project", async () => { + const found = await store.getProject("non-existent-id"); + expect(found).toBeNull(); + }); + + test("should get project by databaseId", async () => { + await store.createProject({ + databaseId: "db-unique", + name: "Linked Project", + }); + + const found = await store.getProjectByDatabaseId("db-unique"); + expect(found).toBeDefined(); + expect(found!.name).toBe("Linked Project"); + }); + + test("should return null for unlinked databaseId", async () => { + const found = await store.getProjectByDatabaseId("no-such-db"); + expect(found).toBeNull(); + }); + + test("should list all projects", async () => { + await store.createProject({ databaseId: "db-1", name: "P1" }); + await store.createProject({ databaseId: "db-2", name: "P2" }); + await store.createProject({ databaseId: "db-3", name: "P3" }); + + const projects = await store.listProjects(); + expect(projects).toHaveLength(3); + expect(projects.map((p) => p.name).sort()).toEqual(["P1", "P2", "P3"]); + }); + + test("should update project metadata", async () => { + const project = await store.createProject({ + databaseId: "db-1", + name: "Original", + description: "Old desc", + }); + + const updated = await store.updateProject(project.id, { + name: "Renamed", + description: "New desc", + }); + + expect(updated).toBeDefined(); + expect(updated!.name).toBe("Renamed"); + expect(updated!.description).toBe("New desc"); + expect(updated!.updatedAt).not.toBe(project.updatedAt); + }); + + test("should only update whitelisted fields", async () => { + const project = await store.createProject({ + databaseId: "db-1", + name: "WhitelistTest", + }); + + const updated = await store.updateProject(project.id, { + name: "NewName", + // These should be ignored / not writable: + ...({ id: "injected-id", databaseId: "injected-db" } as any), + }); + + expect(updated!.name).toBe("NewName"); + expect(updated!.id).toBe(project.id); // unchanged + expect(updated!.databaseId).toBe(project.databaseId); // unchanged + }); + + test("should return null when updating non-existent project", async () => { + const result = await store.updateProject("no-such-id", { name: "x" }); + expect(result).toBeNull(); + }); + + test("should sync index after update", async () => { + const project = await store.createProject({ + databaseId: "db-1", + name: "SyncTest", + }); + + await store.updateProject(project.id, { name: "Updated" }); + + const projects = await store.listProjects(); + expect(projects.find((p) => p.id === project.id)?.name).toBe("Updated"); + }); + + test("should delete a project", async () => { + const project = await store.createProject({ + databaseId: "db-1", + name: "DeleteTest", + }); + + const dir = getProjectDir(project.id); + expect(fsSync.existsSync(dir)).toBe(true); + + await store.deleteProject(project.id); + + expect(fsSync.existsSync(dir)).toBe(false); + const projects = await store.listProjects(); + expect(projects.find((p) => p.id === project.id)).toBeUndefined(); + }); + + test("should handle deleting non-existent project gracefully", async () => { + // Should not throw + await expect(store.deleteProject("no-such-id")).resolves.not.toThrow(); + }); + }); + + // ========================================== + // Schema Operations + // ========================================== + + describe("Schema Operations", () => { + let projectId: string; + + beforeEach(async () => { + const project = await store.createProject({ + databaseId: "db-1", + name: "SchemaTest", + }); + projectId = project.id; + }); + + const mockSchemas: SchemaSnapshot[] = [ + { + name: "public", + tables: [ + { + name: "users", + type: "BASE TABLE", + columns: [ + { name: "id", type: "integer", nullable: false, isPrimaryKey: true, isForeignKey: false, defaultValue: null, isUnique: true }, + { name: "email", type: "varchar(255)", nullable: false, isPrimaryKey: false, isForeignKey: false, defaultValue: null, isUnique: true }, + ], + }, + ], + }, + ]; + + test("should get initial empty schema", async () => { + const schema = await store.getSchema(projectId); + expect(schema).toBeDefined(); + expect(schema!.schemas).toEqual([]); + }); + + test("should save and retrieve schema", async () => { + await store.saveSchema(projectId, mockSchemas); + const saved = await store.getSchema(projectId); + + expect(saved).toBeDefined(); + expect(saved!.schemas).toHaveLength(1); + expect(saved!.schemas[0].name).toBe("public"); + expect(saved!.schemas[0].tables[0].name).toBe("users"); + }); + + test("should skip write when schema is identical (cachedAt dedup)", async () => { + const first = await store.saveSchema(projectId, mockSchemas); + const second = await store.saveSchema(projectId, mockSchemas); + + // Same cachedAt means the write was skipped + expect(second.cachedAt).toBe(first.cachedAt); + }); + + test("should write when schema changes", async () => { + const first = await store.saveSchema(projectId, mockSchemas); + + const changedSchemas: SchemaSnapshot[] = [ + { + ...mockSchemas[0], + tables: [ + ...mockSchemas[0].tables, + { + name: "posts", + type: "BASE TABLE", + columns: [ + { name: "id", type: "integer", nullable: false, isPrimaryKey: true, isForeignKey: false, defaultValue: null, isUnique: true }, + ], + }, + ], + }, + ]; + + // Allow time difference + await new Promise((r) => setTimeout(r, 10)); + const second = await store.saveSchema(projectId, changedSchemas); + + expect(second.cachedAt).not.toBe(first.cachedAt); + expect(second.schemas[0].tables).toHaveLength(2); + }); + + test("should throw when saving schema for non-existent project", async () => { + await expect( + store.saveSchema("no-such-project", mockSchemas) + ).rejects.toThrow("Project no-such-project not found"); + }); + }); + + // ========================================== + // ER Diagram Operations + // ========================================== + + describe("ER Diagram Operations", () => { + let projectId: string; + + beforeEach(async () => { + const project = await store.createProject({ + databaseId: "db-1", + name: "ERTest", + }); + projectId = project.id; + }); + + test("should get initial empty diagram", async () => { + const diagram = await store.getERDiagram(projectId); + expect(diagram).toBeDefined(); + expect(diagram!.nodes).toEqual([]); + }); + + test("should save and retrieve diagram", async () => { + const nodes = [ + { tableId: "users", x: 100, y: 200 }, + { tableId: "posts", x: 300, y: 400, collapsed: true }, + ]; + + const saved = await store.saveERDiagram(projectId, { + nodes, + zoom: 1.5, + panX: 50, + panY: 75, + }); + + expect(saved.nodes).toHaveLength(2); + expect(saved.zoom).toBe(1.5); + expect(saved.panX).toBe(50); + + const retrieved = await store.getERDiagram(projectId); + expect(retrieved!.nodes).toHaveLength(2); + expect(retrieved!.nodes[0].tableId).toBe("users"); + }); + }); + + // ========================================== + // Query Operations + // ========================================== + + describe("Query Operations", () => { + let projectId: string; + + beforeEach(async () => { + const project = await store.createProject({ + databaseId: "db-1", + name: "QueryTest", + }); + projectId = project.id; + }); + + test("should get initial empty queries", async () => { + const queries = await store.getQueries(projectId); + expect(queries).toBeDefined(); + expect(queries!.queries).toEqual([]); + }); + + test("should add a query", async () => { + const query = await store.addQuery(projectId, { + name: "Get Users", + sql: "SELECT * FROM users", + description: "Fetch all users", + }); + + expect(query).toBeDefined(); + expect(query.id).toBeDefined(); + expect(query.name).toBe("Get Users"); + expect(query.sql).toBe("SELECT * FROM users"); + expect(query.description).toBe("Fetch all users"); + }); + + test("should list queries after adding", async () => { + await store.addQuery(projectId, { name: "Q1", sql: "SELECT 1" }); + await store.addQuery(projectId, { name: "Q2", sql: "SELECT 2" }); + + const queries = await store.getQueries(projectId); + expect(queries!.queries).toHaveLength(2); + }); + + test("should update a query", async () => { + const query = await store.addQuery(projectId, { + name: "Old Name", + sql: "SELECT 1", + }); + + const updated = await store.updateQuery(projectId, query.id, { + name: "New Name", + sql: "SELECT 2", + }); + + expect(updated).toBeDefined(); + expect(updated!.name).toBe("New Name"); + expect(updated!.sql).toBe("SELECT 2"); + expect(updated!.updatedAt).not.toBe(query.updatedAt); + }); + + test("should return null when updating non-existent query", async () => { + const result = await store.updateQuery(projectId, "no-such-query", { + name: "x", + }); + expect(result).toBeNull(); + }); + + test("should delete a query", async () => { + const query = await store.addQuery(projectId, { + name: "To Delete", + sql: "SELECT 1", + }); + + await store.deleteQuery(projectId, query.id); + + const queries = await store.getQueries(projectId); + expect(queries!.queries.find((q) => q.id === query.id)).toBeUndefined(); + }); + }); + + // ========================================== + // Export + // ========================================== + + describe("Export", () => { + test("should export full project bundle", async () => { + const project = await store.createProject({ + databaseId: "db-1", + name: "ExportTest", + }); + + // Add some data + await store.saveSchema(project.id, [ + { name: "public", tables: [] }, + ]); + await store.saveERDiagram(project.id, { nodes: [{ tableId: "t1", x: 0, y: 0 }] }); + await store.addQuery(project.id, { name: "Q1", sql: "SELECT 1" }); + + const bundle = await store.exportProject(project.id); + expect(bundle).toBeDefined(); + expect(bundle!.metadata.name).toBe("ExportTest"); + expect(bundle!.schema).toBeDefined(); + expect(bundle!.erDiagram!.nodes).toHaveLength(1); + expect(bundle!.queries!.queries).toHaveLength(1); + }); + + test("should return null for non-existent project export", async () => { + const bundle = await store.exportProject("no-such-id"); + expect(bundle).toBeNull(); + }); + }); + + // ========================================== + // Local Config (git-ignored) + // ========================================== + + describe("Local Config", () => { + let projectId: string; + + beforeEach(async () => { + const project = await store.createProject({ + databaseId: "db-1", + name: "ConfigTest", + }); + projectId = project.id; + }); + + test("should get initial empty local config", async () => { + const config = await store.getLocalConfig(projectId); + expect(config).toBeDefined(); + expect(config).toEqual({}); + }); + + test("should save and retrieve local config", async () => { + const saved = await store.saveLocalConfig(projectId, { + connectionUrl: "postgres://localhost:5432/mydb", + environment: "development", + notes: "My dev setup", + }); + + expect(saved.connectionUrl).toBe("postgres://localhost:5432/mydb"); + + const retrieved = await store.getLocalConfig(projectId); + expect(retrieved!.environment).toBe("development"); + }); + }); + + // ========================================== + // .gitignore Management + // ========================================== + + describe("Gitignore Management", () => { + let projectId: string; + + beforeEach(async () => { + const project = await store.createProject({ + databaseId: "db-1", + name: "GitignoreTest", + }); + projectId = project.id; + }); + + test("should create .gitignore on project creation", async () => { + const dir = getProjectDir(projectId); + const giPath = path.join(dir, ".gitignore"); + expect(fsSync.existsSync(giPath)).toBe(true); + }); + + test("should include relwave.local.json in .gitignore", async () => { + const dir = getProjectDir(projectId); + const content = await fs.readFile(path.join(dir, ".gitignore"), "utf-8"); + expect(content).toContain("relwave.local.json"); + }); + + test("should be idempotent", async () => { + // Already created once during project creation + const result = await store.ensureGitignore(projectId); + // Should return false = already has our rules + expect(result).toBe(false); + }); + + test("should append to existing .gitignore without our rules", async () => { + const dir = getProjectDir(projectId); + const giPath = path.join(dir, ".gitignore"); + + // Overwrite with custom content (without our rules) + await fs.writeFile(giPath, "node_modules/\n*.log\n", "utf-8"); + + const result = await store.ensureGitignore(projectId); + expect(result).toBe(true); + + const content = await fs.readFile(giPath, "utf-8"); + expect(content).toContain("node_modules/"); + expect(content).toContain("relwave.local.json"); + }); + }); +});