FtpTool/src/main/java/com/ftptool/sync/service/GitClientService.java
redbotu 3ff3cc89de feat: 新增 Git 增量同步并补充中文注释
- 在 GitClientService 中新增增量同步入口
- 增量同步改为仅新增和覆盖文件,不删除仓库已有旧文件
- 抽取公共的 add/commit/push 提交流程
- 补充全量同步与增量同步的中文注释说明
- 新增测试覆盖增量同步保留旧文件的场景
2026-05-07 22:52:19 +08:00

338 lines
14 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<String> 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<String> matchedBranches = new ArrayList<String>();
List<Ref> 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<Path, String> sourceFileHashes = collectRelativeFileHashes(sourceDirectory, true);
Map<Path, String> repositoryFileHashes = collectRelativeFileHashes(repositoryPath, true);
List<Path> filesToCopy = new ArrayList<Path>();
for (Map.Entry<Path, String> 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<Path, String> collectRelativeFileHashes(Path rootDirectory, boolean excludeGitDirectory) throws IOException {
Map<Path, String> fileHashes = new LinkedHashMap<Path, String>();
try (Stream<Path> stream = Files.walk(rootDirectory)) {
List<Path> 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<Path> 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);
}
});
}
}
}