From 0c0acac4160a31af9d728bcd67b97d89ce256cb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=B6=E8=AE=AD?= Date: Tue, 4 Aug 2026 17:01:07 +0800 Subject: [PATCH] feat: isolate AI tests and integrate local branches --- .../aitest/git/BranchIntegrationRequest.java | 8 ++ .../aitest/git/BranchIntegrationResult.java | 8 ++ .../aitest/git/BranchIntegrationVerifier.java | 8 ++ .../biz/aitest/git/GitCommandRunner.java | 32 +++++ .../aitest/git/TargetBranchIntegrator.java | 108 ++++++++++++++++ .../biz/aitest/git/TemporaryWorktree.java | 35 +++++ .../aitest/git/TemporaryWorktreeManager.java | 121 ++++++++++++++++++ .../workutils/biz/aitest/git/GitFixture.java | 39 ++++++ .../git/TargetBranchIntegratorTest.java | 55 ++++++++ .../git/TemporaryWorktreeManagerTest.java | 33 +++++ 10 files changed, 447 insertions(+) create mode 100644 src/main/java/com/example/workutils/biz/aitest/git/BranchIntegrationRequest.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/git/BranchIntegrationResult.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/git/BranchIntegrationVerifier.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/git/GitCommandRunner.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/git/TargetBranchIntegrator.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/git/TemporaryWorktree.java create mode 100644 src/main/java/com/example/workutils/biz/aitest/git/TemporaryWorktreeManager.java create mode 100644 src/test/java/com/example/workutils/biz/aitest/git/GitFixture.java create mode 100644 src/test/java/com/example/workutils/biz/aitest/git/TargetBranchIntegratorTest.java create mode 100644 src/test/java/com/example/workutils/biz/aitest/git/TemporaryWorktreeManagerTest.java diff --git a/src/main/java/com/example/workutils/biz/aitest/git/BranchIntegrationRequest.java b/src/main/java/com/example/workutils/biz/aitest/git/BranchIntegrationRequest.java new file mode 100644 index 0000000..c32fa99 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/git/BranchIntegrationRequest.java @@ -0,0 +1,8 @@ +package com.example.workutils.biz.aitest.git; + +import java.nio.file.Path; + +public record BranchIntegrationRequest(Path repository, Path generationWorktree, + String targetBranch, Path selectedModuleRelativePath, + String commitMessage) { +} diff --git a/src/main/java/com/example/workutils/biz/aitest/git/BranchIntegrationResult.java b/src/main/java/com/example/workutils/biz/aitest/git/BranchIntegrationResult.java new file mode 100644 index 0000000..c469214 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/git/BranchIntegrationResult.java @@ -0,0 +1,8 @@ +package com.example.workutils.biz.aitest.git; + +public record BranchIntegrationResult(boolean successful, String candidateCommit, + String targetCommit, String message) { + public static BranchIntegrationResult failure(String message) { + return new BranchIntegrationResult(false, "", "", message); + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/git/BranchIntegrationVerifier.java b/src/main/java/com/example/workutils/biz/aitest/git/BranchIntegrationVerifier.java new file mode 100644 index 0000000..620311a --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/git/BranchIntegrationVerifier.java @@ -0,0 +1,8 @@ +package com.example.workutils.biz.aitest.git; + +import java.nio.file.Path; + +@FunctionalInterface +public interface BranchIntegrationVerifier { + boolean verify(Path integrationWorktree); +} diff --git a/src/main/java/com/example/workutils/biz/aitest/git/GitCommandRunner.java b/src/main/java/com/example/workutils/biz/aitest/git/GitCommandRunner.java new file mode 100644 index 0000000..16b599f --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/git/GitCommandRunner.java @@ -0,0 +1,32 @@ +package com.example.workutils.biz.aitest.git; + +import com.example.workutils.biz.aitest.process.CommandResult; +import com.example.workutils.biz.aitest.process.CommandSpec; +import com.example.workutils.biz.aitest.process.ProcessExecutor; + +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +final class GitCommandRunner { + private final ProcessExecutor executor; + + GitCommandRunner(ProcessExecutor executor) { + this.executor = executor; + } + + String required(Path directory, String... arguments) { + List command = new ArrayList<>(); + command.add("git"); + command.addAll(List.of(arguments)); + CommandResult result = executor.execute(new CommandSpec(directory, command, Map.of(), + Duration.ofMinutes(2)), ignored -> { }); + if (!result.successful()) { + throw new IllegalStateException("Git command failed: " + String.join(" ", command) + + System.lineSeparator() + result.stderr() + result.stdout()); + } + return result.stdout(); + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/git/TargetBranchIntegrator.java b/src/main/java/com/example/workutils/biz/aitest/git/TargetBranchIntegrator.java new file mode 100644 index 0000000..a045b12 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/git/TargetBranchIntegrator.java @@ -0,0 +1,108 @@ +package com.example.workutils.biz.aitest.git; + +import com.example.workutils.biz.aitest.process.ProcessExecutor; +import org.springframework.stereotype.Service; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +@Service +public class TargetBranchIntegrator { + private final GitCommandRunner git; + + public TargetBranchIntegrator(ProcessExecutor executor) { + this.git = new GitCommandRunner(executor); + } + + public BranchIntegrationResult integrate(BranchIntegrationRequest request, + BranchIntegrationVerifier verifier) { + Path repository = request.repository().toAbsolutePath().normalize(); + Path generation = request.generationWorktree().toAbsolutePath().normalize(); + try { + List changedPaths = changedPaths(generation); + if (changedPaths.isEmpty()) { + return BranchIntegrationResult.failure("没有可集成的测试文件"); + } + String allowedRoot = allowedRoot(request.selectedModuleRelativePath()); + for (String path : changedPaths) { + String normalized = path.replace('\\', '/'); + if (!normalized.startsWith(allowedRoot) || !normalized.endsWith(".java")) { + return BranchIntegrationResult.failure( + "候选提交只能包含所选模块 src/test/java 下的 Java 文件: " + path); + } + } + git.required(generation, "add", "--", trimTrailingSlash(allowedRoot)); + git.required(generation, "commit", "-m", request.commitMessage()); + String candidate = git.required(generation, "rev-parse", "HEAD").trim(); + + String targetRef = "refs/heads/" + request.targetBranch(); + String targetBefore = git.required(repository, "rev-parse", "--verify", targetRef).trim(); + if (branchCheckedOut(repository, targetRef)) { + return new BranchIntegrationResult(false, candidate, "", + "目标分支正在某个 worktree 中使用,请先切换到其他分支"); + } + + Path parent = Files.createTempDirectory("workutils-ai-integrate-"); + Path integration = parent.resolve("target"); + try { + git.required(repository, "worktree", "add", "--detach", integration.toString(), targetBefore); + git.required(integration, "cherry-pick", candidate); + if (!verifier.verify(integration)) { + return new BranchIntegrationResult(false, candidate, "", "目标分支二次验证失败"); + } + String targetNow = git.required(repository, "rev-parse", "--verify", targetRef).trim(); + if (!targetBefore.equals(targetNow)) { + return new BranchIntegrationResult(false, candidate, "", "目标分支在验证期间发生变化"); + } + String integratedHead = git.required(integration, "rev-parse", "HEAD").trim(); + git.required(repository, "update-ref", targetRef, integratedHead, targetBefore); + return new BranchIntegrationResult(true, candidate, integratedHead, "已更新本地目标分支"); + } finally { + if (Files.exists(integration)) { + try { git.required(repository, "worktree", "remove", "--force", integration.toString()); } + catch (RuntimeException ignored) { } + } + try { git.required(repository, "worktree", "prune"); } catch (RuntimeException ignored) { } + TemporaryWorktreeManager.deleteTree(parent); + } + } catch (Exception e) { + return BranchIntegrationResult.failure(e.getMessage()); + } + } + + private List changedPaths(Path generation) { + String status = git.required(generation, "-c", "core.quotepath=false", "status", "--porcelain", + "--untracked-files=all"); + List paths = new ArrayList<>(); + for (String line : status.split("\\R")) { + if (line.length() < 4) { + continue; + } + String path = line.substring(3); + int rename = path.indexOf(" -> "); + paths.add(rename >= 0 ? path.substring(rename + 4) : path); + } + return paths; + } + + private boolean branchCheckedOut(Path repository, String targetRef) { + String list = git.required(repository, "worktree", "list", "--porcelain"); + for (String line : list.split("\\R")) { + if (line.equals("branch " + targetRef)) { + return true; + } + } + return false; + } + + private static String allowedRoot(Path moduleRelative) { + String module = moduleRelative.normalize().toString().replace('\\', '/'); + return (module.isBlank() ? "" : module + "/") + "src/test/java/"; + } + + private static String trimTrailingSlash(String value) { + return value.endsWith("/") ? value.substring(0, value.length() - 1) : value; + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/git/TemporaryWorktree.java b/src/main/java/com/example/workutils/biz/aitest/git/TemporaryWorktree.java new file mode 100644 index 0000000..1e567da --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/git/TemporaryWorktree.java @@ -0,0 +1,35 @@ +package com.example.workutils.biz.aitest.git; + +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class TemporaryWorktree implements AutoCloseable { + private final Path repository; + private final Path path; + private final Path temporaryParent; + private final String head; + private final TemporaryWorktreeManager owner; + private final AtomicBoolean closed = new AtomicBoolean(); + + TemporaryWorktree(Path repository, Path path, Path temporaryParent, + String head, TemporaryWorktreeManager owner) { + this.repository = repository; + this.path = path; + this.temporaryParent = temporaryParent; + this.head = head; + this.owner = owner; + } + + public Path repository() { return repository; } + public Path path() { return path; } + public String head() { return head; } + + Path temporaryParent() { return temporaryParent; } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + owner.remove(this); + } + } +} diff --git a/src/main/java/com/example/workutils/biz/aitest/git/TemporaryWorktreeManager.java b/src/main/java/com/example/workutils/biz/aitest/git/TemporaryWorktreeManager.java new file mode 100644 index 0000000..973c96d --- /dev/null +++ b/src/main/java/com/example/workutils/biz/aitest/git/TemporaryWorktreeManager.java @@ -0,0 +1,121 @@ +package com.example.workutils.biz.aitest.git; + +import com.example.workutils.biz.aitest.process.ProcessExecutor; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Comparator; + +@Service +public class TemporaryWorktreeManager { + private final GitCommandRunner git; + + public TemporaryWorktreeManager(ProcessExecutor executor) { + this.git = new GitCommandRunner(executor); + } + + public TemporaryWorktree create(Path repository, boolean includeWorkingTree) { + Path repo = repository.toAbsolutePath().normalize(); + if (Files.notExists(repo.resolve(".git"))) { + throw new IllegalArgumentException("Not a Git repository: " + repo); + } + try { + String head = git.required(repo, "rev-parse", "HEAD").trim(); + Path parent = Files.createTempDirectory("workutils-ai-test-"); + Path worktree = parent.resolve("generation"); + git.required(repo, "worktree", "add", "--detach", worktree.toString(), head); + try { + if (includeWorkingTree) { + copyWorkingTreeChanges(repo, worktree); + } + return new TemporaryWorktree(repo, worktree, parent, head, this); + } catch (Exception failure) { + try { git.required(repo, "worktree", "remove", "--force", worktree.toString()); } + catch (RuntimeException ignored) { } + deleteTree(parent); + throw failure; + } + } catch (IOException e) { + throw new IllegalStateException("Unable to create temporary worktree", e); + } + } + + void remove(TemporaryWorktree worktree) { + try { + if (Files.exists(worktree.path())) { + git.required(worktree.repository(), "worktree", "remove", "--force", worktree.path().toString()); + } + git.required(worktree.repository(), "worktree", "prune"); + } finally { + deleteTree(worktree.temporaryParent()); + } + } + + private void copyWorkingTreeChanges(Path repository, Path worktree) throws IOException { + String tracked = git.required(repository, "-c", "core.quotepath=false", "diff", + "--name-status", "HEAD"); + for (String line : tracked.split("\\R")) { + if (line.isBlank()) { + continue; + } + String[] fields = line.split("\\t"); + String status = fields[0]; + if (status.startsWith("R") && fields.length >= 3) { + deleteSnapshotFile(worktree, Path.of(fields[1])); + copySnapshotFile(repository, worktree, Path.of(fields[2])); + } else if (fields.length >= 2 && status.startsWith("D")) { + deleteSnapshotFile(worktree, Path.of(fields[1])); + } else if (fields.length >= 2) { + copySnapshotFile(repository, worktree, Path.of(fields[1])); + } + } + String untracked = git.required(repository, "-c", "core.quotepath=false", "ls-files", + "--others", "--exclude-standard"); + for (String value : untracked.split("\\R")) { + if (value.isBlank()) { + continue; + } + Path relative = Path.of(value.trim()).normalize(); + Path source = repository.resolve(relative).normalize(); + Path target = worktree.resolve(relative).normalize(); + if (!source.startsWith(repository) || !target.startsWith(worktree) || Files.isDirectory(source)) { + continue; + } + copySnapshotFile(repository, worktree, relative); + } + } + + private static void copySnapshotFile(Path repository, Path worktree, Path relative) throws IOException { + Path source = repository.resolve(relative).normalize(); + Path target = worktree.resolve(relative).normalize(); + if (!source.startsWith(repository) || !target.startsWith(worktree) || Files.notExists(source)) { + return; + } + Files.createDirectories(target.getParent()); + Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES); + } + + private static void deleteSnapshotFile(Path worktree, Path relative) throws IOException { + Path target = worktree.resolve(relative).normalize(); + if (!target.startsWith(worktree)) { + throw new IllegalArgumentException("Working tree path escapes repository: " + relative); + } + Files.deleteIfExists(target); + } + + static void deleteTree(Path root) { + if (root == null || Files.notExists(root)) { + return; + } + try (var paths = Files.walk(root)) { + paths.sorted(Comparator.reverseOrder()).forEach(path -> { + try { Files.deleteIfExists(path); } catch (IOException ignored) { } + }); + } catch (IOException ignored) { + // The caller can inspect a retained OS temp directory if an external process still owns it. + } + } +} diff --git a/src/test/java/com/example/workutils/biz/aitest/git/GitFixture.java b/src/test/java/com/example/workutils/biz/aitest/git/GitFixture.java new file mode 100644 index 0000000..75097b8 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/aitest/git/GitFixture.java @@ -0,0 +1,39 @@ +package com.example.workutils.biz.aitest.git; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +final class GitFixture { + private GitFixture() { } + + static void init(Path repository) throws Exception { + git(repository, "init"); + git(repository, "config", "user.email", "workutils@example.com"); + git(repository, "config", "user.name", "WorkUtils Test"); + write(repository.resolve("app-core/src/main/java/a/A.java"), "class A { int value = 1; }"); + write(repository.resolve("app-start/pom.xml"), ""); + git(repository, "add", "."); + git(repository, "commit", "-m", "base"); + } + + static void write(Path file, String content) throws Exception { + Files.createDirectories(file.getParent()); + Files.writeString(file, content, StandardCharsets.UTF_8); + } + + static String git(Path directory, String... arguments) throws Exception { + List command = new ArrayList<>(); + command.add("git"); + command.addAll(List.of(arguments)); + Process process = new ProcessBuilder(command).directory(directory.toFile()) + .redirectErrorStream(true).start(); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertEquals(0, process.waitFor(), output); + return output; + } +} diff --git a/src/test/java/com/example/workutils/biz/aitest/git/TargetBranchIntegratorTest.java b/src/test/java/com/example/workutils/biz/aitest/git/TargetBranchIntegratorTest.java new file mode 100644 index 0000000..649efc5 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/aitest/git/TargetBranchIntegratorTest.java @@ -0,0 +1,55 @@ +package com.example.workutils.biz.aitest.git; + +import com.example.workutils.biz.aitest.process.DefaultProcessExecutor; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +class TargetBranchIntegratorTest { + + @Test + void integratesOnlyVerifiedTestCommitIntoLocalBranch(@TempDir Path repository) throws Exception { + GitFixture.init(repository); + GitFixture.git(repository, "branch", "target"); + TemporaryWorktreeManager manager = new TemporaryWorktreeManager(new DefaultProcessExecutor()); + TargetBranchIntegrator integrator = new TargetBranchIntegrator(new DefaultProcessExecutor()); + + BranchIntegrationResult result; + try (TemporaryWorktree generation = manager.create(repository, false)) { + Path test = generation.path().resolve("app-start/src/test/java/a/AServiceTest.java"); + GitFixture.write(test, "package a; class AServiceTest {}"); + result = integrator.integrate(new BranchIntegrationRequest(repository, generation.path(), + "target", Path.of("app-start"), "test: add AService tests"), + integration -> Files.exists(integration.resolve("app-start/src/test/java/a/AServiceTest.java"))); + } + + assertTrue(result.successful(), result.message()); + assertTrue(GitFixture.git(repository, "show", + "target:app-start/src/test/java/a/AServiceTest.java").contains("AServiceTest")); + assertEquals("class A { int value = 1; }", GitFixture.git(repository, "show", + "target:app-core/src/main/java/a/A.java").trim()); + } + + @Test + void rejectsCandidateContainingProductionChanges(@TempDir Path repository) throws Exception { + GitFixture.init(repository); + GitFixture.git(repository, "branch", "target"); + TemporaryWorktreeManager manager = new TemporaryWorktreeManager(new DefaultProcessExecutor()); + TargetBranchIntegrator integrator = new TargetBranchIntegrator(new DefaultProcessExecutor()); + + BranchIntegrationResult result; + try (TemporaryWorktree generation = manager.create(repository, false)) { + GitFixture.write(generation.path().resolve("app-core/src/main/java/a/A.java"), + "class A { int value = 99; }"); + result = integrator.integrate(new BranchIntegrationRequest(repository, generation.path(), + "target", Path.of("app-start"), "test: unsafe"), ignored -> true); + } + + assertFalse(result.successful()); + assertTrue(result.message().contains("src/test/java")); + } +} diff --git a/src/test/java/com/example/workutils/biz/aitest/git/TemporaryWorktreeManagerTest.java b/src/test/java/com/example/workutils/biz/aitest/git/TemporaryWorktreeManagerTest.java new file mode 100644 index 0000000..8b58573 --- /dev/null +++ b/src/test/java/com/example/workutils/biz/aitest/git/TemporaryWorktreeManagerTest.java @@ -0,0 +1,33 @@ +package com.example.workutils.biz.aitest.git; + +import com.example.workutils.biz.aitest.process.DefaultProcessExecutor; +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.*; + +class TemporaryWorktreeManagerTest { + + @Test + void copiesOptionalWorkingTreeChangesWithoutTouchingSource(@TempDir Path repository) throws Exception { + GitFixture.init(repository); + Path source = repository.resolve("app-core/src/main/java/a/A.java"); + GitFixture.write(source, "class A { int value = 2; }"); + TemporaryWorktreeManager manager = new TemporaryWorktreeManager(new DefaultProcessExecutor()); + + Path worktreePath; + try (TemporaryWorktree worktree = manager.create(repository, true)) { + worktreePath = worktree.path(); + assertEquals("class A { int value = 2; }", + Files.readString(worktree.path().resolve("app-core/src/main/java/a/A.java"), StandardCharsets.UTF_8)); + assertEquals("class A { int value = 2; }", Files.readString(source, StandardCharsets.UTF_8)); + } + + assertFalse(Files.exists(worktreePath)); + assertEquals("class A { int value = 2; }", Files.readString(source, StandardCharsets.UTF_8)); + } +}