package update import ( "bytes" "context" "crypto/sha1" "encoding/hex" "fmt" "os" "os/exec" "path/filepath" "runtime" "sort" "strings" "time" ) // recoverAdoption resumes a checkout interrupted before the directory had a // usable HEAD. It only repeats a forced checkout if every differing local file // still matches its recorded backup; later operator edits are never overwritten. func recoverAdoption(ctx context.Context, opts Options) error { root, err := opts.installRoot() if err != nil { return err } pending, present, err := readBuildPending(root) if err != nil { return &Error{Reason: "state_unreadable", Message: "无法读取接入恢复记录:" + err.Error()} } if !present || pending.AdoptBranch == "" { return nil } if !ValidName(pending.AdoptBranch) { return &Error{Reason: "bad_state", Message: "接入恢复记录的分支不合法"} } head, headErr := gitCmd(ctx, root, "rev-parse", "HEAD") if headErr != nil { scan, err := scanAdoption(ctx, root, root, pending.Commit) if err != nil { return err } for _, rel := range append(scan.changed, scan.kept...) { backup := filepath.Join(pending.BackupDir, "overwritten", filepath.FromSlash(rel)) if Protected(rel) { backup = filepath.Join(pending.BackupDir, filepath.FromSlash(rel)) } old, oldErr := os.ReadFile(backup) current, currentErr := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel))) if oldErr != nil || currentErr != nil || !bytes.Equal(old, current) { return &Error{Reason: "local_source_edits", Message: "接入中断后本地文件发生变化,已保留现场:" + rel} } } if _, err := gitCmd(ctx, root, "checkout", "--force", "-B", pending.AdoptBranch, pending.Commit); err != nil { return err } } else if head != pending.Commit { return &Error{Reason: "build_state_mismatch", Message: "接入中断后 HEAD 发生变化,已保留现场"} } if err := restoreInterruptedProtected(ctx, root, pending, true); err != nil { return err } pending.AdoptBranch = "" pending.VersionBefore, pending.VersionAfter = plannedVersionChange(root) return writeBuildPending(root, pending) } // Adopt connects a plain directory - a tarball or zip installation, or a copy carried in by // hand - to the configured update source, so it becomes a git work tree the one-click // update applies to from then on. // // The rules are the ones an update already keeps: operator content (roles/skills/tools/ // agents/knowledge_base/data/config.yaml) is put aside and put back, and nothing else is // replaced without having been named first - the preview lists every local file whose // content differs from the target, and each replaced file is kept under // /overwritten/ so "the update ate my edit" always has an address. // maxAdoptList caps the file lists a plan carries over the wire; totals are separate so a // truncated list can never be read as the whole truth. const maxAdoptList = 50 // AdoptPlan is the preview of connecting a directory to a source. Producing it must not // change the directory. type AdoptPlan struct { Root string `json:"root"` Source string `json:"source"` Branch string `json:"branch"` Commit string `json:"commit"` Subject string `json:"subject"` Incoming int `json:"incoming"` // files the target branch tracks OverwrittenTotal int `json:"overwrittenTotal"` Overwritten []string `json:"overwritten"` // differing local files the target's version will replace (capped) ProtectedTotal int `json:"protectedTotal"` Protected []string `json:"protected"` // operator content put aside and put back (capped) } // adoptionScan is the full (uncapped) comparison an adoption is based on. type adoptionScan struct { target map[string]string // tracked path -> blob sha paths []string // every tracked path, sorted changed []string // local file differs from the target and is not protected kept []string // local file differs from the target and is protected } // Preview builds an adoption plan without touching the directory: the fetch happens in a // throwaway bare repository outside it, so a failure cannot leave a half-adopted tree. func Preview(ctx context.Context, opts Options) (*AdoptPlan, error) { root, source, err := adoptPreflight(ctx, opts) if err != nil { return nil, err } tmp, err := os.MkdirTemp("", "csai-adopt-preview-") if err != nil { return nil, err } defer os.RemoveAll(tmp) if _, err := gitCmd(ctx, tmp, "init", "--quiet", "--bare"); err != nil { return nil, err } branch, commit, subject, err := fetchSource(ctx, tmp, source) if err != nil { return nil, err } scan, err := scanAdoption(ctx, root, tmp, "origin/"+branch) if err != nil { return nil, err } return scan.plan(root, source, branch, commit, subject), nil } // Adopt carries the directory onto the source. It runs its own fetch inside the directory // (the preview's throwaway fetch cannot be reused), and on any failure removes only the // .git it created itself, so a retry starts from the same pristine state. func Adopt(ctx context.Context, opts Options, onStep func(Step)) (*Result, error) { release, err := LockInstall(opts) if err != nil { return nil, err } defer release() if onStep == nil { onStep = func(Step) {} } step := func(phase, msg string) { onStep(Step{Phase: phase, Message: msg, At: time.Now().Format(time.RFC3339)}) } start := time.Now() root, source, err := adoptPreflight(ctx, opts) if err != nil { return nil, err } step("preflight", fmt.Sprintf("把目录 %s 接入更新源 %s", root, source)) if _, err := gitCmd(ctx, root, "init", "--quiet"); err != nil { return nil, &Error{Reason: "init_failed", Message: "git init 失败:" + err.Error()} } adopted := false defer func() { if !adopted { // Only the repository git just created; the directory's own files are untouched. _ = os.RemoveAll(filepath.Join(root, ".git")) } }() if _, err := gitCmd(ctx, root, "remote", "add", "origin", source); err != nil { return nil, &Error{Reason: "init_failed", Message: "添加远端失败:" + err.Error()} } branch, commit, subject, err := fetchSource(ctx, root, source) if err != nil { return nil, err } ref := "origin/" + branch scan, err := scanAdoption(ctx, root, root, ref) if err != nil { return nil, err } step("fetch", fmt.Sprintf("%s/%s(%s %s)共 %d 个文件:%d 个本机版本会被目标版本替换,%d 个运维者文件先暂存再放回", source, branch, commit, subject, len(scan.paths), len(scan.changed), len(scan.kept))) backupDir := "" backupAll := append(append([]string{}, scan.changed...), scan.kept...) if len(backupAll) > 0 { backupDir, err = newBackupDir(root, time.Now().Format("20060102_150405")) if err != nil { return nil, err } } for _, p := range backupAll { abs := filepath.Join(root, filepath.FromSlash(p)) info, statErr := os.Stat(abs) if statErr != nil || !info.Mode().IsRegular() { return nil, &Error{Reason: "backup_failed", Message: "备份前本地文件发生变化,未执行接入:" + p} } dst := filepath.Join(backupDir, "overwritten", filepath.FromSlash(p)) if Protected(p) { dst = filepath.Join(backupDir, filepath.FromSlash(p)) } if err := copyFile(abs, dst, info.Mode()); err != nil { return nil, &Error{Reason: "backup_failed", Message: fmt.Sprintf("备份 %s 失败:%v", p, err)} } } if len(backupAll) > 0 { step("protect", fmt.Sprintf("%d 个文件已留底(被替换的在 %s/overwritten/,运维者的会原样放回)", len(backupAll), backupDir)) } targetCommit, err := gitCmd(ctx, root, "rev-parse", ref) if err != nil { return nil, err } pending := buildPendingState{ Commit: targetCommit, BackupDir: backupDir, KeptContent: append([]string(nil), scan.kept...), OverwrittenContent: append([]string(nil), scan.changed...), AdoptBranch: branch, PreviousBinaryAbsent: !fileExists(filepath.Join(root, opts.binaryName())), } if opts.binaryName() != "none" { bin := filepath.Join(root, opts.binaryName()) if fileExists(bin) { pending.PreviousBinarySHA256, err = fileSHA256(bin) if err != nil { return nil, err } } } // Record both the target and backups before checkout can replace any file. if err := writeBuildPending(root, pending); err != nil { return nil, &Error{Reason: "state_unwritable", Message: "无法写入接入恢复状态:" + err.Error()} } adopted = true if _, err := gitCmd(ctx, root, "checkout", "--force", "-B", branch, targetCommit); err != nil { return nil, &Error{Reason: "checkout_failed", Message: "落地目标内容失败,已保留恢复记录,再次更新可恢复:" + err.Error()} } if err := restoreProtected(backupDir, scan.kept); err != nil { return nil, err } pending.AdoptBranch = "" pending.VersionBefore, pending.VersionAfter = plannedVersionChange(root) if err := writeBuildPending(root, pending); err != nil { return nil, &Error{Reason: "state_unwritable", Message: "无法更新接入恢复状态:" + err.Error()} } if _, err := gitCmd(ctx, root, "branch", "--set-upstream-to="+ref); err != nil { return nil, &Error{Reason: "upstream_failed", Message: "设置分支跟踪失败:" + err.Error()} } versionBefore, versionAfter, versionErr := syncVersionToConfig(root) versionStep(step, versionBefore, versionAfter, versionErr) res := &Result{ ToCommit: commit, FilesTouched: len(scan.paths), KeptContent: scan.kept, Overwritten: scan.changed, BackupDir: backupDir, Adopted: true, NeedsRestart: true, } if opts.binaryName() == "none" { if err := clearBuildPending(root); err != nil { return res, err } step("done", fmt.Sprintf("目录已接入 %s/%s(%s);按请求跳过重新编译", source, branch, commit)) res.Duration = time.Since(start).Round(time.Millisecond).String() return res, nil } bin := filepath.Join(root, opts.binaryName()) res.BinaryPath = bin if _, canBuild := toolchain(ctx, root); !canBuild { step("done", fmt.Sprintf("目录已接入 %s/%s(%s);本机没有 Go 工具链,二进制未重编", source, branch, commit)) res.Duration = time.Since(start).Round(time.Millisecond).String() return res, &Error{ Reason: "no_toolchain", Message: "目录已接入更新源,但本机没有 Go 工具链,无法重新编译。装好 go 后再点一次更新即可补上二进制。", } } if err := buildAndSwap(ctx, root, opts, res, step, targetCommit, nil, pending); err != nil { res.Duration = time.Since(start).Round(time.Millisecond).String() return res, err } step("done", fmt.Sprintf("目录已接入 %s/%s(%s)并换好二进制", source, branch, commit)) res.Duration = time.Since(start).Round(time.Millisecond).String() return res, nil } // adoptPreflight validates the operation's preconditions: a plain directory and a usable // source address. It does not touch the directory. func adoptPreflight(ctx context.Context, opts Options) (root, source string, err error) { root, err = opts.installRoot() if err != nil { return "", "", err } if _, err := exec.LookPath("git"); err != nil { return "", "", &Error{Reason: "no_git", Message: "本机没有 git,无法把目录接入更新源"} } if _, err := gitCmd(ctx, root, "rev-parse", "--is-inside-work-tree"); err == nil { return "", "", &Error{Reason: "already_a_repo", Message: "这个目录已经是 git 工作树:用「一键更新」,不需要接入"} } if fi, statErr := os.Stat(root); statErr != nil || !fi.IsDir() { return "", "", &Error{Reason: "bad_root", Message: "安装目录不存在:" + root} } if _, statErr := os.Lstat(filepath.Join(root, ".git")); !os.IsNotExist(statErr) { return "", "", &Error{Reason: "existing_git_metadata", Message: "安装目录已存在 .git 元数据,接入不会覆盖它;请先人工检查仓库状态"} } source = opts.repoURL() if !ValidRemoteURL(source) { return "", "", &Error{Reason: "bad_source", Message: "更新源仓库地址不合法(只允许 https/http/ssh/git/file:// 或本机绝对路径)"} } return root, source, nil } // fetchSource fetches the source's default branch into repo and reports which branch // that ended up being, its short commit and its subject. Both callers fetch into a // repository of their own. func fetchSource(ctx context.Context, repo, source string) (string, string, string, error) { out, err := gitCmd(ctx, repo, "ls-remote", "--symref", source, "HEAD") if err != nil { return "", "", "", &Error{Reason: "fetch_failed", Message: "读取远端默认分支失败:" + err.Error()} } branch := parseDefaultBranch(out) if branch == "" { return "", "", "", &Error{Reason: "no_branch", Message: "无法确定远端默认分支:" + source} } if _, err := gitCmd(ctx, repo, "fetch", "--quiet", "--no-tags", "--recurse-submodules=no", source, "+refs/heads/"+branch+":refs/remotes/origin/"+branch); err != nil { return "", "", "", &Error{Reason: "fetch_failed", Message: "拉取失败:" + err.Error()} } ref := "origin/" + branch commit, err := gitCmd(ctx, repo, "rev-parse", "--short", ref) if err != nil { return "", "", "", &Error{Reason: "no_branch", Message: fmt.Sprintf("远端没有分支 %s", branch)} } subject, _ := gitCmd(ctx, repo, "log", "-1", "--format=%s", ref) return branch, commit, subject, nil } // scanAdoption compares the directory against the tracked files of ref in repo. func scanAdoption(ctx context.Context, root, repo, ref string) (*adoptionScan, error) { out, err := gitRaw(ctx, repo, "ls-tree", "-r", "-z", "--full-tree", ref) if err != nil { return nil, &Error{Reason: "diff_failed", Message: err.Error()} } scan := &adoptionScan{target: map[string]string{}} targetModes := map[string]os.FileMode{} for _, rec := range nulList(out) { tab := strings.IndexByte(rec, '\t') if tab < 0 { continue } fields := strings.Fields(rec[:tab]) if len(fields) < 3 || fields[1] != "blob" { continue // submodules and trees are not files this comparison replaces } p := rec[tab+1:] scan.target[p] = fields[2] targetModes[p] = 0644 if fields[0] == "100755" { targetModes[p] = 0755 } scan.paths = append(scan.paths, p) } sort.Strings(scan.paths) for _, p := range scan.paths { if err := validateFilePath(root, p); err != nil { return nil, err } abs := filepath.Join(root, filepath.FromSlash(p)) info, err := os.Lstat(abs) if err != nil || !info.Mode().IsRegular() { continue } hash, err := gitBlobHash(abs) if err != nil { return nil, &Error{Reason: "backup_failed", Message: "无法读取待接入的本地文件,未修改目录:" + p + ": " + err.Error()} } modeChanged := runtime.GOOS != "windows" && Protected(p) && info.Mode().Perm() != targetModes[p] if hash == scan.target[p] && !modeChanged { continue } if Protected(p) { scan.kept = append(scan.kept, p) } else { scan.changed = append(scan.changed, p) } } return scan, nil } // plan renders the capped, wire-facing view of a scan. func (s *adoptionScan) plan(root, source, branch, commit, subject string) *AdoptPlan { p := &AdoptPlan{ Root: root, Source: source, Branch: branch, Commit: commit, Subject: subject, Incoming: len(s.paths), OverwrittenTotal: len(s.changed), ProtectedTotal: len(s.kept), Overwritten: append([]string{}, s.changed...), Protected: append([]string{}, s.kept...), } if len(p.Overwritten) > maxAdoptList { p.Overwritten = p.Overwritten[:maxAdoptList] } if len(p.Protected) > maxAdoptList { p.Protected = p.Protected[:maxAdoptList] } if p.Overwritten == nil { p.Overwritten = []string{} } if p.Protected == nil { p.Protected = []string{} } return p } // gitBlobHash computes the object id git would give a file's content. It hashes the bytes // as they are (no filters), because the comparison is against raw blobs in the tree. sha1 is // not a security choice here - it is the blob-id algorithm of the repository format. func gitBlobHash(path string) (string, error) { data, err := os.ReadFile(path) if err != nil { return "", err } h := sha1.New() fmt.Fprintf(h, "blob %d\x00", len(data)) h.Write(data) return hex.EncodeToString(h.Sum(nil)), nil }