JGit 的 AddCommand 把新增文件加不进暂存区,这个坑我在做代码生成器和编辑器插件时踩过不止一次。表现很像灵异事件:文件确实写到磁盘,Files.exists返回 true,git.add().addFilepattern(...).call()也不报错,但git.status().getUntracked()里还在,getAdded()始终为空,后面提交自然带不上。AddCommand、新增文件、暂存区这三个词凑在一起,问题往往不在 JGit 本身,而在路径匹配、更新模式、忽略规则和仓库工作树配置。下面我按实际排查顺序,把 JGit AddCommand 的行为拆开讲清楚,给出可直接复用的代码模板、常见故障速查表和踩坑经验,适合正在用 JGit 做自动化提交、代码生成、IDE 插件或构建流水线的开发者参考,也适合刚接触 JGit 索引机制的同学补基础。
1. 问题现象与定位思路
1.1 不报错但暂存区没变化,先别怀疑 JGit 坏了
AddCommand 最让人难受的地方是“静默”。它不像命令行 Git 那样经常给你一句fatal: pathspec ... did not match any files,很多不匹配的 filepattern 会被直接跳过,调用正常返回,索引文件却没有任何变化。尤其是你在循环里批量添加文件时,单个路径不匹配不会中断流程,最后只看到“新增文件没进暂存区”的结果。实际排查时,我建议先把“文件是否存在”“仓库工作树是否正确”“路径是否相对工作树根”“是否误用了只更新模式”“是否被忽略规则挡住”这五件事按顺序过一遍,而不是一上来就翻 JGit 源码。因为绝大多数问题都发生在业务代码传参这一层,AddCommand 只是按规则执行,它没有义务猜你想添加哪个文件。
另一个常见误区是把“文件没进暂存区”和“提交没带上”混为一谈。暂存区对应的是.git/index,AddCommand 的职责是往索引里写记录;提交是在索引基础上生成 commit。如果 AddCommand 没有把路径写进索引,后面提交当然带不上。所以验证时不要只看提交结果,要在 add 之后立刻调用git.status().call(),看getAdded()和getUntracked()的变化。getAdded()里出现目标文件,说明已经进暂存区;文件还在getUntracked()里,说明 AddCommand 没匹配到它。这个中间状态是定位问题的关键,能帮你把问题范围缩小一半。
1.2 先分清三种“新文件”:未跟踪、被忽略、被更新模式排除
同样是新文件,在 JGit 眼里可能处于完全不同的状态。第一种是真正的未跟踪文件,既不在索引里,也没被忽略规则命中,这种文件默认可以被 AddCommand 添加。第二种是被.gitignore、全局 excludes 或嵌套忽略规则命中的文件,JGit 在扫描工作树时会把它归入 ignored 集合,常规 AddCommand 不会把它塞进索引。第三种是被setUpdate(true)排除掉的新文件:这个模式的本意是“只更新索引里已经存在的路径”,新增文件本来就不在索引里,自然被过滤。很多人从网上抄了一段git.add().addFilepattern(".").setUpdate(true).call(),然后发现新文件永远加不进去,原因就在这。
我通常用一张简单的状态表来区分:文件在磁盘上存在但status.getUntracked()里有它,说明路径匹配或忽略规则有问题;文件在status.getIgnoredNotInIndex()里有它,说明被忽略规则挡住了;文件既不在 untracked 也不在 added,同时索引里也没有它,可能是 filepattern 根本没匹配上,或者仓库工作树配置不对。把这三类分开之后,排查就不会乱。下面这张表可以先放在手边,遇到问题时逐项对照。
| 现象 | 常见原因 | 快速验证 | 处理方向 |
|---|---|---|---|
add 调用不报错,getAdded()为空 | filepattern 是绝对路径或反斜杠路径 | 打印传给 add 的字符串 | 改成相对工作树根、用/ |
新文件一直在getUntracked()里 | 目录路径没带结尾/,或大小写不一致 | 用精确文件路径再试一次 | 文件用精确路径,目录用dir/ |
| 新文件完全不在 status 里 | 被.gitignore忽略 | 看getIgnoredNotInIndex() | 调整忽略规则或换目录 |
| 新文件加不进去,老文件能更新 | 误用了setUpdate(true) | 检查 add 链式调用 | 去掉setUpdate(true) |
| 所有文件都加不进去 | 工作树为空、仓库是 bare、路径在仓库外 | 打印repo.getWorkTree() | 设置正确 workTree |
偶尔失败,偶发index.lock | 并发写索引或进程未释放 | 看异常栈和.git/index.lock | 串行化 add,清理锁 |
1.3 我习惯的定位顺序:从业务参数往仓库底层走
实际排查时,我会先加一行日志,把repo.getWorkTree()、file.toAbsolutePath()、传给addFilepattern的字符串、setUpdate的取值全部打印出来。别小看这一行日志,很多“玄学问题”在这一步就现形了:有人传的是C:\work\repo\src\Foo.java,有人传的是./src/Foo.java,有人传的是src\Foo.java,这些在 JGit 的路径过滤器里都可能匹配不上。确认参数没问题后,再看git.status().call()的 untracked 和 ignored 集合。最后才去检查FileRepositoryBuilder是否设置了 workTree、仓库是不是 bare、索引是否被锁。这个顺序的好处是先排除高频低级错误,避免在底层 API 里绕圈。
还有一点要提醒:不要把异常吞掉。AddCommand 在索引锁冲突、过滤器执行失败、文件读取失败时是会抛异常的,但很多业务代码为了“保证流程继续”写了一个大 try-catch,把异常转成日志就结束了,最后表现就是“没报错但没加上”。如果你确实要容错,至少把异常类型和当前 filepattern 记录清楚,不要让 AddCommand 的失败变成无声无息。下面开始拆 AddCommand 的内部行为,理解了机制,排查会快很多。
2. JGit AddCommand 工作机制与路径匹配
2.1 AddCommand 不是执行命令行 git,它直接操作索引
JGit 的 AddCommand 不会去调系统里的git add,它是在 Java 进程内扫描工作树、比较索引、然后写 DirCache。也正因为如此,它不会继承你终端里的当前目录、环境变量和 shell 展开规则。你传给addFilepattern的字符串,必须符合 JGit 自己的路径匹配逻辑。它大致做三件事:用工作树迭代器扫描文件,用路径过滤器筛选出匹配的路径,把匹配到的未跟踪或已修改文件写入索引。这个过程中,忽略规则会被尊重,setUpdate(true)会改变筛选范围,路径分隔符和相对根路径也会影响匹配结果。
很多人把 AddCommand 当成“黑盒”,觉得只要调用 call 就应该把文件加进去。实际上它非常依赖你给的 filepattern。JGit 不会因为文件在磁盘上存在就自动添加,它要先通过路径过滤器。过滤器匹配不上,后面写索引的逻辑根本不会执行。更麻烦的是,这种不匹配在很多场景下是静默的,不会抛“路径不存在”之类的异常。所以你要把addFilepattern当成一个查询条件,而不是一个文件句柄。查询条件写错,结果自然为空。
2.2 addFilepattern 的匹配规则:相对根路径、正斜杠、目录要带结尾斜杠
addFilepattern接收的是相对工作树根的路径,不是绝对路径,也不是相对于当前 Java 工作目录的路径。比如工作树是/data/repo,你要添加/data/repo/generated/OrderService.java,传给 AddCommand 的应该是generated/OrderService.java。如果你传绝对路径,路径过滤器拿它和索引里的相对路径比对,基本匹配不上。Windows 上尤其要注意,File.getPath()可能返回反斜杠,而 JGit 内部路径习惯用/,所以要做一次替换。还有./前缀、../回退、重复斜杠,这些最好在传参前统一规范化掉。
目录匹配也有讲究。JGit 的路径过滤器对目录通常需要用结尾/来表示“匹配这个目录下的所有内容”。比如你要添加generated目录下的所有新文件,写generated/比写generated更稳。写generated有些版本只匹配名为generated的精确路径,不匹配其子文件,表现出来就是“目录明明有文件,但一个都没加进去”。文件路径则建议精确到文件名,不要用模糊前缀去猜。下面这段路径规范化代码可以直接抄:
Path repoRoot = git.getRepository().getWorkTree().toPath() .toAbsolutePath().normalize(); Path target = Paths.get("/data/repo/generated/OrderService.java") .toAbsolutePath().normalize(); if (!target.startsWith(repoRoot)) { throw new IllegalArgumentException("文件不在工作树内: " + target); } String rel = repoRoot.relativize(target).toString().replace('\\', '/'); git.add().addFilepattern(rel).call();这段代码做了三件事:把工作树根和文件路径都转成绝对规范化路径,防止..和符号链接造成的路径歧义;检查文件确实在工作树内,避免把仓库外文件传进去;用relativize算出相对路径,再把反斜杠替换成正斜杠。看起来有点啰嗦,但能消灭大部分路径类问题。尤其是自动化场景,文件路径经常来自配置、数据库或上游任务,不做规范化很容易带进奇怪的前缀。
2.3 setUpdate(true) 的真实语义:只更新已跟踪文件,不负责新增
setUpdate(true)是 AddCommand 里最容易误用的开关。它的语义接近命令行git add -u,只更新索引中已经存在的路径,不添加未跟踪文件。很多人为了让“只提交修改,不提交新增”而打开它,后来业务需要新增文件时忘了关,结果就是新文件永远进不了暂存区。更隐蔽的是,有些代码库把 add 封装成一个公共方法,默认setUpdate(true),调用方以为自己在“添加文件”,实际只是在“更新已跟踪文件”。排查时看到setUpdate(true),基本可以先把它列为头号嫌疑。
需要新增文件时,正确的做法是使用默认行为,或者显式setUpdate(false)。JGit 的默认值就是 false,所以大多数情况下你不需要写这一行。但如果你在封装方法里保留了参数,建议把命名写清楚,比如addNewFile和updateTrackedFile分开,不要用一个布尔参数让调用方猜。下面两种写法对比很直观:
// 错误:新文件不在索引里,setUpdate(true) 会把它过滤掉 git.add().addFilepattern("generated/OrderService.java") .setUpdate(true) .call(); // 正确:新增文件使用默认模式 git.add().addFilepattern("generated/OrderService.java") .call();注意:
setUpdate(true)不会报错,也不会提示“你正在跳过新文件”,它只是安静地按“只更新已跟踪路径”执行。新文件加不进去时,先检查这里。
2.4 工作树和 gitDir 不是一回事,裸仓库没有暂存区可加
还有一种情况是仓库对象建错了。FileRepositoryBuilder可以设置gitDir,也可以设置workTree。gitDir指向.git目录,workTree指向你实际存放文件的工作目录。只设置gitDir时,JGit 在某些目录结构下能推断出工作树,但在工作区与 Git 目录分离、软链接路径、CI 临时目录等场景下,推断可能不符合预期。工作树为空或指向错误目录时,AddCommand 扫描不到你的新文件,表现同样是“调用成功但没进暂存区”。如果是 bare 仓库,本身就没有工作树,更没有“新增工作区文件”这回事。
我一般会显式设置工作树,并在调用 add 前打印确认:
File repoDir = new File("/data/repo"); FileRepositoryBuilder builder = new FileRepositoryBuilder() .setGitDir(new File(repoDir, ".git")) .setWorkTree(repoDir) .readEnvironment() .findGitDir(); Repository repository = builder.build(); try (Git git = new Git(repository)) { File workTree = git.getRepository().getWorkTree(); if (workTree == null) { throw new IllegalStateException("当前仓库没有工作树,无法添加工作区文件"); } System.out.println("workTree = " + workTree.getAbsolutePath()); }这段代码不复杂,但能避免很多“仓库打开方式不对”的问题。尤其是你的程序可能同时处理多个仓库,或者仓库路径来自用户输入,显式设置 workTree 比依赖推断可靠得多。
3. 从最小案例复现到正确写法
3.1 一个最小复现:绝对路径为什么加不进去
先看一个最小案例。假设工作树是/data/repo,我们用 Java 创建一个新文件generated/OrderService.java,然后故意用绝对路径调用 AddCommand:
Path repoRoot = Paths.get("/data/repo"); Path newFile = repoRoot.resolve("generated/OrderService.java"); Files.createDirectories(newFile.getParent()); Files.writeString(newFile, "public class OrderService {}\n"); try (Git git = Git.open(repoRoot.toFile())) { git.add().addFilepattern(newFile.toAbsolutePath().toString()).call(); Status status = git.status().call(); System.out.println("untracked = " + status.getUntracked()); System.out.println("added = " + status.getAdded()); }这段代码大概率会把generated/OrderService.java留在untracked里,added为空。文件确实存在,add 也没抛异常,但路径过滤器没有匹配上。把传参改成相对路径后,结果立刻不同:
String rel = repoRoot.toAbsolutePath().normalize() .relativize(newFile.toAbsolutePath().normalize()) .toString() .replace('\\', '/'); git.add().addFilepattern(rel).call();这就是最典型的“新增文件不能添加到暂存区”。它不是什么版本 bug,而是 filepattern 语义用错了。实际项目里,文件路径往往不是手写的,而是从配置、模板引擎、上传接口或任务队列里来的,很容易带绝对路径、反斜杠、./前缀。统一做一次相对化,比在每个调用点猜路径可靠。
3.2 正确写法模板:创建、关闭、添加、验证四步走
我现在写新增文件并加入暂存区的代码,基本固定成四步:创建文件并确保流关闭,计算相对工作树根的路径,调用 AddCommand 时不加setUpdate(true),最后用 Status 验证。下面是一个完整模板,Java 11 以上可以直接用:
public void addNewFile(Path repoDir, Path file) throws Exception { Path repoRoot = repoDir.toAbsolutePath().normalize(); Path target = file.toAbsolutePath().normalize(); if (!Files.exists(target)) { throw new IllegalArgumentException("文件不存在: " + target); } if (!target.startsWith(repoRoot)) { throw new IllegalArgumentException("文件不在工作树内: " + target); } String rel = repoRoot.relativize(target).toString().replace('\\', '/'); try (Git git = Git.open(repoRoot.toFile())) { // 新增文件不要 setUpdate(true) git.add().addFilepattern(rel).call(); Status status = git.status().call(); if (!status.getAdded().contains(rel)) { throw new IllegalStateException( "文件未进入暂存区, rel=" + rel + ", untracked=" + status.getUntracked() + ", ignored=" + status.getIgnoredNotInIndex()); } } }这个模板里有两个设计点值得说明。第一,先检查Files.exists,因为 AddCommand 对不存在的路径通常不会给你明确提示,提前检查能让错误更早暴露。第二,add 之后立刻验证status.getAdded()是否包含目标路径,不包含就抛出带上下文的异常。这样即使路径、忽略规则或更新模式有问题,也能在业务代码里快速定位,而不是等到提交时才发现少文件。getIgnoredNotInIndex()在部分 JGit 版本里可能为空集合,但打印出来对排查有帮助,不会影响主流程。
3.3 批量新增目录:用dir/而不是裸目录名
批量新增时,最常见的错误是传一个不带结尾斜杠的目录名。比如你要把generated目录下所有新文件加进去,写addFilepattern("generated")可能只匹配精确路径generated,不匹配generated/OrderService.java。写成addFilepattern("generated/")才是“这个目录下的内容”的语义。下面两种写法建议用第二种:
// 不推荐:目录匹配语义不明确,部分版本只匹配精确路径 git.add().addFilepattern("generated").call(); // 推荐:明确表示目录下的所有路径 git.add().addFilepattern("generated/").call();如果你要添加多个目录,可以多次调用addFilepattern,最后只 call 一次:
AddCommand add = git.add(); add.addFilepattern("generated/"); add.addFilepattern("src/main/java/"); add.call();不要在每个文件上反复new Git或反复 call。AddCommand 支持一次传多个 filepattern,批量执行可以减少索引写入次数,也能降低并发锁冲突的概率。对于文件数量很大的场景,建议按目录聚合,而不是逐个文件调用。还有一点,.这种写法在不同版本和不同路径过滤器实现下表现不完全一致,我不建议把它当成“添加全部”的万能写法。需要全量添加时,显式列出目录,或者用Files.walk收集相对路径后批量传入,行为更可控。
3.4 提交前做一次暂存区核对
AddCommand 成功后,建议在提交前做一次核对。最简单的办法是打印status.getAdded()、status.getModified()和status.getUntracked(),确认目标文件在 added 里,且没有意外把不该提交的文件带进去。如果是自动化提交,可以把核对做成断言:目标文件必须在 added 集合里,否则不执行 commit。这样能避免“空提交”或者“提交了半截”的问题。下面是一个核对片段:
Status status = git.status().call(); System.out.println("added = " + status.getAdded()); System.out.println("modified = " + status.getModified()); System.out.println("untracked = " + status.getUntracked()); if (!status.getAdded().contains(rel)) { throw new IllegalStateException("目标文件未暂存: " + rel); } git.commit() .setMessage("add generated file: " + rel) .call();这里的rel就是前面算出来的相对路径,注意不要用绝对路径去比对,否则永远不相等。提交消息里也不要带真实项目敏感信息,用通用描述即可。核对这一步看起来多余,但在批量生成文件、多人并发、CI 临时目录这些场景下,能帮你省掉很多回滚和排查时间。
4. 逐项排查:为什么你的新文件没进暂存区
4.1 路径类问题:绝对路径、反斜杠、大小写和目录斜杠
路径类问题是出现频率最高的。第一,绝对路径匹配不上相对索引路径。第二,Windows 反斜杠没有替换成正斜杠。第三,./前缀或重复斜杠导致过滤器不识别。第四,文件系统大小写不敏感,但索引路径大小写敏感,传了OrderService.java,实际文件是orderservice.java,匹配不上。第五,目录没带结尾斜杠,只匹配精确目录。第六,文件在仓库外,relativize出来带../,这种路径不应该传给 AddCommand。下面这张表可以逐项对照。
| 路径写法 | 问题 | 建议 |
|---|---|---|
/data/repo/src/Foo.java | 绝对路径不匹配索引相对路径 | 用repoRoot.relativize |
src\\Foo.java | 反斜杠不是 JGit 习惯分隔符 | 替换为/ |
./src/Foo.java | 前缀可能不被规范化 | 去掉./ |
src/foo.java实际为src/Foo.java | 大小写不一致 | 统一用实际文件名 |
generated想匹配目录 | 可能只匹配精确路径 | 写generated/ |
../other/Foo.java | 文件不在工作树内 | 检查仓库根和文件位置 |
我一般会要求所有进入 AddCommand 的路径都经过一个toRepoRelativePath方法,方法内部做绝对化、规范化、工作树检查、反斜杠替换。不要在调用点直接拼字符串,拼错一次就可能静默失败。路径问题最怕“看起来差不多”,JGit 的匹配是严格的,差一个斜杠就是两个世界。
4.2 忽略规则类问题:AddCommand 不会强行添加被忽略文件
新文件如果被.gitignore命中,AddCommand 默认不会把它加进暂存区。这一点和命令行 Git 的默认行为一致,但 JGit 的 AddCommand 在常规 API 里没有像git add -f那样直观的强制开关,所以你不能指望“调了 add 就一定加进去”。常见场景是代码生成器把输出目录设成generated/,而仓库的.gitignore里正好有generated/,你生成的文件全被忽略,add 自然没反应。排查时看status.getIgnoredNotInIndex(),如果目标文件在里面,就不要再怀疑路径了,先处理忽略规则。
处理方式有三种:一是把生成目录移出忽略范围,比如改成src/generated/并调整忽略规则;二是修改.gitignore,用否定规则把特定文件放出来;三是如果业务确实不需要提交生成物,那就不要 add,改成构建时生成。不要试图用 AddCommand 硬塞被忽略文件,这会让仓库规则和实际提交行为不一致,后面的人很难维护。下面是一个忽略规则示例,只说明语义,不涉及具体项目:
# 忽略整个生成目录 generated/ # 但保留需要提交的说明文件 !generated/README.md改完忽略规则后,记得重新调用git.status().call()看 ignored 集合是否变化。有些 JGit 版本会缓存工作树扫描结果,重新打开 Git 实例最稳。
4.3 更新模式与索引状态:新文件不在索引里,-u语义帮不上忙
setUpdate(true)的坑前面已经讲过,这里再强调一次:它只更新已跟踪路径,新文件不在索引里,所以一定被过滤。除了这个开关,还要注意索引本身的状态。比如你前面执行过git.reset()把文件从索引里撤掉,文件变成未跟踪,这时再用 update 模式添加同样无效。还有一种情况是文件已经存在于索引,但你修改了文件名大小写,在大小写不敏感的文件系统上,JGit 可能认为还是同一个路径,新增行为不符合预期。遇到这种边缘情况,先用git.status()看清楚文件到底在 added、modified、untracked 还是 ignored 里,再决定用哪种添加模式。
索引状态还会影响“看起来没加上”的判断。比如文件已经被暂存过,再次 add 不会让它从 added 里消失;你看到getAdded()里有它,其实已经成功了。反过来,如果文件已经被提交过,再修改后 add,它会出现在 modified 而不是 added。判断“新增文件是否进暂存区”,一定要用getAdded(),不要用getModified()或getChanged()混淆。
4.4 仓库配置与锁:工作树为空、bare 仓库、index.lock 冲突
仓库配置类问题相对少,但一旦出现就很难从业务代码看出来。第一,仓库是 bare 的,没有工作树,AddCommand 没有可扫描的工作区文件。第二,FileRepositoryBuilder只设置了gitDir,没有设置workTree,JGit 推断到了错误目录。第三,仓库目录权限不足,索引文件不可写,AddCommand 抛异常但被吞掉。第四,另一个进程正在写索引,.git/index.lock存在,JGit 抛LockFailedException。第五,磁盘满或文件被占用,写索引失败。这些问题的共同点是:AddCommand 可能抛异常,但业务代码如果只记日志不处理,你看到的就是“没加上”。
排查时先打印repo.getWorkTree(),确认它不是 null 且路径正确;再看.git/index.lock是否存在;最后看异常栈里有没有LockFailedException、IOException或过滤器相关错误。并发场景下,建议把同一个仓库的 add/commit 操作串行化,不要在多个线程里同时对同一个索引写。JGit 的索引锁是文件锁,跨进程也生效,所以 CI 上多个任务共用工作区时尤其要注意。
4.5 文件系统与编码:文件没关闭、符号链接、特殊字符
最后还有一类容易被忽略的问题:文件写入了但没关闭,AddCommand 读到的内容不完整,或者文件被其他进程占用导致读取失败。虽然“没关闭”不一定会让路径匹配失败,但在 Windows 上可能引发文件锁问题。建议写完文件后使用 try-with-resources 或Files.writeString这类会自动关闭的方法。符号链接也要注意,JGit 对符号链接的处理依赖配置,如果你添加的是链接本身还是链接目标,结果可能不同。文件名包含中文、空格或特殊字符时,路径本身不需要额外转义,但你要确保字符串编码和文件系统一致,不要用平台默认编码随意转换。
如果以上都排查完还是不行,我建议做一个最小化对照实验:在同一个仓库里手动创建一个test.txt,用相对路径test.txt调用 AddCommand,看能不能进暂存区。如果能,说明 AddCommand 和仓库配置没问题,问题在你目标文件的路径或忽略规则;如果不能,说明仓库工作树、权限或索引锁有问题。这个对照实验能快速把问题分成两半,比盲目改代码高效得多。
5. 工程化封装与验证
5.1 封装一个安全的 addNewFile 方法
在业务代码里到处写git.add().addFilepattern(...)很容易重复踩坑,我通常封装一个addNewFile方法,把路径规范化、存在性检查、工作树检查、add 和结果验证都收进去。这样调用方只需要传仓库根和文件路径,不用关心 JGit 的路径语义。下面是一个偏工程化的版本,异常信息带足上下文,方便日志排查:
public final class JGitAddHelper { public static void addNewFile(Path repoDir, Path file) throws IOException, GitAPIException { Path repoRoot = repoDir.toAbsolutePath().normalize(); Path target = file.toAbsolutePath().normalize(); if (!Files.isRegularFile(target)) { throw new IllegalArgumentException("不是普通文件: " + target); } if (!target.startsWith(repoRoot)) { throw new IllegalArgumentException("文件不在工作树内: " + target); } String rel = repoRoot.relativize(target).toString().replace('\\', '/'); try (Git git = Git.open(repoRoot.toFile())) { git.add().addFilepattern(rel).call(); Status status = git.status().call(); if (!status.getAdded().contains(rel)) { throw new IllegalStateException( "add 后文件仍未暂存, rel=" + rel + ", workTree=" + git.getRepository().getWorkTree() + ", untracked=" + status.getUntracked() + ", ignored=" + status.getIgnoredNotInIndex()); } } } }这个封装的关键不是代码多,而是“失败要响”。如果 add 后文件没进 added,直接抛异常,调用方立刻知道有问题。异常信息里带rel、workTree、untracked和ignored,基本能覆盖大部分排查场景。对于批量添加,可以再封装一个addDirectory,内部用addFilepattern(dir + "/"),不要逐个文件调用。
5.2 批量添加时先聚合路径,再一次性调用
批量新增文件时,逐个文件调用 AddCommand 不是不可以,但效率低,而且每次调用都可能触碰索引锁。更好的做法是按目录聚合,或者把多个相对路径收集起来,一次性传给 AddCommand。下面是一个按目录聚合的示例:
try (Git git = Git.open(repoRoot.toFile())) { AddCommand add = git.add(); add.addFilepattern("generated/"); add.addFilepattern("src/main/java/"); add.call(); Status status = git.status().call(); System.out.println("added = " + status.getAdded()); }如果文件分散在很多目录,可以用Files.walk收集相对路径,但要注意过滤掉.git目录和忽略文件。更稳妥的方式是先让 JGit 自己扫描目录,再用 Status 验证结果,而不是在 Java 层自己实现一套忽略规则。自己实现忽略规则很容易和 JGit 行为不一致,最后又回到“为什么这个文件没加上”的老问题。
5.3 并发场景下给索引操作加锁
同一个仓库的索引不是线程安全的,多个线程同时 add 可能触发index.lock冲突。我的做法是在应用层对“同一个仓库路径”的 add/commit 操作加互斥锁,锁的粒度按仓库根路径区分。简单实现可以用ConcurrentHashMap<Path, Object>存锁对象,或者用单线程执行器串行处理某个仓库的写操作。不要用全局大锁,否则多个仓库之间会互相阻塞;也不要完全不加锁,CI 上并发任务很容易撞锁。下面是一个思路示例:
private final ConcurrentHashMap<Path, Object> repoLocks = new ConcurrentHashMap<>(); public void addSafely(Path repoDir, Path file) throws Exception { Path key = repoDir.toAbsolutePath().normalize(); Object lock = repoLocks.computeIfAbsent(key, k -> new Object()); synchronized (lock) { JGitAddHelper.addNewFile(repoDir, file); } }这个锁只解决同一进程内的并发,跨进程仍然要靠 JGit 的文件锁。如果你的场景是多进程共用工作区,最好从架构上避免同时写同一个仓库,或者让每个任务使用独立的工作区副本。索引锁冲突不是靠重试就能完全解决的,重试次数太多还会拖慢整体流程。
6. 实操心得与避坑清单
6.1 我踩过的几个典型坑
第一个坑是setUpdate(true)。早期做“只提交修改文件”的功能时,我在公共方法里默认开了 update 模式,后来新增文件怎么也加不进去,排查了半天才发现是模式问题。第二个坑是绝对路径。文件路径来自上传接口,直接传了绝对路径,AddCommand 静默跳过,日志里没有任何异常。第三个坑是目录没带斜杠,批量添加generated目录时只匹配了目录本身,子文件一个没进。第四个坑是生成目录被.gitignore忽略,add 怎么调都没用,最后调整了输出目录。第五个坑是只设置了gitDir没设置workTree,在 CI 临时目录下 JGit 推断错了工作树,扫描不到文件。
这些坑的共同点是“不报错”。JGit 的 AddCommand 不是交互式工具,它不会像命令行那样给你醒目的提示。所以我现在养成了一个习惯:只要 add 之后 status 不符合预期,先打印路径和状态集合,不猜。路径、更新模式、忽略规则、工作树这四项检查完,基本能解决九成问题。剩下的一成再去看索引锁、权限和文件系统。
6.2 一分钟自查清单
| 检查项 | 正确做法 | 错误示例 |
|---|---|---|
| 文件是否存在 | Files.isRegularFile | 文件还没写完就 add |
| 工作树是否正确 | 打印repo.getWorkTree() | 只设 gitDir,workTree 为 null |
| 路径是否相对 | repoRoot.relativize | 传绝对路径 |
| 分隔符是否正斜杠 | replace('\\', '/') | 传 Windows 反斜杠 |
| 目录是否带斜杠 | generated/ | generated想匹配子文件 |
| 是否误用 update | 新增文件不加setUpdate(true) | 公共方法默认 update |
| 是否被忽略 | 看getIgnoredNotInIndex() | 被.gitignore命中还硬 add |
| 索引是否被锁 | 检查.git/index.lock | 并发 add 不处理异常 |
| add 后是否验证 | 看getAdded() | 直接 commit 不检查 |
这张表我一般放在团队文档里,新人接 JGit 相关任务先过一遍。它不解决所有边缘问题,但能挡住大多数“新增文件没进暂存区”的低级错误。最后再分享一个小技巧:如果你不确定某个路径在 JGit 眼里长什么样,可以先用git.status().call().getUntracked()打印所有未跟踪路径,拿目标文件和集合里的字符串做精确比对。你传的 filepattern 必须和集合里的相对路径对得上,对不上就说明路径规范化有问题。这个办法比读源码快,也比猜靠谱。