- 在 GitClientService 中新增增量同步入口 - 增量同步改为仅新增和覆盖文件,不删除仓库已有旧文件 - 抽取公共的 add/commit/push 提交流程 - 补充全量同步与增量同步的中文注释说明 - 新增测试覆盖增量同步保留旧文件的场景
338 lines
14 KiB
Java
338 lines
14 KiB
Java
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);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|