mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-21 23:01:37 +02:00
The review found two failure paths that reported success for work the engine had not done. Rollback: it refused only when *non-protected* source files were modified, and then ran `git reset --hard` to the previous commit - which discards every local change to tracked files, not just the update's. Edits to roles/skills/tools (the update itself keeps those via its own stash) and edits made after the update were silently wiped, while Result said nothing: deployment.md's promise that "work done afterwards is not swept away" did not hold. Rollback now copies every protected file that differs right now into `.update-backup/rollback_<ts>/` before the reset, sets the copies back after it, re-applies hand deletions, and names what it kept in Result.KeptContent like an update does. Retry: Apply merges and writes the state file before it builds, so a failed (or toolchain-less) build leaves HEAD on the new commit with the old binary still in place. The retry then took the `!snap.UpdateAvailable` no-op branch, aligned the version and returned err=nil - the job was recorded as succeeded, the page said "update finished", the CLI printed that source and binary were in place, and the old executable kept running. The tree now carries a `.update-build-pending` marker, written the moment the merge lands (and when an adoption commits to its work tree) and cleared only after the new binary is in place. The no-op branch reads it: with a toolchain it compiles and reports the build (commits=0, binaryBuilt=true); without one it keeps refusing exactly like the first attempt, so "install go and click again" is now a real instruction. The console stops treating "nothing to pull" as "nothing to do" (the retry button stays live, the confirm dialog and the done toast say the build is what will happen), and the CLI's printResult no longer swallows the build-only result. Both paths are covered by regression tests that fail on the pre-fix tree: two rollback scenarios driven through real git (edit before and after the update, a hand deletion), and four retry scenarios driven through a scripted go on PATH (failed build, state-write failure, an adoption's failed build retried by the next update, and no toolchain then toolchain).
547 lines
22 KiB
Go
547 lines
22 KiB
Go
package update
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// stateFile records what the last update moved, so a rollback has a target that is not
|
||
// guessed at. It lives in the install tree and is deliberately not in git: it describes
|
||
// this machine's installation.
|
||
const stateFile = ".update-state.json"
|
||
|
||
// State is the rollback contract of one installation.
|
||
type State struct {
|
||
PreviousCommit string `json:"previous_commit"`
|
||
UpdatedCommit string `json:"updated_commit"`
|
||
BackupDir string `json:"backup_dir"`
|
||
UpdatedAt string `json:"updated_at"`
|
||
// VersionBefore/VersionAfter record a version-line change this update made, so a
|
||
// rollback can put the old number back next to the old code. Absent for updates that
|
||
// touched no version (old state files simply have no such fields).
|
||
VersionBefore string `json:"version_before,omitempty"`
|
||
VersionAfter string `json:"version_after,omitempty"`
|
||
}
|
||
|
||
func statePath(root string) string { return filepath.Join(root, stateFile) }
|
||
|
||
// buildPendingFile records "the source has moved (or was moved), but the binary has not
|
||
// followed": a failed build, or a machine with no Go toolchain. It is written the moment
|
||
// the tree moves and removed only once the new binary is in place, so a retry on an
|
||
// already-current tree still knows there is a compile to finish. A file of its own rather
|
||
// than a State field because an adoption owes a build too, and an adoption has no state
|
||
// (there is no previous commit to roll back to).
|
||
const buildPendingFile = ".update-build-pending"
|
||
|
||
func buildPendingPath(root string) string { return filepath.Join(root, buildPendingFile) }
|
||
|
||
// markBuildPending is best-effort on purpose: the marker only makes a later retry sharper,
|
||
// and a bookkeeping write it cannot do must not turn a working update into a failed one.
|
||
func markBuildPending(root string) {
|
||
_ = os.WriteFile(buildPendingPath(root), []byte("source is ahead of the binary\n"), 0o644)
|
||
}
|
||
|
||
func clearBuildPending(root string) { _ = os.Remove(buildPendingPath(root)) }
|
||
|
||
func buildPending(root string) bool { return fileExists(buildPendingPath(root)) }
|
||
|
||
func readState(root string) (State, bool) {
|
||
var st State
|
||
data, err := os.ReadFile(statePath(root))
|
||
if err != nil {
|
||
return st, false
|
||
}
|
||
if err := json.Unmarshal(data, &st); err != nil || st.PreviousCommit == "" || st.UpdatedCommit == "" {
|
||
return st, false
|
||
}
|
||
return st, true
|
||
}
|
||
|
||
func writeState(root string, st State) error {
|
||
st.UpdatedAt = time.Now().Format(time.RFC3339)
|
||
data, err := json.MarshalIndent(st, "", " ")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
tmp := statePath(root) + ".tmp"
|
||
if err := os.WriteFile(tmp, append(data, '\n'), 0o644); err != nil {
|
||
return err
|
||
}
|
||
return os.Rename(tmp, statePath(root))
|
||
}
|
||
|
||
// Step is one progress line. A build takes minutes, so Apply reports where it is rather
|
||
// than appearing to hang; the console renders these directly.
|
||
type Step struct {
|
||
Phase string `json:"phase"`
|
||
Message string `json:"message"`
|
||
At string `json:"at"`
|
||
}
|
||
|
||
// Result is what one update attempt produced. Everything the operator would otherwise
|
||
// have to go and inspect by hand is in here.
|
||
type Result struct {
|
||
FromCommit string `json:"fromCommit"`
|
||
ToCommit string `json:"toCommit"`
|
||
Commits int `json:"commits"`
|
||
FilesTouched int `json:"filesTouched"`
|
||
// KeptContent names the operator-owned files that were restored over the update.
|
||
// Upstream may well have changed them; saying which ones is the difference between
|
||
// "my roles disappeared" and "my roles are still mine, and these upstream files did
|
||
// not land".
|
||
KeptContent []string `json:"keptContent"`
|
||
// Overwritten names local files whose content the target repository's version replaced
|
||
// during an adoption; copies are kept under <BackupDir>/overwritten/ so nothing is lost
|
||
// without a way back.
|
||
Overwritten []string `json:"overwritten,omitempty"`
|
||
// Adopted marks a result that comes from connecting a plain directory to a source
|
||
// rather than from moving an existing work tree.
|
||
Adopted bool `json:"adopted,omitempty"`
|
||
BinaryPath string `json:"binaryPath"`
|
||
BinaryBuilt bool `json:"binaryBuilt"`
|
||
PrevBinary string `json:"prevBinary"`
|
||
BackupDir string `json:"backupDir"`
|
||
NeedsRestart bool `json:"needsRestart"`
|
||
Duration string `json:"duration"`
|
||
}
|
||
|
||
// Error is a refusal with the information needed to act on it. Callers render the
|
||
// Message; Details is for the page to list file names or commits.
|
||
type Error struct {
|
||
Reason string `json:"reason"`
|
||
Message string `json:"message"`
|
||
Items []string `json:"items,omitempty"`
|
||
}
|
||
|
||
func (e *Error) Error() string { return e.Message }
|
||
|
||
// Apply carries an installation to the tip of the branch it tracks, then rebuilds and
|
||
// swaps the binary. It is all-or-nothing up to the point of the working-tree move: a
|
||
// failed build leaves the tree updated (source and previous binary are both recoverable),
|
||
// while a refused precondition leaves nothing touched at all.
|
||
func Apply(ctx context.Context, opts Options, onStep func(Step)) (*Result, error) {
|
||
if onStep == nil {
|
||
onStep = func(Step) {}
|
||
}
|
||
step := func(phase, msg string) { onStep(Step{Phase: phase, Message: msg, At: time.Now().Format(time.RFC3339)}) }
|
||
|
||
snap, err := Check(ctx, opts)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
start := time.Now()
|
||
|
||
// A refusal is not the end of the timeline: the page renders these lines, so the first
|
||
// one says what was looked at even when the answer is "nothing to do".
|
||
step("preflight", fmt.Sprintf("检查安装目录 %s(本机分支 %s / 更新源 %s / 提交 %s)", snap.Root, snap.Branch, snap.sourceLabel(), snap.Commit))
|
||
|
||
if !snap.Installed {
|
||
return nil, &Error{Reason: "not_a_repo", Message: "这个目录不是 git 工作树,无法自动更新"}
|
||
}
|
||
if snap.CheckError != "" {
|
||
return nil, &Error{Reason: "check_failed", Message: "检查更新失败:" + snap.CheckError}
|
||
}
|
||
if len(snap.BlockingChanges) > 0 {
|
||
items := make([]string, 0, len(snap.BlockingChanges))
|
||
for _, c := range snap.BlockingChanges {
|
||
items = append(items, fmt.Sprintf("%s (%s)", c.Path, c.Status))
|
||
}
|
||
return nil, &Error{
|
||
Reason: "local_source_edits",
|
||
Message: fmt.Sprintf("有 %d 个源码文件被本地改过,自动更新不会覆盖它们。先提交或还原这些文件再更新。", len(items)),
|
||
Items: items,
|
||
}
|
||
}
|
||
if snap.Diverged {
|
||
return nil, &Error{
|
||
Reason: "diverged",
|
||
Message: fmt.Sprintf("本地领先 %d 个提交、落后 %d 个提交:这是合并而不是下载,请手工处理后重试。", snap.Ahead, snap.Behind),
|
||
}
|
||
}
|
||
if !snap.UpdateAvailable {
|
||
// Nothing to move - but the installation's own bookkeeping still gets aligned: a
|
||
// version left over from a tree that was updated before this behaviour existed (or
|
||
// from a hand edit) is corrected here instead of waiting for the next commit.
|
||
versionBefore, versionAfter, versionErr := syncVersionToConfig(snap.Root)
|
||
versionStep(step, versionBefore, versionAfter, versionErr)
|
||
res := &Result{
|
||
FromCommit: snap.Commit, ToCommit: snap.Commit,
|
||
BinaryPath: filepath.Join(snap.Root, opts.binaryName()),
|
||
}
|
||
// "Nothing to pull" is not "nothing to do": a build that failed - or could not run
|
||
// at all - leaves exactly this state, and retrying is what the no_toolchain message
|
||
// told the operator to do. Deciding by the marker is what keeps this branch from
|
||
// reporting success while the old binary is still in place.
|
||
if buildPending(snap.Root) && opts.binaryName() != "none" {
|
||
// The attempt that parked the content is the one that knows the backup dir;
|
||
// the retry reports it so "where did my files go" stays answerable.
|
||
if st, ok := readState(snap.Root); ok {
|
||
res.BackupDir = st.BackupDir
|
||
}
|
||
if err := finishPendingBuild(ctx, snap, opts, res, step); err != nil {
|
||
res.Duration = time.Since(start).Round(time.Millisecond).String()
|
||
return res, err
|
||
}
|
||
}
|
||
res.Duration = time.Since(start).Round(time.Millisecond).String()
|
||
return res, nil
|
||
}
|
||
|
||
step("fetch", fmt.Sprintf("%s(默认分支 %s)有 %d 个新提交:%s → %s", snap.sourceLabel(), snap.TargetBranch, snap.Behind, snap.Commit, snap.RemoteCommit))
|
||
|
||
// Put operator-owned content aside, and clear the paths the incoming change would
|
||
// write to, so the fast-forward cannot be refused by "your local changes would be
|
||
// overwritten". Nothing here deletes a file the operator made: every path is copied
|
||
// out first and copied back after.
|
||
root := snap.Root
|
||
ref := snap.sourceRef()
|
||
backupDir, kept, err := stashProtected(ctx, root, ref)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
step("protect", fmt.Sprintf("已暂存 %d 个本地内容文件(roles/skills/tools/数据/配置),更新完成后放回", len(kept)))
|
||
|
||
if out, err := gitCmd(ctx, root, "merge", "--ff-only", "--no-verify", ref); err != nil {
|
||
restoreProtected(backupDir, kept)
|
||
return nil, &Error{Reason: "merge_failed", Message: fmt.Sprintf("快进合并失败:%v\n%s", err, strings.TrimSpace(out))}
|
||
}
|
||
// The working tree has moved: from this point the binary is owed, even if the state
|
||
// write or the compile below never happens (a full disk, a crash, a killed process).
|
||
// The marker is what the next update click reads to know that "nothing to pull" still
|
||
// means "there is a compile to finish" - without it this window is the "retry reports
|
||
// success without building" defect all over again.
|
||
if opts.binaryName() != "none" {
|
||
markBuildPending(root)
|
||
}
|
||
// The merge wrote upstream's version of the content paths; the operator's copies win
|
||
// them back, which is what "update the code, keep my work" means.
|
||
if err := restoreProtected(backupDir, kept); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
files, _ := gitRaw(ctx, root, "diff", "--name-only", "-z", snap.Commit+".."+snap.RemoteCommit)
|
||
res := &Result{
|
||
FromCommit: snap.Commit,
|
||
ToCommit: snap.RemoteCommit,
|
||
Commits: snap.Behind,
|
||
KeptContent: kept,
|
||
BackupDir: backupDir,
|
||
FilesTouched: len(nulList(files)),
|
||
}
|
||
|
||
// The new code carries its own release version; the live config (which no merge ever
|
||
// touches) gets it now, so the page header and the static cache buster follow the code.
|
||
versionBefore, versionAfter, versionErr := syncVersionToConfig(root)
|
||
versionStep(step, versionBefore, versionAfter, versionErr)
|
||
|
||
if err := writeState(root, State{
|
||
PreviousCommit: snap.Commit, UpdatedCommit: snap.RemoteCommit, BackupDir: backupDir,
|
||
VersionBefore: versionBefore, VersionAfter: versionAfter,
|
||
}); err != nil {
|
||
return nil, &Error{Reason: "state_unwritable", Message: "无法写入更新状态文件,回滚将不可用:" + err.Error()}
|
||
}
|
||
|
||
bin := filepath.Join(root, opts.binaryName())
|
||
res.BinaryPath = bin
|
||
if opts.binaryName() == "none" {
|
||
step("build", "按请求跳过重新编译;源码已更新,重启后由外部构建流程产出二进制")
|
||
res.NeedsRestart = true
|
||
res.Duration = time.Since(start).Round(time.Millisecond).String()
|
||
return res, nil
|
||
}
|
||
if !snap.CanBuild {
|
||
res.NeedsRestart = true
|
||
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); err != nil {
|
||
res.Duration = time.Since(start).Round(time.Millisecond).String()
|
||
return res, err
|
||
}
|
||
step("done", fmt.Sprintf("已更新 %d 个提交并换好二进制:%s → %s", res.Commits, res.FromCommit, res.ToCommit))
|
||
res.Duration = time.Since(start).Round(time.Millisecond).String()
|
||
return res, nil
|
||
}
|
||
|
||
// finishPendingBuild compiles the binary a previous update left missing. It only claims
|
||
// success with a compile to show for it: without a toolchain it repeats the same refusal
|
||
// the first attempt gave, because the environment has to change before this can happen.
|
||
func finishPendingBuild(ctx context.Context, snap *Snapshot, opts Options, res *Result, step func(string, string)) error {
|
||
if !snap.CanBuild {
|
||
return &Error{
|
||
Reason: "no_toolchain",
|
||
Message: fmt.Sprintf("源码已经是更新源最新(%s),但二进制还是旧的:本机没有 Go 工具链,无法补编译。装好 go 后再点一次更新即可补上二进制。", snap.Commit),
|
||
}
|
||
}
|
||
if err := buildAndSwap(ctx, snap.Root, opts, res, step); err != nil {
|
||
return err
|
||
}
|
||
step("done", fmt.Sprintf("源码已经是更新源最新(%s),本次补上了二进制", snap.Commit))
|
||
return nil
|
||
}
|
||
|
||
// buildAndSwap compiles the tree and moves the new binary into place, keeping the previous
|
||
// one as .prev. The pending marker comes off only after the swap: until the install holds
|
||
// the new executable, "there is a build to finish" is still true.
|
||
func buildAndSwap(ctx context.Context, root string, opts Options, res *Result, step func(string, string)) error {
|
||
step("build", "开始编译二进制(首次会下载依赖,可能需要几分钟)")
|
||
staging := filepath.Join(root, ".update-staging")
|
||
if err := os.MkdirAll(staging, 0o755); err != nil {
|
||
return &Error{Reason: "staging_failed", Message: err.Error()}
|
||
}
|
||
defer os.RemoveAll(staging)
|
||
|
||
newBin := filepath.Join(staging, opts.binaryName())
|
||
if err := build(ctx, root, newBin); err != nil {
|
||
return &Error{Reason: "build_failed", Message: "编译失败,二进制保持原版本:\n" + err.Error()}
|
||
}
|
||
prev, err := installBinary(newBin, filepath.Join(root, opts.binaryName()))
|
||
if err != nil {
|
||
return &Error{Reason: "swap_failed", Message: err.Error()}
|
||
}
|
||
res.BinaryBuilt = true
|
||
res.PrevBinary = prev
|
||
res.NeedsRestart = true
|
||
clearBuildPending(root)
|
||
return nil
|
||
}
|
||
|
||
// stashProtected copies every operator-owned file that stands in the way of the
|
||
// fast-forward into backupDir and removes it from the tree. Only paths the update
|
||
// actually touches are removed - a user's extra skill directory is never disturbed.
|
||
func stashProtected(ctx context.Context, root, ref string) (backupDir string, kept []string, err error) {
|
||
ts := time.Now().Format("20060102_150405")
|
||
backupDir = filepath.Join(root, ".update-backup", ts)
|
||
|
||
tracked, _ := localChanges(ctx, root)
|
||
incoming, err := gitRaw(ctx, root, "diff", "--name-only", "-z", "HEAD.."+ref)
|
||
if err != nil {
|
||
return "", nil, &Error{Reason: "diff_failed", Message: err.Error()}
|
||
}
|
||
willWrite := map[string]bool{}
|
||
for _, f := range nulList(incoming) {
|
||
willWrite[filepath.ToSlash(f)] = true
|
||
}
|
||
|
||
// A locally modified content file would refuse the merge; an untracked content file
|
||
// whose path upstream now adds would also refuse it. Both are put aside.
|
||
targets := map[string]bool{}
|
||
for _, c := range tracked {
|
||
if c.Protected && willWrite[c.Path] {
|
||
targets[c.Path] = true
|
||
}
|
||
}
|
||
for _, p := range untrackedUnder(ctx, root, willWrite) {
|
||
targets[p] = true
|
||
}
|
||
|
||
written := false
|
||
for p := range targets {
|
||
abs := filepath.Join(root, p)
|
||
info, statErr := os.Stat(abs)
|
||
if statErr != nil || info.IsDir() {
|
||
continue
|
||
}
|
||
if err := copyFile(abs, filepath.Join(backupDir, p), info.Mode()); err != nil {
|
||
return backupDir, kept, &Error{Reason: "backup_failed", Message: fmt.Sprintf("备份 %s 失败:%v", p, err)}
|
||
}
|
||
kept = append(kept, p)
|
||
if err := os.Remove(abs); err != nil {
|
||
return backupDir, kept, &Error{Reason: "backup_failed", Message: fmt.Sprintf("移开 %s 失败:%v", p, err)}
|
||
}
|
||
written = true
|
||
}
|
||
if written || len(kept) > 0 {
|
||
sort.Strings(kept)
|
||
return backupDir, kept, nil
|
||
}
|
||
// Nothing to put aside: don't leave an empty backup directory behind.
|
||
return "", nil, nil
|
||
}
|
||
|
||
// stashProtectedEdits copies the operator's local edits to protected files out of the
|
||
// tree before a rollback resets it. Unlike an update's stash it leaves the working copy
|
||
// alone - the reset is what replaces those files - because reset --hard does not know
|
||
// which local changes are the update's and which are the operator's. A file deleted by
|
||
// hand carries no content to copy, so its name is recorded instead and the deletion is
|
||
// re-applied after the reset.
|
||
func stashProtectedEdits(root string, changed []Change) (backupDir string, kept, deleted []string, err error) {
|
||
ts := time.Now().Format("20060102_150405")
|
||
for _, c := range changed {
|
||
if !c.Protected {
|
||
continue
|
||
}
|
||
if c.Status == "deleted" {
|
||
deleted = append(deleted, c.Path)
|
||
continue
|
||
}
|
||
abs := filepath.Join(root, filepath.FromSlash(c.Path))
|
||
info, statErr := os.Stat(abs)
|
||
if statErr != nil || !info.Mode().IsRegular() {
|
||
continue
|
||
}
|
||
if backupDir == "" {
|
||
backupDir = filepath.Join(root, ".update-backup", "rollback_"+ts)
|
||
}
|
||
if err := copyFile(abs, filepath.Join(backupDir, filepath.FromSlash(c.Path)), info.Mode()); err != nil {
|
||
return backupDir, kept, deleted, &Error{Reason: "backup_failed", Message: fmt.Sprintf("备份 %s 失败:%v", c.Path, err)}
|
||
}
|
||
kept = append(kept, c.Path)
|
||
}
|
||
sort.Strings(kept)
|
||
sort.Strings(deleted)
|
||
return backupDir, kept, deleted, nil
|
||
}
|
||
|
||
// restoreProtected puts the named operator-owned copies back over whatever the merge
|
||
// wrote. The list is the one stashProtected returned: an adoption parks replaced
|
||
// non-protected files in the same backup directory (under overwritten/), and those must
|
||
// NOT be restored over the target repository's version.
|
||
func restoreProtected(backupDir string, kept []string) error {
|
||
if backupDir == "" {
|
||
return nil
|
||
}
|
||
root := filepath.Join(filepath.Dir(backupDir), "..")
|
||
for _, rel := range kept {
|
||
abs := filepath.Join(backupDir, filepath.FromSlash(rel))
|
||
info, err := os.Stat(abs)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := copyFile(abs, filepath.Join(root, filepath.FromSlash(rel)), info.Mode()); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// untrackedUnder lists untracked files under the given repository paths - the operator's
|
||
// own additions, which must be backed up but never deleted by an update.
|
||
func untrackedUnder(ctx context.Context, root string, willWrite map[string]bool) []string {
|
||
out, err := gitRaw(ctx, root, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--no-renames")
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
var list []string
|
||
for _, entry := range nulList(out) {
|
||
if len(entry) < 4 || entry[:2] != "??" {
|
||
continue
|
||
}
|
||
p := filepath.ToSlash(entry[3:])
|
||
if willWrite[p] && Protected(p) {
|
||
list = append(list, p)
|
||
}
|
||
}
|
||
return list
|
||
}
|
||
|
||
// Rollback returns an installation to the commit it came from, using the binary kept
|
||
// before the swap. It refuses unless HEAD is still exactly what the update wrote, so it
|
||
// can never discard work done after that update.
|
||
func Rollback(ctx context.Context, opts Options) (*Result, error) {
|
||
root, err := opts.installRoot()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
st, ok := readState(root)
|
||
if !ok {
|
||
return nil, &Error{Reason: "no_state", Message: "没有可回滚的更新记录"}
|
||
}
|
||
bin := filepath.Join(root, opts.binaryName())
|
||
if !fileExists(bin + ".prev") {
|
||
return nil, &Error{Reason: "no_binary", Message: "上一次更新没有留下旧二进制,无法回滚二进制(源码仍可用 git 处理)"}
|
||
}
|
||
head, err := gitCmd(ctx, root, "rev-parse", "HEAD")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
updated, err := gitCmd(ctx, root, "rev-parse", st.UpdatedCommit)
|
||
if err != nil {
|
||
return nil, &Error{Reason: "bad_state", Message: "更新记录里的提交在本仓库不存在:" + st.UpdatedCommit}
|
||
}
|
||
if head != updated {
|
||
return nil, &Error{
|
||
Reason: "moved_since_update",
|
||
Message: fmt.Sprintf("自那次更新之后 HEAD 已经变了(现在是 %s),回滚只会撤到更新前的提交,因此拒绝执行。", shortHash(head)),
|
||
}
|
||
}
|
||
|
||
start := time.Now()
|
||
changes, blocking := localChanges(ctx, root)
|
||
if len(blocking) > 0 {
|
||
items := make([]string, 0, len(blocking))
|
||
for _, c := range blocking {
|
||
items = append(items, c.Path)
|
||
}
|
||
return nil, &Error{Reason: "local_source_edits", Message: "有本地源码改动,回滚会覆盖它们", Items: items}
|
||
}
|
||
|
||
// Content the operator owns is not the rollback's to discard either: reset --hard takes
|
||
// every local change to tracked files, so protected files that differ right now - edits
|
||
// made before the update and edits made after it alike - are copied aside first and set
|
||
// back on top of the old commit.
|
||
backupDir, kept, deleted, err := stashProtectedEdits(root, changes)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if _, err := gitCmd(ctx, root, "reset", "--hard", "--quiet", st.PreviousCommit); err != nil {
|
||
return nil, &Error{Reason: "reset_failed", Message: err.Error()}
|
||
}
|
||
if err := restoreProtected(backupDir, kept); err != nil {
|
||
return nil, err
|
||
}
|
||
for _, p := range deleted {
|
||
// A protected file the operator deleted by hand stays deleted: the reset just
|
||
// brought the old commit's copy back, and that deletion is a local change like any
|
||
// other this rollback promised not to touch.
|
||
if err := os.Remove(filepath.Join(root, filepath.FromSlash(p))); err != nil && !os.IsNotExist(err) {
|
||
return nil, err
|
||
}
|
||
}
|
||
if err := restorePrevBinary(bin); err != nil {
|
||
return nil, &Error{Reason: "swap_failed", Message: err.Error()}
|
||
}
|
||
// Source and binary agree again (both the previous commit's), so a pending-build
|
||
// marker from a failed attempt no longer describes anything - keeping it would invite
|
||
// a rebuild of the old tree.
|
||
clearBuildPending(root)
|
||
// The version number goes back with the code it belongs to (only when it is still the
|
||
// one that update wrote - see restoreVersionAfterRollback).
|
||
restoreVersionAfterRollback(root, st)
|
||
if err := os.Remove(statePath(root)); err != nil && !os.IsNotExist(err) {
|
||
return nil, err
|
||
}
|
||
return &Result{
|
||
FromCommit: shortHash(head),
|
||
ToCommit: shortHash(st.PreviousCommit),
|
||
KeptContent: kept,
|
||
BinaryPath: bin,
|
||
BinaryBuilt: true,
|
||
BackupDir: st.BackupDir,
|
||
NeedsRestart: true,
|
||
Duration: time.Since(start).Round(time.Millisecond).String(),
|
||
}, nil
|
||
}
|
||
|
||
func shortHash(h string) string {
|
||
if len(h) > 8 {
|
||
return h[:8]
|
||
}
|
||
return h
|
||
}
|
||
|
||
func fileExists(p string) bool {
|
||
_, err := os.Stat(p)
|
||
return err == nil
|
||
}
|