package cc.unitmesh.server.service import cc.unitmesh.agent.logging.AutoDevLogger import cc.unitmesh.agent.AgentEvent import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import java.io.BufferedReader import java.io.File import java.io.InputStreamReader import java.net.URLEncoder import java.nio.file.Files import java.nio.file.Path import kotlin.io.path.pathString class GitCloneService { private val logger = AutoDevLogger.getLogger("GitCloneService") private val logChannel = Channel(Channel.UNLIMITED) // Map to track projectId -> workspace path for persistence private val tempDirectoryMap = mutableMapOf() data class CloneResult( val success: Boolean, val projectPath: String, val error: String? = null ) /** * Clone git repository and emit logs and result as SSE events */ suspend fun cloneRepositoryWithLogs( gitUrl: String, branch: String? = null, username: String? = null, password: String? = null, projectId: String ): Flow = flow { logger.info { "Starting clone process for projectId: $projectId, gitUrl: $gitUrl, branch: ${branch ?: "default"}" } val workspaceDir = createWorkspaceDir(projectId) val projectPath = workspaceDir.pathString logger.info { "Created workspace directory at: $projectPath" } try { // Send initial progress emit(AgentEvent.CloneProgress("Preparing to clone repository", 0)) val processedGitUrl = processGitUrl(gitUrl, username, password) // Check if already cloned val cloneSuccess = if (isGitRepository(workspaceDir.toFile())) { logger.info { "Repository already exists at $projectPath, pulling latest changes" } emit(AgentEvent.CloneLog("Repository already exists, pulling latest changes...")) emit(AgentEvent.CloneProgress("Updating repository", 50)) val pullSuccess = gitPull(workspaceDir.toFile(), branch) { log -> emit(log) } if (pullSuccess) { logger.info { "Successfully pulled latest changes for $projectPath" } emit(AgentEvent.CloneProgress("Repository updated", 100)) true } else { logger.warn { "Pull failed, attempting fresh clone for $projectPath" } emit(AgentEvent.CloneLog("Failed to pull, will try fresh clone", isError = true)) deleteDirectory(workspaceDir) Files.createDirectories(workspaceDir) gitClone(processedGitUrl, workspaceDir.toFile(), branch) { log -> emit(log) } } } else { // Fresh clone logger.info { "Starting fresh clone from $gitUrl to $projectPath" } emit(AgentEvent.CloneLog("Cloning repository from $gitUrl...")) emit(AgentEvent.CloneProgress("Cloning repository", 10)) gitClone(processedGitUrl, workspaceDir.toFile(), branch) { log -> emit(log) } } if (cloneSuccess) { logger.info { "✓ Clone completed successfully at: $projectPath" } emit(AgentEvent.CloneProgress("Clone completed successfully", 100)) emit(AgentEvent.CloneLog("✓ Repository ready at: $projectPath")) // Store the path for agent to use later lastClonedPath = projectPath logger.info { "Stored lastClonedPath: $projectPath for projectId: $projectId" } } else { logger.error { "✗ Git clone failed for $gitUrl at $projectPath" } emit(AgentEvent.Error("Git clone failed")) } } catch (e: Exception) { logger.error(e) { "Error during clone: ${e.message}" } emit(AgentEvent.CloneLog("Error during clone: ${e.message}", isError = true)) emit(AgentEvent.Error("Clone failed: ${e.message}")) } } var lastClonedPath: String? = null private set private fun createWorkspaceDir(projectId: String): Path { val tempDir = Files.createTempDirectory("autodev-clone-") logger.info { "Created temporary directory: ${tempDir.pathString}" } val workspaceDir = tempDir.resolve(projectId) Files.createDirectories(workspaceDir) logger.info { "Created workspace directory: ${workspaceDir.pathString} for projectId: $projectId" } // Store temp directory mapping for reference tempDirectoryMap[projectId] = workspaceDir.pathString return workspaceDir } private fun isGitRepository(dir: File): Boolean { return File(dir, ".git").isDirectory } private fun processGitUrl(gitUrl: String, username: String?, password: String?): String { return if (!username.isNullOrBlank() && !password.isNullOrBlank()) { gitUrl.replace("//", "//${urlEncode(username)}:${urlEncode(password)}@") } else { gitUrl } } private fun urlEncode(msg: String): String { return URLEncoder.encode(msg, "UTF-8") } private suspend fun gitClone( gitUrl: String, workspaceDir: File, branch: String?, emitLog: suspend (AgentEvent) -> Unit ): Boolean { val cmd = mutableListOf("git", "clone") // Add depth for shallow clone (faster) cmd.addAll(listOf("--depth", "1")) // Add branch if specified if (!branch.isNullOrBlank()) { logger.info { "Cloning with specified branch: $branch" } cmd.addAll(listOf("-b", branch)) } else { logger.info { "No branch specified, Git will use repository's default branch" } } cmd.add(gitUrl) cmd.add(".") val success = executeGitCommand(cmd, workspaceDir, emitLog) // If clone with specified branch failed, try without branch (use default) if (!success && !branch.isNullOrBlank()) { logger.warn { "Clone with branch '$branch' failed, retrying with default branch" } emitLog(AgentEvent.CloneLog("Branch '$branch' not found, trying repository's default branch...")) // Clear the directory and try again without branch deleteDirectory(workspaceDir.toPath()) workspaceDir.mkdirs() val fallbackCmd = mutableListOf("git", "clone", "--depth", "1", gitUrl, ".") return executeGitCommand(fallbackCmd, workspaceDir, emitLog) } return success } private suspend fun gitPull( workspaceDir: File, branch: String?, emitLog: suspend (AgentEvent) -> Unit ): Boolean { val cmd = mutableListOf("git", "pull", "origin") if (!branch.isNullOrBlank()) { logger.info { "Pulling specified branch: $branch" } cmd.add(branch) } else { logger.info { "No branch specified for pull, Git will pull current/tracking branch" } // Don't specify branch - let git pull from the current tracking branch } val success = executeGitCommand(cmd, workspaceDir, emitLog) // If pull with specified branch failed, try without branch (current branch) if (!success && !branch.isNullOrBlank()) { logger.warn { "Pull with branch '$branch' failed, retrying with current branch" } emitLog(AgentEvent.CloneLog("Branch '$branch' not found, trying current branch...")) val fallbackCmd = mutableListOf("git", "pull", "origin") return executeGitCommand(fallbackCmd, workspaceDir, emitLog) } return success } private suspend fun executeGitCommand( cmd: List, workingDir: File, emitLog: suspend (AgentEvent) -> Unit ): Boolean { try { val cmdString = cmd.joinToString(" ") logger.debug { "Executing git command: $cmdString in directory: ${workingDir.absolutePath}" } emitLog(AgentEvent.CloneLog("Executing: $cmdString")) val processBuilder = ProcessBuilder(cmd) .directory(workingDir) .redirectErrorStream(true) val process = processBuilder.start() // Read output in real-time BufferedReader(InputStreamReader(process.inputStream)).use { reader -> var line: String? while (reader.readLine().also { line = it } != null) { line?.let { logger.debug { "Git output: $it" } emitLog(AgentEvent.CloneLog(it)) } } } val exitCode = process.waitFor() if (exitCode == 0) { logger.info { "✓ Git command completed successfully: $cmdString" } emitLog(AgentEvent.CloneLog("✓ Git command completed successfully")) return true } else { logger.error { "✗ Git command failed with exit code $exitCode: $cmdString" } emitLog(AgentEvent.CloneLog("✗ Git command failed with exit code: $exitCode", isError = true)) return false } } catch (e: Exception) { logger.error(e) { "✗ Error executing git command: ${cmd.joinToString(" ")}" } emitLog(AgentEvent.CloneLog("✗ Error executing git command: ${e.message}", isError = true)) e.printStackTrace() return false } } private fun deleteDirectory(path: Path) { try { logger.info { "Deleting directory: ${path.pathString}" } path.toFile().deleteRecursively() logger.info { "Successfully deleted directory: ${path.pathString}" } } catch (e: Exception) { logger.warn(e) { "Failed to delete directory: ${path.pathString}" } } } /** * Get the workspace path for a given projectId */ fun getWorkspacePath(projectId: String): String? { return tempDirectoryMap[projectId] ?: lastClonedPath } /** * Get all tracked workspace paths */ fun getAllWorkspaces(): Map { logger.info { "Retrieved all workspaces: ${tempDirectoryMap.size} entries" } return tempDirectoryMap.toMap() } }