package com.codechecker.git; import com.codechecker.model.ChangedClassFile; import com.codechecker.model.ClassType; import com.codechecker.parser.ClassDeclParser; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; /** * 执行 git diff,识别 Dto/Vo/Entity/Model 的修改、删除、重命名(含 R* 与同目录 D+A 配对)。 */ public class GitChangeScanner { private final Path repoRoot; private final ClassDeclParser classDeclParser = new ClassDeclParser(); public GitChangeScanner(Path repoRoot) { this.repoRoot = repoRoot; } /** 扫描两次提交间的模型类变更 */ public List scanChangedClasses(String oldSha, String newSha) throws IOException { List lines = runGit("diff", "--name-status", oldSha, newSha); List deletions = new ArrayList<>(); Map additionsByParent = new LinkedHashMap<>(); List result = new ArrayList<>(); for (String line : lines) { if (line.isBlank()) { continue; } String[] parts = line.split("\t"); if (parts.length < 2) { continue; } String status = parts[0].trim(); if (status.startsWith("R") && parts.length >= 3) { ChangedClassFile renamed = buildRenamed(parts[1], parts[2], oldSha, newSha); if (renamed != null) { result.add(renamed); } continue; } String path = normalizePath(parts[parts.length - 1]); if (!path.endsWith(".java")) { continue; } String fallbackName = ClassDeclParser.classNameFromPath(path); ClassType classType = ClassType.fromClassName(fallbackName); if (classType == null) { continue; } if (status.equals("A")) { String newSource = readFileAtCommit(newSha, path); String className = classDeclParser.resolveClassName(newSource, fallbackName); additionsByParent.computeIfAbsent(parentDir(path), k -> new PendingAdd()) .add(path, className, classType, newSource); continue; } if (status.equals("D")) { String oldSource = readFileAtCommit(oldSha, path); String className = classDeclParser.resolveClassName(oldSource, fallbackName); deletions.add(new ChangedClassFile(path, ChangedClassFile.ChangeStatus.DELETED, className, classType)); continue; } if (status.startsWith("M")) { ChangedClassFile modified = buildModified(path, oldSha, newSha, fallbackName, classType); if (modified != null) { result.add(modified); } } } pairDeleteAndAdd(deletions, additionsByParent, oldSha, result); result.addAll(deletions); return result; } /** 处理 git R 状态:路径重命名 */ private ChangedClassFile buildRenamed(String oldPathRaw, String newPathRaw, String oldSha, String newSha) throws IOException { String oldPath = normalizePath(oldPathRaw); String newPath = normalizePath(newPathRaw); if (!oldPath.endsWith(".java") || !newPath.endsWith(".java")) { return null; } String oldFallback = ClassDeclParser.classNameFromPath(oldPath); String newFallback = ClassDeclParser.classNameFromPath(newPath); ClassType classType = ClassType.fromClassName(newFallback); if (classType == null) { classType = ClassType.fromClassName(oldFallback); } if (classType == null) { return null; } String oldSource = readFileAtCommit(oldSha, oldPath); String newSource = readFileAtCommit(newSha, newPath); String oldClassName = classDeclParser.resolveClassName(oldSource, oldFallback); String newClassName = classDeclParser.resolveClassName(newSource, newFallback); return new ChangedClassFile(newPath, oldPath, ChangedClassFile.ChangeStatus.RENAMED, newClassName, oldClassName, classType); } /** 处理 M 状态:同路径下对比 AST 类名判断是否重命名 */ private ChangedClassFile buildModified(String path, String oldSha, String newSha, String fallbackName, ClassType classType) throws IOException { String oldSource = readFileAtCommit(oldSha, path); String newSource = readFileAtCommit(newSha, path); if (newSource == null || newSource.isBlank()) { newSource = readFileAtHead(path); } String oldClassName = classDeclParser.resolveClassName(oldSource, fallbackName); String newClassName = classDeclParser.resolveClassName(newSource, fallbackName); if (oldClassName.equals(newClassName)) { return new ChangedClassFile(path, ChangedClassFile.ChangeStatus.MODIFIED, newClassName, classType); } return new ChangedClassFile(path, path, ChangedClassFile.ChangeStatus.RENAMED, newClassName, oldClassName, classType); } /** 同目录 D+A 配对为 RENAMED(Git 未显式标记 R 时) */ private void pairDeleteAndAdd(List deletions, Map additionsByParent, String oldSha, List result) throws IOException { List unpaired = new ArrayList<>(); for (ChangedClassFile deleted : deletions) { String parent = parentDir(deleted.getRelativePath()); PendingAdd pending = additionsByParent.get(parent); if (pending == null || pending.isEmpty()) { unpaired.add(deleted); continue; } PendingAdd.Candidate candidate = pending.poll(deleted.getClassType()); if (candidate == null) { unpaired.add(deleted); continue; } String oldSource = readFileAtCommit(oldSha, deleted.getRelativePath()); String oldClassName = classDeclParser.resolveClassName(oldSource, deleted.getClassName()); result.add(new ChangedClassFile(candidate.path(), deleted.getRelativePath(), ChangedClassFile.ChangeStatus.RENAMED, candidate.className(), oldClassName, deleted.getClassType())); } deletions.clear(); deletions.addAll(unpaired); } /** 取路径父目录,用于 D+A 配对 */ private static String parentDir(String path) { int idx = path.lastIndexOf('/'); return idx >= 0 ? path.substring(0, idx) : ""; } /** 读取指定 commit 下的文件内容 */ public String readFileAtCommit(String commitSha, String relativePath) throws IOException { List lines = runGit("show", commitSha + ":" + relativePath); if (lines.isEmpty()) { return ""; } if (lines.size() == 1 && lines.get(0).startsWith("fatal:")) { return ""; } return String.join("\n", lines); } /** 读取工作区 HEAD 文件(commit 中缺失时的回退) */ public String readFileAtHead(String relativePath) throws IOException { Path file = repoRoot.resolve(relativePath); if (!Files.exists(file)) { return null; } return Files.readString(file, StandardCharsets.UTF_8); } /** 两次提交间变更文件路径列表(--name-only) */ public List diffNameOnly(String oldSha, String newSha) throws IOException { return runGit("diff", "--name-only", oldSha, newSha); } /** 在 repoRoot 下执行 git 命令并返回 stdout 行 */ private List runGit(String... args) throws IOException { String[] command = new String[args.length + 3]; command[0] = "git"; command[1] = "-C"; command[2] = repoRoot.toString(); System.arraycopy(args, 0, command, 3, args.length); ProcessBuilder builder = new ProcessBuilder(command); builder.redirectErrorStream(true); Process process = builder.start(); List output = new ArrayList<>(); try (BufferedReader reader = new BufferedReader( new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { output.add(line); } } try { int exitCode = process.waitFor(); if (exitCode != 0 && !isBenignGitShowFailure(args, output)) { throw new IOException("git 命令失败: " + String.join(" ", command) + "\n" + String.join("\n", output)); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IOException("git 命令被中断", e); } return output; } /** git show 文件不存在等情况视为可忽略 */ private boolean isBenignGitShowFailure(String[] args, List output) { if (args.length > 0 && "show".equals(args[0])) { String joined = String.join("\n", output).toLowerCase(Locale.ROOT); return joined.contains("exists on disk") || joined.contains("bad object") || joined.contains("path") && joined.contains("does not exist"); } return false; } /** 统一路径分隔符为 / */ private String normalizePath(String path) { return path.replace("\\", "/"); } /** 同目录新增文件缓冲,供 D+A 配对 */ private static final class PendingAdd { private final Map> byType = new HashMap<>(); void add(String path, String className, ClassType classType, String source) { byType.computeIfAbsent(classType, k -> new ArrayList<>()) .add(new Candidate(path, className)); } boolean isEmpty() { return byType.values().stream().allMatch(List::isEmpty); } /** 按类型取出一个候选新增文件 */ Candidate poll(ClassType classType) { List list = byType.get(classType); if (list == null || list.isEmpty()) { return null; } return list.remove(0); } private static final class Candidate { private final String path; private final String className; private Candidate(String path, String className) { this.path = path; this.className = className; } private String path() { return path; } private String className() { return className; } } } }