package com.ftptool.sync.service; import com.ftptool.sync.config.GitRepoProperties; import com.ftptool.sync.util.FileHashUtils; import com.ftptool.sync.util.FileTreeUtils; import org.eclipse.jgit.api.CreateBranchCommand; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.ListBranchCommand; import org.eclipse.jgit.api.Status; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.lib.PersonIdent; import org.eclipse.jgit.lib.Ref; import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.transport.CredentialsProvider; import org.eclipse.jgit.transport.RefSpec; import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; /** * Git 客户端服务,负责仓库准备、分支切换、快照导出以及内容回写。 */ @Service public class GitClientService { private static final Logger log = LoggerFactory.getLogger(GitClientService.class); private final GitRepoProperties gitRepoProperties; private final Object lock = new Object(); public GitClientService(GitRepoProperties gitRepoProperties) { this.gitRepoProperties = gitRepoProperties; } /** * 准备本地仓库并返回目标分支当前 HEAD。 */ public String prepareRepositoryAndGetHead(String branch) throws IOException, GitAPIException { synchronized (lock) { // 同一份本地仓库会被多个任务复用,这里串行化避免分支切换互相污染工作区。 try (Git git = openOrCloneRepository()) { checkoutBranch(git, branch); pullIfRemoteBranchExists(git, branch); return git.getRepository().resolve("HEAD").name(); } } } public Path getRepositoryPath() { return new File(gitRepoProperties.getLocalPath()).toPath().toAbsolutePath().normalize(); } /** * 拉取远端分支列表,并返回匹配正则的分支名。 */ public List listMatchingBranches(String branchRegex) throws IOException, GitAPIException { synchronized (lock) { try (Git git = openOrCloneRepository()) { git.fetch() .setCredentialsProvider(credentialsProvider()) .setRemote("origin") .call(); Pattern pattern = Pattern.compile(branchRegex); List matchedBranches = new ArrayList(); List remoteBranches = git.branchList() .setListMode(ListBranchCommand.ListMode.REMOTE) .call(); for (Ref remoteBranch : remoteBranches) { String shortenedRef = Repository.shortenRefName(remoteBranch.getName()); if (!shortenedRef.startsWith("origin/")) { continue; } String branchName = shortenedRef.substring("origin/".length()); if ("HEAD".equals(branchName) || branchName.startsWith("HEAD/")) { continue; } if (pattern.matcher(branchName).matches()) { matchedBranches.add(branchName); } } Collections.sort(matchedBranches); return matchedBranches; } } } /** * 导出指定分支的工作区快照,显式排除 .git 目录。 */ public Path exportBranchSnapshot(String branch, Path targetDirectory) throws IOException, GitAPIException { synchronized (lock) { try (Git git = openOrCloneRepository()) { checkoutBranch(git, branch); pullIfRemoteBranchExists(git, branch); FileTreeUtils.deleteRecursively(targetDirectory); FileTreeUtils.ensureDirectory(targetDirectory); copyWorkingTreeWithoutGit(getRepositoryPath(), targetDirectory); } return targetDirectory; } } /** * 全量同步:用 sourceDirectory 整体覆盖目标分支工作区内容。 */ public boolean syncDirectoryToBranch(Path sourceDirectory, String branch, String message) throws IOException, GitAPIException { return syncDirectoryToBranch(sourceDirectory, branch, message, false); } /** * 增量同步:只新增或覆盖 sourceDirectory 中出现的文件,不删除仓库中已有旧文件。 */ public boolean syncDirectoryToBranchIncrementally(Path sourceDirectory, String branch, String message) throws IOException, GitAPIException { return syncDirectoryToBranch(sourceDirectory, branch, message, true); } private boolean syncDirectoryToBranch(Path sourceDirectory, String branch, String message, boolean incrementalUpdate) throws IOException, GitAPIException { synchronized (lock) { try (Git git = openOrCloneRepository()) { checkoutBranch(git, branch); Path repositoryPath = getRepositoryPath(); if (!Files.exists(repositoryPath.resolve(".git"))) { throw new IOException("Git repository does not exist: " + repositoryPath); } // 统一走同一套提交与推送流程,只区分工作区内容如何写入。 if (incrementalUpdate) { applyIncrementalWorkingTreeSync(sourceDirectory, repositoryPath, branch); } else { replaceWorkingTree(sourceDirectory, repositoryPath); } return commitAndPushIfNeeded(git, branch, message); } } } /** * 本地仓库不存在时先 clone,存在时直接打开。 */ private Git openOrCloneRepository() throws IOException, GitAPIException { Path repositoryPath = getRepositoryPath(); if (Files.exists(repositoryPath.resolve(".git"))) { return Git.open(repositoryPath.toFile()); } FileTreeUtils.ensureDirectory(repositoryPath); return Git.cloneRepository() .setURI(gitRepoProperties.getRemoteUri()) .setDirectory(repositoryPath.toFile()) .setCredentialsProvider(credentialsProvider()) .call(); } /** * 切换到目标分支;若本地分支不存在,则优先跟踪远端同名分支。 */ private void checkoutBranch(Git git, String branch) throws GitAPIException, IOException { Repository repository = git.getRepository(); Ref localRef = repository.findRef(branch); Ref remoteRef = repository.findRef("refs/remotes/origin/" + branch); if (localRef == null) { if (remoteRef != null) { git.checkout() .setCreateBranch(true) .setName(branch) .setStartPoint("origin/" + branch) .setUpstreamMode(CreateBranchCommand.SetupUpstreamMode.TRACK) .call(); } else { git.checkout() .setCreateBranch(true) .setName(branch) .call(); } } else { git.checkout().setName(branch).call(); } } /** * 仅在远端分支存在时执行 pull,避免首次本地新建分支时出现无意义拉取。 */ private void pullIfRemoteBranchExists(Git git, String branch) throws GitAPIException, IOException { Repository repository = git.getRepository(); Ref remoteRef = repository.findRef("refs/remotes/origin/" + branch); if (remoteRef == null) { // 本地未必已有远端分支缓存,先显式 fetch 一次再判断。 git.fetch() .setCredentialsProvider(credentialsProvider()) .setRemote("origin") .call(); remoteRef = repository.findRef("refs/remotes/origin/" + branch); } if (remoteRef != null) { git.pull() .setRemote("origin") .setRemoteBranchName(branch) .setRebase(gitRepoProperties.isPullRebase()) .setCredentialsProvider(credentialsProvider()) .call(); } } private CredentialsProvider credentialsProvider() { return new UsernamePasswordCredentialsProvider( gitRepoProperties.getUsername(), gitRepoProperties.getPassword() ); } /** * 全量覆盖工作区,仅保留仓库元数据目录 .git。 */ private void replaceWorkingTree(Path sourceDirectory, Path repositoryPath) throws IOException { FileTreeUtils.deleteChildrenExcept(repositoryPath, ".git"); FileTreeUtils.copyDirectory(sourceDirectory, repositoryPath); } /** * 增量写入工作区: * 1. 源目录中不存在的仓库文件保持不动; * 2. 源目录中新增的文件复制到仓库; * 3. 同一路径内容变化时执行覆盖。 */ private void applyIncrementalWorkingTreeSync(Path sourceDirectory, Path repositoryPath, String branch) throws IOException { Map sourceFileHashes = collectRelativeFileHashes(sourceDirectory, true); Map repositoryFileHashes = collectRelativeFileHashes(repositoryPath, true); List filesToCopy = new ArrayList(); for (Map.Entry sourceFile : sourceFileHashes.entrySet()) { String repositoryHash = repositoryFileHashes.get(sourceFile.getKey()); if (repositoryHash == null || !repositoryHash.equals(sourceFile.getValue())) { filesToCopy.add(sourceFile.getKey()); } } if (!filesToCopy.isEmpty()) { // copySelectedFiles 内部使用 REPLACE_EXISTING,因此同路径文件会被覆盖。 log.info( "Applying incremental Git sync on branch {}. copyCount={}", branch, filesToCopy.size() ); FileTreeUtils.copySelectedFiles(sourceDirectory, repositoryPath, filesToCopy); } } /** * 工作区有变更时统一执行 add / commit / push;无变更时直接返回 false。 */ private boolean commitAndPushIfNeeded(Git git, String branch, String message) throws GitAPIException { git.add().addFilepattern(".").call(); git.add().setUpdate(true).addFilepattern(".").call(); Status status = git.status().call(); if (status.isClean()) { log.info("No Git changes detected on branch {}", branch); return false; } PersonIdent personIdent = new PersonIdent( gitRepoProperties.getCommitAuthorName(), gitRepoProperties.getCommitAuthorEmail() ); git.commit() .setMessage(message) .setAuthor(personIdent) .setCommitter(personIdent) .call(); git.push() .setCredentialsProvider(credentialsProvider()) .setRemote("origin") .setRefSpecs(new RefSpec("refs/heads/" + branch + ":refs/heads/" + branch)) .call(); return true; } /** * 收集目录下普通文件的相对路径与内容哈希,用于判断是否需要复制。 */ private Map collectRelativeFileHashes(Path rootDirectory, boolean excludeGitDirectory) throws IOException { Map fileHashes = new LinkedHashMap(); try (Stream stream = Files.walk(rootDirectory)) { List files = stream .filter(Files::isRegularFile) .filter(path -> !excludeGitDirectory || !isGitInternalPath(rootDirectory, path)) .sorted() .collect(Collectors.toList()); for (Path file : files) { fileHashes.put(rootDirectory.relativize(file), FileHashUtils.sha256(file)); } } return fileHashes; } private boolean isGitInternalPath(Path rootDirectory, Path path) { Path relativePath = rootDirectory.relativize(path); return relativePath.getNameCount() > 0 && ".git".equals(relativePath.getName(0).toString()); } /** * 导出工作区内容时跳过 .git,避免把仓库元数据带入外部目录。 */ private void copyWorkingTreeWithoutGit(Path repositoryPath, Path targetDirectory) throws IOException { try (Stream stream = Files.list(repositoryPath)) { stream.filter(path -> !".git".equals(path.getFileName().toString())) .forEach(path -> { try { Path target = targetDirectory.resolve(path.getFileName().toString()); if (Files.isDirectory(path)) { FileTreeUtils.copyDirectory(path, target); } else { Files.copy(path, target, StandardCopyOption.REPLACE_EXISTING); } } catch (IOException e) { throw new IllegalStateException("Failed to export repository snapshot", e); } }); } } }