mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-29 05:50:51 +01:00
feat(git): refactor git utilities and introduce Git class for repository operations
This commit is contained in:
@@ -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']);
|
||||
|
||||
@@ -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<git.UpdateInfo> {
|
||||
async checkForUpdates(id: number): Promise<UpdateInfo> {
|
||||
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<git.GitStatus> {
|
||||
async getStatus(id: number): Promise<GitStatus> {
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
32
src/lib/server/utils/git/Git.ts
Normal file
32
src/lib/server/utils/git/Git.ts
Normal file
@@ -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<GitStatus> => status.getStatus(this.repoPath);
|
||||
checkForUpdates = (): Promise<UpdateInfo> => status.checkForUpdates(this.repoPath);
|
||||
getLastPushed = () => status.getLastPushed(this.repoPath);
|
||||
|
||||
// Operation file methods
|
||||
getUncommittedOps = (): Promise<OperationFile[]> => ops.getUncommittedOps(this.repoPath);
|
||||
getMaxOpNumber = () => ops.getMaxOpNumber(this.repoPath);
|
||||
discardOps = (filepaths: string[]) => ops.discardOps(this.repoPath, filepaths);
|
||||
addOps = (filepaths: string[], message: string): Promise<CommitResult> =>
|
||||
ops.addOps(this.repoPath, filepaths, message);
|
||||
}
|
||||
43
src/lib/server/utils/git/exec.ts
Normal file
43
src/lib/server/utils/git/exec.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Git command execution helper
|
||||
*/
|
||||
|
||||
/**
|
||||
* Execute a git command with sandboxed environment
|
||||
*/
|
||||
export async function execGit(args: string[], cwd: string): Promise<string> {
|
||||
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<string | null> {
|
||||
try {
|
||||
return await execGit(args, cwd);
|
||||
} catch {
|
||||
return 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<string> {
|
||||
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<boolean> {
|
||||
// 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<boolean> {
|
||||
// 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<void> {
|
||||
await execGit(['pull'], repoPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch from remote without merging
|
||||
*/
|
||||
export async function fetchRemote(repoPath: string): Promise<void> {
|
||||
await execGit(['fetch'], repoPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current branch name
|
||||
*/
|
||||
export async function getCurrentBranch(repoPath: string): Promise<string> {
|
||||
return await execGit(['branch', '--show-current'], repoPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout a branch
|
||||
*/
|
||||
export async function checkout(repoPath: string, branch: string): Promise<void> {
|
||||
await execGit(['checkout', branch], repoPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get repository status
|
||||
*/
|
||||
export async function getStatus(repoPath: string): Promise<GitStatus> {
|
||||
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<UpdateInfo> {
|
||||
// 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<void> {
|
||||
const currentBranch = await getCurrentBranch(repoPath);
|
||||
const remoteBranch = `origin/${currentBranch}`;
|
||||
|
||||
await execGit(['reset', '--hard', remoteBranch], repoPath);
|
||||
}
|
||||
9
src/lib/server/utils/git/index.ts
Normal file
9
src/lib/server/utils/git/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Git utilities
|
||||
*/
|
||||
|
||||
export { Git } from './Git.ts';
|
||||
export * from './types.ts';
|
||||
|
||||
// Direct function exports
|
||||
export { clone } from './repo.ts';
|
||||
179
src/lib/server/utils/git/ops.ts
Normal file
179
src/lib/server/utils/git/ops.ts
Normal file
@@ -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<Partial<OperationFile>> {
|
||||
try {
|
||||
const content = await Deno.readTextFile(filepath);
|
||||
const lines = content.split('\n');
|
||||
const metadata: Partial<OperationFile> = {};
|
||||
|
||||
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<OperationFile[]> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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<CommitResult> {
|
||||
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) };
|
||||
}
|
||||
}
|
||||
159
src/lib/server/utils/git/repo.ts
Normal file
159
src/lib/server/utils/git/repo.ts
Normal file
@@ -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<boolean> {
|
||||
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<string, string> = {
|
||||
'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<boolean> {
|
||||
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<void> {
|
||||
await execGitSafe(['fetch', '--quiet'], repoPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull with rebase
|
||||
*/
|
||||
export async function pull(repoPath: string): Promise<void> {
|
||||
await execGit(['pull', '--rebase'], repoPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push to remote
|
||||
*/
|
||||
export async function push(repoPath: string): Promise<void> {
|
||||
await execGit(['push'], repoPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout a branch
|
||||
*/
|
||||
export async function checkout(repoPath: string, branch: string): Promise<void> {
|
||||
await execGit(['checkout', branch], repoPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset repository to match remote (discards local changes)
|
||||
*/
|
||||
export async function resetToRemote(repoPath: string): Promise<void> {
|
||||
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<void> {
|
||||
for (const filepath of filepaths) {
|
||||
await execGit(['add', filepath], repoPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit staged changes
|
||||
*/
|
||||
export async function commit(repoPath: string, message: string): Promise<void> {
|
||||
await execGit(['commit', '-m', message], repoPath);
|
||||
}
|
||||
111
src/lib/server/utils/git/status.ts
Normal file
111
src/lib/server/utils/git/status.ts
Normal file
@@ -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<string> {
|
||||
return await execGit(['branch', '--show-current'], repoPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full repository status
|
||||
*/
|
||||
export async function getStatus(repoPath: string): Promise<GitStatus> {
|
||||
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<UpdateInfo> {
|
||||
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<string | null> {
|
||||
const branch = await getBranch(repoPath);
|
||||
const output = await execGitSafe(['log', '-1', '--format=%cI', `origin/${branch}`], repoPath);
|
||||
return output || null;
|
||||
}
|
||||
35
src/lib/server/utils/git/types.ts
Normal file
35
src/lib/server/utils/git/types.ts
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user