仰望星辰工作室

feat: 新增升级/重命名、媒体处理与测试套件,增强仓库浏览与 Git HTTP

F fqh 提交于 2026-10-02 17:10 · e04166a ·父提交 b6a0ede
feat: 新增升级/重命名、媒体处理与测试套件,增强仓库浏览与 Git HTTP
30 个文件变更 +3015 -101 fqh@example.com
•.gitignore +1 -0
•internal/gitx/gitx.go +202 -14
•internal/gitx/gitx_test.go +153 -0
•internal/gitx/helper_test.go +28 -0
•internal/server/content_test.go +49 -0
•internal/server/githttp.go +98 -41
•internal/server/media.go +122 -0
•internal/server/pushauth_test.go +89 -0
•internal/server/raw_test.go +108 -0
•internal/server/rename_test.go +144 -0
•internal/server/repo.go +202 -32
•internal/server/server.go +25 -4
•internal/server/settingsrename_test.go +87 -0
•internal/server/testhelper_test.go +110 -0
•internal/server/upgrade.go +240 -0
•internal/store/repos.go +24 -2
•internal/store/store.go +196 -3
•internal/store/store_test.go +175 -0
•internal/upgrade/hidden_other.go +8 -0
•internal/upgrade/hidden_windows.go +10 -0
•internal/upgrade/upgrade.go +520 -0
•internal/upgrade/upgrade_test.go +66 -0
•main.go +55 -3
•scripts/build-release.ps1 +188 -0
•webroot/static/style.css +7 -0
•webroot/templates/admin_settings.html +1 -0
•webroot/templates/admin_upgrade.html +99 -0
•webroot/templates/layout.html +1 -0
•webroot/templates/repo_blob.html +2 -2
•webroot/templates/repo_settings.html +5 -0
变更内容
diff --git a/.gitignore b/.gitignore
index 76fe14d..86699a5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
 # 编译产物
 gitcat-linux-amd64
 *.exe
+dist/
 
 # 运行时数据(SQLite 数据库、仓库与头像)
 data/
diff --git a/internal/gitx/gitx.go b/internal/gitx/gitx.go
index b5095dc..c31a8ce 100644
--- a/internal/gitx/gitx.go
+++ b/internal/gitx/gitx.go
@@ -2,7 +2,9 @@
 package gitx
 
 import (
+	"bufio"
 	"bytes"
+	"context"
 	"errors"
 	"fmt"
 	"io"
@@ -14,14 +16,70 @@ import (
 	"sort"
 	"strconv"
 	"strings"
+	"sync/atomic"
 	"time"
 )
 
 // EmptyTreeSHA 是 git 中"空树"对象的哈希,用于与首次提交做 diff。
 const EmptyTreeSHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
 
+// MaxBlobSize 是单个文件允许被读入内存的硬上限(64MB)。
+// 超过这个体积的内容不应该整份进入内存——raw 下载走流式,见 BlobReader。
+const MaxBlobSize = 64 << 20
+
+// ErrTooLarge 表示内容体积超过读取上限。
+var ErrTooLarge = errors.New("内容过大")
+
+// defaultTimeout 是单条 git 命令的默认上限。
+//
+// 以前所有 git 调用都是 exec.Command:一旦 git 卡死(网络盘上的仓库、
+// 损坏的 object、极端情况下 git 自身的缺陷),对应的 goroutine 会被
+// 永久占住,而 HTTP 请求还在等它返回。改为统一带超时的 CommandContext。
+var defaultTimeout atomic.Int64
+
+func init() { defaultTimeout.Store(int64(60 * time.Second)) }
+
+// SetCommandTimeout 调整单条 git 命令的超时上限。
+func SetCommandTimeout(d time.Duration) {
+	if d > 0 {
+		defaultTimeout.Store(int64(d))
+	}
+}
+
+func commandTimeout() time.Duration {
+	return time.Duration(defaultTimeout.Load())
+}
+
 var repoNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
 
+// ValidRef 校验来自 URL 的引用名(分支 / 标签 / commit)是否可以安全地
+// 交给 git 命令行。
+//
+// git 命令行把以 "-" 开头的参数当作选项,"feature/a b" 这类含空格的引用
+// 会被拆成多个参数,`--output=...` 之类的值甚至可能让 git 写文件。
+// 与其依赖每个调用点都记得加 `--`,不如在入口处统一拒绝。
+func ValidRef(ref string) bool {
+	if ref == "" || len(ref) > 255 {
+		return false
+	}
+	if strings.HasPrefix(ref, "-") || strings.HasPrefix(ref, "!") {
+		return false
+	}
+	if strings.Contains(ref, "..") || strings.Contains(ref, "@{") {
+		return false
+	}
+	for _, r := range ref {
+		switch {
+		case r < 0x20 || r == 0x7f:
+			return false
+		case r == ' ' || r == '~' || r == '^' || r == ':' || r == '?' ||
+			r == '*' || r == '[' || r == '\\' || r == '\'' || r == '"':
+			return false
+		}
+	}
+	return true
+}
+
 // Version 返回 git 版本号。
 func Version() string {
 	out, err := exec.Command("git", "--version").Output()
@@ -49,22 +107,61 @@ func ValidRepoName(name string) bool {
 }
 
 func run(dir string, args ...string) ([]byte, error) {
-	cmd := exec.Command("git", args...)
+	return runLimit(context.Background(), dir, 0, args...)
+}
+
+// runLimit 执行一条 git 命令并返回其标准输出。
+//
+// max > 0 时最多接收 max 字节,超出即返回 ErrTooLarge——这一点很重要:
+// 之前 Blob() 会把整个文件读进内存才让调用方判断大小,一个 2GB 的
+// 仓库文件就能把进程撑爆。
+func runLimit(ctx context.Context, dir string, max int, args ...string) ([]byte, error) {
+	if ctx == nil {
+		ctx = context.Background()
+	}
+	// 传入的 ctx 若自带更短的超时(例如 HTTP 请求上下文),尊重调用方。
+	if _, ok := ctx.Deadline(); !ok {
+		var cancel context.CancelFunc
+		ctx, cancel = context.WithTimeout(ctx, commandTimeout())
+		defer cancel()
+	}
+	cmd := exec.CommandContext(ctx, "git", args...)
 	if dir != "" {
 		cmd.Dir = dir
 	}
 	cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "LC_ALL=C")
 	var stdout, stderr bytes.Buffer
-	cmd.Stdout = &stdout
+	var sink io.Writer = &stdout
+	var lb *limitedBuffer
+	if max > 0 {
+		lb = &limitedBuffer{max: max}
+		sink = lb
+	}
+	cmd.Stdout = sink
 	cmd.Stderr = &stderr
-	if err := cmd.Run(); err != nil {
+	err := cmd.Run()
+
+	// 截断优先于错误判断:git 在 stdout 被关闭后会以 EPIPE 失败,
+	// 那不是"命令出错",而是"内容太大读不完"。
+	if lb != nil && lb.truncated {
+		return nil, ErrTooLarge
+	}
+	out := stdout.Bytes()
+	if lb != nil {
+		// 限量模式下 stdout 是空的,真正的内容在限流缓冲里。
+		out = lb.buf.Bytes()
+	}
+	if err != nil {
+		if errors.Is(ctx.Err(), context.DeadlineExceeded) {
+			return out, fmt.Errorf("git %s: 命令超过 %s 未返回,已中止", args[0], commandTimeout())
+		}
 		msg := strings.TrimSpace(stderr.String())
 		if msg == "" {
 			msg = err.Error()
 		}
-		return stdout.Bytes(), fmt.Errorf("git %s: %s", strings.Join(args, " "), msg)
+		return out, fmt.Errorf("git %s: %s", strings.Join(args, " "), msg)
 	}
-	return stdout.Bytes(), nil
+	return out, nil
 }
 
 // InitBare 创建一个裸仓库。
@@ -72,6 +169,9 @@ func InitBare(path, defaultBranch string) error {
 	if defaultBranch == "" {
 		defaultBranch = "main"
 	}
+	if !ValidRef(defaultBranch) {
+		return fmt.Errorf("非法的默认分支名: %q", defaultBranch)
+	}
 	if _, err := run("", "init", "--bare", "--initial-branch="+defaultBranch, path); err != nil {
 		// 兼容老版本 git:退化为默认分支再修正 HEAD
 		if _, err2 := run("", "init", "--bare", path); err2 != nil {
@@ -352,14 +452,42 @@ func LastCommitForPath(repo, ref, path string) (Commit, bool) {
 	return commits[0], true
 }
 
-// Blob 读取文件内容。
+// Blob 读取文件内容,最多 MaxBlobSize。
 func Blob(repo, ref, path string) ([]byte, error) {
-	return run(repo, "show", ref+":"+strings.Trim(path, "/"))
+	return BlobLimited(repo, ref, path, MaxBlobSize)
+}
+
+// BlobLimited 读取文件内容,超过 max 字节时返回 ErrTooLarge。
+//
+// 关键点:先用 cat-file -s 拿对象大小再决定是否读,让超大文件永远不进入
+// 内存;即便该查询失败(比如极老版本 git),runLimit 的输出上限仍会兜住。
+func BlobLimited(repo, ref, path string, max int) ([]byte, error) {
+	if max > 0 && BlobSize(repo, ref, path) > int64(max) {
+		return nil, ErrTooLarge
+	}
+	return runLimit(context.Background(), repo, max, "show", blobSpec(ref, path))
+}
+
+// BlobReader 以流式方式把文件内容写入 w,用于原始文件下载。
+// 走管道而不是整份读进内存,因此下载 GB 级文件也不会把进程撑爆。
+func BlobReader(repo, ref, path string, w io.Writer) error {
+	ctx, cancel := context.WithTimeout(context.Background(), commandTimeout())
+	defer cancel()
+	cmd := exec.CommandContext(ctx, "git", "cat-file", "blob", blobSpec(ref, path))
+	cmd.Dir = repo
+	cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "LC_ALL=C")
+	cmd.Stdout = w
+	cmd.Stderr = io.Discard
+	return cmd.Run()
+}
+
+func blobSpec(ref, path string) string {
+	return ref + ":" + strings.Trim(path, "/")
 }
 
 // BlobSize 返回文件字节数。
 func BlobSize(repo, ref, path string) int64 {
-	out, err := run(repo, "cat-file", "-s", ref+":"+strings.Trim(path, "/"))
+	out, err := run(repo, "cat-file", "-s", blobSpec(ref, path))
 	if err != nil {
 		return 0
 	}
@@ -367,6 +495,60 @@ func BlobSize(repo, ref, path string) int64 {
 	return n
 }
 
+// maxCountedLines 是行数统计的扫描上限:超过就返回上限值,
+// 免得在一个 10 亿行的文件上空转。
+const maxCountedLines = 5_000_000
+
+// maxScanToken 是行扫描的单行上限。超过它就算「一行」,不再继续读。
+const maxScanToken = 1 << 20
+
+// LineCount 流式统计文件行数,不把文件读进内存。
+func LineCount(repo, ref, path string) (int, error) {
+	ctx, cancel := context.WithTimeout(context.Background(), commandTimeout())
+	defer cancel()
+	cmd := exec.CommandContext(ctx, "git", "cat-file", "blob", blobSpec(ref, path))
+	cmd.Dir = repo
+	cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "LC_ALL=C")
+	// 兜底:万一下面哪个环节没能让 git 自行退出,2 秒后强制杀掉,
+	// 绝不让一个「统计行数」的只读操作挂住整个请求。
+	cmd.WaitDelay = 2 * time.Second
+
+	pipe, err := cmd.StdoutPipe()
+	if err != nil {
+		return 0, err
+	}
+	if err := cmd.Start(); err != nil {
+		return 0, err
+	}
+
+	sc := bufio.NewScanner(pipe)
+	sc.Buffer(make([]byte, 0, 64<<10), maxScanToken)
+	n := 0
+	for sc.Scan() {
+		n++
+		if n >= maxCountedLines {
+			break
+		}
+	}
+	scanErr := sc.Err()
+
+	// 关键:先关掉读端。scanner 提前停止(超长行、达到行数上限、出错)后
+	// 没人再读管道,git 会阻塞在 write 上;此时 Wait 会一直等到命令超时。
+	// 关掉读端后 git 立刻收到 EPIPE 退出,Wait 才能正常返回。
+	_ = pipe.Close()
+	_ = cmd.Wait()
+
+	switch {
+	case scanErr == nil:
+		return n, nil
+	case errors.Is(scanErr, bufio.ErrTooLong) && n >= 0:
+		// 触到单行上限:这行本身算一行,只是没数完。
+		return n + 1, nil
+	default:
+		return n, nil
+	}
+}
+
 // IsBinary 用内容启发式判断是否为二进制文件。
 func IsBinary(b []byte) bool {
 	n := len(b)
@@ -464,11 +646,9 @@ func Show(repo, sha string) (*CommitDetail, error) {
 	}
 
 	buf := &limitedBuffer{max: 400 << 10}
-	cmd := exec.Command("git", "show", "--patch", "--no-color", "--format=", "-M", sha)
-	cmd.Dir = repo
-	cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "LC_ALL=C")
-	cmd.Stdout = buf
-	_ = cmd.Run()
+	if out, err := runLimit(context.Background(), repo, 400<<10, "show", "--patch", "--no-color", "--format=", "-M", sha); err == nil {
+		buf.Write(out)
+	}
 	d.Diff = buf.String()
 	d.Truncated = buf.truncated
 	return d, nil
@@ -627,6 +807,10 @@ func Languages(repo, ref string, top int) []LangStat {
 // readmeNames 是 README 候选文件名。
 var readmeNames = []string{"readme.md", "readme.markdown", "readme.rst", "readme.txt", "readme"}
 
+// maxReadmeSize 限制 README 的读取体积:README 会被整份渲染进页面,
+// 没必要也没有安全理由把一个 100MB 的 README 读进内存。
+const maxReadmeSize = 2 << 20
+
 // Readme 查找并返回根目录的 README 内容。
 func Readme(repo, ref string) (string, []byte, bool) {
 	entries, err := Tree(repo, ref, "")
@@ -636,8 +820,9 @@ func Readme(repo, ref string) (string, []byte, bool) {
 	for _, want := range readmeNames {
 		for _, e := range entries {
 			if !e.IsDir() && strings.ToLower(e.Name) == want {
-				content, err := Blob(repo, ref, e.Name)
+				content, err := BlobLimited(repo, ref, e.Name, maxReadmeSize)
 				if err != nil {
+					// 过大或读取失败都视为"没有可展示的 README",页面继续渲染其他内容。
 					return "", nil, false
 				}
 				return e.Name, content, true
@@ -743,6 +928,9 @@ func DiffStat(repo, oldSHA, newSHA string) string {
 
 // SetDefaultBranch 修改裸仓库的 HEAD 指向。
 func SetDefaultBranch(repo, branch string) error {
+	if !ValidRef(branch) {
+		return fmt.Errorf("非法的分支名: %q", branch)
+	}
 	_, err := run(repo, "symbolic-ref", "HEAD", "refs/heads/"+branch)
 	return err
 }
diff --git a/internal/gitx/gitx_test.go b/internal/gitx/gitx_test.go
new file mode 100644
index 0000000..0f39d16
--- /dev/null
+++ b/internal/gitx/gitx_test.go
@@ -0,0 +1,153 @@
+package gitx
+
+import (
+	"context"
+	"errors"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+)
+
+func TestValidRef(t *testing.T) {
+	ok := []string{"main", "v1.2.3", "feature/login", "refs/heads/x", "HEAD",
+		"a1b2c3d", "release-2024", "_underscore"}
+	for _, r := range ok {
+		if !ValidRef(r) {
+			t.Errorf("%q 应为合法 ref", r)
+		}
+	}
+	bad := []string{"", "-x", "--upload-pack=evil", "a b", "a..b", "a~1",
+		"a^", "a:b", "a?b", "a*b", "a[b", `a\b`, "a@{0}", "!bang", "a\nb",
+		strings.Repeat("x", 256)}
+	for _, r := range bad {
+		if ValidRef(r) {
+			t.Errorf("%q 应被判为非法 ref", r)
+		}
+	}
+}
+
+func TestValidRepoName(t *testing.T) {
+	for _, n := range []string{"demo", "a", "my-project", "a.b_c", "Repo123"} {
+		if !ValidRepoName(n) {
+			t.Errorf("%q 应为合法仓库名", n)
+		}
+	}
+	for _, n := range []string{"", ".hidden", "-lead", "a/b", `a\b`, "..",
+		"a..b", "x.git", "X.GIT", "a b", strings.Repeat("x", 65)} {
+		if ValidRepoName(n) {
+			t.Errorf("%q 应为非法仓库名", n)
+		}
+	}
+}
+
+func TestCommandTimeoutIsApplied(t *testing.T) {
+	old := commandTimeout()
+	SetCommandTimeout(150 * time.Millisecond)
+	defer SetCommandTimeout(old)
+
+	dir := t.TempDir()
+	if err := InitBare(dir, "main"); err != nil {
+		t.Fatalf("InitBare 失败: %v", err)
+	}
+	// 不存在的对象上跑 log,正常会立刻返回;这里主要验证超时机制
+	// 不会让 run 退化成无超时。
+	start := time.Now()
+	_, _ = Log(filepath.Join(dir, "missing.git"), "HEAD", 5, 0)
+	if d := time.Since(start); d > 5*time.Second {
+		t.Errorf("命令耗时 %v,明显超出预期", d)
+	}
+}
+
+func TestRunLimitRejectsOversizedOutput(t *testing.T) {
+	dir := t.TempDir()
+	if err := InitBare(dir, "main"); err != nil {
+		t.Fatalf("InitBare 失败: %v", err)
+	}
+	// 造一个 1MB 的文件
+	big := strings.Repeat("A", 1<<20)
+	blob := runStdin(t, dir, big, "hash-object", "-w", "--stdin")
+	tree := runStdin(t, dir, "100644 blob "+blob+"\tlarge.txt\n", "mktree")
+	commit := runStdin(t, dir, "", "commit-tree", tree, "-m", "big")
+	_, _ = run(dir, "update-ref", "refs/heads/main", commit)
+
+	if n, err := LineCount(dir, "main", "large.txt"); err != nil || n != 1 {
+		t.Errorf("单行 1MB 文件的行数 = %d (err=%v),期望 1", n, err)
+	}
+	if _, err := BlobLimited(dir, "main", "large.txt", 1024); !errors.Is(err, ErrTooLarge) {
+		t.Errorf("超出上限应返回 ErrTooLarge,实际 %v", err)
+	}
+	if b, err := BlobLimited(dir, "main", "large.txt", 2<<20); err != nil || len(b) != 1<<20 {
+		t.Errorf("未超限时读取失败: len=%d err=%v", len(b), err)
+	}
+}
+
+// TestLineCountNoDeadlock 回归:scanner 提前放弃时若不关闭读端,
+// cmd.Wait 会一直等到命令超时(曾表现为单次请求 60 秒)。
+func TestLineCountNoDeadlock(t *testing.T) {
+	dir := t.TempDir()
+	if err := InitBare(dir, "main"); err != nil {
+		t.Fatalf("InitBare 失败: %v", err)
+	}
+	// 无换行的 3MB 内容:scanner 会因超长行中止
+	huge := strings.Repeat("A", 3<<20)
+	blob := runStdin(t, dir, huge, "hash-object", "-w", "--stdin")
+	tree := runStdin(t, dir, "100644 blob "+blob+"\thuge.txt\n", "mktree")
+	commit := runStdin(t, dir, "", "commit-tree", tree, "-m", "huge")
+	_, _ = run(dir, "update-ref", "refs/heads/main", commit)
+
+	done := make(chan struct{})
+	go func() {
+		_, _ = LineCount(dir, "main", "huge.txt")
+		close(done)
+	}()
+	select {
+	case <-done:
+	case <-time.After(10 * time.Second):
+		t.Fatal("LineCount 卡死(读端未关闭)")
+	}
+}
+
+func TestIsBinary(t *testing.T) {
+	if !IsBinary([]byte("abc\x00def")) {
+		t.Error("含 NUL 应判为二进制")
+	}
+	if IsBinary([]byte("普通文本内容")) {
+		t.Error("纯文本不应判为二进制")
+	}
+}
+
+func TestIsBareRepo(t *testing.T) {
+	dir := filepath.Join(t.TempDir(), "r.git")
+	if IsBareRepo(dir) {
+		t.Error("不存在的目录不应被识别为裸仓库")
+	}
+	if err := InitBare(dir, "main"); err != nil {
+		t.Fatalf("InitBare 失败: %v", err)
+	}
+	if !IsBareRepo(dir) {
+		t.Error("初始化后应被识别为裸仓库")
+	}
+	if _, err := os.Stat(filepath.Join(dir, "HEAD")); err != nil {
+		t.Errorf("缺少 HEAD: %v", err)
+	}
+}
+
+func TestSetDefaultBranchRejectsBadRef(t *testing.T) {
+	dir := t.TempDir()
+	if err := InitBare(dir, "main"); err != nil {
+		t.Fatalf("InitBare 失败: %v", err)
+	}
+	if err := SetDefaultBranch(dir, "--upload-pack=evil"); err == nil {
+		t.Error("非法分支名应被拒绝")
+	}
+	if err := SetDefaultBranch(dir, "dev"); err != nil {
+		t.Errorf("合法分支名被拒: %v", err)
+	}
+	if got := CurrentBranch(dir); got != "dev" {
+		t.Errorf("当前分支 = %q,期望 dev", got)
+	}
+}
+
+var _ = context.Background
diff --git a/internal/gitx/helper_test.go b/internal/gitx/helper_test.go
new file mode 100644
index 0000000..cfdb4ac
--- /dev/null
+++ b/internal/gitx/helper_test.go
@@ -0,0 +1,28 @@
+package gitx
+
+import (
+	"context"
+	"os"
+	"os/exec"
+	"strings"
+	"testing"
+	"time"
+)
+
+// runStin 跑一条带 stdin 的 git 命令并返回去空格后的输出。
+func runStdin(t *testing.T, dir, stdin string, args ...string) string {
+	t.Helper()
+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+	defer cancel()
+	cmd := exec.CommandContext(ctx, "git", args...)
+	cmd.Dir = dir
+	cmd.Stdin = strings.NewReader(stdin)
+	cmd.Env = append(os.Environ(),
+		"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@gitcat.local",
+		"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@gitcat.local")
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		t.Fatalf("git %s 失败: %v\n%s", strings.Join(args, " "), err, out)
+	}
+	return strings.TrimSpace(string(out))
+}
diff --git a/internal/server/content_test.go b/internal/server/content_test.go
new file mode 100644
index 0000000..9b1cea0
--- /dev/null
+++ b/internal/server/content_test.go
@@ -0,0 +1,49 @@
+package server
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+)
+
+// TestBlobRendersContent 回归测试:曾出现「限量读取返回空内容」的 bug——
+// 限流缓冲拿到了数据,却返回了另一个空缓冲,导致所有文件预览全白。
+func TestBlobRendersContent(t *testing.T) {
+	srv, st, _, _ := newTestServer(t)
+	dir := makeRepo(t, srv, st, "read", nil)
+	commitFiles(t, dir, "init", map[string]string{"main.go": "package main\n\nfunc main() {}\n"})
+
+	r := httptest.NewRequest(http.MethodGet, "/read/blob/main/main.go", nil)
+	w := httptest.NewRecorder()
+	srv.Handler().ServeHTTP(w, r)
+	if w.Code != http.StatusOK {
+		t.Fatalf("状态码 = %d", w.Code)
+	}
+	html := w.Body.String()
+	for _, want := range []string{"package main", "func main"} {
+		if !strings.Contains(html, want) {
+			t.Errorf("页面缺少文件内容 %q,疑似读取返回空内容", want)
+		}
+	}
+	if !strings.Contains(html, "codeline") {
+		t.Error("未渲染代码视图")
+	}
+}
+
+// TestReadmeRendersOnHome 确认首页能渲染 README(同样依赖 BlobLimited)。
+func TestReadmeRendersOnHome(t *testing.T) {
+	srv, st, _, _ := newTestServer(t)
+	dir := makeRepo(t, srv, st, "doc", nil)
+	commitFiles(t, dir, "init", map[string]string{"README.md": "# 标题\n\n一段说明。\n"})
+
+	r := httptest.NewRequest(http.MethodGet, "/doc", nil)
+	w := httptest.NewRecorder()
+	srv.Handler().ServeHTTP(w, r)
+	if w.Code != http.StatusOK {
+		t.Fatalf("状态码 = %d", w.Code)
+	}
+	if html := w.Body.String(); !strings.Contains(html, "一段说明") {
+		t.Error("README 未渲染,疑似 BlobLimited 返回空内容")
+	}
+}
diff --git a/internal/server/githttp.go b/internal/server/githttp.go
index b9f08d1..33fd01a 100644
--- a/internal/server/githttp.go
+++ b/internal/server/githttp.go
@@ -8,6 +8,7 @@ import (
 	"net/http"
 	"os"
 	"os/exec"
+	"runtime/debug"
 	"strings"
 	"time"
 
@@ -16,6 +17,17 @@ import (
 	"gitcat/internal/store"
 )
 
+// gitTransferTimeout 是单次 clone / fetch / push 允许的最长时长。
+//
+// 不能靠 http.Server 的 WriteTimeout 兜底:写超时会把正在上传/下载的
+// 大仓库硬生生切断。正确做法是给 git 子进程一个明确的 context——它同时
+// 覆盖了"客户端断开"(r.Context 会被取消)和"服务端卡死"两种情况。
+const gitTransferTimeout = 30 * time.Minute
+
+// maxGitBodySize 限制单个 Git 请求体的解压后体积。
+// 恶意(或失控的)客户端可以用 gzip 炸弹把任意大的数据灌进 git 的 stdin。
+const maxGitBodySize = 512 << 20
+
 // handleGitAction 处理 Git Smart HTTP 协议请求。
 func (s *Server) handleGitAction(w http.ResponseWriter, r *http.Request, name, action string) {
 	if !s.st.Installed() {
@@ -41,13 +53,8 @@ func (s *Server) handleGitAction(w http.ResponseWriter, r *http.Request, name, a
 		case "git-upload-pack":
 			s.serveInfoRefs(w, r, dir, "upload-pack")
 		case "git-receive-pack":
-			u := s.basicAuthUser(r)
-			if u == nil {
-				challengeGit(w)
-				return
-			}
-			if repo.IsArchived {
-				http.Error(w, "项目已归档,暂不接受推送", http.StatusForbidden)
+			// 写操作:必须校验身份 + 仓库归属 + 归档状态。
+			if !s.authorizeGitWrite(w, r, repo) {
 				return
 			}
 			s.serveInfoRefs(w, r, dir, "receive-pack")
@@ -57,27 +64,51 @@ func (s *Server) handleGitAction(w http.ResponseWriter, r *http.Request, name, a
 	case "git-upload-pack":
 		s.serveUploadPack(w, r, dir)
 	case "git-receive-pack":
-		u := s.basicAuthUser(r)
-		if u == nil {
-			challengeGit(w)
-			return
-		}
-		if repo.IsArchived {
-			http.Error(w, "项目已归档,暂不接受推送", http.StatusForbidden)
+		if !s.authorizeGitWrite(w, r, repo) {
 			return
 		}
-		s.serveReceivePack(w, r, repo, dir, u)
+		s.serveReceivePack(w, r, repo, dir, userFrom(r.Context()))
 	default:
 		http.Error(w, "未知的 git 请求", http.StatusNotFound)
 	}
 }
 
-func gitEnv(r *http.Request) []string {
+// authorizeGitWrite 校验 Git 写操作(push)权限。
+//
+// 这里必须调用 canManageRepo:只校验"已登录"意味着任何一个注册用户都能
+// 往别人的仓库推送、强推、删除分支和标签,等于把所有人的代码托管权限
+// 交给了最低权限的成员。Git 协议没有 CSRF 保护,权限只能在这里守住。
+//
+// 成功时把用户写入请求上下文,供 serveReceivePack 使用,避免重复做一次
+// bcrypt 校验。
+func (s *Server) authorizeGitWrite(w http.ResponseWriter, r *http.Request, repo *store.Repo) bool {
+	u := s.basicAuthUser(r)
+	if u == nil {
+		challengeGit(w)
+		return false
+	}
+	if repo.IsArchived {
+		http.Error(w, "项目已归档,暂不接受推送", http.StatusForbidden)
+		return false
+	}
+	if !canManageRepo(u, repo) {
+		http.Error(w, "没有向该项目推送的权限", http.StatusForbidden)
+		return false
+	}
+	ctx := context.WithValue(r.Context(), ctxUser, u)
+	*r = *r.WithContext(ctx)
+	return true
+}
+
+// gitEnv 组装 git 子进程环境,并在请求上下文上挂上传输超时。
+func gitEnv(r *http.Request) ([]string, context.CancelFunc) {
+	ctx, cancel := context.WithTimeout(r.Context(), gitTransferTimeout)
+	r = r.WithContext(ctx)
 	env := append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "LC_ALL=C")
 	if proto := r.Header.Get("Git-Protocol"); proto != "" {
 		env = append(env, "GIT_PROTOCOL="+proto)
 	}
-	return env
+	return env, cancel
 }
 
 // serveInfoRefs 输出 refs 广告(clone / fetch / push 的握手阶段)。
@@ -89,8 +120,10 @@ func (s *Server) serveInfoRefs(w http.ResponseWriter, r *http.Request, dir, serv
 	header := "# service=git-" + service + "\n"
 	fmt.Fprintf(w, "%04x%s0000", len(header)+4, header)
 
-	cmd := exec.Command("git", service, "--stateless-rpc", "--advertise-refs", dir)
-	cmd.Env = gitEnv(r)
+	env, cancel := gitEnv(r)
+	defer cancel()
+	cmd := exec.CommandContext(r.Context(), "git", service, "--stateless-rpc", "--advertise-refs", dir)
+	cmd.Env = env
 	cmd.Stdout = w
 	cmd.Stderr = os.Stderr
 	if err := cmd.Run(); err != nil {
@@ -102,12 +135,15 @@ func (s *Server) serveInfoRefs(w http.ResponseWriter, r *http.Request, dir, serv
 func (s *Server) serveUploadPack(w http.ResponseWriter, r *http.Request, dir string) {
 	w.Header().Set("Content-Type", "application/x-git-upload-pack-result")
 	w.Header().Set("Cache-Control", "no-cache")
-	body := requestBody(w, r)
-	if body == nil {
+	body, closeBody, err := requestBody(w, r)
+	if err != nil {
 		return
 	}
-	cmd := exec.Command("git", "upload-pack", "--stateless-rpc", dir)
-	cmd.Env = gitEnv(r)
+	defer closeBody()
+	env, cancel := gitEnv(r)
+	defer cancel()
+	cmd := exec.CommandContext(r.Context(), "git", "upload-pack", "--stateless-rpc", dir)
+	cmd.Env = env
 	cmd.Stdin = body
 	cmd.Stdout = w
 	cmd.Stderr = os.Stderr
@@ -118,31 +154,35 @@ func (s *Server) serveUploadPack(w http.ResponseWriter, r *http.Request, dir str
 
 // serveReceivePack 处理 push,并在完成后记录推送留痕。
 func (s *Server) serveReceivePack(w http.ResponseWriter, r *http.Request, repo *store.Repo, dir string, u *store.User) {
-	body := requestBody(w, r)
-	if body == nil {
+	body, closeBody, err := requestBody(w, r)
+	if err != nil {
 		return
 	}
+	defer closeBody()
 	w.Header().Set("Content-Type", "application/x-git-receive-pack-result")
 	w.Header().Set("Cache-Control", "no-cache")
 
+	env, cancel := gitEnv(r)
+	defer cancel()
+
 	mu := s.repoLock(repo.Name)
 	mu.Lock()
 	before, _ := gitx.Refs(dir)
 
-	cmd := exec.Command("git", "receive-pack", "--stateless-rpc", dir)
-	cmd.Env = gitEnv(r)
+	cmd := exec.CommandContext(r.Context(), "git", "receive-pack", "--stateless-rpc", dir)
+	cmd.Env = env
 	cmd.Stdin = body
 	cmd.Stdout = w
 	cmd.Stderr = os.Stderr
-	err := cmd.Run()
+	runErr := cmd.Run()
 	if flusher, ok := w.(http.Flusher); ok {
 		flusher.Flush()
 	}
 	after, _ := gitx.Refs(dir)
 	mu.Unlock()
 
-	if err != nil {
-		fmt.Fprintf(os.Stderr, "git receive-pack 失败: %v\n", err)
+	if runErr != nil {
+		fmt.Fprintf(os.Stderr, "git receive-pack 失败: %v\n", runErr)
 	}
 	s.recordPush(repo, dir, u, before, after)
 }
@@ -244,7 +284,16 @@ func (s *Server) recordPush(repo *store.Repo, dir string, u *store.User, before,
 		return
 	}
 
-	go s.generateWhatsNew(push.ID, repo, primary, refLabel, details, diffStat, additions, deletions)
+	go func() {
+		// 这是脱离请求生命周期的独立 goroutine,仓库里没有 recover 中间件,
+		// 一次 panic 就会带走整个进程。宁可丢掉一条更新说明,也不能让服务崩。
+		defer func() {
+			if rec := recover(); rec != nil {
+				fmt.Fprintf(os.Stderr, "生成更新说明时 panic: %v\n%s\n", rec, debug.Stack())
+			}
+		}()
+		s.generateWhatsNew(push.ID, repo, primary, refLabel, details, diffStat, additions, deletions)
+	}()
 }
 
 func sortRefChanges(changes []refChange) {
@@ -321,14 +370,22 @@ func fallbackSummary(refLabel string, details []gitx.PushCommit, additions, dele
 }
 
 // requestBody 取出请求体并按需解压。
-func requestBody(w http.ResponseWriter, r *http.Request) io.Reader {
-	if strings.Contains(r.Header.Get("Content-Encoding"), "gzip") {
-		gz, err := gzip.NewReader(r.Body)
-		if err != nil {
-			http.Error(w, "无法解析请求体", http.StatusBadRequest)
-			return nil
-		}
-		return gz
-	}
-	return r.Body
+//
+// 解压后的数据会被直接喂给 git 的 stdin,因此必须限量:否则一个几 KB 的
+// gzip 炸弹就能把 git 的输入撑到任意大。调用方必须 defer closeBody()。
+func requestBody(w http.ResponseWriter, r *http.Request) (io.Reader, func(), error) {
+	noop := func() {}
+	if !strings.Contains(r.Header.Get("Content-Encoding"), "gzip") {
+		return r.Body, noop, nil
+	}
+	gz, err := gzip.NewReader(r.Body)
+	if err != nil {
+		http.Error(w, "无法解析请求体", http.StatusBadRequest)
+		return nil, noop, err
+	}
+	// 多包一层:gzip 之后还要限流,且必须在关闭时同时关掉压缩流。
+	limited := io.LimitReader(gz, maxGitBodySize)
+	return limited, func() {
+		_ = gz.Close()
+	}, nil
 }
diff --git a/internal/server/media.go b/internal/server/media.go
index cf3a25d..bf26e65 100644
--- a/internal/server/media.go
+++ b/internal/server/media.go
@@ -1,20 +1,142 @@
 package server
 
 import (
+	"fmt"
 	"net/http"
 	"os"
 	"path/filepath"
 	"strings"
+
+	"gitcat/internal/gitx"
+	"gitcat/internal/store"
 )
 
+// safeRepoPath 校验并返回仓库在磁盘上的路径。
+//
+// name 是仓库名(不含 .git 后缀)。名称来自数据库而非 URL,但仍要求它
+// base 化、不含分隔符与 "..",并额外确认拼出来的路径没有跳出 repos 目录——
+// 这是 RemoveAll / Rename 前的最后一道防线。
+func (s *Server) safeRepoPath(name string) (string, error) {
+	if name == "" || name != filepath.Base(name) ||
+		strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
+		return "", fmt.Errorf("非法的项目名: %q", name)
+	}
+	full := filepath.Clean(filepath.Join(s.repoRoot, name+".git"))
+	if filepath.Dir(full) != filepath.Clean(s.repoRoot) {
+		return "", fmt.Errorf("非法的项目名: %q", name)
+	}
+	return full, nil
+}
+
 // removeRepoDir 删除仓库目录。
 func removeRepoDir(path string) error {
 	if !strings.HasSuffix(path, ".git") {
 		return os.ErrInvalid
 	}
+	dir := filepath.Base(filepath.Dir(path))
+	if dir == "." || dir == string(filepath.Separator) {
+		return os.ErrInvalid
+	}
 	return os.RemoveAll(path)
 }
 
+// renameRepoDir 把裸仓库目录从 oldName 改到 newName。
+//
+// 大小写不敏感的文件系统(Windows、默认配置的 macOS)上,
+// foo.git → Foo.git 会被内核判定为"目标已存在"而报错,但目录其实
+// 就是同一个。这种情况只要 old/new 指向同一份数据就无需搬动。
+func (s *Server) renameRepoDir(oldName, newName string) error {
+	oldPath, err := s.safeRepoPath(oldName)
+	if err != nil {
+		return err
+	}
+	newPath, err := s.safeRepoPath(newName)
+	if err != nil {
+		return err
+	}
+	if oldPath == newPath {
+		return nil
+	}
+	if _, err := os.Stat(newPath); err == nil {
+		if same, sameErr := sameFile(oldPath, newPath); sameErr == nil && same {
+			return nil
+		}
+		return fmt.Errorf("磁盘上已存在目录 %s", filepath.Base(newPath))
+	}
+	if err := os.Rename(oldPath, newPath); err != nil {
+		return err
+	}
+	return nil
+}
+
+func sameFile(a, b string) (bool, error) {
+	fa, err := os.Stat(a)
+	if err != nil {
+		return false, err
+	}
+	fb, err := os.Stat(b)
+	if err != nil {
+		return false, err
+	}
+	return os.SameFile(fa, fb), nil
+}
+
+// renameRepo 完成仓库改名:先搬磁盘目录,再改数据库,失败则回滚。
+//
+// 顺序很关键——反过来做的话,中途失败会留下一个"数据库指向不存在的
+// 目录"的仓库,比"目录还没改名"更难恢复。改名与 push 可能并发,
+// 因此全程持有仓库锁。
+func (s *Server) renameRepo(repo *store.Repo, newName string) error {
+	oldName := repo.Name
+	if newName == oldName {
+		return nil
+	}
+
+	mu := s.repoLock(oldName)
+	mu.Lock()
+	defer mu.Unlock()
+
+	// 目标名可能已经被别人占用,重新读一次避免用调用方读到的陈旧数据判断。
+	fresh, err := s.st.RepoByID(repo.ID)
+	if err != nil {
+		return err
+	}
+	if fresh.Name != oldName {
+		return fmt.Errorf("项目名称已在别处变更,请刷新后重试")
+	}
+	if _, err := s.st.RepoByName(newName); err == nil {
+		return fmt.Errorf("项目 %s 已存在", newName)
+	}
+
+	if err := s.renameRepoDir(oldName, newName); err != nil {
+		return err
+	}
+	if err := s.st.RenameRepo(repo.ID, newName); err != nil {
+		// 回滚磁盘,尽量把系统恢复到改动前的状态。
+		if rbErr := s.renameRepoDir(newName, oldName); rbErr != nil {
+			return fmt.Errorf("%w(且磁盘目录回滚失败:%v,请手工检查 data/repos/%s.git)", err, rbErr, oldName)
+		}
+		return err
+	}
+	repo.Name = newName
+	return nil
+}
+
+// validateNewRepoName 校验改名目标,并返回去掉 .git 后缀的最终名称。
+func validateNewRepoName(raw string) (string, error) {
+	name := strings.TrimSpace(raw)
+	name = strings.TrimSuffix(name, ".git")
+	switch {
+	case name == "":
+		return "", fmt.Errorf("请填写新的项目名称")
+	case !gitx.ValidRepoName(name):
+		return "", fmt.Errorf("项目名称只能包含字母、数字、点、下划线、短横线,且需以字母或数字开头")
+	case reservedRepoNames[strings.ToLower(name)]:
+		return "", fmt.Errorf("该名称为系统保留名,请换一个")
+	}
+	return name, nil
+}
+
 // handleAvatar 提供用户头像文件。
 func (s *Server) handleAvatar(w http.ResponseWriter, r *http.Request) {
 	name := r.PathValue("name")
diff --git a/internal/server/pushauth_test.go b/internal/server/pushauth_test.go
new file mode 100644
index 0000000..360adaa
--- /dev/null
+++ b/internal/server/pushauth_test.go
@@ -0,0 +1,89 @@
+package server
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+)
+
+// doPush 发起一次推送请求。
+func doPush(srv *Server, target, user, pass string) *httptest.ResponseRecorder {
+	r := httptest.NewRequest(http.MethodPost, target, strings.NewReader("0000"))
+	if user != "" {
+		r.SetBasicAuth(user, pass)
+	}
+	w := httptest.NewRecorder()
+	srv.Handler().ServeHTTP(w, r)
+	return w
+}
+
+// TestPushRequiresRepoOwnership 是 P0-1 的回归测试:任意已登录用户都不得
+// 向他人仓库推送。修复前 bob 能往 alice 的仓库任意推、删分支、删标签。
+func TestPushRequiresRepoOwnership(t *testing.T) {
+	srv, st, alice, bob := newTestServer(t)
+	makeRepo(t, srv, st, "victim", alice)
+
+	for _, target := range []string{
+		"/victim.git/git-receive-pack",
+		"/victim.git/info/refs?service=git-receive-pack",
+	} {
+		if w := doPush(srv, target, bob.Username, testPass); w.Code != http.StatusForbidden {
+			t.Errorf("bob 推送 alice 的仓库(%s)返回 %d,期望 403", target, w.Code)
+		}
+	}
+	// owner 本人必须放行,否则说明权限判断写死了
+	if w := doPush(srv, "/victim.git/git-receive-pack", alice.Username, testPass); w.Code == http.StatusForbidden {
+		t.Error("仓库 owner 自己推送被误拒")
+	}
+}
+
+func TestPushRequiresAuth(t *testing.T) {
+	srv, st, _, _ := newTestServer(t)
+	makeRepo(t, srv, st, "open", nil)
+	w := doPush(srv, "/open.git/git-receive-pack", "", "")
+	if w.Code != http.StatusUnauthorized {
+		t.Fatalf("匿名推送返回 %d,期望 401", w.Code)
+	}
+	if w.Header().Get("WWW-Authenticate") == "" {
+		t.Error("缺少 WWW-Authenticate 挑战头")
+	}
+}
+
+func TestArchivedRepoBlocksPush(t *testing.T) {
+	srv, st, alice, _ := newTestServer(t)
+	makeRepo(t, srv, st, "old", alice)
+	repo, _ := st.RepoByName("old")
+	repo.IsArchived = true
+	if err := st.UpdateRepo(repo); err != nil {
+		t.Fatalf("归档失败: %v", err)
+	}
+	if w := doPush(srv, "/old.git/git-receive-pack", alice.Username, testPass); w.Code != http.StatusForbidden {
+		t.Fatalf("归档仓库推送返回 %d,期望 403", w.Code)
+	}
+}
+
+// TestCloneStaysPublic 确认修复没有把只读克隆也一起拦住。
+func TestCloneStaysPublic(t *testing.T) {
+	srv, st, _, _ := newTestServer(t)
+	makeRepo(t, srv, st, "public", nil)
+	r := httptest.NewRequest(http.MethodGet, "/public.git/info/refs?service=git-upload-pack", nil)
+	w := httptest.NewRecorder()
+	srv.Handler().ServeHTTP(w, r)
+	if w.Code != http.StatusOK {
+		t.Fatalf("匿名 clone 返回 %d,期望 200(只读克隆必须保持公开)", w.Code)
+	}
+}
+
+// TestDisabledUserCannotPush 停用账号不得推送。
+func TestDisabledUserCannotPush(t *testing.T) {
+	srv, st, _, _ := newTestServer(t)
+	makeRepo(t, srv, st, "repo1", nil)
+	bob, _ := st.UserByUsername("bob")
+	if err := st.SetUserFlags(bob.ID, false, true); err != nil {
+		t.Fatalf("停用 bob 失败: %v", err)
+	}
+	if w := doPush(srv, "/repo1.git/git-receive-pack", "bob", testPass); w.Code != http.StatusUnauthorized {
+		t.Fatalf("停用账号推送返回 %d,期望 401", w.Code)
+	}
+}
diff --git a/internal/server/raw_test.go b/internal/server/raw_test.go
new file mode 100644
index 0000000..55f935a
--- /dev/null
+++ b/internal/server/raw_test.go
@@ -0,0 +1,108 @@
+package server
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+)
+
+// TestRawEndpointHardening 是 P0-3 的回归测试:raw 端点不得把仓库里的可执行
+// 内容以内联方式返回。修复前 evil.html 会带着 text/html + inline 在 gitcat
+// 自己的源下渲染,提交一个文件等于对所有访客执行脚本。
+func TestRawEndpointHardening(t *testing.T) {
+	srv, st, _, _ := newTestServer(t)
+	dir := makeRepo(t, srv, st, "files", nil)
+	commitFiles(t, dir, "add files", map[string]string{
+		"evil.html": `<script>alert(document.domain)</script>`,
+		"logo.svg":  `<svg><script>alert(1)</script></svg>`,
+		"notes.txt": "hello",
+		"pic.png":   "\x89PNG\r\n\x1a\nfake",
+	})
+
+	cases := []struct {
+		file     string
+		wantDisp string
+		denyHTML bool
+	}{
+		{"evil.html", "attachment", true},
+		{"logo.svg", "attachment", true},
+		{"notes.txt", "inline", false},
+		{"pic.png", "inline", false},
+	}
+	for _, tc := range cases {
+		t.Run(tc.file, func(t *testing.T) {
+			r := httptest.NewRequest(http.MethodGet, "/files/raw/main/"+tc.file, nil)
+			w := httptest.NewRecorder()
+			srv.Handler().ServeHTTP(w, r)
+			if w.Code != http.StatusOK {
+				t.Fatalf("状态码 = %d,期望 200", w.Code)
+			}
+			ct := w.Header().Get("Content-Type")
+			if disp := w.Header().Get("Content-Disposition"); !strings.Contains(disp, tc.wantDisp) {
+				t.Errorf("Content-Disposition = %q,期望包含 %q", disp, tc.wantDisp)
+			}
+			if tc.denyHTML && (strings.Contains(ct, "text/html") || strings.Contains(ct, "svg")) {
+				t.Errorf("可执行类型被原样返回: Content-Type = %q", ct)
+			}
+			if got := w.Header().Get("Content-Security-Policy"); !strings.Contains(got, "sandbox") {
+				t.Errorf("缺少 CSP sandbox,实际 = %q", got)
+			}
+			if got := w.Header().Get("X-Content-Type-Options"); got != "nosniff" {
+				t.Errorf("X-Content-Type-Options = %q,期望 nosniff", got)
+			}
+		})
+	}
+}
+
+// TestRawRejectsOptionInjection 确认 ref 参数无法被 git 当成选项。
+func TestRawRejectsOptionInjection(t *testing.T) {
+	srv, st, _, _ := newTestServer(t)
+	makeRepo(t, srv, st, "safe", nil)
+	for _, ref := range []string{"--upload-pack=evil", "-x", "a%20b", "a..b", "!bang"} {
+		r := httptest.NewRequest(http.MethodGet, "/safe/raw/"+ref+"/f.txt", nil)
+		w := httptest.NewRecorder()
+		srv.Handler().ServeHTTP(w, r)
+		if w.Code == http.StatusOK {
+			t.Errorf("非法 ref %q 被放行", ref)
+		}
+	}
+}
+
+// TestPagesHaveCSP 确认所有页面都带上了 CSP。
+func TestPagesHaveCSP(t *testing.T) {
+	srv, st, _, _ := newTestServer(t)
+	makeRepo(t, srv, st, "proj", nil)
+	commitFiles(t, srv.repoPath("proj"), "init", map[string]string{"a.txt": "hi"})
+
+	for _, path := range []string{"/", "/proj", "/login"} {
+		r := httptest.NewRequest(http.MethodGet, path, nil)
+		w := httptest.NewRecorder()
+		srv.Handler().ServeHTTP(w, r)
+		if got := w.Header().Get("Content-Security-Policy"); !strings.Contains(got, "default-src 'self'") {
+			t.Errorf("%s 缺少 CSP: %q", path, got)
+		}
+	}
+}
+
+// TestBlobSkipsOversizedFile 确认超大文件不进内存(只渲染下载入口)。
+func TestBlobSkipsOversizedFile(t *testing.T) {
+	srv, st, _, _ := newTestServer(t)
+	dir := makeRepo(t, srv, st, "big", nil)
+	big := strings.Repeat("A", 3<<20) // 3MB > 2MB 预览上限
+	commitFiles(t, dir, "big file", map[string]string{"big.txt": big})
+
+	r := httptest.NewRequest(http.MethodGet, "/big/blob/main/big.txt", nil)
+	w := httptest.NewRecorder()
+	srv.Handler().ServeHTTP(w, r)
+	if w.Code != http.StatusOK {
+		t.Fatalf("状态码 = %d", w.Code)
+	}
+	body := w.Body.String()
+	if !strings.Contains(body, "超过 2 MB") {
+		t.Error("超大文件应显示「跳过预览」提示")
+	}
+	if strings.Contains(body, "codeline") {
+		t.Error("超大文件不应渲染代码行")
+	}
+}
diff --git a/internal/server/rename_test.go b/internal/server/rename_test.go
new file mode 100644
index 0000000..1875f78
--- /dev/null
+++ b/internal/server/rename_test.go
@@ -0,0 +1,144 @@
+package server
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"gitcat/internal/gitx"
+)
+
+// TestRenameRepoKeepsDiskInSync 是 P0-2 的回归测试:改名后数据库与磁盘目录
+// 必须同时指向新名字。修复前 UpdateRepo 会改 DB 名却不改目录,会把仓库
+// 变成「数据缺失」。
+func TestRenameRepoKeepsDiskInSync(t *testing.T) {
+	srv, st, alice, _ := newTestServer(t)
+	oldDir := makeRepo(t, srv, st, "oldname", alice)
+
+	repo, err := st.RepoByName("oldname")
+	if err != nil {
+		t.Fatalf("读仓库失败: %v", err)
+	}
+	if err := srv.renameRepo(repo, "newname"); err != nil {
+		t.Fatalf("改名失败: %v", err)
+	}
+	if _, err := os.Stat(oldDir); !os.IsNotExist(err) {
+		t.Error("旧目录仍在,磁盘未被同步重命名")
+	}
+	newDir := srv.repoPath("newname")
+	if info, err := os.Stat(filepath.Join(newDir, "HEAD")); err != nil || info.IsDir() {
+		t.Errorf("新目录不是有效的裸仓库: %v", err)
+	}
+	if !gitx.IsBareRepo(newDir) {
+		t.Error("改名后无法被识别为裸仓库")
+	}
+	got, err := st.RepoByName("newname")
+	if err != nil {
+		t.Fatalf("按新名查不到仓库: %v", err)
+	}
+	if got.ID != repo.ID {
+		t.Errorf("改名后 ID 变了: %d -> %d", repo.ID, got.ID)
+	}
+	if _, err := os.Stat(srv.repoPath(got.Name) + "/HEAD"); err != nil {
+		t.Errorf("改名后 DB 与磁盘脱钩: %v", err)
+	}
+}
+
+func TestRenameToExistingNameFails(t *testing.T) {
+	srv, st, alice, _ := newTestServer(t)
+	dirA := makeRepo(t, srv, st, "alpha", alice)
+	dirB := makeRepo(t, srv, st, "beta", alice)
+
+	repo, err := st.RepoByName("alpha")
+	if err != nil {
+		t.Fatalf("读仓库失败: %v", err)
+	}
+	if err := srv.renameRepo(repo, "beta"); err == nil {
+		t.Fatal("改名到已存在名称应失败")
+	}
+	// 失败后两侧都必须保持原样
+	if _, err := os.Stat(filepath.Join(dirA, "HEAD")); err != nil {
+		t.Errorf("失败的改名破坏了源目录: %v", err)
+	}
+	if _, err := os.Stat(filepath.Join(dirB, "HEAD")); err != nil {
+		t.Errorf("失败的改名破坏了目标目录: %v", err)
+	}
+	if got, _ := st.RepoByName("alpha"); got == nil {
+		t.Error("失败的改名却改了数据库")
+	}
+}
+
+func TestRenameRejectsIllegalNames(t *testing.T) {
+	srv, st, alice, _ := newTestServer(t)
+	makeRepo(t, srv, st, "keep", alice)
+
+	bad := []string{"", "   ", "../escape", "a/b", `a\b`, "has space",
+		"admin", "-lead", ".hidden", "a..b", ".git", "a.git.git", "x" + string(rune(0))}
+	for _, name := range bad {
+		if got, err := validateNewRepoName(name); err == nil {
+			t.Errorf("非法名 %q 通过了校验,得到 %q", name, got)
+		}
+	}
+	// 与「新建项目」一致:输入带 .git 后缀时会被剥掉,而不是报错
+	if got, err := validateNewRepoName("proj.git"); err != nil || got != "proj" {
+		t.Errorf("带 .git 后缀应被接受并剥离,得到 %q(err=%v)", got, err)
+	}
+	if got, err := validateNewRepoName("good-name.v2"); err != nil || got != "good-name.v2" {
+		t.Errorf("合法名被误拒: got=%q err=%v", got, err)
+	}
+	if _, err := os.Stat(srv.repoPath("keep") + "/HEAD"); err != nil {
+		t.Errorf("非法改名破坏了目录: %v", err)
+	}
+	if got, _ := st.RepoByName("keep"); got == nil {
+		t.Error("非法改名却改了数据库")
+	}
+}
+
+// TestUpdateRepoCannotRename 锁死根因:普通更新不得改名字。
+func TestUpdateRepoCannotRename(t *testing.T) {
+	srv, st, _, _ := newTestServer(t)
+	makeRepo(t, srv, st, "solid", nil)
+	repo, _ := st.RepoByName("solid")
+	repo.Name = "hijacked"
+	repo.Description = "改描述"
+	if err := st.UpdateRepo(repo); err != nil {
+		t.Fatalf("UpdateRepo 失败: %v", err)
+	}
+	got, err := st.RepoByName("solid")
+	if err != nil {
+		t.Fatalf("原名查不到仓库(说明 UpdateRepo 改了名字): %v", err)
+	}
+	if got.Description != "改描述" {
+		t.Errorf("描述未更新: %q", got.Description)
+	}
+	if _, err := st.RepoByName("hijacked"); err == nil {
+		t.Error("UpdateRepo 竟然把仓库改名了")
+	}
+	if _, err := os.Stat(srv.repoPath("solid") + "/HEAD"); err != nil {
+		t.Errorf("磁盘目录被动过: %v", err)
+	}
+}
+
+// TestRenameKeepsPushHistory 改名不应影响已有的推送留痕。
+func TestRenameKeepsPushHistory(t *testing.T) {
+	srv, st, alice, _ := newTestServer(t)
+	makeRepo(t, srv, st, "proj", alice)
+	repo, _ := st.RepoByName("proj")
+	if _, err := st.CreatePush(repo.ID, &alice.ID, alice.Username, "Alice",
+		"main", "0000", "1111", nil); err != nil {
+		t.Fatalf("造推送记录失败: %v", err)
+	}
+	if err := srv.renameRepo(repo, "proj-renamed"); err != nil {
+		t.Fatalf("改名失败: %v", err)
+	}
+	pushes, err := st.PushesByRepo(repo.ID, 10)
+	if err != nil {
+		t.Fatalf("读推送记录失败: %v", err)
+	}
+	if len(pushes) != 1 {
+		t.Fatalf("改名后推送记录丢失,剩 %d 条", len(pushes))
+	}
+	if pushes[0].RepoName != "proj-renamed" {
+		t.Errorf("推送记录里的仓库名未跟着更新: %q", pushes[0].RepoName)
+	}
+}
diff --git a/internal/server/repo.go b/internal/server/repo.go
index 2db4d72..8938126 100644
--- a/internal/server/repo.go
+++ b/internal/server/repo.go
@@ -1,10 +1,13 @@
 package server
 
 import (
+	"errors"
 	"fmt"
 	"html/template"
 	"mime"
 	"net/http"
+	"net/url"
+	"os"
 	"path"
 	"strconv"
 	"strings"
@@ -15,6 +18,14 @@ import (
 
 const commitsPerPage = 30
 
+// 页面预览的限制。
+const (
+	// maxBlobPreview 是允许读进内存做页面预览的文件体积上限。
+	maxBlobPreview = 2 << 20
+	// maxPreviewLines 是预览时最多渲染的行数。
+	maxPreviewLines = 5000
+)
+
 // loadRepo 读取 URL 中的仓库并校验磁盘数据。
 func (s *Server) loadRepo(w http.ResponseWriter, r *http.Request) (*store.Repo, string, bool) {
 	name := r.PathValue("repo")
@@ -33,12 +44,18 @@ func (s *Server) loadRepo(w http.ResponseWriter, r *http.Request) (*store.Repo,
 
 // resolveRefAndPath 解决分支名带斜杠时的歧义:优先匹配最长的 ref。
 func resolveRefAndPath(dir, ref string, rest []string) (string, string) {
+	if !gitx.ValidRef(ref) {
+		return "", strings.Join(rest, "/")
+	}
 	candidates := []string{ref}
 	for _, seg := range rest {
 		candidates = append(candidates, candidates[len(candidates)-1]+"/"+seg)
 	}
 	for i := len(candidates) - 1; i >= 0; i-- {
 		cand := candidates[i]
+		if !gitx.ValidRef(cand) {
+			continue
+		}
 		for _, full := range []string{"refs/heads/" + cand, "refs/tags/" + cand, cand} {
 			if _, err := gitx.Resolve(dir, full); err == nil {
 				return cand, strings.Join(rest[i:], "/")
@@ -56,9 +73,22 @@ func splitPath(p string) []string {
 	return strings.Split(p, "/")
 }
 
+// refFromPath 取出并校验 URL 中的 ref / sha。
+//
+// 这些值会直接成为 git 命令行的参数:以 "-" 开头会被 git 当成选项,
+// 含空格或 ".." 的值会被拆开或指向别的对象。统一在入口拦掉。
+func (s *Server) refFromPath(w http.ResponseWriter, r *http.Request, key string) (string, bool) {
+	v := r.PathValue(key)
+	if !gitx.ValidRef(v) {
+		s.renderError(w, r, http.StatusBadRequest, "非法的引用名")
+		return "", false
+	}
+	return v, true
+}
+
 // pickRef 选择展示用的 ref:优先指定值,其次默认分支,最后任意分支。
 func (s *Server) pickRef(dir string, repo *store.Repo, want string) string {
-	if want != "" {
+	if want != "" && gitx.ValidRef(want) {
 		if _, err := gitx.Resolve(dir, want); err == nil {
 			return want
 		}
@@ -220,7 +250,15 @@ func (s *Server) handleRepoTree(w http.ResponseWriter, r *http.Request) {
 	if !ok {
 		return
 	}
-	ref, sub := resolveRefAndPath(dir, r.PathValue("ref"), splitPath(r.PathValue("path")))
+	want, ok := s.refFromPath(w, r, "ref")
+	if !ok {
+		return
+	}
+	ref, sub := resolveRefAndPath(dir, want, splitPath(r.PathValue("path")))
+	if ref == "" {
+		s.renderError(w, r, http.StatusBadRequest, "非法的引用名")
+		return
+	}
 	if _, err := gitx.Resolve(dir, ref); err != nil {
 		s.renderError(w, r, http.StatusNotFound, "找不到引用 "+ref)
 		return
@@ -282,7 +320,15 @@ func (s *Server) handleRepoBlob(w http.ResponseWriter, r *http.Request) {
 	if !ok {
 		return
 	}
-	ref, file := resolveRefAndPath(dir, r.PathValue("ref"), splitPath(r.PathValue("path")))
+	want, ok := s.refFromPath(w, r, "ref")
+	if !ok {
+		return
+	}
+	ref, file := resolveRefAndPath(dir, want, splitPath(r.PathValue("path")))
+	if ref == "" {
+		s.renderError(w, r, http.StatusBadRequest, "非法的引用名")
+		return
+	}
 	if file == "" {
 		http.Redirect(w, r, "/"+repo.Name, http.StatusFound)
 		return
@@ -291,14 +337,28 @@ func (s *Server) handleRepoBlob(w http.ResponseWriter, r *http.Request) {
 		s.renderError(w, r, http.StatusNotFound, "文件 "+file+" 不存在")
 		return
 	}
-	content, err := gitx.Blob(dir, ref, file)
-	if err != nil {
-		s.renderError(w, r, http.StatusInternalServerError, "读取文件失败:"+err.Error())
-		return
-	}
+
+	// 先拿体积再决定要不要读进内存。以前是无条件 Blob() 读完整个文件,
+	// 才在后面用 size 判断"是否过大"——一个几百 MB 的文件就能把进程撑爆。
 	size := gitx.BlobSize(dir, ref, file)
-	isBinary := gitx.IsBinary(content)
-	tooLarge := size > 2<<20
+	tooLarge := size > maxBlobPreview
+
+	var content []byte
+	if !tooLarge {
+		var err error
+		content, err = gitx.BlobLimited(dir, ref, file, maxBlobPreview)
+		if errors.Is(err, gitx.ErrTooLarge) {
+			tooLarge, size = true, maxBlobPreview+1
+		} else if err != nil {
+			s.renderError(w, r, http.StatusInternalServerError, "读取文件失败:"+err.Error())
+			return
+		}
+	}
+	isBinary := len(content) > 0 && gitx.IsBinary(content)
+	if len(content) == 0 && !tooLarge {
+		// 空文件
+		isBinary = false
+	}
 
 	p := s.page(r, repo.Name+" · "+path.Base(file))
 	p.Active = "repo"
@@ -310,17 +370,22 @@ func (s *Server) handleRepoBlob(w http.ResponseWriter, r *http.Request) {
 	p.Data["IsBinary"] = isBinary
 	p.Data["TooLarge"] = tooLarge
 	ext := strings.ToLower(path.Ext(file))
-	p.Data["IsImage"] = map[string]bool{".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".webp": true, ".svg": true, ".ico": true, ".bmp": true}[ext]
+	p.Data["IsImage"] = inlineSafeExt[ext]
 	if !isBinary && !tooLarge {
 		text := string(content)
 		lines := strings.Split(strings.TrimRight(text, "\n"), "\n")
-		if len(lines) > 5000 {
-			lines = lines[:5000]
+		if len(lines) > maxPreviewLines {
+			lines = lines[:maxPreviewLines]
 			p.Data["TruncatedLines"] = true
 		}
 		p.Data["Lines"] = lines
 	}
-	p.Data["LineCount"] = strings.Count(string(content), "\n") + 1
+	// 行数用流式统计,超大文件不必读进内存也能显示。
+	if n, err := gitx.LineCount(dir, ref, file); err == nil {
+		p.Data["LineCount"] = n
+	} else if len(content) > 0 {
+		p.Data["LineCount"] = strings.Count(string(content), "\n") + 1
+	}
 	lastCommits := gitx.LastCommits(dir, ref, file, 1)
 	if c, ok := lastCommits[file]; ok {
 		p.Data["LastCommit"] = c
@@ -328,32 +393,109 @@ func (s *Server) handleRepoBlob(w http.ResponseWriter, r *http.Request) {
 	s.render(w, r, "repo_blob", p)
 }
 
+// inlineSafeExt 是允许在浏览器里直接内联渲染的扩展名白名单。
+//
+// 仓库里的文件属于不可信内容:HTML / SVG / XML 天生带脚本能力,一旦以内联
+// 方式从 gitcat 自己的源返回,提交者放一个 evil.html 就等于对所有访客执行
+// 任意脚本(存储型 XSS)。所以白名单只留位图,其余一律降级为
+// text/plain 或 attachment。
+var inlineSafeExt = map[string]bool{
+	".png": true, ".jpg": true, ".jpeg": true, ".gif": true,
+	".webp": true, ".bmp": true, ".ico": true,
+}
+
+// forceDownloadExt 是浏览器会当作「活动内容」处理的扩展名。
+//
+// 它们多半是文本文件,会落进下面的 text/plain 内联分支。虽然 text/plain
+// 本身不会执行脚本,但既然代价只是少一次「点开看源码」,就没必要赌浏览器
+// 的嗅探行为——一律强制下载。
+var forceDownloadExt = map[string]bool{
+	".html": true, ".htm": true, ".xhtml": true, ".shtml": true,
+	".js": true, ".mjs": true, ".cjs": true, ".jsx": true, ".ts": true,
+	".xml": true, ".xsl": true, ".xslt": true, ".svg": true, ".svgz": true,
+	".swf": true, ".hta": true, ".vtt": true, ".wasm": true,
+}
+
+// maxInlineSize 是 raw 原样查看的大小上限,超过则提示下载。
+const maxInlineSize = 8 << 20
+
+// rawSecurityHeaders 施加在 raw 响应上。
+//
+// sandbox 会把响应放进一个不透明来源(unique origin):即便将来某天
+// Content-Type 判断出了偏差,脚本也执行不了,更碰不到 gitcat 的 Cookie。
+func rawSecurityHeaders(w http.ResponseWriter) {
+	w.Header().Set("X-Content-Type-Options", "nosniff")
+	w.Header().Set("Content-Security-Policy", "sandbox")
+	w.Header().Set("Cache-Control", "no-cache")
+}
+
 func (s *Server) handleRepoRaw(w http.ResponseWriter, r *http.Request) {
 	_, dir, ok := s.loadRepo(w, r)
 	if !ok {
 		return
 	}
-	ref, file := resolveRefAndPath(dir, r.PathValue("ref"), splitPath(r.PathValue("path")))
-	content, err := gitx.Blob(dir, ref, file)
-	if err != nil {
-		http.Error(w, "文件不存在", http.StatusNotFound)
+	want, ok := s.refFromPath(w, r, "ref")
+	if !ok {
 		return
 	}
-	ctype := mime.TypeByExtension(strings.ToLower(path.Ext(file)))
-	if ctype == "" {
-		if gitx.IsBinary(content) {
-			ctype = "application/octet-stream"
-		} else {
-			ctype = "text/plain; charset=utf-8"
-		}
+	ref, file := resolveRefAndPath(dir, want, splitPath(r.PathValue("path")))
+	if ref == "" || file == "" {
+		http.NotFound(w, r)
+		return
+	}
+	if !gitx.TreeExists(dir, ref, file) {
+		http.NotFound(w, r)
+		return
 	}
-	if strings.HasPrefix(ctype, "text/") && !strings.Contains(ctype, "charset") {
-		ctype += "; charset=utf-8"
+
+	ext := strings.ToLower(path.Ext(file))
+	size := gitx.BlobSize(dir, ref, file)
+	ctype := ""
+	disposition := "attachment"
+
+	switch {
+	case inlineSafeExt[ext]:
+		ctype = mime.TypeByExtension(ext)
+		disposition = "inline"
+	case forceDownloadExt[ext]:
+		ctype = "application/octet-stream"
+	case size <= maxInlineSize && !looksBinaryName(ext):
+		// 其余文本统一按 text/plain 内联:浏览器不会执行其中的任何标记,
+		// 同时保留「点开直接看源码」的体验。
+		ctype = "text/plain; charset=utf-8"
+		disposition = "inline"
+	default:
+		ctype = "application/octet-stream"
 	}
+	if ctype == "" {
+		ctype = "application/octet-stream"
+	}
+
+	rawSecurityHeaders(w)
 	w.Header().Set("Content-Type", ctype)
-	w.Header().Set("Content-Disposition", "inline")
-	w.Header().Set("Cache-Control", "no-cache")
-	w.Write(content)
+	w.Header().Set("Content-Disposition", disposition+"; filename*=UTF-8''"+url.PathEscape(path.Base(file)))
+	if size > 0 {
+		w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
+	}
+	// 流式输出:不再把整个文件读进内存,GB 级文件也能正常下载。
+	if err := gitx.BlobReader(dir, ref, file, w); err != nil {
+		// 响应头已发出,无法再改状态码;只记录并中断传输。
+		fmt.Fprintf(os.Stderr, "输出原始文件 %s:%s 失败: %v\n", ref, file, err)
+	}
+}
+
+// looksBinaryName 按扩展名粗判二进制,用于在流式响应里决定内联还是下载。
+func looksBinaryName(ext string) bool {
+	switch ext {
+	case ".zip", ".gz", ".tar", ".bz2", ".xz", ".7z", ".rar", ".jar", ".war",
+		".exe", ".dll", ".so", ".dylib", ".bin", ".dat", ".db", ".sqlite",
+		".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
+		".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".svg",
+		".mp3", ".mp4", ".avi", ".mov", ".mkv", ".webm", ".ogg", ".wav",
+		".ttf", ".otf", ".woff", ".woff2", ".eot":
+		return true
+	}
+	return false
 }
 
 func (s *Server) handleRepoCommits(w http.ResponseWriter, r *http.Request) {
@@ -361,7 +503,11 @@ func (s *Server) handleRepoCommits(w http.ResponseWriter, r *http.Request) {
 	if !ok {
 		return
 	}
-	ref := s.pickRef(dir, repo, r.PathValue("ref"))
+	want, ok := s.refFromPath(w, r, "ref")
+	if !ok {
+		return
+	}
+	ref := s.pickRef(dir, repo, want)
 	// 空仓库还没有任何提交,HEAD 指向的分支尚不存在。仓库主页照样会给出
 	// “提交历史”入口,所以这里渲染空状态,而不是把它变成 404。
 	empty := !gitx.HasCommits(dir)
@@ -404,7 +550,10 @@ func (s *Server) handleRepoCommit(w http.ResponseWriter, r *http.Request) {
 	if !ok {
 		return
 	}
-	sha := r.PathValue("sha")
+	sha, ok := s.refFromPath(w, r, "sha")
+	if !ok {
+		return
+	}
 	detail, err := gitx.Show(dir, sha)
 	if err != nil {
 		s.renderError(w, r, http.StatusNotFound, "找不到提交 "+sha)
@@ -490,8 +639,29 @@ func (s *Server) handleRepoSettingsSave(w http.ResponseWriter, r *http.Request)
 		s.renderError(w, r, http.StatusBadRequest, "项目描述不能超过 300 个字符")
 		return
 	}
+	if raw := strings.TrimSpace(r.FormValue("new_name")); raw != "" {
+		newName, err := validateNewRepoName(raw)
+		if err != nil {
+			s.renderError(w, r, http.StatusBadRequest, err.Error())
+			return
+		}
+		if newName != repo.Name {
+			if err := s.renameRepo(repo, newName); err != nil {
+				s.renderError(w, r, http.StatusBadRequest, "重命名失败:"+err.Error())
+				return
+			}
+			// 目录已经搬走,后续对默认分支的校验必须用新路径,
+			// 否则会拿一个已不存在的目录去 Resolve,把成功的改名报成 400。
+			dir = s.repoPath(repo.Name)
+			setFlash(w, "项目已重命名为 "+repo.Name)
+		}
+	}
 	defaultBranch := strings.TrimSpace(r.FormValue("default_branch"))
 	if defaultBranch != "" {
+		if !gitx.ValidRef(defaultBranch) {
+			s.renderError(w, r, http.StatusBadRequest, "非法的默认分支名")
+			return
+		}
 		if _, err := gitx.Resolve(dir, defaultBranch); err != nil {
 			s.renderError(w, r, http.StatusBadRequest, "默认分支不存在")
 			return
diff --git a/internal/server/server.go b/internal/server/server.go
index c424f0e..af71ff0 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -20,6 +20,7 @@ import (
 	"strconv"
 	"strings"
 	"sync"
+	"sync/atomic"
 	"time"
 
 	"github.com/yuin/goldmark"
@@ -37,6 +38,14 @@ type Config struct {
 	Addr    string
 	DataDir string
 	Version string
+	// Commit / BuildDate 由构建时注入,仅用于后台展示与升级记录。
+	Commit    string
+	BuildDate string
+	// ExePath 是当前可执行文件路径;一键升级靠它定位替换目标。
+	// 为空(go run 场景)时升级只提示不替换。
+	ExePath string
+	// Restart 用于在升级完成后重启进程;为 nil 时只提示手工重启。
+	Restart func()
 }
 
 // Server 持有全部运行时依赖。
@@ -50,6 +59,7 @@ type Server struct {
 	static    http.Handler
 	locks     sync.Map // repo name -> *sync.Mutex
 	md        goldmark.Markdown
+	upgrading atomic.Bool // 防止并发的升级 / 重启请求叠加
 }
 
 // New 构造服务器。
@@ -173,7 +183,7 @@ func (s *Server) buildTemplates() error {
 		"error", "install", "login", "home", "new_project",
 		"repo_home", "repo_commits", "repo_commit", "repo_blob", "repo_branches", "repo_activity", "repo_settings",
 		"profile",
-		"admin_dashboard", "admin_users", "admin_user", "admin_repos", "admin_repo", "admin_ai", "admin_settings", "admin_activity",
+		"admin_dashboard", "admin_users", "admin_user", "admin_repos", "admin_repo", "admin_ai", "admin_settings", "admin_activity", "admin_upgrade",
 	}
 	for _, page := range pages {
 		t, err := template.New("layout.html").Funcs(tmplFuncs).ParseFS(webroot.FS,
@@ -286,6 +296,10 @@ func (s *Server) routes() {
 	m.HandleFunc("POST /admin/settings", s.handleAdminSettingsSave)
 	m.HandleFunc("GET /admin/activity", s.handleAdminActivity)
 
+	// 版本与一键升级
+	m.HandleFunc("GET /admin/upgrade", s.handleAdminUpgradePage)
+	m.HandleFunc("POST /admin/upgrade", s.handleAdminUpgradeAction)
+
 	// 静态资源在 Handler 中单独处理(避免与 /{repo}/tree/... 通配路由冲突)
 	m.HandleFunc("GET /media/avatars/{name}", s.handleAvatar)
 	m.HandleFunc("GET /favicon.ico", func(w http.ResponseWriter, r *http.Request) {
@@ -371,9 +385,16 @@ func sessionFrom(ctx context.Context) *store.Session {
 
 func (s *Server) withSecurityHeaders(next http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		w.Header().Set("X-Content-Type-Options", "nosniff")
-		w.Header().Set("Referrer-Policy", "same-origin")
-		w.Header().Set("X-Frame-Options", "SAMEORIGIN")
+		h := w.Header()
+		h.Set("X-Content-Type-Options", "nosniff")
+		h.Set("Referrer-Policy", "same-origin")
+		h.Set("X-Frame-Options", "SAMEORIGIN")
+		// README 与 AI 摘要会以 template.HTML 直出,goldmark 默认不开启
+		// html.WithUnsafe,但这层 CSP 是第二道保险:万一将来有人打开了
+		// unsafe,脚本也会被这里挡住。
+		h.Set("Content-Security-Policy",
+			"default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; "+
+				"script-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'")
 		next.ServeHTTP(w, r)
 	})
 }
diff --git a/internal/server/settingsrename_test.go b/internal/server/settingsrename_test.go
new file mode 100644
index 0000000..b12e529
--- /dev/null
+++ b/internal/server/settingsrename_test.go
@@ -0,0 +1,87 @@
+package server
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"regexp"
+	"strings"
+	"testing"
+)
+
+var csrfRe = regexp.MustCompile(`name="csrf" value="([^"]+)"`)
+
+func login(t *testing.T, srv *Server, user, pass string) *httptest.ResponseRecorder {
+	t.Helper()
+	r := httptest.NewRequest(http.MethodPost, "/login",
+		strings.NewReader(url.Values{"username": {user}, "password": {pass}}.Encode()))
+	r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	w := httptest.NewRecorder()
+	srv.Handler().ServeHTTP(w, r)
+	if w.Code != http.StatusFound && w.Code != http.StatusOK {
+		t.Fatalf("登录 %s 返回 %d", user, w.Code)
+	}
+	return w
+}
+
+func csrfOf(t *testing.T, srv *Server, path, cookie string) string {
+	t.Helper()
+	r := httptest.NewRequest(http.MethodGet, path, nil)
+	r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: cookie})
+	w := httptest.NewRecorder()
+	srv.Handler().ServeHTTP(w, r)
+	m := csrfRe.FindStringSubmatch(w.Body.String())
+	if m == nil {
+		t.Fatalf("页面 %s 里找不到 csrf", path)
+	}
+	return m[1]
+}
+
+func sessionCookie(t *testing.T, w *httptest.ResponseRecorder) string {
+	t.Helper()
+	for _, c := range w.Result().Cookies() {
+		if c.Name == SessionCookieName {
+			return c.Value
+		}
+	}
+	t.Fatal("登录响应里没有会话 Cookie")
+	return ""
+}
+
+// TestRepoSettingsRenameIsAtomic 回归:改名成功后,同一请求里的后续校验
+// 曾因继续使用已失效的旧目录而返回 400,把成功的改名报成失败。
+func TestRepoSettingsRenameIsAtomic(t *testing.T) {
+	srv, st, _, _ := newTestServer(t)
+	dir := makeRepo(t, srv, st, "before", nil)
+	commitFiles(t, dir, "init", map[string]string{"a.txt": "x"})
+
+	sess := sessionCookie(t, login(t, srv, "alice", testPass))
+	csrf := csrfOf(t, srv, "/before/settings", sess)
+
+	form := url.Values{
+		"csrf":           {csrf},
+		"new_name":       {"after"},
+		"description":    {"改过的描述"},
+		"default_branch": {"main"},
+		"ai_enabled":     {"on"},
+	}
+	r := httptest.NewRequest(http.MethodPost, "/before/settings", strings.NewReader(form.Encode()))
+	r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: sess})
+	w := httptest.NewRecorder()
+	srv.Handler().ServeHTTP(w, r)
+
+	if w.Code >= 400 {
+		t.Errorf("改名 + 保存设置返回 %d,应为 3xx(改名已生效,不该报错)", w.Code)
+	}
+	got, err := st.RepoByName("after")
+	if err != nil {
+		t.Fatalf("改名未生效: %v", err)
+	}
+	if got.Description != "改过的描述" {
+		t.Errorf("同一次提交里的描述没有保存: %q", got.Description)
+	}
+	if got.DefaultBranch != "main" {
+		t.Errorf("默认分支未保存: %q", got.DefaultBranch)
+	}
+}
diff --git a/internal/server/testhelper_test.go b/internal/server/testhelper_test.go
new file mode 100644
index 0000000..c9d62e2
--- /dev/null
+++ b/internal/server/testhelper_test.go
@@ -0,0 +1,110 @@
+package server
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"gitcat/internal/gitx"
+	"gitcat/internal/store"
+)
+
+// testHash 是测试账号的明文密码;哈希由服务自己的 hashPassword 生成,
+// 必须用真 bcrypt 串——basicAuthUser 会走 CompareHashAndAuthentication。
+const testPass = "secret-pw"
+
+func testHash(t *testing.T) string {
+	t.Helper()
+	h, err := hashPassword(testPass)
+	if err != nil {
+		t.Fatalf("生成密码哈希失败: %v", err)
+	}
+	return h
+}
+
+// newTestServer 起一个已完成安装的测试服务。alice 是管理员兼仓库 owner,
+// bob 是无关的普通用户——权限测试只需要这个区分。
+func newTestServer(t *testing.T) (*Server, *store.Store, *store.User, *store.User) {
+	t.Helper()
+	dir := t.TempDir()
+	st, err := store.Open(filepath.Join(dir, "gitcat.db"))
+	if err != nil {
+		t.Fatalf("打开数据库失败: %v", err)
+	}
+	t.Cleanup(func() { st.Close() })
+	for _, kv := range [][2]string{{"installed", "1"}, {"site_name", "测试站"}} {
+		if err := st.SetSetting(kv[0], kv[1]); err != nil {
+			t.Fatalf("写入设置失败: %v", err)
+		}
+	}
+	alice, err := st.CreateUser("alice", "Alice", "", testHash(t), true)
+	if err != nil {
+		t.Fatalf("建 alice 失败: %v", err)
+	}
+	bob, err := st.CreateUser("bob", "Bob", "", testHash(t), false)
+	if err != nil {
+		t.Fatalf("建 bob 失败: %v", err)
+	}
+	srv, err := New(Config{Addr: ":0", DataDir: dir, Version: "1.0.0-test"}, st)
+	if err != nil {
+		t.Fatalf("构造服务失败: %v", err)
+	}
+	return srv, st, alice, bob
+}
+
+// makeRepo 造一个仓库记录 + 磁盘裸仓库,返回目录路径。
+// owner 为 nil 时挂到 alice 名下——repos.owner_id 有外键,传 0 会失败。
+func makeRepo(t *testing.T, srv *Server, st *store.Store, name string, owner *store.User) string {
+	t.Helper()
+	if owner == nil {
+		owner, _ = st.UserByUsername("alice")
+	}
+	repo, err := st.CreateRepo(name, "", owner.ID)
+	if err != nil {
+		t.Fatalf("建仓库记录失败: %v", err)
+	}
+	dir := srv.repoPath(repo.Name)
+	if err := gitx.InitBare(dir, "main"); err != nil {
+		t.Fatalf("初始化裸仓库失败: %v", err)
+	}
+	return dir
+}
+
+// gitOut 在裸仓库里跑一条 git 命令并返回输出。
+func gitOut(t *testing.T, dir, stdin string, args ...string) string {
+	t.Helper()
+	cmd := exec.Command("git", args...)
+	cmd.Dir = dir
+	cmd.Env = testGitEnv()
+	if stdin != "" {
+		cmd.Stdin = strings.NewReader(stdin)
+	}
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		t.Fatalf("git %s 失败: %v\n%s", strings.Join(args, " "), err, out)
+	}
+	return strings.TrimSpace(string(out))
+}
+
+// commitFiles 把一组文件写进裸仓库并提交到 main。
+func commitFiles(t *testing.T, dir, message string, files map[string]string) {
+	t.Helper()
+	var lines []string
+	for name, content := range files {
+		sha := gitOut(t, dir, content, "hash-object", "-w", "--stdin")
+		lines = append(lines, fmt.Sprintf("100644 blob %s\t%s", sha, name))
+	}
+	tree := gitOut(t, dir, strings.Join(lines, "\n")+"\n", "mktree")
+	commit := gitOut(t, dir, "", "commit-tree", tree, "-m", message)
+	gitOut(t, dir, "", "update-ref", "refs/heads/main", commit)
+}
+
+// testGitEnv 补上 git 作者身份,测试机未必配了 user.name/email。
+func testGitEnv() []string {
+	return append(os.Environ(),
+		"GIT_AUTHOR_NAME=gitcat", "GIT_AUTHOR_EMAIL=test@gitcat.local",
+		"GIT_COMMITTER_NAME=gitcat", "GIT_COMMITTER_EMAIL=test@gitcat.local")
+}
diff --git a/internal/server/upgrade.go b/internal/server/upgrade.go
new file mode 100644
index 0000000..2c739fa
--- /dev/null
+++ b/internal/server/upgrade.go
@@ -0,0 +1,240 @@
+package server
+
+import (
+	"fmt"
+	"net/http"
+	"os"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"time"
+
+	"gitcat/internal/store"
+	"gitcat/internal/upgrade"
+)
+
+// 升级相关的设置键。
+const (
+	settingUpgradeURL       = "upgrade_url"
+	settingUpgradeAutoApply = "upgrade_auto_apply"
+)
+
+// upgradeInfo 返回当前二进制的版本信息。
+func (s *Server) upgradeInfo() upgrade.Info {
+	return upgrade.Info{
+		Version:   s.cfg.Version,
+		Commit:    s.cfg.Commit,
+		BuildDate: s.cfg.BuildDate,
+		ExePath:   s.cfg.ExePath,
+		GOOS:      runtime.GOOS,
+		GOARCH:    runtime.GOARCH,
+	}
+}
+
+func (s *Server) handleAdminUpgradePage(w http.ResponseWriter, r *http.Request) {
+	if s.requireAdmin(w, r) == nil {
+		return
+	}
+	info := s.upgradeInfo()
+	p := s.page(r, "版本与升级")
+	p.Active = "admin"
+	p.Data["Tab"] = "upgrade"
+	p.Data["Version"] = info.Version
+	p.Data["Commit"] = info.ShortSHA()
+	p.Data["HasCommit"] = info.Commit != ""
+	p.Data["BuildDate"] = info.BuildDate
+	p.Data["Platform"] = info.GOOS + "/" + info.GOARCH
+	p.Data["IsDev"] = info.IsDev()
+	p.Data["ExePath"] = info.ExePath
+	p.Data["SchemaVersion"] = s.st.SchemaVersion()
+	p.Data["SchemaSupported"] = store.SchemaVersion
+	p.Data["UpgradeURL"] = s.st.GetSetting(settingUpgradeURL)
+	p.Data["AutoApply"] = s.st.GetSetting(settingUpgradeAutoApply) == "1"
+	p.Data["RestartSupported"] = s.cfg.Restart != nil
+	p.Data["ExeReady"] = s.upgradeExeExists()
+	p.Data["LastCheck"] = s.st.GetSetting("upgrade_checked_at")
+	p.Data["LastCheckText"] = humanTime(s.st.GetSetting("upgrade_checked_at"))
+	p.Data["BackupDir"] = filepath.Join(s.cfg.DataDir, "backup-bin")
+
+	if pend := upgrade.ReadPending(s.cfg.DataDir); pend != nil {
+		p.Data["Pending"] = pend
+		p.Data["PendingFrom"] = pend.FromVersion
+		p.Data["PendingTo"] = pend.ToVersion
+		p.Data["PendingBackup"] = pend.BackupPath
+		p.Data["PendingAt"] = pend.AppliedAt
+	}
+	s.render(w, r, "admin_upgrade", p)
+}
+
+// handleAdminUpgradeAction 统一处理升级页的三种动作。
+//
+// 用 POST + CSRF 而不是可被预取的 GET:升级会替换服务端的可执行文件,
+// 必须确认请求来自已登录的管理员。
+func (s *Server) handleAdminUpgradeAction(w http.ResponseWriter, r *http.Request) {
+	if s.requireAdmin(w, r) == nil {
+		return
+	}
+	if !s.checkCSRF(r) {
+		s.renderError(w, r, http.StatusForbidden, "表单已过期,请重试")
+		return
+	}
+	action := r.FormValue("action")
+	target := "/admin/upgrade"
+
+	switch action {
+	case "save":
+		url := strings.TrimSpace(r.FormValue("upgrade_url"))
+		if url != "" && !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
+			url = "https://" + url
+		}
+		if url != "" && !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
+			setFlash(w, "升级源必须是 http/https 地址")
+			http.Redirect(w, r, target, http.StatusFound)
+			return
+		}
+		_ = s.st.SetSetting(settingUpgradeURL, url)
+		_ = s.st.SetSetting(settingUpgradeAutoApply, boolSetting(r.FormValue("auto_apply") == "on"))
+		setFlash(w, "升级设置已保存")
+		http.Redirect(w, r, target, http.StatusFound)
+
+	case "check":
+		res, err := s.upgradeInfo().Check(r.Context(), s.st.GetSetting(settingUpgradeURL))
+		if err != nil {
+			setFlash(w, "检查更新失败:"+err.Error())
+			http.Redirect(w, r, target, http.StatusFound)
+			return
+		}
+		_ = s.st.SetSetting("upgrade_checked_at", res.CheckedAt.Format(time.RFC3339))
+		_ = s.st.SetSetting("upgrade_latest", res.Latest)
+		msg := res.Message
+		if res.Upgradable && res.Release != nil {
+			msg = fmt.Sprintf("发现新版本 %s,当前 %s", res.Release.Version, res.Current)
+			if res.Release.Notes != "" {
+				msg += ":" + res.Release.Notes
+			}
+		}
+		setFlash(w, msg)
+		http.Redirect(w, r, target, http.StatusFound)
+
+	case "apply":
+		s.applyUpgrade(w, r, target)
+
+	case "restart":
+		s.triggerRestart(w, r, target)
+
+	default:
+		http.Redirect(w, r, target, http.StatusFound)
+	}
+}
+
+// applyUpgrade 执行下载 → 校验 → 替换。
+func (s *Server) applyUpgrade(w http.ResponseWriter, r *http.Request, target string) {
+	if !s.upgrading.CompareAndSwap(false, true) {
+		setFlash(w, "已有升级任务在进行中,请稍候")
+		http.Redirect(w, r, target, http.StatusFound)
+		return
+	}
+	defer s.upgrading.Store(false)
+
+	// 危险操作二次确认:要求管理员输入当前版本号。
+	if want := strings.TrimSpace(r.FormValue("confirm")); want != s.cfg.Version {
+		setFlash(w, "请输入当前版本号 "+s.cfg.Version+" 以确认升级")
+		http.Redirect(w, r, target, http.StatusFound)
+		return
+	}
+	manifestURL := s.st.GetSetting(settingUpgradeURL)
+	if manifestURL == "" {
+		setFlash(w, "请先配置升级源")
+		http.Redirect(w, r, target, http.StatusFound)
+		return
+	}
+	info := s.upgradeInfo()
+	res, err := info.Check(r.Context(), manifestURL)
+	if err != nil {
+		setFlash(w, "检查更新失败:"+err.Error())
+		http.Redirect(w, r, target, http.StatusFound)
+		return
+	}
+	if !res.Upgradable || res.Release == nil {
+		msg := res.Message
+		if msg == "" {
+			msg = "没有可安装的新版本"
+		}
+		setFlash(w, msg)
+		http.Redirect(w, r, target, http.StatusFound)
+		return
+	}
+
+	backupDir := filepath.Join(s.cfg.DataDir, "backup-bin")
+	inst, err := upgrade.Install(s.cfg.DataDir, info.ExePath, backupDir, res.Release, s.cfg.Version)
+	if err != nil {
+		setFlash(w, "升级失败:"+err.Error())
+		http.Redirect(w, r, target, http.StatusFound)
+		return
+	}
+	_ = s.st.SetSetting("upgrade_history", time.Now().Format(time.RFC3339)+" → "+inst.ToVersion)
+
+	if inst.Note != "" {
+		setFlash(w, inst.Note)
+		http.Redirect(w, r, target, http.StatusFound)
+		return
+	}
+	if s.cfg.Restart == nil {
+		setFlash(w, fmt.Sprintf("已升级到 %s,请重启服务后生效(旧程序备份在 %s)",
+			inst.ToVersion, inst.BackupPath))
+		http.Redirect(w, r, target, http.StatusFound)
+		return
+	}
+	setFlash(w, fmt.Sprintf("已升级到 %s,正在重启…", inst.ToVersion))
+	http.Redirect(w, r, target, http.StatusFound)
+	// 响应已经写出,goroutine 里不能再碰 w —— 只负责稍后触发重启。
+	scheduleRestart(s.cfg.Restart)
+}
+
+// scheduleRestart 延迟触发重启,留出时间把响应刷给浏览器。
+func scheduleRestart(restart func()) {
+	go func() {
+		time.Sleep(time.Second)
+		restart()
+	}()
+}
+
+// triggerRestart 触发优雅重启。
+func (s *Server) triggerRestart(w http.ResponseWriter, r *http.Request, target string) {
+	if s.cfg.Restart == nil {
+		setFlash(w, "当前部署方式无法自动重启,请手工重启服务")
+		http.Redirect(w, r, target, http.StatusFound)
+		return
+	}
+	if !s.upgrading.CompareAndSwap(false, true) {
+		http.Redirect(w, r, target, http.StatusFound)
+		return
+	}
+	setFlash(w, "服务正在重启,请稍候几秒后刷新页面")
+	http.Redirect(w, r, target, http.StatusFound)
+	if f, ok := w.(http.Flusher); ok {
+		f.Flush()
+	}
+	scheduleRestart(s.cfg.Restart)
+}
+
+// upgradeExeExists 用于页面提示:可执行文件是否可定位。
+// humanTime 把 RFC3339 字符串渲染成人话;解析失败原样返回。
+func humanTime(s string) string {
+	if s == "" {
+		return "从未"
+	}
+	t, err := time.Parse(time.RFC3339, s)
+	if err != nil {
+		return s
+	}
+	return t.Local().Format("2006-01-02 15:04")
+}
+
+func (s *Server) upgradeExeExists() bool {
+	if s.cfg.ExePath == "" {
+		return false
+	}
+	_, err := os.Stat(s.cfg.ExePath)
+	return err == nil
+}
diff --git a/internal/store/repos.go b/internal/store/repos.go
index b8a6a9a..6afe0c7 100644
--- a/internal/store/repos.go
+++ b/internal/store/repos.go
@@ -129,6 +129,11 @@ func (s *Store) ListRepos(q string) ([]*RepoCard, error) {
 }
 
 // UpdateRepo 保存仓库的可编辑字段。
+//
+// 注意:这里刻意不写 name。仓库名同时决定了磁盘上的目录
+// (<data>/repos/<name>.git),如果这里允许改名而调用方忘了同步重命名
+// 目录,数据库与磁盘就会脱钩,仓库直接变成"数据缺失"。
+// 改名请走 RenameRepo —— 它把"改名字"和"改目录"作为同一个操作。
 func (s *Store) UpdateRepo(r *Repo) error {
 	b2i := func(b bool) int {
 		if b {
@@ -136,11 +141,28 @@ func (s *Store) UpdateRepo(r *Repo) error {
 		}
 		return 0
 	}
-	_, err := s.db.Exec(`UPDATE repos SET name = ?, description = ?, default_branch = ?, is_archived = ?, ai_enabled = ?, updated_at = ? WHERE id = ?`,
-		r.Name, r.Description, r.DefaultBranch, b2i(r.IsArchived), b2i(r.AIEnabled), nowString(), r.ID)
+	_, err := s.db.Exec(`UPDATE repos SET description = ?, default_branch = ?, is_archived = ?, ai_enabled = ?, updated_at = ? WHERE id = ?`,
+		r.Description, r.DefaultBranch, b2i(r.IsArchived), b2i(r.AIEnabled), nowString(), r.ID)
 	return err
 }
 
+// RenameRepo 修改仓库名。
+//
+// 调用方负责保证磁盘目录已经(或即将)同步到新名字;本方法只负责数据层。
+func (s *Store) RenameRepo(id int64, newName string) error {
+	res, err := s.db.Exec(`UPDATE repos SET name = ?, updated_at = ? WHERE id = ?`, newName, nowString(), id)
+	if err != nil {
+		if isUniqueErr(err) {
+			return fmt.Errorf("项目 %q 已存在", newName)
+		}
+		return err
+	}
+	if n, _ := res.RowsAffected(); n == 0 {
+		return ErrNotFound
+	}
+	return nil
+}
+
 // SetRepoOwner 单独更新仓库的所属者。
 func (s *Store) SetRepoOwner(id int64, ownerID *int64) error {
 	if ownerID == nil {
diff --git a/internal/store/store.go b/internal/store/store.go
index 59ee749..27a047b 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -2,9 +2,14 @@
 package store
 
 import (
+	"crypto/rand"
 	"database/sql"
+	"encoding/hex"
 	"errors"
 	"fmt"
+	"os"
+	"path/filepath"
+	"strconv"
 	"strings"
 	"time"
 
@@ -18,7 +23,8 @@ const timeFormat = time.RFC3339Nano
 
 // Store 是数据库访问入口。
 type Store struct {
-	db *sql.DB
+	db     *sql.DB
+	dbPath string // 用于迁移前备份;为空表示不备份
 }
 
 // Open 打开(必要时创建)SQLite 数据库并完成建表。
@@ -34,7 +40,7 @@ func Open(path string) (*Store, error) {
 		_ = db.Close()
 		return nil, err
 	}
-	s := &Store{db: db}
+	s := &Store{db: db, dbPath: path}
 	if err := s.migrate(); err != nil {
 		_ = db.Close()
 		return nil, err
@@ -45,6 +51,10 @@ func Open(path string) (*Store, error) {
 // Close 关闭数据库。
 func (s *Store) Close() error { return s.db.Close() }
 
+// schema 是基线结构,按顺序执行。
+//
+// 注意语句顺序:SQLite 不允许为尚不存在的表建索引,所以索引必须排在其
+// 对应的 CREATE TABLE 之后。
 var schema = []string{
 	`CREATE TABLE IF NOT EXISTS settings (
 		key   TEXT PRIMARY KEY,
@@ -98,18 +108,192 @@ var schema = []string{
 		ai_status      TEXT NOT NULL DEFAULT 'pending',
 		created_at     TEXT NOT NULL
 	)`,
+
+	// 索引。原先只有三处,而 ListUsers / ListRepos 恰恰在 owner_id、
+	// user_id 上做相关子查询,列表页每次都要全表扫。
+	`CREATE INDEX IF NOT EXISTS idx_repos_owner ON repos(owner_id)`,
+	`CREATE INDEX IF NOT EXISTS idx_repos_updated ON repos(updated_at DESC)`,
 	`CREATE INDEX IF NOT EXISTS idx_pushes_repo ON pushes(repo_id, id DESC)`,
 	`CREATE INDEX IF NOT EXISTS idx_pushes_created ON pushes(created_at DESC)`,
+	`CREATE INDEX IF NOT EXISTS idx_pushes_user ON pushes(user_id, id DESC)`,
 	`CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at)`,
+	`CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)`,
+}
+
+// ---------------------------------------------------------------- 迁移
+//
+// schema 里每条语句都是 IF NOT EXISTS,本身幂等;schema_meta 记录的版本号
+// 用来做两件事:
+//  1. 降级保护:数据库版本高于二进制版本时拒绝启动,避免旧代码把新结构写坏。
+//  2. 增量升级:新增的、无法用 IF NOT EXISTS 表达的变更(加列、改类型、
+//     填数据)放进 migrations 切片,按版本号依次执行。
+//
+// 版本 1 = 最原始的 5 张表(无 schema_meta),用于识别旧版数据库。
+
+// SchemaVersion 是当前二进制支持的最新数据库版本。
+const SchemaVersion = 1
+
+// execer 让迁移既能跑在事务里(*sql.Tx)也能直接跑在连接上(*sql.DB)。
+type execer interface {
+	Exec(query string, args ...any) (sql.Result, error)
+	Query(query string, args ...any) (*sql.Rows, error)
+	QueryRow(query string, args ...any) *sql.Row
+}
+
+// migration 是一条按序执行的结构变更。
+type migration struct {
+	version int
+	name    string
+	run     func(execer) error
+}
+
+// migrations 追加式列表:新增变更时在末尾追加,version 必须递增。
+var migrations = []migration{
+	// 示例:
+	// {version: 2, name: "repos 加 visibility", run: func(db *sql.DB) error {
+	//     _, err := db.Exec(`ALTER TABLE repos ADD COLUMN visibility TEXT NOT NULL DEFAULT 'public'`)
+	//     return err
+	// }},
+}
+
+const schemaMetaTable = `CREATE TABLE IF NOT EXISTS schema_meta (
+	key   TEXT PRIMARY KEY,
+	value TEXT NOT NULL DEFAULT ''
+)`
+
+// dbVersion 读取数据库当前版本;没有 schema_meta 的旧库返回 0。
+func dbVersion(db *sql.DB) (int, error) {
+	var name string
+	err := db.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name='schema_meta'`).Scan(&name)
+	if errors.Is(err, sql.ErrNoRows) {
+		return 0, nil
+	}
+	if err != nil {
+		return 0, err
+	}
+	var v string
+	if err := db.QueryRow(`SELECT value FROM schema_meta WHERE key='schema_version'`).Scan(&v); err != nil {
+		if errors.Is(err, sql.ErrNoRows) {
+			return 0, nil
+		}
+		return 0, err
+	}
+	n, err := strconv.Atoi(strings.TrimSpace(v))
+	if err != nil {
+		return 0, fmt.Errorf("无法解析 schema 版本号 %q", v)
+	}
+	return n, nil
+}
+
+func setDBVersion(db *sql.DB, v int) error {
+	_, err := db.Exec(`INSERT INTO schema_meta(key, value) VALUES('schema_version', ?)
+		ON CONFLICT(key) DO UPDATE SET value = excluded.value`, strconv.Itoa(v))
+	return err
 }
 
 func (s *Store) migrate() error {
+	// 是否需要在迁移前备份,必须在建表之前判断,而且不能只看文件大小:
+	// 驱动一连上就会创建出带页头的文件,"全新安装"看起来也像是有内容的库。
+	// 精确判据是"users 表是否已存在"。
+	var existing int
+	err := s.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('users','repos','pushes')`).Scan(&existing)
+	if err != nil {
+		return fmt.Errorf("migrate: %w", err)
+	}
+	hasLegacyData := existing > 0
+
 	for _, stmt := range schema {
 		if _, err := s.db.Exec(stmt); err != nil {
 			return fmt.Errorf("migrate: %w", err)
 		}
 	}
-	return nil
+	if _, err := s.db.Exec(schemaMetaTable); err != nil {
+		return fmt.Errorf("migrate: %w", err)
+	}
+	current, err := dbVersion(s.db)
+	if err != nil {
+		return fmt.Errorf("migrate: %w", err)
+	}
+	// 降级保护:用旧二进制打开被新版本迁移过的库,字段可能对不上,
+	// 与其运行到一半报诡异错误,不如明确拒绝启动。
+	if current > SchemaVersion {
+		return fmt.Errorf("数据库结构版本为 v%d,高于当前程序支持的 v%d;请先升级 gitcat 再启动", current, SchemaVersion)
+	}
+	if current == SchemaVersion {
+		return nil
+	}
+
+	if hasLegacyData {
+		s.backup()
+	}
+
+	for _, m := range migrations {
+		if m.version <= current {
+			continue
+		}
+		if err := s.applyMigration(m); err != nil {
+			return fmt.Errorf("迁移 v%d(%s)失败: %w", m.version, m.name, err)
+		}
+	}
+	return setDBVersion(s.db, SchemaVersion)
+}
+
+// applyMigration 在一个事务里执行单条迁移。
+func (s *Store) applyMigration(m migration) error {
+	tx, err := s.db.Begin()
+	if err != nil {
+		return err
+	}
+	defer func() { _ = tx.Rollback() }()
+	if err := m.run(tx); err != nil {
+		return err
+	}
+	if _, err := tx.Exec(`INSERT INTO schema_meta(key, value) VALUES('schema_version', ?)
+		ON CONFLICT(key) DO UPDATE SET value = excluded.value`, strconv.Itoa(m.version)); err != nil {
+		return err
+	}
+	return tx.Commit()
+}
+
+// backupDir 是数据库备份目录名。
+const backupDir = "backups"
+
+// backup 用 SQLite 的 VACUUM INTO 生成一个一致性快照。
+//
+// 迁移前留一份副本,是为了在迁移逻辑有问题时还能把数据捞回来。
+// 首次安装(空库)不需要备份。
+func (s *Store) backup() {
+	if s.dbPath == "" {
+		return
+	}
+	st, err := os.Stat(s.dbPath)
+	if err != nil || st.Size() < 4096 {
+		return // 还没建过表的空库
+	}
+	dir := filepath.Join(filepath.Dir(s.dbPath), backupDir)
+	if err := os.MkdirAll(dir, 0o755); err != nil {
+		fmt.Fprintf(os.Stderr, "创建备份目录失败: %v\n", err)
+		return
+	}
+	// 文件名带随机后缀:同一秒内连续两次启动(例如服务自动重启)也不会撞名。
+	dst := filepath.Join(dir, fmt.Sprintf("gitcat-%s-%s.db",
+		time.Now().Format("20060102-150405"), randomToken(4)))
+	// VACUUM INTO 的目标路径不能是绑定参数(SQLite 语法限制),只能自己拼;
+	// 时间与随机串都由本进程生成,不含外部输入。
+	if _, err := s.db.Exec(`VACUUM INTO ?`, dst); err != nil {
+		fmt.Fprintf(os.Stderr, "迁移前备份数据库失败(不影响升级继续): %v\n", err)
+		return
+	}
+	fmt.Fprintf(os.Stderr, "迁移前已备份数据库: %s\n", dst)
+}
+
+// SchemaVersion 返回数据库当前的版本号。
+func (s *Store) SchemaVersion() int {
+	v, err := dbVersion(s.db)
+	if err != nil {
+		return 0
+	}
+	return v
 }
 
 func nowString() string { return time.Now().UTC().Format(timeFormat) }
@@ -131,6 +315,15 @@ func isUniqueErr(err error) bool {
 	return err != nil && strings.Contains(strings.ToLower(err.Error()), "unique constraint")
 }
 
+// randomToken 生成用于文件名等非安全场景的短随机串。
+func randomToken(n int) string {
+	b := make([]byte, n)
+	if _, err := rand.Read(b); err != nil {
+		return strconv.FormatInt(time.Now().UnixNano(), 36)
+	}
+	return hex.EncodeToString(b)
+}
+
 // ---------------------------------------------------------------- settings
 
 // GetSetting 读取配置项,不存在时返回空串。
diff --git a/internal/store/store_test.go b/internal/store/store_test.go
new file mode 100644
index 0000000..f596928
--- /dev/null
+++ b/internal/store/store_test.go
@@ -0,0 +1,175 @@
+package store
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+// TestOpenFreshInstall 验证全新安装能建表并写入版本号。
+func TestOpenFreshInstall(t *testing.T) {
+	dir := t.TempDir()
+	st, err := Open(filepath.Join(dir, "gitcat.db"))
+	if err != nil {
+		t.Fatalf("打开新库失败: %v", err)
+	}
+	defer st.Close()
+	if got := st.SchemaVersion(); got != SchemaVersion {
+		t.Fatalf("全新库版本 = %d, 期望 %d", got, SchemaVersion)
+	}
+	if st.Installed() {
+		t.Fatal("全新库不应处于已安装状态")
+	}
+}
+
+// TestLegacyDatabaseUpgrade 模拟旧版本数据库:有表、无 schema_meta、缺索引。
+// 这是"兼容旧版本"的核心保证——旧库必须能被新二进制直接打开。
+func TestLegacyDatabaseUpgrade(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "gitcat.db")
+	legacy, err := Open(path)
+	if err != nil {
+		t.Fatalf("准备旧库失败: %v", err)
+	}
+	// 造出旧版本的样子:写一条数据,然后抹掉 schema_meta 与新增的索引。
+	hash, err := legacy.CreateUser("alice", "Alice", "a@example.com", "x", true)
+	if err != nil {
+		t.Fatalf("建用户失败: %v", err)
+	}
+	if _, err := legacy.CreateRepo("demo", "旧项目", hash.ID); err != nil {
+		t.Fatalf("建仓库失败: %v", err)
+	}
+	for _, stmt := range []string{
+		`DROP TABLE schema_meta`,
+		`DROP INDEX IF EXISTS idx_repos_owner`,
+		`DROP INDEX IF EXISTS idx_repos_updated`,
+		`DROP INDEX IF EXISTS idx_pushes_user`,
+		`DROP INDEX IF EXISTS idx_sessions_user`,
+	} {
+		if _, err := legacy.db.Exec(stmt); err != nil {
+			t.Fatalf("构造旧库失败 (%s): %v", stmt, err)
+		}
+	}
+	legacy.Close()
+
+	// 关键:数据文件还在,用新二进制重新打开。
+	st, err := Open(path)
+	if err != nil {
+		t.Fatalf("新版本无法打开旧数据库: %v", err)
+	}
+	defer st.Close()
+
+	if got := st.SchemaVersion(); got != SchemaVersion {
+		t.Errorf("升级后版本 = %d, 期望 %d", got, SchemaVersion)
+	}
+	// 旧数据必须完好
+	if u, err := st.UserByUsername("ALICE"); err != nil || u.DisplayName != "Alice" {
+		t.Errorf("旧用户丢失或查询失败: %v", err)
+	}
+	if r, err := st.RepoByName("demo"); err != nil || r.Description != "旧项目" {
+		t.Errorf("旧仓库丢失或查询失败: %v", err)
+	}
+	// 索引必须被补上
+	for _, idx := range []string{"idx_repos_owner", "idx_repos_updated", "idx_pushes_user", "idx_sessions_user"} {
+		var name string
+		err := st.db.QueryRow(`SELECT name FROM sqlite_master WHERE type='index' AND name=?`, idx).Scan(&name)
+		if err != nil {
+			t.Errorf("索引 %s 未被创建: %v", idx, err)
+		}
+	}
+	// 迁移前的自动备份
+	backups, _ := filepath.Glob(filepath.Join(dir, backupDir, "*.db"))
+	if len(backups) == 0 {
+		t.Error("迁移前应生成数据库备份")
+	}
+}
+
+// TestDowngradeRefused 验证降级保护:数据库版本高于程序时拒绝启动。
+func TestDowngradeRefused(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "gitcat.db")
+	st, err := Open(path)
+	if err != nil {
+		t.Fatalf("准备库失败: %v", err)
+	}
+	if _, err := st.db.Exec(`UPDATE schema_meta SET value=? WHERE key='schema_version'`,
+		itoa(SchemaVersion+5)); err != nil {
+		t.Fatalf("抬高版本号失败: %v", err)
+	}
+	st.Close()
+
+	if _, err := Open(path); err == nil {
+		t.Fatal("数据库版本高于程序时应拒绝启动")
+	}
+}
+
+// TestUpdateRepoKeepsName 验证 UpdateRepo 不再改名字——
+// 名字变更必须走 RenameRepo,否则磁盘目录会与数据库脱钩。
+func TestUpdateRepoKeepsName(t *testing.T) {
+	st, err := Open(filepath.Join(t.TempDir(), "gitcat.db"))
+	if err != nil {
+		t.Fatalf("打开库失败: %v", err)
+	}
+	defer st.Close()
+
+	owner, err := st.CreateUser("owner", "Owner", "", "x", false)
+	if err != nil {
+		t.Fatalf("建用户失败: %v", err)
+	}
+
+	r, err := st.CreateRepo("keepme", "描述", owner.ID)
+	if err != nil {
+		t.Fatalf("建仓库失败: %v", err)
+	}
+	r.Name = "hijacked"
+	r.Description = "新描述"
+	if err := st.UpdateRepo(r); err != nil {
+		t.Fatalf("UpdateRepo 失败: %v", err)
+	}
+	got, err := st.RepoByID(r.ID)
+	if err != nil {
+		t.Fatalf("读取失败: %v", err)
+	}
+	if got.Name != "keepme" {
+		t.Fatalf("UpdateRepo 意外改了仓库名: %q", got.Name)
+	}
+	if got.Description != "新描述" {
+		t.Fatalf("描述未更新: %q", got.Description)
+	}
+
+	// 显式改名才生效
+	if err := st.RenameRepo(r.ID, "renamed"); err != nil {
+		t.Fatalf("RenameRepo 失败: %v", err)
+	}
+	if got, _ := st.RepoByName("renamed"); got == nil {
+		t.Fatal("RenameRepo 未生效")
+	}
+	// 大小写不敏感的唯一性仍然被遵守
+	if _, err := st.CreateRepo("taken", "", owner.ID); err != nil {
+		t.Fatalf("建第二个仓库失败: %v", err)
+	}
+	if err := st.RenameRepo(r.ID, "TAKEN"); err == nil {
+		t.Fatal("重名(大小写不同)应被拒绝")
+	}
+}
+
+func itoa(n int) string {
+	if n == 0 {
+		return "0"
+	}
+	neg := n < 0
+	if neg {
+		n = -n
+	}
+	var b []byte
+	for n > 0 {
+		b = append([]byte{byte('0' + n%10)}, b...)
+		n /= 10
+	}
+	if neg {
+		return "-" + string(b)
+	}
+	return string(b)
+}
+
+var _ = os.Stat // 保持 os 导入(Windows 下 TempDir 清理相关)
diff --git a/internal/upgrade/hidden_other.go b/internal/upgrade/hidden_other.go
new file mode 100644
index 0000000..9475bf9
--- /dev/null
+++ b/internal/upgrade/hidden_other.go
@@ -0,0 +1,8 @@
+//go:build !windows
+
+package upgrade
+
+import "syscall"
+
+// hideWindow 在非 Windows 平台上无需特殊处理。
+func hideWindow() *syscall.SysProcAttr { return nil }
diff --git a/internal/upgrade/hidden_windows.go b/internal/upgrade/hidden_windows.go
new file mode 100644
index 0000000..9b0e067
--- /dev/null
+++ b/internal/upgrade/hidden_windows.go
@@ -0,0 +1,10 @@
+//go:build windows
+
+package upgrade
+
+import "syscall"
+
+// hideWindow 让替换助手以隐藏窗口启动,避免服务端部署时弹出一个黑框。
+func hideWindow() *syscall.SysProcAttr {
+	return &syscall.SysProcAttr{HideWindow: true}
+}
diff --git a/internal/upgrade/upgrade.go b/internal/upgrade/upgrade.go
new file mode 100644
index 0000000..1e49139
--- /dev/null
+++ b/internal/upgrade/upgrade.go
@@ -0,0 +1,520 @@
+// Package upgrade 实现 gitcat 的一键升级:检查更新、下载、校验并就地替换
+// 当前运行的二进制文件。
+//
+// 设计上有一条硬约束:**绝不在新二进制被验证可用之前动现有的那个**。
+// 升级失败时,服务必须还能照常启动。因此流程是
+//
+//	拉清单 → 选版本 → 下载到临时文件 → 校验 sha256 → 试运行 → 备份旧文件
+//	→ 替换 → 写 pending 标记 → 重启
+//
+// 中间任何一步失败都直接返回错误,旧二进制保持原样。
+package upgrade
+
+import (
+	"context"
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"runtime"
+	"strconv"
+	"strings"
+	"time"
+)
+
+const (
+	// maxManifestSize 限制升级清单大小,避免被超大响应撑爆内存。
+	maxManifestSize = 1 << 20
+	// maxBinarySize 限制下载的二进制大小(200MB),同理。
+	maxBinarySize = 200 << 20
+	// fetchTimeout 是拉取清单 / 下载的默认上限。
+	fetchTimeout = 10 * time.Minute
+)
+
+// Info 描述当前正在运行的二进制。
+type Info struct {
+	Version   string // 语义化版本,如 1.2.0
+	Commit    string // 构建时注入的提交号
+	BuildDate string // 构建时间
+	ExePath   string // 可执行文件绝对路径
+	GOOS      string
+	GOARCH    string
+}
+
+// Release 是发布清单中的一个版本。
+//
+// 清单由管理员在后台配置的一个 URL 提供,格式示例:
+//
+//	{
+//	  "latest": "1.2.0",
+//	  "min_supported": "1.0.0",
+//	  "releases": [
+//	    {"version": "1.2.0", "url": "https://…/gitcat-linux-amd64",
+//	     "sha256": "…", "size": 8388608, "os": "linux", "arch": "amd64",
+//	     "notes": "修复推送越权"}
+//	  ]
+//	}
+type Release struct {
+	Version string `json:"version"`
+	URL     string `json:"url"`
+	SHA256  string `json:"sha256"`
+	Size    int64  `json:"size"`
+	OS      string `json:"os"`
+	Arch    string `json:"arch"`
+	Notes   string `json:"notes"`
+}
+
+// Manifest 是升级源提供的版本清单。
+type Manifest struct {
+	Latest string `json:"latest"`
+	// MinSupported 是允许直接跳到的最低版本:低于它的版本需要先中转升级,
+	// 避免一次跨度过大的结构变更。
+	MinSupported string    `json:"min_supported"`
+	Releases     []Release `json:"releases"`
+}
+
+// CheckResult 是一次检查更新的结论。
+type CheckResult struct {
+	Current     string
+	Latest      string
+	Upgradable  bool
+	Release     *Release
+	Manifest    *Manifest
+	Message     string
+	CheckedAt   time.Time
+	AssetExists bool
+}
+
+// InstallResult 描述一次替换的结果。
+type InstallResult struct {
+	FromVersion string
+	ToVersion   string
+	ExePath     string
+	BackupPath  string
+	PendingFile string
+	Restarted   bool
+	Note        string
+}
+
+// platformKey 返回当前平台在清单里的标识。
+func platformKey() string { return runtime.GOOS + "/" + runtime.GOARCH }
+
+// IsDev 判断当前是否是开发版(未注入正式版本号)。
+func (i Info) IsDev() bool {
+	v := strings.ToLower(strings.TrimSpace(i.Version))
+	return v == "" || v == "dev" || v == "devel" || strings.Contains(v, "-dirty") || strings.Contains(v, "-dev")
+}
+
+// ShortSHA 返回短提交号。
+func (i Info) ShortSHA() string {
+	if len(i.Commit) > 7 {
+		return i.Commit[:7]
+	}
+	return i.Commit
+}
+
+// Compare 比较两个语义化版本:a<b 返回 -1,a>b 返回 1,相等返回 0。
+//
+// 允许带 "v" 前缀,比较时忽略;带预发布后缀(1.0.0-rc1)视为低于 1.0.0。
+func Compare(a, b string) int {
+	na, pa := splitVersion(a)
+	nb, pb := splitVersion(b)
+	for i := 0; i < 3; i++ {
+		if na[i] != nb[i] {
+			if na[i] < nb[i] {
+				return -1
+			}
+			return 1
+		}
+	}
+	switch {
+	case pa == pb:
+		return 0
+	case pa == "":
+		return 1 // 正式版 > 预发布版
+	case pb == "":
+		return -1
+	default:
+		return strings.Compare(pa, pb)
+	}
+}
+
+func splitVersion(v string) ([3]int, string) {
+	var out [3]int
+	v = strings.TrimSpace(v)
+	v = strings.TrimPrefix(strings.TrimPrefix(v, "v"), "V")
+	pre := ""
+	if i := strings.IndexAny(v, "-+"); i >= 0 {
+		pre = v[i+1:]
+		v = v[:i]
+	}
+	for i, part := range strings.SplitN(v, ".", 3) {
+		if i > 2 {
+			break
+		}
+		n, err := strconv.Atoi(strings.TrimSpace(part))
+		if err != nil {
+			continue
+		}
+		out[i] = n
+	}
+	return out, pre
+}
+
+// ValidVersion 判断字符串是否像一个可比较的版本号。
+func ValidVersion(v string) bool {
+	v = strings.TrimSpace(v)
+	if v == "" {
+		return false
+	}
+	nums, _ := splitVersion(v)
+	return nums[0] > 0 || nums[1] > 0 || nums[2] > 0
+}
+
+// httpClient 是升级流程专用的客户端:短超时、限制重定向。
+func httpClient(timeout time.Duration) *http.Client {
+	return &http.Client{
+		Timeout: timeout,
+		CheckRedirect: func(req *http.Request, via []*http.Request) error {
+			if len(via) >= 5 {
+				return errors.New("重定向次数过多")
+			}
+			return nil
+		},
+	}
+}
+
+// FetchManifest 拉取并解析版本清单。
+func FetchManifest(ctx context.Context, manifestURL string) (*Manifest, error) {
+	manifestURL = strings.TrimSpace(manifestURL)
+	if manifestURL == "" {
+		return nil, errors.New("未配置升级源")
+	}
+	u, err := url.Parse(manifestURL)
+	if err != nil || !strings.HasPrefix(u.Scheme, "http") {
+		return nil, errors.New("升级源必须是 http/https 地址")
+	}
+	ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
+	defer cancel()
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil)
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Accept", "application/json")
+	resp, err := httpClient(60 * time.Second).Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf("升级源返回 HTTP %d", resp.StatusCode)
+	}
+	raw, err := io.ReadAll(io.LimitReader(resp.Body, maxManifestSize))
+	if err != nil {
+		return nil, err
+	}
+	var m Manifest
+	if err := json.Unmarshal(raw, &m); err != nil {
+		return nil, fmt.Errorf("升级源返回的内容不是合法 JSON: %w", err)
+	}
+	if len(m.Releases) == 0 {
+		return nil, errors.New("升级源里没有任何版本")
+	}
+	return &m, nil
+}
+
+// pickRelease 从清单中挑出适用于当前平台的目标版本。
+func pickRelease(m *Manifest, version string) *Release {
+	want := strings.TrimSpace(version)
+	if want == "" {
+		want = strings.TrimSpace(m.Latest)
+	}
+	for i := range m.Releases {
+		r := m.Releases[i]
+		if !strings.EqualFold(strings.TrimSpace(r.Version), want) {
+			continue
+		}
+		if r.OS != "" && r.Arch != "" && r.OS+"/"+r.Arch != platformKey() {
+			return nil // 清单里明确标注了其它平台
+		}
+		rr := r
+		return &rr
+	}
+	return nil
+}
+
+// Check 对比当前版本与升级源,给出升级结论。
+func (i Info) Check(ctx context.Context, manifestURL string) (*CheckResult, error) {
+	m, err := FetchManifest(ctx, manifestURL)
+	if err != nil {
+		return nil, err
+	}
+	res := &CheckResult{
+		Current:   i.Version,
+		Latest:    m.Latest,
+		Manifest:  m,
+		CheckedAt: time.Now(),
+	}
+	rel := pickRelease(m, m.Latest)
+	if rel == nil {
+		res.Message = fmt.Sprintf("升级源未提供适用于 %s 的版本 %s", platformKey(), m.Latest)
+		return res, nil
+	}
+	res.AssetExists = true
+	res.Release = rel
+
+	switch {
+	case i.IsDev():
+		res.Message = "当前是开发构建,无法自动判断新版本,请对照发布页确认。"
+	case rel.Version == "" || !ValidVersion(rel.Version):
+		res.Message = "升级源中的版本号不合法。"
+	case Compare(i.Version, rel.Version) >= 0:
+		res.Message = "已经是最新版本。"
+	case m.MinSupported != "" && ValidVersion(m.MinSupported) && Compare(i.Version, m.MinSupported) < 0:
+		res.Message = fmt.Sprintf("当前版本过旧,需先升级到 %s 或更高版本再继续。", m.MinSupported)
+	default:
+		res.Upgradable = true
+		res.Message = fmt.Sprintf("发现新版本 %s", rel.Version)
+	}
+	return res, nil
+}
+
+// Download 下载指定版本到 dest,返回校验后的文件大小。
+func Download(ctx context.Context, rel *Release, dest string) (int64, error) {
+	if rel == nil || strings.TrimSpace(rel.URL) == "" {
+		return 0, errors.New("该版本没有提供下载地址")
+	}
+	u, err := url.Parse(rel.URL)
+	if err != nil || !strings.HasPrefix(u.Scheme, "http") {
+		return 0, errors.New("下载地址必须是 http/https 地址")
+	}
+	ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
+	defer cancel()
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, rel.URL, nil)
+	if err != nil {
+		return 0, err
+	}
+	req.Header.Set("User-Agent", "gitcat-upgrade")
+	resp, err := httpClient(fetchTimeout).Do(req)
+	if err != nil {
+		return 0, err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		return 0, fmt.Errorf("下载失败:HTTP %d", resp.StatusCode)
+	}
+	limit := int64(maxBinarySize)
+	if rel.Size > 0 && rel.Size < limit {
+		limit = rel.Size
+	}
+
+	f, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
+	if err != nil {
+		return 0, err
+	}
+	defer f.Close()
+	h := sha256.New()
+	n, err := io.Copy(io.MultiWriter(f, h), io.LimitReader(resp.Body, limit+1))
+	if err != nil {
+		return 0, err
+	}
+	if n > limit {
+		return 0, fmt.Errorf("下载内容超过预期体积(%d 字节),已中止", limit)
+	}
+	sum := hex.EncodeToString(h.Sum(nil))
+	if want := strings.ToLower(strings.TrimSpace(rel.SHA256)); want != "" && want != sum {
+		return 0, fmt.Errorf("校验失败:期望 sha256 %s,实际 %s", want, sum)
+	}
+	if rel.Size > 0 && n != rel.Size {
+		return 0, fmt.Errorf("校验失败:期望 %d 字节,实际 %d 字节", rel.Size, n)
+	}
+	if err := f.Sync(); err != nil {
+		return 0, err
+	}
+	return n, nil
+}
+
+// Verify 对新下载的可执行文件做一次试运行,确认它真的能跑起来。
+//
+// 这一步是"不损坏现网"的关键:架构不匹配、动态库缺失、文件被截断时,
+// 试运行会立刻失败,而此时旧二进制还没被碰过。
+func Verify(binPath string) error {
+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+	defer cancel()
+	cmd := exec.CommandContext(ctx, binPath, "-version")
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		return fmt.Errorf("新版本无法执行(可能是平台不匹配或文件损坏):%v", err)
+	}
+	text := string(out)
+	if !strings.Contains(text, "gitcat") {
+		return fmt.Errorf("新版本试运行输出异常:%q", strings.TrimSpace(text))
+	}
+	return nil
+}
+
+// pendingFile 是标记"已替换、待重启"的文件名。
+const pendingFile = "upgrade-pending.json"
+
+// Pending 描述一次已落地但尚未生效的升级。
+type Pending struct {
+	FromVersion string    `json:"from_version"`
+	ToVersion   string    `json:"to_version"`
+	BackupPath  string    `json:"backup_path"`
+	AppliedAt   time.Time `json:"applied_at"`
+}
+
+// PendingPath 返回 pending 标记文件路径。
+func PendingPath(dataDir string) string { return filepath.Join(dataDir, pendingFile) }
+
+// ReadPending 读取待重启标记;不存在时返回 nil。
+func ReadPending(dataDir string) *Pending {
+	raw, err := os.ReadFile(PendingPath(dataDir))
+	if err != nil {
+		return nil
+	}
+	var p Pending
+	if err := json.Unmarshal(raw, &p); err != nil {
+		return nil
+	}
+	return &p
+}
+
+// ClearPending 清除待重启标记。
+func ClearPending(dataDir string) error {
+	err := os.Remove(PendingPath(dataDir))
+	if errors.Is(err, os.ErrNotExist) {
+		return nil
+	}
+	return err
+}
+
+// Install 执行替换:备份 → 替换 → 写标记。
+//
+// exePath 为空(开发模式 `go run` 等)时只做备份并返回提示,不做替换。
+func Install(dataDir, exePath, backupDir string, rel *Release, currentVersion string) (*InstallResult, error) {
+	if rel == nil {
+		return nil, errors.New("没有可安装的版本")
+	}
+	res := &InstallResult{
+		FromVersion: currentVersion,
+		ToVersion:   rel.Version,
+		ExePath:     exePath,
+	}
+	tmpDir := filepath.Join(dataDir, "tmp")
+	if err := os.MkdirAll(tmpDir, 0o755); err != nil {
+		return nil, err
+	}
+	tmp := filepath.Join(tmpDir, fmt.Sprintf("gitcat-%s-%s", rel.Version, platformKeyRepl()))
+	if _, err := Download(context.Background(), rel, tmp); err != nil {
+		os.Remove(tmp)
+		return nil, err
+	}
+	if err := Verify(tmp); err != nil {
+		os.Remove(tmp)
+		return nil, err
+	}
+
+	if exePath == "" {
+		res.Note = "当前不是以独立二进制方式运行(可能是 go run),已下载新版本到 " + tmp + ",请手工替换后重启。"
+		return res, nil
+	}
+	if err := os.MkdirAll(backupDir, 0o755); err != nil {
+		return nil, err
+	}
+	backup := filepath.Join(backupDir, "gitcat-"+time.Now().Format("20060102-150405")+".bak")
+	if err := copyFile(exePath, backup); err != nil {
+		os.Remove(tmp)
+		return nil, fmt.Errorf("备份当前程序失败: %w", err)
+	}
+	res.BackupPath = backup
+
+	if err := replaceExecutable(tmp, exePath); err != nil {
+		os.Remove(tmp)
+		return nil, err
+	}
+	res.PendingFile = PendingPath(dataDir)
+	if err := os.WriteFile(res.PendingFile, mustJSON(Pending{
+		FromVersion: currentVersion,
+		ToVersion:   rel.Version,
+		BackupPath:  backup,
+		AppliedAt:   time.Now(),
+	}), 0o644); err != nil {
+		// 替换已经完成,标记写失败只影响"自动重启"这一步,不算失败。
+		res.Note = "程序已更新,但写入待重启标记失败,请手工重启服务。"
+		return res, nil
+	}
+	return res, nil
+}
+
+func mustJSON(v any) []byte {
+	b, err := json.MarshalIndent(v, "", "  ")
+	if err != nil {
+		return []byte("{}")
+	}
+	return b
+}
+
+func platformKeyRepl() string {
+	s := platformKey()
+	return strings.ReplaceAll(s, "/", "-")
+}
+
+// replaceExecutable 用 newPath 覆盖 exePath。
+//
+// Unix 上 rename 可以直接替换正在运行的可执行文件(内核持有的是 inode
+// 引用,替换只改目录项),这是最干净的做法。Windows 拒绝替换正在运行的
+// exe,只能起一个后台辅助进程:等本进程退出后再完成搬运。
+func replaceExecutable(newPath, exePath string) error {
+	err := os.Rename(newPath, exePath)
+	if err == nil {
+		return nil
+	}
+	if runtime.GOOS != "windows" {
+		return fmt.Errorf("替换程序文件失败: %w", err)
+	}
+	// Windows 兜底:让一个后台 PowerShell 等本进程退出后再完成替换。
+	// 之所以必须等退出,是因为映像文件在进程存活期间被系统独占锁定。
+	ps := fmt.Sprintf(
+		`$ErrorActionPreference='Stop'; `+
+			`Wait-Process -Id %d -ErrorAction SilentlyContinue; `+
+			`Start-Sleep -Milliseconds 800; `+
+			`Copy-Item -LiteralPath '%s' -Destination '%s' -Force; `+
+			`Remove-Item -LiteralPath '%s' -Force -ErrorAction SilentlyContinue`,
+		os.Getpid(), psQuote(newPath), psQuote(exePath), psQuote(newPath))
+	c := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", ps)
+	c.SysProcAttr = hideWindow()
+	if err := c.Start(); err != nil {
+		return fmt.Errorf("替换程序文件失败,且无法启动替换助手: %w", err)
+	}
+	// 不 Wait:助手必须在本次请求返回之后、本进程退出之前完成启动。
+	go func() { _ = c.Wait() }()
+	return nil
+}
+
+// psQuote 转义单引号,供 PowerShell 单引号字符串使用。
+func psQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" }
+
+func copyFile(src, dst string) error {
+	in, err := os.Open(src)
+	if err != nil {
+		return err
+	}
+	defer in.Close()
+	out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
+	if err != nil {
+		return err
+	}
+	defer out.Close()
+	if _, err := io.Copy(out, in); err != nil {
+		return err
+	}
+	return out.Sync()
+}
diff --git a/internal/upgrade/upgrade_test.go b/internal/upgrade/upgrade_test.go
new file mode 100644
index 0000000..be9a386
--- /dev/null
+++ b/internal/upgrade/upgrade_test.go
@@ -0,0 +1,66 @@
+package upgrade
+
+import "testing"
+
+func TestCompare(t *testing.T) {
+	cases := []struct {
+		a, b string
+		want int
+	}{
+		{"1.0.0", "1.0.0", 0},
+		{"v1.0.0", "1.0.0", 0},
+		{"1.0.0", "1.0.1", -1},
+		{"1.2.0", "1.10.0", -1},
+		{"1.0.9", "1.0.10", -1},
+		{"2.0.0", "1.9.9", 1},
+		{"1.0.0-rc1", "1.0.0", -1},
+		{"1.0", "1.0.0", 0},
+	}
+	for _, c := range cases {
+		if got := Compare(c.a, c.b); got != c.want {
+			t.Errorf("Compare(%q,%q)=%d 期望 %d", c.a, c.b, got, c.want)
+		}
+	}
+}
+
+func TestIsDev(t *testing.T) {
+	for _, v := range []string{"", "dev", "1.0.0-dev", "1.0.0-dirty"} {
+		if !(Info{Version: v}).IsDev() {
+			t.Errorf("%q 应识别为开发版", v)
+		}
+	}
+	for _, v := range []string{"1.0.0", "v1.2.3", "2.0.0-rc1"} {
+		if (Info{Version: v}).IsDev() {
+			t.Errorf("%q 不应识别为开发版", v)
+		}
+	}
+}
+
+func TestValidVersion(t *testing.T) {
+	for _, v := range []string{"1", "1.2", "1.2.3", "v1.2.3", "0.0.1"} {
+		if !ValidVersion(v) {
+			t.Errorf("%q 应为合法版本", v)
+		}
+	}
+	for _, v := range []string{"", "abc", "v"} {
+		if ValidVersion(v) {
+			t.Errorf("%q 应为非法版本", v)
+		}
+	}
+}
+
+func TestPickRelease(t *testing.T) {
+	m := &Manifest{Latest: "1.1.0", Releases: []Release{
+		{Version: "1.1.0", OS: "plan9", Arch: "mips", URL: "https://x/other"},
+		{Version: "1.0.0", URL: "https://x/all"},
+	}}
+	if r := pickRelease(m, "1.0.0"); r == nil || r.URL != "https://x/all" {
+		t.Errorf("未标注平台的版本应可选,实际 %+v", r)
+	}
+	if r := pickRelease(m, "1.1.0"); r != nil {
+		t.Error("平台不匹配不应被选中")
+	}
+	if r := pickRelease(m, "9.9.9"); r != nil {
+		t.Error("清单外的版本不应被选中")
+	}
+}
diff --git a/main.go b/main.go
index 074fa5b..68acfd6 100644
--- a/main.go
+++ b/main.go
@@ -22,10 +22,20 @@ import (
 
 	"gitcat/internal/server"
 	"gitcat/internal/store"
+	"gitcat/internal/upgrade"
 )
 
-// version 可在构建时通过 -ldflags "-X main.version=..." 注入。
-var version = "1.0.0"
+// 这些变量可在构建时注入:
+//
+//	go build -ldflags "-X main.version=1.2.0 -X main.commit=$(git rev-parse --short HEAD) -X main.buildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+//
+// version 保持语义化格式,后台"版本与升级"页依赖它做版本比较;
+// 未注入时属于 dev 构建,自动升级会拒绝执行。
+var (
+	version   = "1.0.0-dev"
+	commit    = ""
+	buildDate = ""
+)
 
 func main() {
 	log.SetFlags(log.LstdFlags)
@@ -37,7 +47,14 @@ func main() {
 	flag.Parse()
 
 	if *showVersion {
+		// 一键升级会用 `-version` 的输出做试运行校验,因此首行格式必须稳定。
 		fmt.Println("gitcat", version)
+		if commit != "" {
+			fmt.Println("commit:", commit)
+		}
+		if buildDate != "" {
+			fmt.Println("built:", buildDate)
+		}
 		return
 	}
 
@@ -64,7 +81,25 @@ func run(addr, dataDir string) error {
 	}
 	defer st.Close()
 
-	srv, err := server.New(server.Config{Addr: addr, DataDir: abs, Version: version}, st)
+	exePath, _ := os.Executable()
+	srv, err := server.New(server.Config{
+		Addr:      addr,
+		DataDir:   abs,
+		Version:   version,
+		Commit:    commit,
+		BuildDate: buildDate,
+		ExePath:   exePath,
+		// 一键升级替换完二进制后调用它:走与外部信号完全相同的优雅关闭
+		// 路径,这样 systemd 的 Restart=always 就能干净地拉起新版本。
+		Restart: func() {
+			proc, err := os.FindProcess(os.Getpid())
+			if err != nil {
+				log.Printf("触发重启失败: %v", err)
+				return
+			}
+			_ = proc.Signal(syscall.SIGTERM)
+		},
+	}, st)
 	if err != nil {
 		return err
 	}
@@ -74,11 +109,28 @@ func run(addr, dataDir string) error {
 		Handler:           srv.Handler(),
 		ReadHeaderTimeout: 20 * time.Second,
 		IdleTimeout:       90 * time.Second,
+		// 刻意不设 WriteTimeout:clone / push 需要长时间持续写响应,写超时
+		// 会把大仓库的传输硬切断。git 子进程的超时改由
+		// server.gitTransferTimeout 单独控制。
 	}
 
 	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
 	defer stop()
 
+	// 升级待生效提醒:替换二进制之后到下次重启之间,这个标记会一直存在。
+	// 一旦确认新版本真的跑起来了(当前版本已不低于目标版本),就清掉标记,
+	// 免得每次启动都以为还有升级没生效。
+	if pend := upgrade.ReadPending(abs); pend != nil {
+		if upgrade.Compare(version, pend.ToVersion) >= 0 {
+			log.Printf("升级已生效:%s → %s(旧版本备份在 %s)",
+				pend.FromVersion, version, pend.BackupPath)
+			_ = upgrade.ClearPending(abs)
+		} else {
+			log.Printf("提示: 已替换为 %s,但当前仍在运行 %s,请检查部署流程;备份在 %s",
+				pend.ToVersion, version, pend.BackupPath)
+		}
+	}
+
 	// 定时清理过期会话。
 	go func() {
 		t := time.NewTicker(time.Hour)
diff --git a/scripts/build-release.ps1 b/scripts/build-release.ps1
new file mode 100644
index 0000000..38af243
--- /dev/null
+++ b/scripts/build-release.ps1
@@ -0,0 +1,188 @@
+<#
+.SYNOPSIS
+    构建 gitcat 发布二进制:多平台交叉编译 + 校验和 + 升级清单。
+
+.DESCRIPTION
+    产物落在 dist\,包含:
+      - gitcat-<版本>-<os>-<arch>[.exe]   各平台单文件(静态,依赖 CGO_ENABLED=0)
+      - checksums.txt                      sha256sum 兼容格式
+      - manifest.json                      internal/upgrade 可直接消费的升级源清单
+
+    升级功能会用 manifest.json 里的 sha256 校验下载到的二进制,因此这个脚本
+    同时也是「发布」动作:把 dist\ 整个目录传到任意 HTTP 静态托管,
+    再把 manifest.json 的 URL 填进后台「系统设置 → 升级源」即可。
+
+.PARAMETER Version
+    语义化版本号,如 1.1.0。不传则自动取 git describe。
+
+.PARAMETER OutDir
+    产物目录,默认 dist\。
+
+.EXAMPLE
+    .\scripts\build-release.ps1 -Version 1.1.0
+    .\scripts\build-release.ps1 -Version 1.1.0 -Targets linux/amd64,windows/amd64
+#>
+[CmdletBinding()]
+param(
+    [string]$Version = '',
+    [string]$OutDir = 'dist',
+    [string[]]$Targets = @('linux/amd64', 'linux/arm64', 'windows/amd64', 'darwin/amd64', 'darwin/arm64')
+)
+
+$ErrorActionPreference = 'Stop'
+Set-StrictMode -Version Latest
+
+# Windows PowerShell 5.1 的 `Out-File -Encoding utf8` 会写 BOM,那会让
+# manifest.json 被 Go 的 json.Unmarshal 拒绝(BOM 不是合法 JSON),
+# checksums.txt 也会多出 BOM 而无法通过 sha256sum -c。一律走 .NET 无 BOM 写入。
+$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
+
+function Write-TextNoBom {
+    param([string]$Path, [string]$Text)
+    [System.IO.File]::WriteAllText($Path, $Text, $Utf8NoBom)
+}
+
+# git 在 Windows 上会把 LF/CRLF 警告写进 stderr;配合 $ErrorActionPreference='Stop'
+# 会被 PowerShell 5.1 当成终止性错误。探测版本信息时临时放开错误策略。
+function Invoke-GitQuiet {
+    param([string[]]$Arguments)
+    $prev = $ErrorActionPreference
+    $ErrorActionPreference = 'Continue'
+    try {
+        $out = & git @Arguments 2>$null
+        return ($out | Out-String).Trim()
+    }
+    finally { $ErrorActionPreference = $prev }
+}
+
+$root = Split-Path -Parent $PSScriptRoot
+Push-Location $root
+try {
+    # ---------------------------------------------------------------- 版本信息
+    $commit = Invoke-GitQuiet @('rev-parse', '--short', 'HEAD')
+    if (-not $commit) { $commit = 'unknown' }
+
+    # 未提交的改动如实写进 commit 字段,但不动 version——version 保持干净
+    # 才能让升级页面正常工作(IsDev 只看 Version)。
+    $dirty = Invoke-GitQuiet @('status', '--porcelain')
+    if ($dirty) { $commit = "$commit-dirty" }
+
+    if (-not $Version) {
+        $tag = Invoke-GitQuiet @('describe', '--tags', '--abbrev=0')
+        if ($tag) { $Version = $tag.TrimStart('v') } else { $Version = '0.0.0-dev' }
+    }
+    $buildDate = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
+
+    Write-Host "版本   : $Version" -ForegroundColor Cyan
+    Write-Host "提交   : $commit"
+    Write-Host "构建时间: $buildDate"
+    Write-Host "目标   : $($Targets -join ', ')"
+    Write-Host ''
+
+    # ---------------------------------------------------------------- 前置检查
+    & go build ./...
+    if ($LASTEXITCODE -ne 0) { throw '源码编译失败,已中止打包' }
+    & go vet ./...
+    if ($LASTEXITCODE -ne 0) { Write-Warning 'go vet 有告警,继续打包' }
+
+    if (Test-Path $OutDir) { Remove-Item -Recurse -Force $OutDir }
+    New-Item -ItemType Directory -Path $OutDir | Out-Null
+
+    # ---------------------------------------------------------------- 交叉编译
+    # -trimpath 去掉构建机的绝对路径;-s -w 去符号表,体积小一个量级。
+    $ldflags = "-s -w -X main.version=$Version -X main.commit=$commit -X main.buildDate=$buildDate"
+    $assets = @()
+
+    foreach ($t in $Targets) {
+        $os, $arch = $t -split '/'
+        $ext = if ($os -eq 'windows') { '.exe' } else { '' }
+        $name = "gitcat-$Version-$os-$arch$ext"
+
+        Write-Host "编译 $os/$arch ..." -NoNewline
+        $env:CGO_ENABLED = '0'
+        $env:GOOS = $os
+        $env:GOARCH = $arch
+        & go build -trimpath -ldflags $ldflags -o (Join-Path $OutDir $name) .
+        if ($LASTEXITCODE -ne 0) { throw "编译 $os/$arch 失败" }
+
+        $file = Get-Item (Join-Path $OutDir $name)
+        $hash = (Get-FileHash $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
+        $assets += [pscustomobject]@{
+            name   = $name
+            os     = $os
+            arch   = $arch
+            size   = $file.Length
+            sha256 = $hash
+        }
+        Write-Host (" {0,7:N1} MB" -f ($file.Length / 1MB)) -ForegroundColor DarkGray
+    }
+
+    # 还原环境变量,别把 GOOS/GOARCH 泄漏给后续命令
+    Remove-Item Env:GOOS, Env:GOARCH -ErrorAction SilentlyContinue
+
+    # ---------------------------------------------------------------- 校验和
+    # 格式与 sha256sum -c 兼容:每行 "<hash>  <文件名>"
+    $lines = ($assets | ForEach-Object { "$($_.sha256)  $($_.name)" }) -join "`n"
+    Write-TextNoBom (Join-Path $OutDir 'checksums.txt') "$lines`n"
+
+    # ---------------------------------------------------------------- 升级清单
+    # 结构必须与 internal/upgrade.Manifest 一致,否则后台「检查更新」读不懂。
+    $releases = @($assets | ForEach-Object {
+        [ordered]@{
+            version = $Version
+            url     = $_.name
+            sha256  = $_.sha256
+            size    = $_.size
+            os      = $_.os
+            arch    = $_.arch
+            notes   = ''
+        }
+    })
+    $manifest = [ordered]@{
+        latest        = $Version
+        min_supported = '1.0.0'   # 低于此版本需先中转,避免一次跨度过大的结构变更
+        releases      = $releases
+    }
+    Write-TextNoBom (Join-Path $OutDir 'manifest.json') `
+        (($manifest | ConvertTo-Json -Depth 5) + "`n")
+
+    # ---------------------------------------------------------------- 自检
+    # 1) 清单必须是无 BOM 的合法 JSON,否则 Go 端 json.Unmarshal 会直接报错
+    $mpath = Join-Path $OutDir 'manifest.json'
+    $mb = [System.IO.File]::ReadAllBytes($mpath)
+    if ($mb[0] -eq 0xEF -and $mb[1] -eq 0xBB) { throw 'manifest.json 带 BOM,必须去掉' }
+    $null = [System.Text.Encoding]::UTF8.GetString($mb) | ConvertFrom-Json
+
+    # 2) 拿本机那份产物实跑一次,确认版本注入、静态链接、可执行性都正常
+    $native = $assets | Where-Object { $_.os -eq 'windows' -and $_.arch -eq 'amd64' } |
+        Select-Object -First 1
+    if ($native) {
+        Write-Host ''
+        Write-Host '本机产物自检:'
+        $out = & (Join-Path $OutDir $native.name) -version 2>&1
+        if ($LASTEXITCODE -ne 0) { throw "产物无法执行:$out" }
+        $out | ForEach-Object { Write-Host "  $_" }
+        if ("$out" -notmatch [regex]::Escape($Version)) {
+            throw "版本号未正确注入(期望 $Version)"
+        }
+    }
+
+    # ---------------------------------------------------------------- 汇总
+    $total = ($assets | Measure-Object -Property size -Sum).Sum
+    Write-Host ''
+    Write-Host "产物目录: $OutDir" -ForegroundColor Green
+    Write-Host "文件数  : $($assets.Count) 个二进制 + checksums.txt + manifest.json"
+    Write-Host ("合计    : {0:N1} MB" -f ($total / 1MB))
+    Write-Host ''
+    Write-Host '校验和:'
+    Get-Content (Join-Path $OutDir 'checksums.txt') | ForEach-Object {
+        Write-Host "  $_" -ForegroundColor DarkGray
+    }
+    Write-Host ''
+    Write-Host '用法: 把整个 dist 目录传到静态托管,再把 manifest.json 的地址' -ForegroundColor Yellow
+    Write-Host '      填入 后台 → 系统设置 → 升级源,即可一键升级。' -ForegroundColor Yellow
+}
+finally {
+    Remove-Item Env:GOOS, Env:GOARCH -ErrorAction SilentlyContinue
+    Pop-Location
+}
diff --git a/webroot/static/style.css b/webroot/static/style.css
index 6bbe6ab..d3406a7 100644
--- a/webroot/static/style.css
+++ b/webroot/static/style.css
@@ -497,6 +497,13 @@ legend { font-weight: 700; font-size: .95rem; margin-bottom: 12px; display: flex
 .danger-card { border-color: #f3d9d9; background: #fffafa; }
 .danger-card input { margin-bottom: 12px; }
 
+/* 表单按钮组:与主表单同页但独立的第二个 form */
+.inline-form { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--line); }
+
+/* 说明性要点列表 */
+.tips { margin: 0; padding-left: 1.1em; display: flex; flex-direction: column; gap: 6px; font-size: .86rem; color: var(--ink-2); }
+.tips code { font-size: .78rem; word-break: break-all; }
+
 /* ---------------------------------------------------------- 提交列表 */
 
 .list-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
diff --git a/webroot/templates/admin_settings.html b/webroot/templates/admin_settings.html
index 500ad88..6f06725 100644
--- a/webroot/templates/admin_settings.html
+++ b/webroot/templates/admin_settings.html
@@ -24,6 +24,7 @@
         <li><span>数据目录</span><code class="csha">{{.Data.DataDir}}</code></li>
       </ul>
       <p class="hint">部署参数可通过命令行参数调整:<code class="csha">gitcat -addr :12747 -data /var/lib/gitcat</code></p>
+      <a class="btn btn-ghost btn-block" href="/admin/upgrade">版本与一键升级 →</a>
     </div>
     <div class="card tip-card">
       <h3>账号与权限</h3>
diff --git a/webroot/templates/admin_upgrade.html b/webroot/templates/admin_upgrade.html
new file mode 100644
index 0000000..3d13445
--- /dev/null
+++ b/webroot/templates/admin_upgrade.html
@@ -0,0 +1,99 @@
+{{define "content"}}
+{{template "adminnav" (dict "Tab" "upgrade")}}
+<div class="pagehead">
+  <h1>版本与升级</h1>
+  <p class="muted">查看当前版本、检查更新,并在备份旧程序后一键完成替换。</p>
+</div>
+
+<div class="split">
+  <div class="col-main">
+    <div class="card">
+      <h3 class="card-title">当前版本</h3>
+      <ul class="kv">
+        <li><span>版本</span><strong>{{.Data.Version}}</strong>{{if .Data.IsDev}} <span class="tag tag-warn">开发构建</span>{{end}}</li>
+        {{if .Data.HasCommit}}<li><span>提交</span><code class="csha">{{.Data.Commit}}</code></li>{{end}}
+        {{if .Data.BuildDate}}<li><span>构建时间</span><span class="muted small">{{.Data.BuildDate}}</span></li>{{end}}
+        <li><span>平台</span><code class="csha">{{.Data.Platform}}</code></li>
+        <li><span>数据库结构</span><code class="csha">v{{.Data.SchemaVersion}} / 支持到 v{{.Data.SchemaSupported}}</code></li>
+        <li><span>上次检查</span><span class="muted small">{{.Data.LastCheckText}}</span></li>
+      </ul>
+      {{if not .Data.ExeReady}}
+      <p class="tag tag-warn">无法定位当前可执行文件</p>
+      <p class="hint">若以 <code class="csha">go run</code> 方式启动,程序无法自我替换,请改为部署编译好的二进制后再使用一键升级。路径:<code class="csha">{{if .Data.ExePath}}{{.Data.ExePath}}(未找到){{else}}未知{{end}}</code></p>
+      {{end}}
+      {{if not .Data.RestartSupported}}
+      <p class="hint">当前部署方式不支持自动重启,升级后需要手工重启服务。</p>
+      {{end}}
+    </div>
+
+    {{if .Data.Pending}}
+    <div class="card danger-card">
+      <h3 class="card-title">有待生效的升级</h3>
+      <p>程序已从 <strong>{{.Data.PendingFrom}}</strong> 替换为 <strong>{{.Data.PendingTo}}</strong>,但当前进程仍是旧版本。</p>
+      <ul class="kv">
+        <li><span>替换时间</span><span class="muted small">{{datetime .Data.PendingAt}}</span></li>
+        <li><span>旧程序备份</span><code class="csha">{{.Data.PendingBackup}}</code></li>
+      </ul>
+      <form method="post" action="/admin/upgrade">
+        <input type="hidden" name="csrf" value="{{.CSRF}}">
+        <input type="hidden" name="action" value="restart">
+        <button class="btn btn-primary" type="submit">立即重启使其生效</button>
+      </form>
+    </div>
+    {{end}}
+
+    <div class="card">
+      <h3 class="card-title">升级源</h3>
+      <form method="post" action="/admin/upgrade">
+        <input type="hidden" name="csrf" value="{{.CSRF}}">
+        <input type="hidden" name="action" value="save">
+        <label class="field">
+          <span>版本清单地址</span>
+          <input type="url" name="upgrade_url" placeholder="https://example.com/gitcat/releases.json" value="{{.Data.UpgradeURL}}">
+          <em class="field-hint">一个返回 JSON 的地址,需包含版本号、下载地址与 sha256;留空则只能查看版本、不能升级。</em>
+        </label>
+        <label class="check">
+          <input type="checkbox" name="auto_apply" {{if .Data.AutoApply}}checked{{end}}>
+          <span>重启后自动安装已下载的新版本<em>需配合服务管理器(如 systemd)使用</em></span>
+        </label>
+        <button class="btn btn-primary" type="submit">保存升级设置</button>
+      </form>
+      <form method="post" action="/admin/upgrade" class="inline-form">
+        <input type="hidden" name="csrf" value="{{.CSRF}}">
+        <input type="hidden" name="action" value="check">
+        <button class="btn btn-ghost" type="submit">检查更新</button>
+      </form>
+    </div>
+  </div>
+
+  <aside class="col-side">
+    <div class="card about">
+      <h3>升级清单格式</h3>
+      <pre class="code">{
+  "latest": "1.2.0",
+  "min_supported": "1.0.0",
+  "releases": [{
+    "version": "1.2.0",
+    "url": "https://…/gitcat-linux-amd64",
+    "sha256": "ab12…",
+    "size": 8388608,
+    "os": "linux",
+    "arch": "amd64",
+    "notes": "修复推送越权"
+  }]
+}</pre>
+      <p class="hint">平台标识为 <code class="csha">linux/amd64</code>、<code class="csha">darwin/arm64</code> 等;<code class="csha">os</code> 与 <code class="csha">arch</code> 可省略,表示不限平台。</p>
+    </div>
+    <div class="card tip-card">
+      <h3>升级是怎么做的</h3>
+      <ul class="tips">
+        <li>下载到 <code class="csha">data/tmp</code>,先校验 sha256 与体积;</li>
+        <li>再试运行一次 <code class="csha">-version</code>,确认新程序真的能跑;</li>
+        <li>把当前程序备份到 <code class="csha">{{.Data.BackupDir}}</code>;</li>
+        <li>替换成功后写待重启标记,重启时自动确认并清除。</li>
+      </ul>
+      <p class="hint">前四步里任何一步失败都不会动现有程序,服务照常运行。</p>
+    </div>
+  </aside>
+</div>
+{{end}}
diff --git a/webroot/templates/layout.html b/webroot/templates/layout.html
index f4892a4..93b1ac4 100644
--- a/webroot/templates/layout.html
+++ b/webroot/templates/layout.html
@@ -61,6 +61,7 @@
   <a class="{{if eq .Tab "activity"}}on{{end}}" href="/admin/activity">操作动态</a>
   <a class="{{if eq .Tab "ai"}}on{{end}}" href="/admin/ai">AI 接入</a>
   <a class="{{if eq .Tab "settings"}}on{{end}}" href="/admin/settings">站点设置</a>
+  <a class="{{if eq .Tab "upgrade"}}on{{end}}" href="/admin/upgrade">升级</a>
 </nav>
 {{end}}
 
diff --git a/webroot/templates/repo_blob.html b/webroot/templates/repo_blob.html
index 60e7332..59721a0 100644
--- a/webroot/templates/repo_blob.html
+++ b/webroot/templates/repo_blob.html
@@ -14,7 +14,7 @@
   <a class="crumb" href="/{{$repo.Name}}/tree/{{.Data.Ref}}">{{$repo.Name}}</a><span class="sep">/</span>
   {{range $i, $c := .Data.Breadcrumbs}}{{if $i}}<span class="sep">/</span>{{end}}{{if eq (add $i 1) (len $.Data.Breadcrumbs)}}<span class="crumb cur">{{$c.Name}}</span>{{else}}<a class="crumb" href="/{{$repo.Name}}/tree/{{$.Data.Ref}}/{{$c.Path}}">{{$c.Name}}</a>{{end}}{{end}}
   <span class="crumb-actions">
-    <span class="muted">{{.Data.BlobSize}}{{if not .Data.IsBinary}} · {{num .Data.LineCount}} 行{{end}}</span>
+    <span class="muted">{{.Data.BlobSize}}{{if and (not .Data.IsBinary) .Data.LineCount}} · {{num .Data.LineCount}} 行{{end}}</span>
     <a class="btn btn-mini" href="/{{$repo.Name}}/raw/{{.Data.Ref}}/{{.Data.Path}}">原始文件</a>
   </span>
 </div>
@@ -30,7 +30,7 @@
 
 <div class="card fileview">
   {{if .Data.TooLarge}}
-    <div class="empty small"><p class="muted">文件过大,暂不在页面中预览。</p><a class="btn btn-ghost" href="/{{$repo.Name}}/raw/{{.Data.Ref}}/{{.Data.Path}}">下载原始文件</a></div>
+    <div class="empty small"><p class="muted">文件较大(超过 2 MB),已跳过在线预览以避免占用服务器内存。</p><a class="btn btn-ghost" href="/{{$repo.Name}}/raw/{{.Data.Ref}}/{{.Data.Path}}">下载原始文件</a></div>
   {{else if .Data.IsImage}}
     <div class="imgview"><img src="/{{$repo.Name}}/raw/{{.Data.Ref}}/{{.Data.Path}}" alt="{{.Data.FileName}}"></div>
   {{else if .Data.IsBinary}}
diff --git a/webroot/templates/repo_settings.html b/webroot/templates/repo_settings.html
index 2821661..ebac26b 100644
--- a/webroot/templates/repo_settings.html
+++ b/webroot/templates/repo_settings.html
@@ -20,6 +20,11 @@
           <span>项目描述</span>
           <textarea name="description" rows="3" maxlength="300" placeholder="一句话说明这个项目">{{$repo.Description}}</textarea>
         </label>
+        <label class="field">
+          <span>项目名称</span>
+          <input type="text" name="new_name" value="{{$repo.Name}}" maxlength="64" pattern="[A-Za-z0-9][A-Za-z0-9._\-]{0,63}" autocomplete="off">
+          <em class="field-hint">改名会同时重命名磁盘上的仓库目录,克隆地址与本地 remote 都需要同步更新。保持不变即可。</em>
+        </label>
         <label class="field">
           <span>默认分支</span>
           <select name="default_branch">