diff --git a/src/main/java/com/example/workutils/biz/git/GitSyncPlugin.java b/src/main/java/com/example/workutils/biz/git/GitSyncPlugin.java index ec02338..e4a6f07 100644 --- a/src/main/java/com/example/workutils/biz/git/GitSyncPlugin.java +++ b/src/main/java/com/example/workutils/biz/git/GitSyncPlugin.java @@ -20,14 +20,10 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Scanner; -import java.util.concurrent.CancellationException; import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicInteger; import org.springframework.beans.factory.InitializingBean; @@ -558,72 +554,87 @@ public class GitSyncPlugin implements Plugin, InitializingBean { // ==================== Git 命令执行 ==================== + /** + * 执行一条 Git 命令,总超时 CMD_TIMEOUT_SECONDS + 10 秒。 + *

+ * 关键:进程输出在独立线程中读取,主线程通过 process.waitFor(timeout) 等待。 + * 超时后强制杀进程 + 中断读线程,杜绝僵尸进程 / 永久阻塞。 + */ private CmdResult runGit(String workingDir, String command) { - // 用 Future 包一层,防止 readLine() 因进程卡死而永久阻塞 - ExecutorService singleExecutor = Executors.newSingleThreadExecutor(); - Future future = singleExecutor.submit(() -> { - CmdResult result = new CmdResult(); - try { - ProcessBuilder pb = isWindows() - ? new ProcessBuilder("cmd.exe", "/c", command) - : new ProcessBuilder("bash", "-c", command); + CmdResult result = new CmdResult(); + Process process = null; + Thread readerThread = null; + StringBuilder output = new StringBuilder(); - if (workingDir != null) { - pb.directory(new File(workingDir)); - } - pb.redirectErrorStream(true); + try { + ProcessBuilder pb = isWindows() + ? new ProcessBuilder("cmd.exe", "/c", command) + : new ProcessBuilder("bash", "-c", command); - Process process = pb.start(); - StringBuilder output = new StringBuilder(); + if (workingDir != null) { + pb.directory(new File(workingDir)); + } + pb.redirectErrorStream(true); + process = pb.start(); + final Process proc = process; // effectively final 副本供 lambda 引用 + + // 独立线程读取进程输出,避免 readLine() 阻塞主逻辑 + readerThread = new Thread(() -> { try (BufferedReader reader = new BufferedReader( - new InputStreamReader(process.getInputStream(), java.nio.charset.StandardCharsets.UTF_8))) { + new InputStreamReader(proc.getInputStream(), java.nio.charset.StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { output.append(line).append("\n"); } + } catch (IOException ignored) { + // 进程被杀时流关闭会抛异常,正常情况 } + }, "git-reader"); + readerThread.setDaemon(true); + readerThread.start(); - boolean finished = process.waitFor(CMD_TIMEOUT_SECONDS, TimeUnit.SECONDS); - if (!finished) { - process.destroyForcibly(); - result.ok = false; - result.error = "命令超时(" + CMD_TIMEOUT_SECONDS + "s)"; - return result; - } - - int exitCode = process.exitValue(); - result.ok = (exitCode == 0); - result.output = output.toString().trim(); - if (!result.ok) { - String detail = output.toString().trim(); - result.error = detail.isEmpty() - ? "exit code " + exitCode - : "exit code " + exitCode + ": " + detail; - } - } catch (Exception e) { + // 等待进程结束(带超时) + boolean finished = process.waitFor(CMD_TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!finished) { + log.warn("Git 命令超时({}s): {} -> {}", CMD_TIMEOUT_SECONDS, workingDir, command); result.ok = false; - result.error = e.getMessage(); + result.error = "命令超时(" + CMD_TIMEOUT_SECONDS + "s)"; + return result; } - return result; - }); - try { - // 整体超时比内部 waitFor 多给 10 秒缓冲 - return future.get(CMD_TIMEOUT_SECONDS + 10, TimeUnit.SECONDS); - } catch (TimeoutException e) { - future.cancel(true); - CmdResult result = new CmdResult(); + // 进程已结束,等待读线程收尾(最多10秒) + readerThread.join(10_000); + + int exitCode = process.exitValue(); + result.ok = (exitCode == 0); + result.output = output.toString().trim(); + if (!result.ok) { + String detail = output.toString().trim(); + result.error = detail.isEmpty() + ? "exit code " + exitCode + : "exit code " + exitCode + ": " + detail; + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); result.ok = false; - result.error = "命令超时(" + (CMD_TIMEOUT_SECONDS + 10) + "s,进程被强制终止)"; - return result; - } catch (InterruptedException | ExecutionException | CancellationException e) { - CmdResult result = new CmdResult(); + result.error = "命令被中断"; + } catch (Exception e) { result.ok = false; result.error = "执行异常: " + e.getMessage(); - return result; } finally { - singleExecutor.shutdownNow(); + // 确保进程和读线程都被清理 + if (process != null) { + if (process.isAlive()) { + process.destroyForcibly(); + try { process.waitFor(5, TimeUnit.SECONDS); } catch (InterruptedException ignored) { } + } + } + if (readerThread != null && readerThread.isAlive()) { + readerThread.interrupt(); + } } + + return result; } private String getRemoteUrl(String repoPath, Map baseUrlMap) { diff --git a/src/main/java/com/example/workutils/biz/scanner/service/ScannerService.java b/src/main/java/com/example/workutils/biz/scanner/service/ScannerService.java index ef09e6d..3a551fd 100644 --- a/src/main/java/com/example/workutils/biz/scanner/service/ScannerService.java +++ b/src/main/java/com/example/workutils/biz/scanner/service/ScannerService.java @@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import javax.annotation.PreDestroy; import java.io.IOException; import java.nio.file.Path; import java.util.ArrayList; @@ -15,6 +16,8 @@ import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -22,6 +25,9 @@ import java.util.regex.Pattern; @Slf4j public class ScannerService { + /** 单个项目扫描超时时间(分钟) */ + private static final long SCAN_TIMEOUT_MINUTES = 5; + private final ExecutorService executorService = Executors.newFixedThreadPool(10); @Autowired private FileOperationHelper fileOperationHelper; @@ -30,11 +36,25 @@ public class ScannerService { @Autowired private WorkUtilsProperties properties; + @PreDestroy + public void shutdown() { + log.info("正在关闭扫描线程池..."); + executorService.shutdown(); + try { + if (!executorService.awaitTermination(10, TimeUnit.SECONDS)) { + executorService.shutdownNow(); + } + } catch (InterruptedException e) { + executorService.shutdownNow(); + Thread.currentThread().interrupt(); + } + log.info("扫描线程池已关闭"); + } + public ScanResult scanAllProjects(ScanType scanType, String pattern) throws Exception { List projectPaths = fileOperationHelper.getProjectPaths(); if (projectPaths.isEmpty()) { - log.warn("No project paths found"); - return new ScanResult(); + throw new IllegalStateException("未配置项目路径,请先在「系统设置」中添加要扫描的项目路径"); } ScanResult result = new ScanResult(); @@ -53,9 +73,25 @@ public class ScannerService { futures.add(future); } + int total = futures.size(); + int idx = 0; for (CompletableFuture future : futures) { - ProjectScanResult projectResult = future.get(); - result.addProjectResult(projectResult); + idx++; + String pathHint = (idx <= projectPaths.size()) + ? " (" + fileOperationHelper.extractProjectName(projectPaths.get(idx - 1)) + ")" + : ""; + try { + ProjectScanResult projectResult = future.get(SCAN_TIMEOUT_MINUTES, TimeUnit.MINUTES); + result.addProjectResult(projectResult); + } catch (TimeoutException e) { + log.error("项目扫描超时({}分钟)第{}/{}个项目{},已跳过", + SCAN_TIMEOUT_MINUTES, idx, total, pathHint); + // 超时后仍尝试取消该任务 + future.cancel(true); + } catch (Exception e) { + log.error("获取扫描结果异常 第{}/{}个项目{}: {}", + idx, total, pathHint, e.getMessage()); + } } return result; diff --git a/src/main/java/com/example/workutils/system/config/ConfigInitializer.java b/src/main/java/com/example/workutils/system/config/ConfigInitializer.java index 1271ff8..49ddf2e 100644 --- a/src/main/java/com/example/workutils/system/config/ConfigInitializer.java +++ b/src/main/java/com/example/workutils/system/config/ConfigInitializer.java @@ -25,8 +25,10 @@ public class ConfigInitializer implements ApplicationListener openInNotepad(properties.getProjectPathsFilePath())); HBox ppBtns = new HBox(10, btnLoadPP, btnSavePP, btnOpenPP); - ppBox.getChildren().addAll(lblProjectPathsFile, taProjectPaths, ppBtns); + ppBox.getChildren().addAll(lblProjectPathsFile, taProjectPaths, ppBtns, createReplaceTool()); Tab ppTab = new Tab("项目路径"); ppTab.setContent(ppBox); @@ -724,6 +727,237 @@ public class SettingsWindow { } } + // ==================== 项目路径替换工具 ==================== + + private VBox createReplaceTool() { + VBox box = new VBox(8); + box.setPadding(new Insets(8, 0, 0, 0)); + box.setStyle("-fx-border-color: #e0e0e0; -fx-border-width: 1 0 0 0; -fx-padding: 10 0 0 0;"); + + Label title = new Label("批量字符替换"); + title.setStyle("-fx-font-weight: bold; -fx-font-size: " + fontSectionTitle + "px;"); + titleLabels.add(title); + box.getChildren().add(title); + + // 父目录替换行 + HBox parentRow = new HBox(8); + parentRow.setAlignment(Pos.CENTER_LEFT); + Label lblParent = new Label("父目录: "); + lblParent.setFont(font(fontFieldLabel)); + fieldLabels.add(lblParent); + tfReplaceParentDir = new TextField(); + tfReplaceParentDir.setPromptText("自动检测当前父目录,在此输入新父目录"); + tfReplaceParentDir.setPrefWidth(260); + textInputs.add(tfReplaceParentDir); + Button btnBrowseParent = addSmallButton("浏览..."); + btnBrowseParent.setOnAction(e -> browseForParentDir()); + Button btnDetectParent = addSmallButton("检测"); + btnDetectParent.setOnAction(e -> detectParentDir()); + Button btnApplyParent = addSmallButton("替换"); + btnApplyParent.setOnAction(e -> applyReplaceParentDir()); + parentRow.getChildren().addAll(lblParent, tfReplaceParentDir, btnBrowseParent, btnDetectParent, btnApplyParent); + + // 账号替换行 + HBox accountRow = new HBox(8); + accountRow.setAlignment(Pos.CENTER_LEFT); + Label lblAccount = new Label("账 号: "); + lblAccount.setFont(font(fontFieldLabel)); + fieldLabels.add(lblAccount); + tfReplaceAccount = new TextField(); + tfReplaceAccount.setPromptText("自动检测当前账号,在此输入新账号"); + tfReplaceAccount.setPrefWidth(300); + textInputs.add(tfReplaceAccount); + Button btnDetectAccount = addSmallButton("检测"); + btnDetectAccount.setOnAction(e -> detectAccount()); + Button btnApplyAccount = addSmallButton("替换"); + btnApplyAccount.setOnAction(e -> applyReplaceAccount()); + accountRow.getChildren().addAll(lblAccount, tfReplaceAccount, btnDetectAccount, btnApplyAccount); + + box.getChildren().addAll(parentRow, accountRow); + return box; + } + + /** 从第一条有效项目路径中检测父目录 */ + private void detectParentDir() { + String firstPath = findFirstProjectPath(); + if (firstPath == null) { + showAlert(AlertType.WARNING, "项目路径为空,无法检测父目录。"); + return; + } + // 取 / 分割后的第一个非空部分作为父目录,如 D:/bestpay-srdcloud/ft-xxx 中的 D:/bestpay-srdcloud + int slashIdx = firstPath.indexOf('/'); + if (slashIdx < 0) { + tfReplaceParentDir.setText(firstPath); + return; + } + // 找到第一个 / 后,继续找 /ft-xxx 的 /,即项目目录前一个 / + int nextSlash = firstPath.indexOf('/', slashIdx + 1); + if (nextSlash > 0) { + tfReplaceParentDir.setText(firstPath.substring(0, nextSlash)); + } else { + tfReplaceParentDir.setText(firstPath); + } + } + + /** 浏览选择父目录 */ + private void browseForParentDir() { + DirectoryChooser chooser = new DirectoryChooser(); + chooser.setTitle("选择父目录"); + String current = tfReplaceParentDir.getText().trim(); + if (!current.isEmpty()) { + File f = new File(current); + if (f.exists()) { + chooser.setInitialDirectory(f); + } else { + // 尝试取已存在的上级目录 + File parent = f.getParentFile(); + while (parent != null && !parent.exists()) { + parent = parent.getParentFile(); + } + if (parent != null) { + chooser.setInitialDirectory(parent); + } + } + } + File selected = chooser.showDialog(stage); + if (selected != null) { + tfReplaceParentDir.setText(selected.getAbsolutePath()); + } + } + + /** 从第一条带 URL 的项目路径中检测账号 */ + private void detectAccount() { + String firstLine = findFirstLineWithUrl(); + if (firstLine == null) { + showAlert(AlertType.WARNING, "未找到带远程 URL 的项目路径,请确保路径格式为 path=URL。"); + return; + } + // 格式: path=https://account@code.srdcloud.cn/... + int eqIdx = firstLine.indexOf('='); + if (eqIdx < 0) return; + String url = firstLine.substring(eqIdx + 1).trim(); + // 提取 @ 前面的账号:https://xxx → 取 // 和 @ 之间的部分 + int atIdx = url.indexOf('@'); + int protoIdx = url.indexOf("://"); + if (atIdx > 0 && protoIdx > 0) { + String account = url.substring(protoIdx + 3, atIdx); + tfReplaceAccount.setText(account); + } + } + + /** 批量替换父目录 */ + private void applyReplaceParentDir() { + String newParent = tfReplaceParentDir.getText().trim(); + if (newParent.isEmpty()) { + showAlert(AlertType.WARNING, "请输入新的父目录。"); + return; + } + // 检测当前父目录 + String firstPath = findFirstProjectPath(); + if (firstPath == null) { + showAlert(AlertType.WARNING, "项目路径为空。"); + return; + } + int slashIdx = firstPath.indexOf('/'); + if (slashIdx < 0) { + showAlert(AlertType.WARNING, "无法解析父目录。"); + return; + } + int nextSlash = firstPath.indexOf('/', slashIdx + 1); + String oldParent = (nextSlash > 0) ? firstPath.substring(0, nextSlash) : firstPath; + + // 规范化:去掉末尾 / + if (newParent.endsWith("/") || newParent.endsWith("\\")) { + newParent = newParent.substring(0, newParent.length() - 1); + } + if (oldParent.endsWith("/") || oldParent.endsWith("\\")) { + oldParent = oldParent.substring(0, oldParent.length() - 1); + } + + int count = replaceInTextArea(oldParent, newParent); + showAlert(AlertType.INFORMATION, + "已将 " + count + " 条路径中的父目录从\n" + oldParent + "\n替换为\n" + newParent); + } + + /** 批量替换账号 */ + private void applyReplaceAccount() { + String newAccount = tfReplaceAccount.getText().trim(); + if (newAccount.isEmpty()) { + showAlert(AlertType.WARNING, "请输入新的账号。"); + return; + } + // 检测当前账号 + String firstLine = findFirstLineWithUrl(); + if (firstLine == null) { + showAlert(AlertType.WARNING, "未找到带远程 URL 的项目路径。"); + return; + } + int eqIdx = firstLine.indexOf('='); + if (eqIdx < 0) return; + String url = firstLine.substring(eqIdx + 1).trim(); + int atIdx = url.indexOf('@'); + int protoIdx = url.indexOf("://"); + if (atIdx <= 0 || protoIdx <= 0) { + showAlert(AlertType.WARNING, "无法解析当前账号。"); + return; + } + String oldAccount = url.substring(protoIdx + 3, atIdx); + + int count = replaceInTextArea(oldAccount, newAccount); + showAlert(AlertType.INFORMATION, + "已将 " + count + " 条URL中的账号从\n" + oldAccount + "\n替换为\n" + newAccount); + } + + /** 在 TextArea 中替换文本,返回替换行数 */ + private int replaceInTextArea(String oldStr, String newStr) { + String text = taProjectPaths.getText(); + int count = 0; + String[] lines = text.split("\n"); + StringBuilder sb = new StringBuilder(); + for (String line : lines) { + if (!line.trim().isEmpty() && !line.trim().startsWith("#") && line.contains(oldStr)) { + sb.append(line.replace(oldStr, newStr)).append("\n"); + count++; + } else { + sb.append(line).append("\n"); + } + } + taProjectPaths.setText(sb.toString()); + return count; + } + + /** 找到第一条有效项目路径(只取路径部分,不含 URL) */ + private String findFirstProjectPath() { + String text = taProjectPaths.getText(); + if (text == null || text.trim().isEmpty()) return null; + for (String line : text.split("\n")) { + line = line.trim(); + if (line.isEmpty() || line.startsWith("#")) continue; + int eqIdx = line.indexOf('='); + if (eqIdx >= 0) { + return line.substring(0, eqIdx).trim(); + } + return line; + } + return null; + } + + /** 找到第一条同时包含路径和 URL 的行 */ + private String findFirstLineWithUrl() { + String text = taProjectPaths.getText(); + if (text == null || text.trim().isEmpty()) return null; + for (String line : text.split("\n")) { + line = line.trim(); + if (line.isEmpty() || line.startsWith("#")) continue; + int eqIdx = line.indexOf('='); + if (eqIdx >= 0) { + String url = line.substring(eqIdx + 1).trim(); + if (!url.isEmpty()) return line; + } + } + return null; + } + private void openInNotepad(Path file) { try { if (!Files.exists(file)) { diff --git a/src/main/java/com/example/workutils/system/config/WorkUtilsProperties.java b/src/main/java/com/example/workutils/system/config/WorkUtilsProperties.java index 77042e1..01a546a 100644 --- a/src/main/java/com/example/workutils/system/config/WorkUtilsProperties.java +++ b/src/main/java/com/example/workutils/system/config/WorkUtilsProperties.java @@ -7,6 +7,7 @@ import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Component; import javax.annotation.PostConstruct; +import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; @@ -69,6 +70,24 @@ public class WorkUtilsProperties { @PostConstruct void snapshotDefaults() { + // 基础目录:未配置则默认取运行目录(user.dir) + if (baseDirectory == null || baseDirectory.trim().isEmpty()) { + baseDirectory = System.getProperty("user.dir"); + log.info("基础目录未配置,默认取运行目录: {}", baseDirectory); + } + // 导出目录:未配置则默认 {user.dir}/export + if (exportDirectory == null || exportDirectory.trim().isEmpty()) { + exportDirectory = System.getProperty("user.dir") + "/export"; + log.info("导出目录未配置,默认: {}", exportDirectory); + ensureExportDirExists(); + } else if (!Paths.get(exportDirectory).isAbsolute()) { + // 相对路径 → 解析到 user.dir 下 + exportDirectory = Paths.get(System.getProperty("user.dir"), exportDirectory).toString(); + ensureExportDirExists(); + } else { + ensureExportDirExists(); + } + defaults.put(ExternalConfigManager.KEY_BASE_DIR, baseDirectory); defaults.put(ExternalConfigManager.KEY_EXPORT_DIR, exportDirectory); defaults.put(ExternalConfigManager.KEY_PROJECT_PATHS, projectPathsFile); @@ -77,6 +96,19 @@ public class WorkUtilsProperties { ensureDataFilesExist(); } + /** 确保导出目录存在 */ + private void ensureExportDirExists() { + try { + Path dir = Paths.get(exportDirectory); + if (!Files.exists(dir)) { + Files.createDirectories(dir); + log.info("已创建导出目录: {}", dir.toAbsolutePath()); + } + } catch (IOException e) { + log.warn("创建导出目录失败: {} ({})", exportDirectory, e.getMessage()); + } + } + /** * 合并外部配置 —— 外部值覆盖 application.yml 值 */ @@ -155,7 +187,7 @@ public class WorkUtilsProperties { * 文件不存在时自动创建空文件。 */ public Path getProjectPathsFilePath() { - Path p = Paths.get(projectPathsFile != null ? projectPathsFile : "代码仓库项目路径.txt"); + Path p = Paths.get(projectPathsFile != null ? projectPathsFile : "project_paths.txt"); if (p.isAbsolute()) { return p; } @@ -183,7 +215,7 @@ public class WorkUtilsProperties { * 无法读取模板时则创建空文件。 */ private void ensureDataFilesExist() { - ensureDataFile(getProjectPathsFilePath(), "config/代码仓库项目路径.txt", "项目路径文件"); + ensureDataFile(getProjectPathsFilePath(), "config/project_paths.txt", "项目路径文件"); ensureDataFile(getDamagedReposFilePath(), "config/damaged_repos.cache", "损坏缓存文件"); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 096008e..564e52f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,10 +3,12 @@ spring: name: workutils workutils: - base-directory: "D:/workspace/workutils" - export-directory: "D:/workspace/workutils/export" - # 数据文件路径(相对路径 → user.dir 同级目录,绝对路径 → 直接使用) - project-paths-file: "代码仓库项目路径.txt" + # base dir, empty = defaults to user.dir + base-directory: "" + # export dir, empty = defaults to {user.dir}/export (auto-created on startup) + export-directory: "" + # data file paths (relative -> under user.dir, absolute -> as-is) + project-paths-file: "project_paths.txt" damaged-repos-file: "damaged_repos.cache" logging: