feat: 新增 Git 增量同步并补充中文注释

- 在 GitClientService 中新增增量同步入口
- 增量同步改为仅新增和覆盖文件,不删除仓库已有旧文件
- 抽取公共的 add/commit/push 提交流程
- 补充全量同步与增量同步的中文注释说明
- 新增测试覆盖增量同步保留旧文件的场景
This commit is contained in:
redbotu 2026-05-07 22:52:19 +08:00
parent c22eff8950
commit 3ff3cc89de
2 changed files with 259 additions and 37 deletions

View File

@ -1,7 +1,9 @@
package com.ftptool.sync.service; package com.ftptool.sync.service;
import com.ftptool.sync.config.GitRepoProperties; import com.ftptool.sync.config.GitRepoProperties;
import com.ftptool.sync.util.FileHashUtils;
import com.ftptool.sync.util.FileTreeUtils; import com.ftptool.sync.util.FileTreeUtils;
import org.eclipse.jgit.api.CreateBranchCommand;
import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.ListBranchCommand; import org.eclipse.jgit.api.ListBranchCommand;
import org.eclipse.jgit.api.Status; import org.eclipse.jgit.api.Status;
@ -23,15 +25,17 @@ import java.nio.file.Path;
import java.nio.file.StandardCopyOption; import java.nio.file.StandardCopyOption;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
@Service
/** /**
* Git 客户端服务 * Git 客户端服务负责仓库准备分支切换快照导出以及内容回写
* 负责 clone / pull / checkout / commit / push 等仓库操作
*/ */
@Service
public class GitClientService { public class GitClientService {
private static final Logger log = LoggerFactory.getLogger(GitClientService.class); private static final Logger log = LoggerFactory.getLogger(GitClientService.class);
@ -44,11 +48,11 @@ public class GitClientService {
} }
/** /**
* 准备仓库并返回指定分支当前 HEAD * 准备本地仓库并返回目标分支当前 HEAD
*/ */
public String prepareRepositoryAndGetHead(String branch) throws IOException, GitAPIException { public String prepareRepositoryAndGetHead(String branch) throws IOException, GitAPIException {
synchronized (lock) { synchronized (lock) {
// 同一套本地仓库会被多个定时任务复用这里串行化避免分支切换互相踩工作区 // 同一份本地仓库会被多个任务复用这里串行化避免分支切换互相污染工作区
try (Git git = openOrCloneRepository()) { try (Git git = openOrCloneRepository()) {
checkoutBranch(git, branch); checkoutBranch(git, branch);
pullIfRemoteBranchExists(git, branch); pullIfRemoteBranchExists(git, branch);
@ -62,7 +66,7 @@ public class GitClientService {
} }
/** /**
* 列出远端所有匹配正则的版本分支名 * 拉取远端分支列表并返回匹配正则的分支名
*/ */
public List<String> listMatchingBranches(String branchRegex) throws IOException, GitAPIException { public List<String> listMatchingBranches(String branchRegex) throws IOException, GitAPIException {
synchronized (lock) { synchronized (lock) {
@ -97,14 +101,13 @@ public class GitClientService {
} }
/** /**
* 导出指定分支的工作树快照供后续打包或哈希计算使用 * 导出指定分支的工作区快照显式排除 .git 目录
*/ */
public Path exportBranchSnapshot(String branch, Path targetDirectory) throws IOException, GitAPIException { public Path exportBranchSnapshot(String branch, Path targetDirectory) throws IOException, GitAPIException {
synchronized (lock) { synchronized (lock) {
try (Git git = openOrCloneRepository()) { try (Git git = openOrCloneRepository()) {
checkoutBranch(git, branch); checkoutBranch(git, branch);
pullIfRemoteBranchExists(git, branch); pullIfRemoteBranchExists(git, branch);
// 导出纯工作树内容显式排除 .git避免把仓库内部文件带入同步包
FileTreeUtils.deleteRecursively(targetDirectory); FileTreeUtils.deleteRecursively(targetDirectory);
FileTreeUtils.ensureDirectory(targetDirectory); FileTreeUtils.ensureDirectory(targetDirectory);
copyWorkingTreeWithoutGit(getRepositoryPath(), targetDirectory); copyWorkingTreeWithoutGit(getRepositoryPath(), targetDirectory);
@ -113,7 +116,22 @@ public class GitClientService {
} }
} }
/**
* 全量同步 sourceDirectory 整体覆盖目标分支工作区内容
*/
public boolean syncDirectoryToBranch(Path sourceDirectory, String branch, String message) throws IOException, GitAPIException { 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) { synchronized (lock) {
try (Git git = openOrCloneRepository()) { try (Git git = openOrCloneRepository()) {
checkoutBranch(git, branch); checkoutBranch(git, branch);
@ -121,35 +139,22 @@ public class GitClientService {
if (!Files.exists(repositoryPath.resolve(".git"))) { if (!Files.exists(repositoryPath.resolve(".git"))) {
throw new IOException("Git repository does not exist: " + repositoryPath); throw new IOException("Git repository does not exist: " + repositoryPath);
} }
// 生产快照回写采用整目录覆盖语义确保 Git 分支内容与当前生产快照一致
FileTreeUtils.deleteChildrenExcept(repositoryPath, ".git"); // 统一走同一套提交与推送流程只区分工作区内容如何写入
FileTreeUtils.copyDirectory(sourceDirectory, repositoryPath); if (incrementalUpdate) {
git.add().addFilepattern(".").call(); applyIncrementalWorkingTreeSync(sourceDirectory, repositoryPath, branch);
git.add().setUpdate(true).addFilepattern(".").call(); } else {
Status status = git.status().call(); replaceWorkingTree(sourceDirectory, repositoryPath);
if (status.isClean()) {
log.info("No Git changes detected on branch {}", branch);
return false;
} }
PersonIdent personIdent = new PersonIdent(
gitRepoProperties.getCommitAuthorName(), return commitAndPushIfNeeded(git, branch, message);
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;
} }
} }
} }
/**
* 本地仓库不存在时先 clone存在时直接打开
*/
private Git openOrCloneRepository() throws IOException, GitAPIException { private Git openOrCloneRepository() throws IOException, GitAPIException {
Path repositoryPath = getRepositoryPath(); Path repositoryPath = getRepositoryPath();
if (Files.exists(repositoryPath.resolve(".git"))) { if (Files.exists(repositoryPath.resolve(".git"))) {
@ -164,7 +169,7 @@ public class GitClientService {
} }
/** /**
* 切换到目标分支不存在时按远端或本地新建分支 * 切换到目标分支若本地分支不存在则优先跟踪远端同名分支
*/ */
private void checkoutBranch(Git git, String branch) throws GitAPIException, IOException { private void checkoutBranch(Git git, String branch) throws GitAPIException, IOException {
Repository repository = git.getRepository(); Repository repository = git.getRepository();
@ -172,12 +177,11 @@ public class GitClientService {
Ref remoteRef = repository.findRef("refs/remotes/origin/" + branch); Ref remoteRef = repository.findRef("refs/remotes/origin/" + branch);
if (localRef == null) { if (localRef == null) {
if (remoteRef != null) { if (remoteRef != null) {
// 本地不存在分支时优先跟踪远端分支保持与仓库约定一致
git.checkout() git.checkout()
.setCreateBranch(true) .setCreateBranch(true)
.setName(branch) .setName(branch)
.setStartPoint("origin/" + branch) .setStartPoint("origin/" + branch)
.setUpstreamMode(org.eclipse.jgit.api.CreateBranchCommand.SetupUpstreamMode.TRACK) .setUpstreamMode(CreateBranchCommand.SetupUpstreamMode.TRACK)
.call(); .call();
} else { } else {
git.checkout() git.checkout()
@ -190,11 +194,14 @@ public class GitClientService {
} }
} }
/**
* 仅在远端分支存在时执行 pull避免首次本地新建分支时出现无意义拉取
*/
private void pullIfRemoteBranchExists(Git git, String branch) throws GitAPIException, IOException { private void pullIfRemoteBranchExists(Git git, String branch) throws GitAPIException, IOException {
Repository repository = git.getRepository(); Repository repository = git.getRepository();
Ref remoteRef = repository.findRef("refs/remotes/origin/" + branch); Ref remoteRef = repository.findRef("refs/remotes/origin/" + branch);
if (remoteRef == null) { if (remoteRef == null) {
// 首次启动时本地未必有远端分支缓存先显式 fetch 一次 // 本地未必有远端分支缓存先显式 fetch 一次再判断
git.fetch() git.fetch()
.setCredentialsProvider(credentialsProvider()) .setCredentialsProvider(credentialsProvider())
.setRemote("origin") .setRemote("origin")
@ -219,7 +226,96 @@ public class GitClientService {
} }
/** /**
* 复制仓库工作树内容显式排除 .git 目录 * 全量覆盖工作区仅保留仓库元数据目录 .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 { private void copyWorkingTreeWithoutGit(Path repositoryPath, Path targetDirectory) throws IOException {
try (Stream<Path> stream = Files.list(repositoryPath)) { try (Stream<Path> stream = Files.list(repositoryPath)) {

View File

@ -0,0 +1,126 @@
package com.ftptool.sync.service;
import com.ftptool.sync.config.GitRepoProperties;
import org.eclipse.jgit.api.CreateBranchCommand;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.transport.RefSpec;
import org.eclipse.jgit.transport.URIish;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class GitClientServiceTest {
@TempDir
Path tempDir;
@Test
void shouldSyncDirectoryToBranchIncrementallyWithoutDeletingExistingFiles() throws Exception {
Path remoteRepository = tempDir.resolve("remote.git");
Path localRepository = tempDir.resolve("local-repo");
initializeRemoteRepository(remoteRepository);
GitClientService gitClientService = new GitClientService(buildProperties(remoteRepository, localRepository));
Path initialSource = tempDir.resolve("source-initial");
writeFile(initialSource, "same.txt", "same");
writeFile(initialSource, "changed.txt", "before");
writeFile(initialSource, "deleted/nested.txt", "remove");
assertTrue(gitClientService.syncDirectoryToBranchIncrementally(initialSource, "feature/incremental", "initial"));
Path incrementalSource = tempDir.resolve("source-incremental");
writeFile(incrementalSource, "same.txt", "same");
writeFile(incrementalSource, "changed.txt", "after");
writeFile(incrementalSource, "added/new.txt", "new");
assertTrue(gitClientService.syncDirectoryToBranchIncrementally(
incrementalSource,
"feature/incremental",
"incremental update"
));
assertFalse(gitClientService.syncDirectoryToBranchIncrementally(
incrementalSource,
"feature/incremental",
"no-op update"
));
Path verifyDirectory = tempDir.resolve("verify");
checkoutRemoteBranch(remoteRepository, "feature/incremental", verifyDirectory);
assertEquals("same", readFile(verifyDirectory, "same.txt"));
assertEquals("after", readFile(verifyDirectory, "changed.txt"));
assertEquals("new", readFile(verifyDirectory, "added/new.txt"));
assertEquals("remove", readFile(verifyDirectory, "deleted/nested.txt"));
}
private GitRepoProperties buildProperties(Path remoteRepository, Path localRepository) {
GitRepoProperties properties = new GitRepoProperties();
properties.setRemoteUri(remoteRepository.toUri().toString());
properties.setLocalPath(localRepository.toString());
properties.setUsername("");
properties.setPassword("");
properties.setCommitAuthorName("tester");
properties.setCommitAuthorEmail("tester@example.com");
properties.setPullRebase(false);
return properties;
}
private void initializeRemoteRepository(Path remoteRepository) throws Exception {
try (Git ignored = Git.init().setBare(true).setDirectory(remoteRepository.toFile()).call()) {
// Bare repository only needs to exist here.
}
Path seedRepository = tempDir.resolve("seed-repo");
try (Git git = Git.init().setDirectory(seedRepository.toFile()).call()) {
writeFile(seedRepository, "README.md", "seed");
git.add().addFilepattern(".").call();
PersonIdent author = new PersonIdent("tester", "tester@example.com");
git.commit()
.setMessage("seed")
.setAuthor(author)
.setCommitter(author)
.call();
git.remoteAdd()
.setName("origin")
.setUri(new URIish(remoteRepository.toUri().toString()))
.call();
git.push()
.setRemote("origin")
.setRefSpecs(new RefSpec("refs/heads/master:refs/heads/master"))
.call();
}
}
private void checkoutRemoteBranch(Path remoteRepository, String branch, Path workingDirectory) throws Exception {
try (Git git = Git.cloneRepository()
.setURI(remoteRepository.toUri().toString())
.setDirectory(workingDirectory.toFile())
.call()) {
git.checkout()
.setCreateBranch(true)
.setName(branch)
.setStartPoint("origin/" + branch)
.setUpstreamMode(CreateBranchCommand.SetupUpstreamMode.TRACK)
.call();
}
}
private void writeFile(Path root, String relativePath, String content) throws Exception {
Path file = root.resolve(relativePath);
Files.createDirectories(file.getParent());
Files.write(file, content.getBytes(StandardCharsets.UTF_8));
}
private String readFile(Path root, String relativePath) throws Exception {
return new String(Files.readAllBytes(root.resolve(relativePath)), StandardCharsets.UTF_8);
}
}