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: