feat: isolate AI tests and integrate local branches
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user