296 lines
11 KiB
Java
296 lines
11 KiB
Java
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<ChangedClassFile> scanChangedClasses(String oldSha, String newSha) throws IOException {
|
||
List<String> lines = runGit("diff", "--name-status", oldSha, newSha);
|
||
List<ChangedClassFile> deletions = new ArrayList<>();
|
||
Map<String, PendingAdd> additionsByParent = new LinkedHashMap<>();
|
||
List<ChangedClassFile> 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<ChangedClassFile> deletions,
|
||
Map<String, PendingAdd> additionsByParent,
|
||
String oldSha,
|
||
List<ChangedClassFile> result) throws IOException {
|
||
List<ChangedClassFile> 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<String> 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<String> diffNameOnly(String oldSha, String newSha) throws IOException {
|
||
return runGit("diff", "--name-only", oldSha, newSha);
|
||
}
|
||
|
||
/** 在 repoRoot 下执行 git 命令并返回 stdout 行 */
|
||
private List<String> 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<String> 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<String> 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<ClassType, List<Candidate>> 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<Candidate> 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;
|
||
}
|
||
}
|
||
}
|
||
}
|