diff --git a/.claude/agents/version-control.md b/.claude/agents/version-control.md index 2bbd3934b..743560fa1 100644 --- a/.claude/agents/version-control.md +++ b/.claude/agents/version-control.md @@ -260,6 +260,7 @@ description: 项目级版本记录领域。任务涉及版本记录/工作段( 47. **`ProjectMemberService` 的构造器被两个测试类手工 `new`,新依赖一律走字段注入**(dev-board#444)——查人卡片的头像要 `AccountBindingRepository` 与 `ai.account.base-url`,两样都用 `@Autowired(required=false)` / `@Value` **字段**注入,包内可见的 `setAccountLookupForTest` 给手工 new 出来的实例补上(`ProjectRepoService.maxTrackedFileSizeBytes` 的同款先例)。改成构造器参数就要同步改 `ProjectMemberAddByContactTest` 与 `ProjectMemberServiceAddMemberRaceTest`,是纯 churn。 48. **hutool 的 `JSONObject`/`JSONArray` 绝不能原样当控制器返回值——`JSONNull` 会把整条响应打成 500**(dev-board#444,app-e2e J11 抓到;`proxyMembers` 那一半在 master 上就有)——hutool 把 JSON null 解析成 `cn.hutool.json.JSONNull` 单例(不是 Java `null`,是一个没有任何属性的对象),Jackson 没有它的序列化器,于是 `HttpMessageConversionException: No serializer found for class cn.hutool.json.JSONNull`。`CloudSyncService.proxyMembers`/`proxyMemberLookup` 原来把上游回包的 `JSONObject` 直接塞进控制器返回值,**只要某个字段是 null 就整条 500**——而 `avatarUrl` 为 null 正是没绑官网、没传头像的默认状态,即绝大多数账号,所以用户侧表现是「案件参与人」列表永远只有自己一个人、查人永远弹「服务器内部错误」,一个能用的账号都碰不到。现在两处都过私有纯函数 `toPlain(Object)`(递归 `JSONObject`→`LinkedHashMap`、`JSONArray`→`ArrayList`、`JSONUtil.isNull`→Java `null`,其余原样)。**刻意没有改成让 Jackson 直接解析上游**——那会牵动 `httpGet` seam 与一整排既有测试,换不来行为收益。同一个类里的 `listRemoteProjects` 不受影响(它用 `getStr`/`getLong` 取值后自己拼 `HashMap`,出来的已经是 Java 原生类型),**这正是判断某处安不安全的判据:看返回值里有没有未经转换的 hutool 对象,不是看这个方法有没有 import hutool**。护栏 `CloudMemberProxyJsonTest`(两条用例都拿 `ObjectMapper.writeValueAsString` 断言序列化不抛且 `"avatarUrl":null` 原样在,去掉 `toPlain` 立刻转红)。**今后任何「透传上游 JSON」的新端点,返回前都要过 `toPlain` 这一关。** +49. **测试里的 file:// 裸仓库必须关 `receive.autogc`,否则 JGit 后台 gc 与 JUnit `@TempDir` 清理赛跑**(dev-board#500,CI run 34168686921 抓到)——往 file:// 远端 push 时 JGit 在本进程内跑 `ReceivePack`,收包收尾按远端仓库配置 `receive.autogc`(默认 true)调 `Repository.autoGC`;auto gc 又按 `gc.autoDetach`(默认 true)丢到 `WorkQueue` 后台线程,**`gc.log.lock` 在调用线程上同步建、在后台线程上异步删**——仓库再小、根本不需要 gc 也一样。测试方法一返回 JUnit 就递归删 `@TempDir`,撞上那几毫秒就是 `IO Failed to delete temp directory ... hub.git/gc.log.lock`,表现为 `MemoryLwwMergeTest.mergeNeverLeavesRepositoryMidMerge` 之类随机一条红。修法不是等后台任务(JGit 没暴露可等的句柄),而是在建裸仓时把 `receive.autogc=false` 写进它的 config:**一律用 `com.checkba.version.BareHub.init(dir)` 建测试裸仓**,已把 `MemoryLwwMergeTest`/`MemorySyncRoundTripTest`/`CloudSyncUploadTest`/`CloudSyncUpdateTest`/`ProjectRepoRemoteTest` 五处收拢。新写任何「裸仓 + push」的测试直接调它,别再手写 `Git.init().setBare(true)`。生产侧不受影响:团队服务器的 `GitHttpController` 收 push 后不清理目录,后台 gc 正是想要的。 ## 验证 diff --git a/backend/src/test/java/com/checkba/version/BareHub.java b/backend/src/test/java/com/checkba/version/BareHub.java new file mode 100644 index 000000000..3f033cac3 --- /dev/null +++ b/backend/src/test/java/com/checkba/version/BareHub.java @@ -0,0 +1,30 @@ +package com.checkba.version; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.lib.StoredConfig; + +import java.nio.file.Path; + +/** + * 测试用 file:// 裸仓库(模拟云端 / 记忆 hub)的唯一建法。 + * + * 必须关掉 {@code receive.autogc}:往 file:// 远端 push 时 JGit 在本进程内跑 ReceivePack, + * 收包结束会按这个开关(默认 true)触发 auto gc,而 auto gc 默认 {@code gc.autoDetach} + * 在后台线程里跑、期间持有 {@code gc.log.lock}。测试方法一结束 JUnit 就清理 @TempDir, + * 与后台 gc 撞上就报「Failed to delete temp directory ... gc.log.lock」(dev-board#500)。 + */ +public final class BareHub { + + private BareHub() {} + + /** 建裸仓(初始分支 master,与 JGit 默认一致),返回其 file:// 地址。 */ + public static String init(Path dir) throws Exception { + try (Git git = Git.init().setBare(true).setDirectory(dir.toFile()) + .setInitialBranch("master").call()) { + StoredConfig cfg = git.getRepository().getConfig(); + cfg.setBoolean("receive", null, "autogc", false); + cfg.save(); + } + return dir.toUri().toString(); + } +} diff --git a/backend/src/test/java/com/checkba/version/CloudSyncUpdateTest.java b/backend/src/test/java/com/checkba/version/CloudSyncUpdateTest.java index d0b3073ef..173e1531e 100644 --- a/backend/src/test/java/com/checkba/version/CloudSyncUpdateTest.java +++ b/backend/src/test/java/com/checkba/version/CloudSyncUpdateTest.java @@ -192,9 +192,7 @@ protected String httpGet(String url, String sessionToken) { // ---- helpers ------------------------------------------------------ private String bareRemote(Path dir) throws Exception { - Git.init().setBare(true).setDirectory(dir.toFile()) - .setInitialBranch("master").call().close(); - return dir.toUri().toString(); + return BareHub.init(dir); } /** 建一个 file:// 裸仓当云端 + CloudConnection/ProjectRemote 行,setRemoteOrigin 指过去。 */ @@ -534,7 +532,7 @@ void abortCloudMergeRefusesForeignMergeWindow() throws Exception { private Path bareCloudRemote(long remoteProjectId, Path cloudRoot) throws Exception { Path bareRepoDir = cloudRoot.resolve("git").resolve(remoteProjectId + ".git"); Files.createDirectories(bareRepoDir.getParent()); - Git.init().setBare(true).setDirectory(bareRepoDir.toFile()).setInitialBranch("master").call().close(); + BareHub.init(bareRepoDir); return bareRepoDir; } diff --git a/backend/src/test/java/com/checkba/version/CloudSyncUploadTest.java b/backend/src/test/java/com/checkba/version/CloudSyncUploadTest.java index 0aa2d6c10..2a4953fcf 100644 --- a/backend/src/test/java/com/checkba/version/CloudSyncUploadTest.java +++ b/backend/src/test/java/com/checkba/version/CloudSyncUploadTest.java @@ -168,9 +168,7 @@ protected String httpPost(String url, String body, String sessionToken) { // ---- helpers ------------------------------------------------------ private String bareRemote(Path dir) throws Exception { - Git.init().setBare(true).setDirectory(dir.toFile()) - .setInitialBranch("master").call().close(); - return dir.toUri().toString(); + return BareHub.init(dir); } /** 建一个 file:// 裸仓当云端 + CloudConnection/ProjectRemote 行,setRemoteOrigin 指过去。 */ diff --git a/backend/src/test/java/com/checkba/version/ProjectRepoRemoteTest.java b/backend/src/test/java/com/checkba/version/ProjectRepoRemoteTest.java index 0682cccda..8fb6b00bb 100644 --- a/backend/src/test/java/com/checkba/version/ProjectRepoRemoteTest.java +++ b/backend/src/test/java/com/checkba/version/ProjectRepoRemoteTest.java @@ -23,9 +23,7 @@ private ProjectRepoService seeded(Path root) throws Exception { } private String bareRemote(Path dir) throws Exception { - Git.init().setBare(true).setDirectory(dir.toFile()) - .setInitialBranch("master").call().close(); - return dir.toUri().toString(); + return BareHub.init(dir); } @Test diff --git a/backend/src/test/java/com/checkba/version/memory/MemoryLwwMergeTest.java b/backend/src/test/java/com/checkba/version/memory/MemoryLwwMergeTest.java index 446f726d7..0713edbb1 100644 --- a/backend/src/test/java/com/checkba/version/memory/MemoryLwwMergeTest.java +++ b/backend/src/test/java/com/checkba/version/memory/MemoryLwwMergeTest.java @@ -1,7 +1,7 @@ package com.checkba.version.memory; import com.checkba.model.entity.MemoryEntry; -import org.eclipse.jgit.api.Git; +import com.checkba.version.BareHub; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -32,8 +32,7 @@ class MemoryLwwMergeTest { @BeforeEach void setUp() throws Exception { Path hub = tmp.resolve("hub.git"); - Git.init().setBare(true).setDirectory(hub.toFile()).call().close(); - String hubUrl = hub.toUri().toString(); + String hubUrl = BareHub.init(hub); a = new MemorySyncTestMachine(tmp.resolve("machine-a"), hubUrl, MemoryRealm.project(1)); b = new MemorySyncTestMachine(tmp.resolve("machine-b"), hubUrl, MemoryRealm.project(2)); } diff --git a/backend/src/test/java/com/checkba/version/memory/MemorySyncRoundTripTest.java b/backend/src/test/java/com/checkba/version/memory/MemorySyncRoundTripTest.java index c3b8789cf..ab0a05e2a 100644 --- a/backend/src/test/java/com/checkba/version/memory/MemorySyncRoundTripTest.java +++ b/backend/src/test/java/com/checkba/version/memory/MemorySyncRoundTripTest.java @@ -1,7 +1,7 @@ package com.checkba.version.memory; import com.checkba.model.entity.MemoryEntry; -import org.eclipse.jgit.api.Git; +import com.checkba.version.BareHub; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -28,8 +28,7 @@ class MemorySyncRoundTripTest { @BeforeEach void setUp() throws Exception { Path hub = tmp.resolve("hub.git"); - Git.init().setBare(true).setDirectory(hub.toFile()).call().close(); - hubUrl = hub.toUri().toString(); + hubUrl = BareHub.init(hub); a = new MemorySyncTestMachine(tmp.resolve("machine-a"), hubUrl, MemoryRealm.project(1)); b = new MemorySyncTestMachine(tmp.resolve("machine-b"), hubUrl, MemoryRealm.project(2)); }