feat: isolate AI tests and integrate local branches

This commit is contained in:
张家训
2026-08-04 17:01:07 +08:00
parent 544c38d304
commit 0c0acac416
10 changed files with 447 additions and 0 deletions
@@ -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) {
}
@@ -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);
}
}
@@ -0,0 +1,8 @@
package com.example.workutils.biz.aitest.git;
import java.nio.file.Path;
@FunctionalInterface
public interface BranchIntegrationVerifier {
boolean verify(Path integrationWorktree);
}
@@ -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<String> 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();
}
}
@@ -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<String> 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<String> changedPaths(Path generation) {
String status = git.required(generation, "-c", "core.quotepath=false", "status", "--porcelain",
"--untracked-files=all");
List<String> 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;
}
}
@@ -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);
}
}
}
@@ -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.
}
}
}
@@ -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"), "<project/>");
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<String> 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;
}
}
@@ -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"));
}
}
@@ -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));
}
}