package main import ( "bufio" "bytes" "context" "errors" "os" "os/exec" "path/filepath" "regexp" "strconv" "strings" "time" "view/platform" ) // ---------------- 从 Git 克隆并添加项目 ---------------- // repoNameFromURL 从 Git 地址推导目录名(支持 https / ssh / 本地路径形式)。 func repoNameFromURL(u string) string { s := strings.TrimSpace(u) s = strings.TrimRight(s, "/\\") s = strings.TrimSuffix(s, ".git") if i := strings.LastIndexAny(s, "/:\\"); i >= 0 { s = s[i+1:] } s = regexp.MustCompile(`[<>:"/\\|?*\s]+`).ReplaceAllString(s, "-") return strings.Trim(s, "-.") } var cloneProgressRe = regexp.MustCompile(`(Counting objects|Compressing objects|Receiving objects|Resolving deltas):\s+(\d+)%`) // cloneProgress 把 git 各阶段百分比映射到 0-98 的总体进度。 func cloneProgress(line string) (int, bool) { m := cloneProgressRe.FindStringSubmatch(line) if m == nil { return 0, false } pct, _ := strconv.Atoi(m[2]) switch m[1] { case "Counting objects": return 2 + pct*3/100, true case "Compressing objects": return 5 + pct*5/100, true case "Receiving objects": return 10 + pct*75/100, true default: // Resolving deltas return 85 + pct*13/100, true } } // scanCRLines 以 \r 或 \n 切行:git 进度用回车原地刷新,普通行以换行结束。 func scanCRLines(data []byte, atEOF bool) (int, []byte, error) { if atEOF && len(data) == 0 { return 0, nil, nil } if i := bytes.IndexAny(data, "\r\n"); i >= 0 { return i + 1, data[:i], nil } if atEOF { return len(data), data, nil } return 0, nil, nil } // CloneProject 把远程仓库克隆到指定父目录后直接加入项目列表。 // 进度通过 analysis:progress 事件复用任务条 / 全屏加载展示。 func (a *App) CloneProject(in CloneInput) (Project, error) { if e := a.ready(); e != nil { return Project{}, e } url := strings.TrimSpace(in.URL) if url == "" { return Project{}, errors.New("URL_REQUIRED") } parent := strings.TrimSpace(in.ParentDir) if st, e := os.Stat(parent); e != nil || !st.IsDir() { return Project{}, errors.New("DIR_NOT_FOUND") } name := strings.TrimSpace(in.Name) if name == "" { name = repoNameFromURL(url) } if name == "" { return Project{}, errors.New("NAME_REQUIRED") } target := filepath.Join(parent, name) if ents, e := os.ReadDir(target); e == nil && len(ents) > 0 { return Project{}, errors.New("TARGET_NOT_EMPTY") } taskID := "clone-" + name emitP := func(stage string, progress int, key string) { a.emit("analysis:progress", TaskEvent{TaskID: taskID, Stage: stage, Progress: progress, MessageKey: key, Params: map[string]any{"project": name}}) } emitP("cloning", 1, "task.cloning") ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) defer cancel() cmd := exec.CommandContext(ctx, "git", "clone", "--progress", url, target) // 禁止 git 弹出交互式凭据询问,私有仓库直接快速失败 cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GCM_INTERACTIVE=never") platform.ConfigureHidden(cmd) stderr, e := cmd.StderrPipe() if e != nil { emitP("error", 100, "task.cloneFailed") return Project{}, e } if e := cmd.Start(); e != nil { emitP("error", 100, "task.cloneFailed") a.store.Log("error", "项目", "Git 克隆失败", url+" | "+e.Error()) return Project{}, e } sc := bufio.NewScanner(stderr) sc.Split(scanCRLines) tail := []string{} for sc.Scan() { line := strings.TrimSpace(sc.Text()) if line == "" { continue } if len(tail) >= 6 { tail = tail[1:] } tail = append(tail, line) if p, ok := cloneProgress(line); ok { emitP("cloning", p, "task.cloning") } } if e := cmd.Wait(); e != nil { _ = os.RemoveAll(target) // 清掉半成品目录(克隆前已确认为空/不存在) msg := strings.Join(tail, "\n") a.store.Log("error", "项目", "Git 克隆失败", url+" | "+msg) emitP("error", 100, "task.cloneFailed") if msg != "" { return Project{}, errors.New(msg) } return Project{}, e } p, e := a.SaveProject(0, ProjectInput{Name: name, Path: target, Description: in.Description, GroupID: in.GroupID}) if e != nil { emitP("error", 100, "task.cloneFailed") return p, e } a.store.Log("info", "项目", "Git 克隆成功", url+" -> "+target) emitP("completed", 100, "task.cloneDone") return p, nil }