feat: 新增 Git 增量同步并补充中文注释
- 在 GitClientService 中新增增量同步入口 - 增量同步改为仅新增和覆盖文件,不删除仓库已有旧文件 - 抽取公共的 add/commit/push 提交流程 - 补充全量同步与增量同步的中文注释说明 - 新增测试覆盖增量同步保留旧文件的场景
This commit is contained in:
parent
c22eff8950
commit
3ff3cc89de
@ -1,7 +1,9 @@
|
||||
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;
|
||||
@ -23,15 +25,17 @@ 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;
|
||||
|
||||
@Service
|
||||
/**
|
||||
* Git 客户端服务。
|
||||
* 负责 clone / pull / checkout / commit / push 等仓库操作。
|
||||
* Git 客户端服务,负责仓库准备、分支切换、快照导出以及内容回写。
|
||||
*/
|
||||
@Service
|
||||
public class GitClientService {
|
||||
|
||||
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 {
|
||||
synchronized (lock) {
|
||||
// 同一套本地仓库会被多个定时任务复用,这里串行化避免分支切换互相踩工作区。
|
||||
// 同一份本地仓库会被多个任务复用,这里串行化避免分支切换互相污染工作区。
|
||||
try (Git git = openOrCloneRepository()) {
|
||||
checkoutBranch(git, branch);
|
||||
pullIfRemoteBranchExists(git, branch);
|
||||
@ -62,7 +66,7 @@ public class GitClientService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出远端所有匹配正则的版本分支名。
|
||||
* 拉取远端分支列表,并返回匹配正则的分支名。
|
||||
*/
|
||||
public List<String> listMatchingBranches(String branchRegex) throws IOException, GitAPIException {
|
||||
synchronized (lock) {
|
||||
@ -97,14 +101,13 @@ public class GitClientService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出指定分支的工作树快照,供后续打包或哈希计算使用。
|
||||
* 导出指定分支的工作区快照,显式排除 .git 目录。
|
||||
*/
|
||||
public Path exportBranchSnapshot(String branch, Path targetDirectory) throws IOException, GitAPIException {
|
||||
synchronized (lock) {
|
||||
try (Git git = openOrCloneRepository()) {
|
||||
checkoutBranch(git, branch);
|
||||
pullIfRemoteBranchExists(git, branch);
|
||||
// 导出纯工作树内容,显式排除 .git,避免把仓库内部文件带入同步包。
|
||||
FileTreeUtils.deleteRecursively(targetDirectory);
|
||||
FileTreeUtils.ensureDirectory(targetDirectory);
|
||||
copyWorkingTreeWithoutGit(getRepositoryPath(), targetDirectory);
|
||||
@ -113,7 +116,22 @@ public class GitClientService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 全量同步:用 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);
|
||||
@ -121,35 +139,22 @@ public class GitClientService {
|
||||
if (!Files.exists(repositoryPath.resolve(".git"))) {
|
||||
throw new IOException("Git repository does not exist: " + repositoryPath);
|
||||
}
|
||||
// 生产快照回写采用“整目录覆盖”语义,确保 Git 分支内容与当前生产快照一致。
|
||||
FileTreeUtils.deleteChildrenExcept(repositoryPath, ".git");
|
||||
FileTreeUtils.copyDirectory(sourceDirectory, repositoryPath);
|
||||
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;
|
||||
|
||||
// 统一走同一套提交与推送流程,只区分工作区内容如何写入。
|
||||
if (incrementalUpdate) {
|
||||
applyIncrementalWorkingTreeSync(sourceDirectory, repositoryPath, branch);
|
||||
} else {
|
||||
replaceWorkingTree(sourceDirectory, repositoryPath);
|
||||
}
|
||||
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;
|
||||
|
||||
return commitAndPushIfNeeded(git, branch, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地仓库不存在时先 clone,存在时直接打开。
|
||||
*/
|
||||
private Git openOrCloneRepository() throws IOException, GitAPIException {
|
||||
Path repositoryPath = getRepositoryPath();
|
||||
if (Files.exists(repositoryPath.resolve(".git"))) {
|
||||
@ -164,7 +169,7 @@ public class GitClientService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换到目标分支,不存在时按远端或本地新建分支。
|
||||
* 切换到目标分支;若本地分支不存在,则优先跟踪远端同名分支。
|
||||
*/
|
||||
private void checkoutBranch(Git git, String branch) throws GitAPIException, IOException {
|
||||
Repository repository = git.getRepository();
|
||||
@ -172,12 +177,11 @@ public class GitClientService {
|
||||
Ref remoteRef = repository.findRef("refs/remotes/origin/" + branch);
|
||||
if (localRef == null) {
|
||||
if (remoteRef != null) {
|
||||
// 本地不存在分支时优先跟踪远端分支,保持与仓库约定一致。
|
||||
git.checkout()
|
||||
.setCreateBranch(true)
|
||||
.setName(branch)
|
||||
.setStartPoint("origin/" + branch)
|
||||
.setUpstreamMode(org.eclipse.jgit.api.CreateBranchCommand.SetupUpstreamMode.TRACK)
|
||||
.setUpstreamMode(CreateBranchCommand.SetupUpstreamMode.TRACK)
|
||||
.call();
|
||||
} else {
|
||||
git.checkout()
|
||||
@ -190,11 +194,14 @@ public class GitClientService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅在远端分支存在时执行 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 一次。
|
||||
// 本地未必已有远端分支缓存,先显式 fetch 一次再判断。
|
||||
git.fetch()
|
||||
.setCredentialsProvider(credentialsProvider())
|
||||
.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 {
|
||||
try (Stream<Path> stream = Files.list(repositoryPath)) {
|
||||
|
||||
126
src/test/java/com/ftptool/sync/service/GitClientServiceTest.java
Normal file
126
src/test/java/com/ftptool/sync/service/GitClientServiceTest.java
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user