diff --git a/src/main/java/com/ftptool/sync/service/GitClientService.java b/src/main/java/com/ftptool/sync/service/GitClientService.java index b283150..4b47465 100644 --- a/src/main/java/com/ftptool/sync/service/GitClientService.java +++ b/src/main/java/com/ftptool/sync/service/GitClientService.java @@ -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 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 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)) { diff --git a/src/test/java/com/ftptool/sync/service/GitClientServiceTest.java b/src/test/java/com/ftptool/sync/service/GitClientServiceTest.java new file mode 100644 index 0000000..9c281b7 --- /dev/null +++ b/src/test/java/com/ftptool/sync/service/GitClientServiceTest.java @@ -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); + } +}