diff --git a/action.yml b/action.yml index 201ff27c..d6bda0bc 100644 --- a/action.yml +++ b/action.yml @@ -320,7 +320,9 @@ inputs: required: false default: '' description: - 'Path to a custom Git LFS transfer agent executable (e.g. elastic-git-storage). When set, the agent is registered + 'Custom Git LFS transfer agent. Set to "elastic-git-storage" for built-in support (auto-installs + from GitHub releases). Append @version for a specific release (e.g. "elastic-git-storage@v1.0.0"). + Or provide a path to any custom transfer agent executable. When set, the agent is registered via git config before LFS operations.' lfsTransferAgentArgs: required: false diff --git a/src/model/orchestrator/services/lfs/elastic-git-storage-service.test.ts b/src/model/orchestrator/services/lfs/elastic-git-storage-service.test.ts new file mode 100644 index 00000000..13e33a6e --- /dev/null +++ b/src/model/orchestrator/services/lfs/elastic-git-storage-service.test.ts @@ -0,0 +1,432 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import { ElasticGitStorageService } from './elastic-git-storage-service'; + +jest.mock('node:fs'); +jest.mock('node:os'); +jest.mock('../core/orchestrator-system', () => ({ + OrchestratorSystem: { + Run: jest.fn().mockResolvedValue(''), + }, +})); +jest.mock('../core/orchestrator-logger', () => ({ + __esModule: true, + default: { + log: jest.fn(), + logWarning: jest.fn(), + error: jest.fn(), + }, +})); +jest.mock('./lfs-agent-service', () => ({ + LfsAgentService: { + configure: jest.fn().mockResolvedValue(undefined), + }, +})); + +const mockFs = fs as jest.Mocked; +const mockOs = os as jest.Mocked; + +describe('ElasticGitStorageService', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('parseAgentValue', () => { + it('should parse name without version', () => { + const result = ElasticGitStorageService.parseAgentValue('elastic-git-storage'); + expect(result.name).toBe('elastic-git-storage'); + expect(result.version).toBe('latest'); + }); + + it('should parse name@version', () => { + const result = ElasticGitStorageService.parseAgentValue('elastic-git-storage@v1.0.0'); + expect(result.name).toBe('elastic-git-storage'); + expect(result.version).toBe('v1.0.0'); + }); + + it('should parse name@latest', () => { + const result = ElasticGitStorageService.parseAgentValue('elastic-git-storage@latest'); + expect(result.name).toBe('elastic-git-storage'); + expect(result.version).toBe('latest'); + }); + + it('should handle trailing @ as latest', () => { + const result = ElasticGitStorageService.parseAgentValue('elastic-git-storage@'); + expect(result.name).toBe('elastic-git-storage'); + expect(result.version).toBe('latest'); + }); + + it('should handle whitespace', () => { + const result = ElasticGitStorageService.parseAgentValue(' elastic-git-storage@v2.0.0 '); + expect(result.name).toBe('elastic-git-storage'); + expect(result.version).toBe('v2.0.0'); + }); + }); + + describe('isElasticGitStorage', () => { + it('should match exact name', () => { + expect(ElasticGitStorageService.isElasticGitStorage('elastic-git-storage')).toBe(true); + }); + + it('should match with .exe extension', () => { + expect(ElasticGitStorageService.isElasticGitStorage('elastic-git-storage.exe')).toBe(true); + }); + + it('should match with @version suffix', () => { + expect(ElasticGitStorageService.isElasticGitStorage('elastic-git-storage@v1.0.0')).toBe(true); + }); + + it('should match with @latest suffix', () => { + expect(ElasticGitStorageService.isElasticGitStorage('elastic-git-storage@latest')).toBe(true); + }); + + it('should match forward-slash path', () => { + expect(ElasticGitStorageService.isElasticGitStorage('/usr/local/bin/elastic-git-storage')).toBe(true); + }); + + it('should match backslash path', () => { + expect(ElasticGitStorageService.isElasticGitStorage('C:\\tools\\elastic-git-storage')).toBe(true); + }); + + it('should match path with .exe', () => { + expect(ElasticGitStorageService.isElasticGitStorage('C:\\tools\\elastic-git-storage.exe')).toBe(true); + }); + + it('should be case-insensitive', () => { + expect(ElasticGitStorageService.isElasticGitStorage('Elastic-Git-Storage')).toBe(true); + }); + + it('should handle whitespace', () => { + expect(ElasticGitStorageService.isElasticGitStorage(' elastic-git-storage ')).toBe(true); + }); + + it('should not match other agent names', () => { + expect(ElasticGitStorageService.isElasticGitStorage('lfs-folderstore')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(ElasticGitStorageService.isElasticGitStorage('')).toBe(false); + }); + + it('should not match partial names', () => { + expect(ElasticGitStorageService.isElasticGitStorage('my-elastic-git-storage-v2')).toBe(false); + }); + }); + + describe('findInstalled', () => { + it('should find on PATH via which/where', async () => { + const { OrchestratorSystem } = require('../core/orchestrator-system'); + mockOs.platform.mockReturnValue('linux'); + OrchestratorSystem.Run.mockResolvedValue('/usr/local/bin/elastic-git-storage\n'); + (mockFs.existsSync as jest.Mock).mockReturnValue(true); + + const result = await ElasticGitStorageService.findInstalled(); + expect(result).toBe('/usr/local/bin/elastic-git-storage'); + }); + + it('should use where on windows', async () => { + const { OrchestratorSystem } = require('../core/orchestrator-system'); + mockOs.platform.mockReturnValue('win32'); + OrchestratorSystem.Run.mockResolvedValue('C:\\tools\\elastic-git-storage.exe\n'); + (mockFs.existsSync as jest.Mock).mockReturnValue(true); + + const result = await ElasticGitStorageService.findInstalled(); + expect(result).toBe('C:\\tools\\elastic-git-storage.exe'); + expect(OrchestratorSystem.Run).toHaveBeenCalledWith( + expect.stringContaining('where'), + false, + true, + ); + }); + + it('should check common install locations when not on PATH', async () => { + const { OrchestratorSystem } = require('../core/orchestrator-system'); + mockOs.platform.mockReturnValue('linux'); + mockOs.homedir.mockReturnValue('/home/runner'); + OrchestratorSystem.Run.mockRejectedValue(new Error('not found')); + (mockFs.existsSync as jest.Mock) + .mockReturnValueOnce(false) // RUNNER_TOOL_CACHE + .mockReturnValueOnce(true); // /usr/local/bin + + const result = await ElasticGitStorageService.findInstalled(); + expect(result).toBe('/usr/local/bin/elastic-git-storage'); + }); + + it('should return empty string when not found anywhere', async () => { + const { OrchestratorSystem } = require('../core/orchestrator-system'); + mockOs.platform.mockReturnValue('linux'); + mockOs.homedir.mockReturnValue('/home/runner'); + OrchestratorSystem.Run.mockRejectedValue(new Error('not found')); + (mockFs.existsSync as jest.Mock).mockReturnValue(false); + + const result = await ElasticGitStorageService.findInstalled(); + expect(result).toBe(''); + }); + + it('should check windows-specific locations on win32', async () => { + const { OrchestratorSystem } = require('../core/orchestrator-system'); + mockOs.platform.mockReturnValue('win32'); + mockOs.homedir.mockReturnValue('C:\\Users\\runner'); + OrchestratorSystem.Run.mockRejectedValue(new Error('not found')); + + const originalEnv = { ...process.env }; + process.env.LOCALAPPDATA = 'C:\\Users\\runner\\AppData\\Local'; + process.env.RUNNER_TOOL_CACHE = ''; + + (mockFs.existsSync as jest.Mock).mockImplementation((p: string) => { + return p.includes('AppData'); + }); + + const result = await ElasticGitStorageService.findInstalled(); + expect(result).toContain('elastic-git-storage.exe'); + + process.env = originalEnv; + }); + }); + + describe('install', () => { + it('should download correct binary for linux amd64', async () => { + const { OrchestratorSystem } = require('../core/orchestrator-system'); + mockOs.platform.mockReturnValue('linux'); + mockOs.arch.mockReturnValue('x64'); + mockOs.tmpdir.mockReturnValue('/tmp'); + OrchestratorSystem.Run.mockResolvedValue(''); + (mockFs.existsSync as jest.Mock).mockReturnValue(true); + + const result = await ElasticGitStorageService.install('latest'); + + expect(OrchestratorSystem.Run).toHaveBeenCalledWith( + expect.stringContaining('elastic-git-storage_linux_amd64'), + ); + expect(OrchestratorSystem.Run).toHaveBeenCalledWith( + expect.stringContaining('chmod +x'), + ); + expect(result).toContain('elastic-git-storage'); + }); + + it('should download correct binary for darwin arm64', async () => { + const { OrchestratorSystem } = require('../core/orchestrator-system'); + mockOs.platform.mockReturnValue('darwin'); + mockOs.arch.mockReturnValue('arm64'); + mockOs.tmpdir.mockReturnValue('/tmp'); + (mockFs.existsSync as jest.Mock).mockReturnValue(true); + + await ElasticGitStorageService.install('v1.2.0'); + + expect(OrchestratorSystem.Run).toHaveBeenCalledWith( + expect.stringContaining('elastic-git-storage_darwin_arm64'), + ); + expect(OrchestratorSystem.Run).toHaveBeenCalledWith( + expect.stringContaining('v1.2.0'), + ); + }); + + it('should download .exe for windows', async () => { + const { OrchestratorSystem } = require('../core/orchestrator-system'); + mockOs.platform.mockReturnValue('win32'); + mockOs.arch.mockReturnValue('x64'); + mockOs.tmpdir.mockReturnValue('C:\\temp'); + (mockFs.existsSync as jest.Mock).mockReturnValue(true); + + await ElasticGitStorageService.install('latest'); + + expect(OrchestratorSystem.Run).toHaveBeenCalledWith( + expect.stringContaining('elastic-git-storage_windows_amd64.exe'), + ); + // Should NOT chmod on windows + expect(OrchestratorSystem.Run).not.toHaveBeenCalledWith( + expect.stringContaining('chmod'), + ); + }); + + it('should use RUNNER_TOOL_CACHE for install dir when available', async () => { + const { OrchestratorSystem } = require('../core/orchestrator-system'); + mockOs.platform.mockReturnValue('linux'); + mockOs.arch.mockReturnValue('x64'); + OrchestratorSystem.Run.mockResolvedValue(''); + + const originalCache = process.env.RUNNER_TOOL_CACHE; + process.env.RUNNER_TOOL_CACHE = '/opt/hostedtoolcache'; + + (mockFs.existsSync as jest.Mock).mockReturnValue(true); + + const result = await ElasticGitStorageService.install('latest'); + expect(result).toContain('hostedtoolcache'); + + if (originalCache === undefined) { + delete process.env.RUNNER_TOOL_CACHE; + } else { + process.env.RUNNER_TOOL_CACHE = originalCache; + } + }); + + it('should use latest release URL when version is latest', async () => { + const { OrchestratorSystem } = require('../core/orchestrator-system'); + mockOs.platform.mockReturnValue('linux'); + mockOs.arch.mockReturnValue('x64'); + mockOs.tmpdir.mockReturnValue('/tmp'); + (mockFs.existsSync as jest.Mock).mockReturnValue(true); + + await ElasticGitStorageService.install('latest'); + + expect(OrchestratorSystem.Run).toHaveBeenCalledWith( + expect.stringContaining('/releases/latest/download/'), + ); + }); + + it('should use tagged release URL when version is specified', async () => { + const { OrchestratorSystem } = require('../core/orchestrator-system'); + mockOs.platform.mockReturnValue('linux'); + mockOs.arch.mockReturnValue('x64'); + mockOs.tmpdir.mockReturnValue('/tmp'); + (mockFs.existsSync as jest.Mock).mockReturnValue(true); + + await ElasticGitStorageService.install('v2.0.0'); + + expect(OrchestratorSystem.Run).toHaveBeenCalledWith( + expect.stringContaining('/releases/download/v2.0.0/'), + ); + }); + + it('should return empty string on download failure', async () => { + const { OrchestratorSystem } = require('../core/orchestrator-system'); + mockOs.platform.mockReturnValue('linux'); + mockOs.arch.mockReturnValue('x64'); + mockOs.tmpdir.mockReturnValue('/tmp'); + OrchestratorSystem.Run.mockRejectedValue(new Error('curl failed')); + + const result = await ElasticGitStorageService.install('latest'); + expect(result).toBe(''); + }); + + it('should return empty string if binary not found after download', async () => { + const { OrchestratorSystem } = require('../core/orchestrator-system'); + mockOs.platform.mockReturnValue('linux'); + mockOs.arch.mockReturnValue('x64'); + mockOs.tmpdir.mockReturnValue('/tmp'); + (mockFs.existsSync as jest.Mock).mockReturnValue(false); + + const result = await ElasticGitStorageService.install('latest'); + expect(result).toBe(''); + }); + }); + + describe('ensureAndConfigure', () => { + it('should use existing installation if found', async () => { + const { LfsAgentService } = require('./lfs-agent-service'); + const { OrchestratorSystem } = require('../core/orchestrator-system'); + mockOs.platform.mockReturnValue('linux'); + mockOs.homedir.mockReturnValue('/home/runner'); + // findInstalled returns a result + OrchestratorSystem.Run.mockResolvedValue('/usr/local/bin/elastic-git-storage\n'); + (mockFs.existsSync as jest.Mock).mockReturnValue(true); + + const result = await ElasticGitStorageService.ensureAndConfigure( + 'latest', + '--verbose', + ['/mnt/lfs'], + '/repo', + ); + + expect(result).toBe('/usr/local/bin/elastic-git-storage'); + expect(LfsAgentService.configure).toHaveBeenCalledWith( + '/usr/local/bin/elastic-git-storage', + '--verbose', + ['/mnt/lfs'], + '/repo', + ); + }); + + it('should install when not found and configure', async () => { + const { LfsAgentService } = require('./lfs-agent-service'); + const { OrchestratorSystem } = require('../core/orchestrator-system'); + mockOs.platform.mockReturnValue('linux'); + mockOs.arch.mockReturnValue('x64'); + mockOs.homedir.mockReturnValue('/home/runner'); + mockOs.tmpdir.mockReturnValue('/tmp'); + + // findInstalled finds nothing + OrchestratorSystem.Run + .mockRejectedValueOnce(new Error('not found')) // which + .mockResolvedValueOnce('') // curl download + .mockResolvedValueOnce(''); // chmod + + (mockFs.existsSync as jest.Mock) + .mockReturnValueOnce(false) // RUNNER_TOOL_CACHE + .mockReturnValueOnce(false) // /usr/local/bin + .mockReturnValueOnce(false) // ~/.local/bin + .mockReturnValueOnce(true); // after install + + const result = await ElasticGitStorageService.ensureAndConfigure( + 'v1.0.0', + '', + [], + '/repo', + ); + + expect(result).toContain('elastic-git-storage'); + expect(LfsAgentService.configure).toHaveBeenCalled(); + }); + + it('should return empty string when install fails', async () => { + const { OrchestratorSystem } = require('../core/orchestrator-system'); + mockOs.platform.mockReturnValue('linux'); + mockOs.arch.mockReturnValue('x64'); + mockOs.homedir.mockReturnValue('/home/runner'); + mockOs.tmpdir.mockReturnValue('/tmp'); + + // findInstalled finds nothing + OrchestratorSystem.Run.mockRejectedValue(new Error('not found')); + (mockFs.existsSync as jest.Mock).mockReturnValue(false); + + const result = await ElasticGitStorageService.ensureAndConfigure( + 'latest', + '', + [], + '/repo', + ); + + expect(result).toBe(''); + }); + + it('should use default version when empty string passed', async () => { + const { OrchestratorSystem } = require('../core/orchestrator-system'); + mockOs.platform.mockReturnValue('linux'); + mockOs.arch.mockReturnValue('x64'); + mockOs.homedir.mockReturnValue('/home/runner'); + mockOs.tmpdir.mockReturnValue('/tmp'); + + // findInstalled finds nothing + OrchestratorSystem.Run + .mockRejectedValueOnce(new Error('not found')) + .mockResolvedValueOnce('') + .mockResolvedValueOnce(''); + + (mockFs.existsSync as jest.Mock) + .mockReturnValueOnce(false) + .mockReturnValueOnce(false) + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + + await ElasticGitStorageService.ensureAndConfigure('', '', [], '/repo'); + + expect(OrchestratorSystem.Run).toHaveBeenCalledWith( + expect.stringContaining('/releases/latest/download/'), + ); + }); + }); + + describe('constants', () => { + it('should have correct repo owner', () => { + expect(ElasticGitStorageService.REPO_OWNER).toBe('frostebite'); + }); + + it('should have correct repo name', () => { + expect(ElasticGitStorageService.REPO_NAME).toBe('elastic-git-storage'); + }); + + it('should have correct agent name', () => { + expect(ElasticGitStorageService.AGENT_NAME).toBe('elastic-git-storage'); + }); + }); +}); diff --git a/src/model/orchestrator/services/lfs/elastic-git-storage-service.ts b/src/model/orchestrator/services/lfs/elastic-git-storage-service.ts new file mode 100644 index 00000000..5fed1725 --- /dev/null +++ b/src/model/orchestrator/services/lfs/elastic-git-storage-service.ts @@ -0,0 +1,211 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { OrchestratorSystem } from '../core/orchestrator-system'; +import OrchestratorLogger from '../core/orchestrator-logger'; +import { LfsAgentService } from './lfs-agent-service'; + +/** + * Built-in support for elastic-git-storage, a custom Git LFS transfer agent + * that supports multiple storage backends (local filesystem, WebDAV, rclone remotes). + * + * When `lfsTransferAgent` is set to 'elastic-git-storage', this service: + * 1. Checks if the agent is already installed on the system + * 2. If not, downloads it from the GitHub release page + * 3. Makes it executable + * 4. Delegates to LfsAgentService for git config setup + * + * @see https://github.com/frostebite/elastic-git-storage + */ +export class ElasticGitStorageService { + static readonly REPO_OWNER = 'frostebite'; + static readonly REPO_NAME = 'elastic-git-storage'; + static readonly AGENT_NAME = 'elastic-git-storage'; + + /** + * Parse an agent value that may include a version suffix. + * Supports formats like: + * - 'elastic-git-storage' → { name: 'elastic-git-storage', version: 'latest' } + * - 'elastic-git-storage@v1.0.0' → { name: 'elastic-git-storage', version: 'v1.0.0' } + * - 'elastic-git-storage@latest' → { name: 'elastic-git-storage', version: 'latest' } + */ + static parseAgentValue(agentValue: string): { name: string; version: string } { + const trimmed = agentValue.trim(); + const atIndex = trimmed.indexOf('@'); + if (atIndex > 0) { + return { + name: trimmed.substring(0, atIndex), + version: trimmed.substring(atIndex + 1) || 'latest', + }; + } + + return { name: trimmed, version: 'latest' }; + } + + /** + * Check if the given lfsTransferAgent value refers to elastic-git-storage. + * Matches the exact name (without path) or a path ending in the agent name. + * Also matches 'elastic-git-storage@version' format. + */ + static isElasticGitStorage(agentValue: string): boolean { + if (!agentValue) return false; + + const { name } = ElasticGitStorageService.parseAgentValue(agentValue); + const normalized = name.trim().toLowerCase(); + + return ( + normalized === 'elastic-git-storage' || + normalized === 'elastic-git-storage.exe' || + normalized.endsWith('/elastic-git-storage') || + normalized.endsWith('\\elastic-git-storage') || + normalized.endsWith('/elastic-git-storage.exe') || + normalized.endsWith('\\elastic-git-storage.exe') + ); + } + + /** + * Resolve the full path to the elastic-git-storage executable. + * + * Search order: + * 1. The provided path (if it's a full path and exists) + * 2. $PATH lookup via `which` / `where` + * 3. Known install locations ($RUNNER_TOOL_CACHE, /usr/local/bin, ~/.local/bin) + * + * @returns Full path to the executable, or empty string if not found + */ + static async findInstalled(): Promise { + // Check PATH + try { + const whichCmd = os.platform() === 'win32' ? 'where elastic-git-storage' : 'which elastic-git-storage'; + const result = await OrchestratorSystem.Run(whichCmd, false, true); + const foundPath = result.trim().split('\n')[0].trim(); + if (foundPath && fs.existsSync(foundPath)) { + return foundPath; + } + } catch { + // Not on PATH + } + + // Check common install locations + const candidates = [ + path.join(process.env.RUNNER_TOOL_CACHE || '', 'elastic-git-storage', 'elastic-git-storage'), + '/usr/local/bin/elastic-git-storage', + path.join(os.homedir(), '.local', 'bin', 'elastic-git-storage'), + ]; + + if (os.platform() === 'win32') { + candidates.push( + path.join(process.env.RUNNER_TOOL_CACHE || '', 'elastic-git-storage', 'elastic-git-storage.exe'), + path.join(process.env.LOCALAPPDATA || '', 'elastic-git-storage', 'elastic-git-storage.exe'), + ); + } + + for (const candidate of candidates) { + if (candidate && fs.existsSync(candidate)) { + return candidate; + } + } + + return ''; + } + + /** + * Install elastic-git-storage from GitHub releases. + * + * @param version - Version to install (e.g., 'v1.0.0', 'latest') + * @returns Path to the installed executable + */ + static async install(version: string = 'latest'): Promise { + const platform = os.platform(); + const arch = os.arch(); + + const osName = platform === 'win32' ? 'windows' : platform === 'darwin' ? 'darwin' : 'linux'; + const archName = arch === 'x64' ? 'amd64' : arch === 'arm64' ? 'arm64' : 'amd64'; + const ext = platform === 'win32' ? '.exe' : ''; + + const installDir = process.env.RUNNER_TOOL_CACHE + ? path.join(process.env.RUNNER_TOOL_CACHE, 'elastic-git-storage') + : path.join(os.tmpdir(), 'elastic-git-storage'); + + const binaryName = `elastic-git-storage${ext}`; + const installPath = path.join(installDir, binaryName); + + OrchestratorLogger.log(`[ElasticGitStorage] Installing to ${installPath}`); + + // Create install directory + fs.mkdirSync(installDir, { recursive: true }); + + // Build download URL + const releaseTag = version === 'latest' ? 'latest' : version; + const assetName = `elastic-git-storage_${osName}_${archName}${ext}`; + + let downloadUrl: string; + if (releaseTag === 'latest') { + downloadUrl = `https://github.com/${ElasticGitStorageService.REPO_OWNER}/${ElasticGitStorageService.REPO_NAME}/releases/latest/download/${assetName}`; + } else { + downloadUrl = `https://github.com/${ElasticGitStorageService.REPO_OWNER}/${ElasticGitStorageService.REPO_NAME}/releases/download/${releaseTag}/${assetName}`; + } + + OrchestratorLogger.log(`[ElasticGitStorage] Downloading from ${downloadUrl}`); + + try { + await OrchestratorSystem.Run(`curl -fsSL -o "${installPath}" "${downloadUrl}"`); + + if (platform !== 'win32') { + await OrchestratorSystem.Run(`chmod +x "${installPath}"`); + } + + // Verify installation + if (!fs.existsSync(installPath)) { + throw new Error(`Binary not found after download at ${installPath}`); + } + + OrchestratorLogger.log(`[ElasticGitStorage] Successfully installed ${releaseTag} to ${installPath}`); + + return installPath; + } catch (error: any) { + OrchestratorLogger.logWarning( + `[ElasticGitStorage] Failed to install: ${error.message}. Continuing without elastic-git-storage.`, + ); + + return ''; + } + } + + /** + * Ensure elastic-git-storage is available (find or install) and configure it. + * + * @param version - Version to install if not found ('latest' or a tag like 'v1.0.0') + * @param agentArgs - Additional arguments to pass to the agent + * @param storagePaths - Storage paths for the agent + * @param repoPath - Path to the git repository + * @returns Path to the configured executable, or empty string if setup failed + */ + static async ensureAndConfigure( + version: string, + agentArgs: string, + storagePaths: string[], + repoPath: string, + ): Promise { + OrchestratorLogger.log(`[ElasticGitStorage] Setting up elastic-git-storage (version: ${version || 'latest'})`); + + // Try to find existing installation + let agentPath = await ElasticGitStorageService.findInstalled(); + + if (agentPath) { + OrchestratorLogger.log(`[ElasticGitStorage] Found existing installation at ${agentPath}`); + } else { + OrchestratorLogger.log(`[ElasticGitStorage] Not found on system, installing...`); + agentPath = await ElasticGitStorageService.install(version || 'latest'); + + if (!agentPath) { + return ''; + } + } + + // Delegate to LfsAgentService for git config setup + await LfsAgentService.configure(agentPath, agentArgs, storagePaths, repoPath); + + return agentPath; + } +}