feat(git): refactor git utilities and introduce Git class for repository operations

This commit is contained in:
Sam Chau
2025-12-28 22:37:14 +10:30
parent 7c07f87d7c
commit 9ddb426f13
10 changed files with 587 additions and 312 deletions

View File

@@ -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']);

View File

@@ -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();
}
/**

View 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);
}

View 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;
}
}

View File

@@ -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);
}

View File

@@ -0,0 +1,9 @@
/**
* Git utilities
*/
export { Git } from './Git.ts';
export * from './types.ts';
// Direct function exports
export { clone } from './repo.ts';

View 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) };
}
}

View 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);
}

View 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;
}

View 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;
}