diff --git a/src/lib/server/pcd/deps.ts b/src/lib/server/pcd/deps.ts index ea03682..86da1ff 100644 --- a/src/lib/server/pcd/deps.ts +++ b/src/lib/server/pcd/deps.ts @@ -3,7 +3,7 @@ * Handles cloning and managing PCD dependencies */ -import * as git from '$utils/git/git.ts'; +import { Git, clone } from '$utils/git/index.ts'; import { loadManifest } from './manifest.ts'; /** @@ -34,10 +34,11 @@ async function cloneDependency( const depPath = getDependencyPath(pcdPath, repoName); // Clone the dependency repository - await git.clone(repoUrl, depPath); + await clone(repoUrl, depPath); // Checkout the specific version tag - await git.checkout(depPath, version); + const git = new Git(depPath); + await git.checkout(version); // Clean up dependency - keep only ops folder and pcd.json const keepItems = new Set(['ops', 'pcd.json']); diff --git a/src/lib/server/pcd/pcd.ts b/src/lib/server/pcd/pcd.ts index 190e60a..82818cb 100644 --- a/src/lib/server/pcd/pcd.ts +++ b/src/lib/server/pcd/pcd.ts @@ -2,7 +2,7 @@ * PCD Manager - High-level orchestration for PCD lifecycle */ -import * as git from '$utils/git/git.ts'; +import { Git, clone, type GitStatus, type UpdateInfo } from '$utils/git/index.ts'; import { databaseInstancesQueries } from '$db/queries/databaseInstances.ts'; import type { DatabaseInstance } from '$db/queries/databaseInstances.ts'; import { loadManifest, type Manifest } from './manifest.ts'; @@ -51,7 +51,7 @@ class PCDManager { try { // Clone the repository and detect if it's private - const isPrivate = await git.clone(options.repositoryUrl, localPath, options.branch, options.personalAccessToken); + const isPrivate = await clone(options.repositoryUrl, localPath, options.branch, options.personalAccessToken); // Validate manifest (loadManifest throws if invalid) await loadManifest(localPath); @@ -151,9 +151,11 @@ class PCDManager { throw new Error(`Database instance ${id} not found`); } + const git = new Git(instance.local_path); + try { // Check for updates first - const updateInfo = await git.checkForUpdates(instance.local_path); + const updateInfo = await git.checkForUpdates(); if (!updateInfo.hasUpdates) { // Already up to date @@ -165,7 +167,7 @@ class PCDManager { } // Pull updates - await git.pull(instance.local_path); + await git.pull(); // Sync dependencies (schema, etc.) if versions changed await syncDependencies(instance.local_path); @@ -202,13 +204,14 @@ class PCDManager { /** * Check for available updates without pulling */ - async checkForUpdates(id: number): Promise { + async checkForUpdates(id: number): Promise { const instance = databaseInstancesQueries.getById(id); if (!instance) { throw new Error(`Database instance ${id} not found`); } - return await git.checkForUpdates(instance.local_path); + const git = new Git(instance.local_path); + return await git.checkForUpdates(); } /** @@ -232,21 +235,23 @@ class PCDManager { throw new Error(`Database instance ${id} not found`); } - await git.checkout(instance.local_path, branch); - await git.pull(instance.local_path); + const git = new Git(instance.local_path); + await git.checkout(branch); + await git.pull(); databaseInstancesQueries.updateSyncedAt(id); } /** * Get git status for a PCD */ - async getStatus(id: number): Promise { + async getStatus(id: number): Promise { const instance = databaseInstancesQueries.getById(id); if (!instance) { throw new Error(`Database instance ${id} not found`); } - return await git.getStatus(instance.local_path); + const git = new Git(instance.local_path); + return await git.status(); } /** diff --git a/src/lib/server/utils/git/Git.ts b/src/lib/server/utils/git/Git.ts new file mode 100644 index 0000000..4c33b35 --- /dev/null +++ b/src/lib/server/utils/git/Git.ts @@ -0,0 +1,32 @@ +/** + * Git class - wraps git operations for a repository + */ + +import type { GitStatus, OperationFile, CommitResult, UpdateInfo } from './types.ts'; +import * as repo from './repo.ts'; +import * as status from './status.ts'; +import * as ops from './ops.ts'; + +export class Git { + constructor(private repoPath: string) {} + + // Repo commands + fetch = () => repo.fetch(this.repoPath); + pull = () => repo.pull(this.repoPath); + push = () => repo.push(this.repoPath); + checkout = (branch: string) => repo.checkout(this.repoPath, branch); + resetToRemote = () => repo.resetToRemote(this.repoPath); + + // Status queries + getBranch = () => status.getBranch(this.repoPath); + status = (): Promise => status.getStatus(this.repoPath); + checkForUpdates = (): Promise => status.checkForUpdates(this.repoPath); + getLastPushed = () => status.getLastPushed(this.repoPath); + + // Operation file methods + getUncommittedOps = (): Promise => ops.getUncommittedOps(this.repoPath); + getMaxOpNumber = () => ops.getMaxOpNumber(this.repoPath); + discardOps = (filepaths: string[]) => ops.discardOps(this.repoPath, filepaths); + addOps = (filepaths: string[], message: string): Promise => + ops.addOps(this.repoPath, filepaths, message); +} diff --git a/src/lib/server/utils/git/exec.ts b/src/lib/server/utils/git/exec.ts new file mode 100644 index 0000000..610ec59 --- /dev/null +++ b/src/lib/server/utils/git/exec.ts @@ -0,0 +1,43 @@ +/** + * Git command execution helper + */ + +/** + * Execute a git command with sandboxed environment + */ +export async function execGit(args: string[], cwd: string): Promise { + const command = new Deno.Command('git', { + args, + cwd, + stdout: 'piped', + stderr: 'piped', + env: { + GIT_TERMINAL_PROMPT: '0', + GIT_ASKPASS: 'echo', + GIT_SSH_COMMAND: 'ssh -o BatchMode=yes', + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'credential.helper', + GIT_CONFIG_VALUE_0: '' + } + }); + + const { code, stdout, stderr } = await command.output(); + + if (code !== 0) { + const errorMessage = new TextDecoder().decode(stderr); + throw new Error(`Git command failed: ${errorMessage}`); + } + + return new TextDecoder().decode(stdout).trim(); +} + +/** + * Execute a git command, returning null on failure instead of throwing + */ +export async function execGitSafe(args: string[], cwd: string): Promise { + try { + return await execGit(args, cwd); + } catch { + return null; + } +} diff --git a/src/lib/server/utils/git/git.ts b/src/lib/server/utils/git/git.ts deleted file mode 100644 index a09d202..0000000 --- a/src/lib/server/utils/git/git.ts +++ /dev/null @@ -1,299 +0,0 @@ -/** - * Git utility functions for managing database repositories - */ - -export interface GitStatus { - currentBranch: string; - isDirty: boolean; - untracked: string[]; - modified: string[]; - staged: string[]; -} - -export interface UpdateInfo { - hasUpdates: boolean; - commitsBehind: number; - commitsAhead: number; - latestRemoteCommit: string; - currentLocalCommit: string; -} - -/** - * Execute a git command with sandboxed environment (no system credentials) - */ -async function execGit(args: string[], cwd?: string): Promise { - const command = new Deno.Command('git', { - args, - cwd, - stdout: 'piped', - stderr: 'piped', - env: { - // Disable all credential helpers and interactive prompts - GIT_TERMINAL_PROMPT: '0', // Fail instead of prompting (git 2.3+) - GIT_ASKPASS: 'echo', // Return empty on credential requests - GIT_SSH_COMMAND: 'ssh -o BatchMode=yes', // Disable SSH password prompts - // Clear credential helpers via environment config - GIT_CONFIG_COUNT: '1', - GIT_CONFIG_KEY_0: 'credential.helper', - GIT_CONFIG_VALUE_0: '' - } - }); - - const { code, stdout, stderr } = await command.output(); - - if (code !== 0) { - const errorMessage = new TextDecoder().decode(stderr); - throw new Error(`Git command failed: ${errorMessage}`); - } - - return new TextDecoder().decode(stdout).trim(); -} - -/** - * Validate that a repository URL is accessible and detect if it's private using GitHub API - * Returns true if the repository is private, false if public - */ -async function validateRepository(repositoryUrl: string, personalAccessToken?: string): Promise { - // Validate GitHub URL format and extract owner/repo - const githubPattern = /^https:\/\/github\.com\/([\w-]+)\/([\w-]+)\/?$/; - const normalizedUrl = repositoryUrl.replace(/\.git$/, ''); - const match = normalizedUrl.match(githubPattern); - - if (!match) { - throw new Error('Repository URL must be a valid GitHub repository (https://github.com/username/repo)'); - } - - const [, owner, repo] = match; - const apiUrl = `https://api.github.com/repos/${owner}/${repo}`; - - // First try without authentication to check if it's public - try { - const response = await globalThis.fetch(apiUrl, { - headers: { - 'Accept': 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - 'User-Agent': 'Profilarr' - } - }); - - if (response.ok) { - const data = await response.json(); - // Repository is accessible without auth - return data.private === true; - } - - // 404 or 403 means repo doesn't exist or is private - if (response.status === 404 || response.status === 403) { - // If we have a PAT, try with authentication - if (personalAccessToken) { - const authResponse = await globalThis.fetch(apiUrl, { - headers: { - 'Accept': 'application/vnd.github+json', - 'Authorization': `Bearer ${personalAccessToken}`, - 'X-GitHub-Api-Version': '2022-11-28', - 'User-Agent': 'Profilarr' - } - }); - - if (authResponse.ok) { - const data = await authResponse.json(); - return data.private === true; - } - - if (authResponse.status === 404) { - throw new Error('Repository not found. Please check the URL.'); - } - - if (authResponse.status === 401 || authResponse.status === 403) { - throw new Error('Unable to access repository. Please check your Personal Access Token has the correct permissions (repo scope required).'); - } - - throw new Error(`GitHub API error: ${authResponse.status} ${authResponse.statusText}`); - } - - throw new Error('Repository not found or is private. Please provide a Personal Access Token if this is a private repository.'); - } - - throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); - } catch (error) { - if (error instanceof Error && ( - error.message.includes('Repository not found') || - error.message.includes('Unable to access') || - error.message.includes('GitHub API error') - )) { - throw error; - } - throw new Error(`Failed to validate repository: ${error instanceof Error ? error.message : String(error)}`); - } -} - -/** - * Clone a git repository - * Returns true if the repository is private, false if public - */ -export async function clone( - repositoryUrl: string, - targetPath: string, - branch?: string, - personalAccessToken?: string -): Promise { - // Validate repository exists and detect if it's private - const isPrivate = await validateRepository(repositoryUrl, personalAccessToken); - - const args = ['clone']; - - if (branch) { - args.push('--branch', branch); - } - - // Inject personal access token into URL if provided (for private repos or push access) - let authUrl = repositoryUrl; - if (personalAccessToken) { - // Format: https://TOKEN@github.com/username/repo - authUrl = repositoryUrl.replace('https://github.com', `https://${personalAccessToken}@github.com`); - } - - args.push(authUrl, targetPath); - - await execGit(args); - - return isPrivate; -} - -/** - * Pull latest changes from remote - */ -export async function pull(repoPath: string): Promise { - await execGit(['pull'], repoPath); -} - -/** - * Fetch from remote without merging - */ -export async function fetchRemote(repoPath: string): Promise { - await execGit(['fetch'], repoPath); -} - -/** - * Get current branch name - */ -export async function getCurrentBranch(repoPath: string): Promise { - return await execGit(['branch', '--show-current'], repoPath); -} - -/** - * Checkout a branch - */ -export async function checkout(repoPath: string, branch: string): Promise { - await execGit(['checkout', branch], repoPath); -} - -/** - * Get repository status - */ -export async function getStatus(repoPath: string): Promise { - const currentBranch = await getCurrentBranch(repoPath); - - // Get short status - const statusOutput = await execGit(['status', '--short'], repoPath); - - const untracked: string[] = []; - const modified: string[] = []; - const staged: string[] = []; - - for (const line of statusOutput.split('\n')) { - if (!line.trim()) continue; - - const status = line.substring(0, 2); - const file = line.substring(3); - - if (status.startsWith('??')) { - untracked.push(file); - } else if (status[1] === 'M' || status[1] === 'D') { - modified.push(file); - } else if (status[0] === 'M' || status[0] === 'A' || status[0] === 'D') { - staged.push(file); - } - } - - const isDirty = untracked.length > 0 || modified.length > 0 || staged.length > 0; - - return { - currentBranch, - isDirty, - untracked, - modified, - staged - }; -} - -/** - * Check for updates from remote - */ -export async function checkForUpdates(repoPath: string): Promise { - // Fetch latest from remote - await fetchRemote(repoPath); - - const currentBranch = await getCurrentBranch(repoPath); - const remoteBranch = `origin/${currentBranch}`; - - // Get current commit - const currentLocalCommit = await execGit(['rev-parse', 'HEAD'], repoPath); - - // Get remote commit - let latestRemoteCommit: string; - try { - latestRemoteCommit = await execGit(['rev-parse', remoteBranch], repoPath); - } catch { - // Remote branch doesn't exist or hasn't been fetched - return { - hasUpdates: false, - commitsBehind: 0, - commitsAhead: 0, - latestRemoteCommit: currentLocalCommit, - currentLocalCommit - }; - } - - // Count commits behind - let commitsBehind = 0; - try { - const behindOutput = await execGit( - ['rev-list', '--count', `HEAD..${remoteBranch}`], - repoPath - ); - commitsBehind = parseInt(behindOutput) || 0; - } catch { - commitsBehind = 0; - } - - // Count commits ahead - let commitsAhead = 0; - try { - const aheadOutput = await execGit( - ['rev-list', '--count', `${remoteBranch}..HEAD`], - repoPath - ); - commitsAhead = parseInt(aheadOutput) || 0; - } catch { - commitsAhead = 0; - } - - return { - hasUpdates: commitsBehind > 0, - commitsBehind, - commitsAhead, - latestRemoteCommit, - currentLocalCommit - }; -} - -/** - * Reset repository to match remote (discards local changes) - */ -export async function resetToRemote(repoPath: string): Promise { - const currentBranch = await getCurrentBranch(repoPath); - const remoteBranch = `origin/${currentBranch}`; - - await execGit(['reset', '--hard', remoteBranch], repoPath); -} diff --git a/src/lib/server/utils/git/index.ts b/src/lib/server/utils/git/index.ts new file mode 100644 index 0000000..f75b793 --- /dev/null +++ b/src/lib/server/utils/git/index.ts @@ -0,0 +1,9 @@ +/** + * Git utilities + */ + +export { Git } from './Git.ts'; +export * from './types.ts'; + +// Direct function exports +export { clone } from './repo.ts'; diff --git a/src/lib/server/utils/git/ops.ts b/src/lib/server/utils/git/ops.ts new file mode 100644 index 0000000..413b16a --- /dev/null +++ b/src/lib/server/utils/git/ops.ts @@ -0,0 +1,179 @@ +/** + * Git operations for PCD operation files + */ + +import { execGitSafe } from './exec.ts'; +import { pull, stage, commit, push } from './repo.ts'; +import type { OperationFile, CommitResult } from './types.ts'; + +/** + * Parse metadata from an operation file header + */ +async function parseOperationMetadata(filepath: string): Promise> { + try { + const content = await Deno.readTextFile(filepath); + const lines = content.split('\n'); + const metadata: Partial = {}; + + for (const line of lines) { + if (!line.startsWith('-- @')) break; + + const match = line.match(/^-- @(\w+): (.+)$/); + if (match) { + const [, key, value] = match; + if (key === 'operation') metadata.operation = value; + if (key === 'entity') metadata.entity = value; + if (key === 'name') metadata.name = value; + if (key === 'previous_name') metadata.previousName = value; + } + } + + return metadata; + } catch { + return {}; + } +} + +/** + * Get uncommitted operation files from ops/ directory + */ +export async function getUncommittedOps(repoPath: string): Promise { + const files: OperationFile[] = []; + + const output = await execGitSafe(['status', '--porcelain', 'ops/'], repoPath); + if (!output) return files; + + for (const line of output.split('\n')) { + if (!line.trim()) continue; + + const status = line.substring(0, 2); + const filename = line.substring(3).trim(); + + // Only include new/modified .sql files + if ((status.includes('?') || status.includes('A') || status.includes('M')) && filename.endsWith('.sql')) { + const filepath = `${repoPath}/${filename}`; + const metadata = await parseOperationMetadata(filepath); + + files.push({ + filename: filename.replace('ops/', ''), + filepath, + operation: metadata.operation || null, + entity: metadata.entity || null, + name: metadata.name || null, + previousName: metadata.previousName || null + }); + } + } + + return files; +} + +/** + * Get the highest operation number in ops/ + */ +export async function getMaxOpNumber(repoPath: string): Promise { + let maxNum = 0; + const opsPath = `${repoPath}/ops`; + + try { + for await (const entry of Deno.readDir(opsPath)) { + if (entry.isFile && entry.name.endsWith('.sql')) { + const match = entry.name.match(/^(\d+)\./); + if (match) { + const num = parseInt(match[1], 10); + if (num > maxNum) maxNum = num; + } + } + } + } catch { + // Directory might not exist + } + + return maxNum; +} + +/** + * Discard operation files (delete them) + */ +export async function discardOps(repoPath: string, filepaths: string[]): Promise { + for (const filepath of filepaths) { + // Security: ensure file is within ops directory + if (!filepath.startsWith(repoPath + '/ops/')) { + continue; + } + + try { + await Deno.remove(filepath); + } catch { + // File might already be deleted + } + } +} + +/** + * Add operation files: pull, renumber if needed, commit, push + */ +export async function addOps( + repoPath: string, + filepaths: string[], + message: string +): Promise { + if (!message?.trim()) { + return { success: false, error: 'Commit message is required' }; + } + + if (filepaths.length === 0) { + return { success: false, error: 'No files selected' }; + } + + try { + // 1. Pull latest + await pull(repoPath); + + // 2. Get max op number after pull + let maxNum = await getMaxOpNumber(repoPath); + + // 3. Renumber and collect files to stage + const filesToStage: string[] = []; + const opsPath = `${repoPath}/ops`; + + for (const filepath of filepaths) { + if (!filepath.startsWith(repoPath + '/ops/')) continue; + + const filename = filepath.split('/').pop()!; + const match = filename.match(/^(\d+)\.(.+)$/); + + if (match) { + const currentNum = parseInt(match[1], 10); + const rest = match[2]; + + if (currentNum <= maxNum) { + // Need to renumber + maxNum++; + const newFilename = `${maxNum}.${rest}`; + const newFilepath = `${opsPath}/${newFilename}`; + await Deno.rename(filepath, newFilepath); + filesToStage.push(newFilepath); + } else { + maxNum = Math.max(maxNum, currentNum); + filesToStage.push(filepath); + } + } else { + filesToStage.push(filepath); + } + } + + // 4. Stage files + await stage(repoPath, filesToStage); + + // 5. Commit + await commit(repoPath, message.trim()); + + // 6. Push + await push(repoPath); + + return { success: true }; + } catch (err) { + return { success: false, error: String(err) }; + } +} diff --git a/src/lib/server/utils/git/repo.ts b/src/lib/server/utils/git/repo.ts new file mode 100644 index 0000000..4b12e80 --- /dev/null +++ b/src/lib/server/utils/git/repo.ts @@ -0,0 +1,159 @@ +/** + * Git repository commands + */ + +import { execGit, execGitSafe } from './exec.ts'; + +/** + * Validate that a repository URL is accessible and detect if it's private + */ +async function validateRepository(repositoryUrl: string, personalAccessToken?: string): Promise { + const githubPattern = /^https:\/\/github\.com\/([\w-]+)\/([\w-]+)\/?$/; + const normalizedUrl = repositoryUrl.replace(/\.git$/, ''); + const match = normalizedUrl.match(githubPattern); + + if (!match) { + throw new Error('Repository URL must be a valid GitHub repository (https://github.com/username/repo)'); + } + + const [, owner, repo] = match; + const apiUrl = `https://api.github.com/repos/${owner}/${repo}`; + + const headers: Record = { + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'Profilarr' + }; + + // Try without auth first + const response = await globalThis.fetch(apiUrl, { headers }); + + if (response.ok) { + const data = await response.json(); + return data.private === true; + } + + // If 404/403 and we have PAT, try with auth + if ((response.status === 404 || response.status === 403) && personalAccessToken) { + const authResponse = await globalThis.fetch(apiUrl, { + headers: { ...headers, 'Authorization': `Bearer ${personalAccessToken}` } + }); + + if (authResponse.ok) { + const data = await authResponse.json(); + return data.private === true; + } + + if (authResponse.status === 404) { + throw new Error('Repository not found. Please check the URL.'); + } + + if (authResponse.status === 401 || authResponse.status === 403) { + throw new Error('Unable to access repository. Please check your Personal Access Token.'); + } + + throw new Error(`GitHub API error: ${authResponse.status}`); + } + + if (response.status === 404 || response.status === 403) { + throw new Error('Repository not found or is private. Provide a Personal Access Token for private repos.'); + } + + throw new Error(`GitHub API error: ${response.status}`); +} + +/** + * Clone a git repository + * Returns true if private, false if public + */ +export async function clone( + repositoryUrl: string, + targetPath: string, + branch?: string, + personalAccessToken?: string +): Promise { + const isPrivate = await validateRepository(repositoryUrl, personalAccessToken); + + const args = ['clone']; + if (branch) args.push('--branch', branch); + + let authUrl = repositoryUrl; + if (personalAccessToken) { + authUrl = repositoryUrl.replace('https://github.com', `https://${personalAccessToken}@github.com`); + } + + args.push(authUrl, targetPath); + + const command = new Deno.Command('git', { + args, + stdout: 'piped', + stderr: 'piped', + env: { + GIT_TERMINAL_PROMPT: '0', + GIT_ASKPASS: 'echo', + GIT_SSH_COMMAND: 'ssh -o BatchMode=yes', + GIT_CONFIG_COUNT: '1', + GIT_CONFIG_KEY_0: 'credential.helper', + GIT_CONFIG_VALUE_0: '' + } + }); + + const { code, stderr } = await command.output(); + if (code !== 0) { + throw new Error(`Git clone failed: ${new TextDecoder().decode(stderr)}`); + } + + return isPrivate; +} + +/** + * Fetch from remote (silent) + */ +export async function fetch(repoPath: string): Promise { + await execGitSafe(['fetch', '--quiet'], repoPath); +} + +/** + * Pull with rebase + */ +export async function pull(repoPath: string): Promise { + await execGit(['pull', '--rebase'], repoPath); +} + +/** + * Push to remote + */ +export async function push(repoPath: string): Promise { + await execGit(['push'], repoPath); +} + +/** + * Checkout a branch + */ +export async function checkout(repoPath: string, branch: string): Promise { + await execGit(['checkout', branch], repoPath); +} + +/** + * Reset repository to match remote (discards local changes) + */ +export async function resetToRemote(repoPath: string): Promise { + const branch = await execGit(['branch', '--show-current'], repoPath); + await execGit(['reset', '--hard', `origin/${branch}`], repoPath); +} + +/** + * Stage files + */ +export async function stage(repoPath: string, filepaths: string[]): Promise { + for (const filepath of filepaths) { + await execGit(['add', filepath], repoPath); + } +} + +/** + * Commit staged changes + */ +export async function commit(repoPath: string, message: string): Promise { + await execGit(['commit', '-m', message], repoPath); +} diff --git a/src/lib/server/utils/git/status.ts b/src/lib/server/utils/git/status.ts new file mode 100644 index 0000000..80792ce --- /dev/null +++ b/src/lib/server/utils/git/status.ts @@ -0,0 +1,111 @@ +/** + * Git status queries (read-only) + */ + +import { execGit, execGitSafe } from './exec.ts'; +import { fetch } from './repo.ts'; +import type { GitStatus, UpdateInfo } from './types.ts'; + +/** + * Get current branch name + */ +export async function getBranch(repoPath: string): Promise { + return await execGit(['branch', '--show-current'], repoPath); +} + +/** + * Get full repository status + */ +export async function getStatus(repoPath: string): Promise { + const branch = await getBranch(repoPath); + + // Fetch to get accurate ahead/behind + await fetch(repoPath); + + // Get ahead/behind + let ahead = 0; + let behind = 0; + const revOutput = await execGitSafe( + ['rev-list', '--left-right', '--count', `origin/${branch}...HEAD`], + repoPath + ); + if (revOutput) { + const parts = revOutput.split('\t').map(n => parseInt(n, 10) || 0); + behind = parts[0] || 0; + ahead = parts[1] || 0; + } + + // Get file status + const statusOutput = await execGit(['status', '--porcelain'], repoPath); + const untracked: string[] = []; + const modified: string[] = []; + const staged: string[] = []; + + for (const line of statusOutput.split('\n')) { + if (!line.trim()) continue; + + const status = line.substring(0, 2); + const file = line.substring(3); + + if (status.startsWith('??')) { + untracked.push(file); + } else if (status[1] === 'M' || status[1] === 'D') { + modified.push(file); + } + if (status[0] === 'M' || status[0] === 'A' || status[0] === 'D') { + staged.push(file); + } + } + + const isDirty = untracked.length > 0 || modified.length > 0 || staged.length > 0; + + return { branch, isDirty, ahead, behind, untracked, modified, staged }; +} + +/** + * Check for updates from remote + */ +export async function checkForUpdates(repoPath: string): Promise { + await fetch(repoPath); + + const branch = await getBranch(repoPath); + const remoteBranch = `origin/${branch}`; + + const currentLocalCommit = await execGit(['rev-parse', 'HEAD'], repoPath); + + let latestRemoteCommit: string; + try { + latestRemoteCommit = await execGit(['rev-parse', remoteBranch], repoPath); + } catch { + return { + hasUpdates: false, + commitsBehind: 0, + commitsAhead: 0, + latestRemoteCommit: currentLocalCommit, + currentLocalCommit + }; + } + + const behindOutput = await execGitSafe(['rev-list', '--count', `HEAD..${remoteBranch}`], repoPath); + const commitsBehind = parseInt(behindOutput || '0') || 0; + + const aheadOutput = await execGitSafe(['rev-list', '--count', `${remoteBranch}..HEAD`], repoPath); + const commitsAhead = parseInt(aheadOutput || '0') || 0; + + return { + hasUpdates: commitsBehind > 0, + commitsBehind, + commitsAhead, + latestRemoteCommit, + currentLocalCommit + }; +} + +/** + * Get the last push timestamp (approximate via remote HEAD) + */ +export async function getLastPushed(repoPath: string): Promise { + const branch = await getBranch(repoPath); + const output = await execGitSafe(['log', '-1', '--format=%cI', `origin/${branch}`], repoPath); + return output || null; +} diff --git a/src/lib/server/utils/git/types.ts b/src/lib/server/utils/git/types.ts new file mode 100644 index 0000000..49ca2f1 --- /dev/null +++ b/src/lib/server/utils/git/types.ts @@ -0,0 +1,35 @@ +/** + * Git utility types + */ + +export interface GitStatus { + branch: string; + isDirty: boolean; + ahead: number; + behind: number; + untracked: string[]; + modified: string[]; + staged: string[]; +} + +export interface OperationFile { + filename: string; + filepath: string; + operation: string | null; + entity: string | null; + name: string | null; + previousName: string | null; +} + +export interface CommitResult { + success: boolean; + error?: string; +} + +export interface UpdateInfo { + hasUpdates: boolean; + commitsBehind: number; + commitsAhead: number; + latestRemoteCommit: string; + currentLocalCommit: string; +}