151 lines
4.3 KiB
Go
151 lines
4.3 KiB
Go
|
|
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
|
|||
|
|
}
|