本文地址:blog.cosine.ren/post/intera...
本文图表、伪代码等由 AI 辅助编写
背景
当你 fork 了一个开源项目作为自己的博客主题,如何优雅地从上游仓库同步更新?手动敲一串 Git 命令既繁琐又容易出错;但直接点 Fork 的 Sync 按钮,又可能覆盖你的自定义配置和内容。
很多人因此在「保持更新」和「保留修改」之间左右为难:要么干脆二开后不再同步,要么每次更新都提心吊胆。
这也是为什么不少项目会像 @fumadocs/cli 一样,提供专门的 CLI 来完成更新等相关操作。
本文将介绍如何简单地构建一个交互式 CLI 工具,把 fork 同步的流程自动化起来。
这个工具的核心目标是:
- 安全:更新前检查工作区状态,必要时可备份
- 透明:预览所有变更,让用户决定是否更新
- 友好:出现冲突时给出明确指引
具体的代码可以看这个 PR:
不过这个 PR 只是最初的版本,后面又缝缝补补了不少东西,整体流程是我研究一个周末后摸索出的,如有不足,那一定是我考虑不周,欢迎指出~
在这个 PR 里,我基于 Ink 构建了一个交互式 TUI 工具,提供了博客内容备份/还原、主题更新、内容生成、备份管理等功能:
bash
pnpm koharu # 交互式主菜单
pnpm koharu backup # 备份博客内容 (--full 完整备份)
pnpm koharu restore # 还原备份 (--latest, --dry-run, --force)
pnpm koharu update # 从上游同步更新 (--check, --skip-backup, --force)
pnpm koharu generate # 生成内容资产 (LQIP, 相似度, AI 摘要)
pnpm koharu clean # 清理旧备份 (--keep N)
pnpm koharu list # 查看所有备份

其中备份功能可以:
- 基础备份:博客文章、配置、头像、.env
- 完整备份:包含所有图片和生成的资产文件
- 自动生成
manifest.json记录主题版本与备份元信息(时间等)
还原功能可以:
- 交互式选择备份文件
- 支持
--dry-run预览模式 - 显示备份类型、版本、时间等元信息
主题更新功能可以:
- 自动配置 upstream remote 指向原始仓库
- 预览待合并的提交列表(显示 hash、message、时间)
- 更新前可选备份,支持冲突检测与处理
- 合并成功后自动安装依赖
- 支持
--check仅检查更新、--force跳过工作区检查
整体架构
infographic
infographic sequence-snake-steps-underline-text
data
title Git Update 命令流程
desc 从 upstream 同步更新的完整工作流
items
- label 检查状态
desc 验证当前分支和工作区状态
icon mdi/source-branch-check
- label 配置远程
desc 确保 upstream remote 已配置
icon mdi/source-repository
- label 获取更新
desc 从 upstream 拉取最新提交
icon mdi/cloud-download
- label 预览变更
desc 显示待合并的提交列表
icon mdi/file-find
- label 确认备份
desc 可选:备份当前内容
icon mdi/backup-restore
- label 执行合并
desc 合并 upstream 分支到本地
icon mdi/merge
- label 处理结果
desc 成功则安装依赖,冲突则提示解决
icon mdi/check-circle
更新相关 Git 命令详解
1. 检查当前分支
bash
git rev-parse --abbrev-ref HEAD
作用:获取当前所在分支的名称。
参数解析:
rev-parse:解析 Git 引用--abbrev-ref:输出简短的引用名称(如main),而不是完整的 SHA
使用场景 :确保用户在正确的分支(如 main)上执行更新,避免在 feature 分支上意外合并上游代码。
typescript
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD")
.toString()
.trim();
if (currentBranch !== "main") {
throw new Error(`仅支持在 main 分支执行更新,当前分支: ${currentBranch}`);
}
2. 检查工作区状态
bash
git status --porcelain
作用:以机器可读的格式输出工作区状态。
参数解析:
--porcelain:输出稳定、易于解析的格式,不受 Git 版本和语言设置影响
输出格式:
plain
M modified-file.ts # 已暂存的修改
M unstaged-file.ts # 未暂存的修改
?? untracked-file.ts # 未跟踪的文件
A new-file.ts # 新添加的文件
D deleted-file.ts # 删除的文件
前两个字符分别表示暂存区和工作区的状态。
typescript
const statusOutput = execSync("git status --porcelain").toString();
const uncommittedFiles = statusOutput.split("\n").filter((line) => line.trim());
const isClean = uncommittedFiles.length === 0;
3. 管理远程仓库
检查 remote 是否存在
bash
git remote get-url upstream
作用:获取指定 remote 的 URL,如果不存在会报错。
添加 upstream remote
bash
# 将 URL 替换为你的上游仓库地址
git remote add upstream https://github.com/original/repo.git
作用 :添加一个名为 upstream 的远程仓库,指向原始项目。
为什么需要 upstream?
当你 fork 一个项目后,你的 origin 指向你自己的 fork,而 upstream 指向原始项目。这样可以:
- 从
upstream拉取原项目的更新 - 向
origin推送你的修改
typescript
// UPSTREAM_URL 需替换为你的上游仓库地址
const UPSTREAM_URL = "https://github.com/original/repo.git";
function ensureUpstreamRemote(): string {
try {
return execSync("git remote get-url upstream").toString().trim();
} catch {
execSync(`git remote add upstream ${UPSTREAM_URL}`);
return UPSTREAM_URL;
}
}
4. 获取远程更新
bash
git fetch upstream
作用 :从 upstream 远程仓库下载所有分支的最新提交,但不会自动合并到本地分支。
与 git pull 的区别:
fetch只下载数据,不修改本地代码pull=fetch+merge,会自动合并
使用 fetch 可以让我们先预览变更,再决定是否合并。
5. 计算提交差异
bash
git rev-list --left-right --count HEAD...upstream/main
作用:计算本地分支与 upstream/main 之间的提交差异。
参数解析:
rev-list:列出提交记录--left-right:区分左侧(本地)和右侧(远程)的提交--count:只输出计数,不列出具体提交HEAD...upstream/main:三个点表示对称差集
输出示例:
plain
2 5
表示本地有 2 个提交不在 upstream 上(ahead),upstream 有 5 个提交不在本地(behind)。
typescript
const revList = execSync(
"git rev-list --left-right --count HEAD...upstream/main"
)
.toString()
.trim();
const [aheadStr, behindStr] = revList.split("\t");
const aheadCount = parseInt(aheadStr, 10);
const behindCount = parseInt(behindStr, 10);
console.log(`本地领先 ${aheadCount} 个提交,落后 ${behindCount} 个提交`);
6. 查看待合并的提交
bash
git log HEAD..upstream/main --pretty=format:"%h|%s|%ar|%an" --no-merges
作用:列出 upstream/main 上有但本地没有的提交。
参数解析:
HEAD..upstream/main:两个点表示 A 到 B 的差集(B 有而 A 没有的)--pretty=format:"...":自定义输出格式%h:短 hash%s:提交信息%ar:相对时间(如 "2 days ago")%an:作者名
--no-merges:排除 merge commit
输出示例:
plain
a1b2c3d|feat: add dark mode|2 days ago|Author Name
e4f5g6h|fix: typo in readme|3 days ago|Author Name
typescript
const commitFormat = "%h|%s|%ar|%an";
const output = execSync(
`git log HEAD..upstream/main --pretty=format:"${commitFormat}" --no-merges`
).toString();
const commits = output
.split("\n")
.filter(Boolean)
.map((line) => {
const [hash, message, date, author] = line.split("|");
return { hash, message, date, author };
});
7. 查看远程文件内容
bash
git show upstream/main:package.json
作用:直接查看远程分支上某个文件的内容,无需切换分支或合并。
使用场景:获取上游仓库的版本号,用于显示"将更新到 x.x.x 版本"。
typescript
const packageJson = execSync("git show upstream/main:package.json").toString();
const { version } = JSON.parse(packageJson);
console.log(`最新版本: ${version}`);
8. 执行合并
bash
git merge upstream/main --no-edit
作用:将 upstream/main 分支合并到当前分支。
参数解析:
--no-edit:使用自动生成的合并提交信息,不打开编辑器
合并策略:Git 会自动选择合适的合并策略:
- Fast-forward:如果本地没有新提交,直接移动指针
- Three-way merge:如果有分叉,创建一个合并提交
注意:本工具采用 merge 同步上游,保留本地历史。如果你的需求是"强制与上游一致"(丢弃本地修改),需要使用 rebase 或 reset 方案,不在本文讨论范围。
9. 检测合并冲突
bash
git diff --name-only --diff-filter=U
作用:列出所有未解决冲突的文件。
参数解析:
--name-only:只输出文件名--diff-filter=U:只显示 Unmerged(未合并/冲突)的文件
另一种方式是解析 git status --porcelain 的输出,查找冲突标记:
typescript
const statusOutput = execSync("git status --porcelain").toString();
const conflictFiles = statusOutput
.split("\n")
.filter((line) => {
const status = line.slice(0, 2);
// U = Unmerged, AA = both added, DD = both deleted
return status.includes("U") || status === "AA" || status === "DD";
})
// 注:为简化展示,这里直接截取路径
// 若需完整兼容重命名/特殊路径,应使用更严格的 porcelain 解析
.map((line) => line.slice(3).trim());
10. 中止合并
bash
git merge --abort
作用:中止当前的合并操作,恢复到合并前的状态。
使用场景:当用户遇到冲突但不想手动解决时,可以选择中止合并。
typescript
function abortMerge(): boolean {
try {
execSync("git merge --abort");
return true;
} catch {
return false;
}
}
状态机设计
如果是简单粗暴的使用 useEffect 的话,会出现很多 useEffect 那自然很不好。
整个更新流程使用简单的 useReducer + Effect Map 模式管理,将状态转换逻辑和副作用处理分离,确保流程清晰可控。
为什么不用 Redux?
在设计 CLI 状态管理时,很自然会想到 Redux,毕竟它是 React 生态中最成熟的状态管理方案,而且还是用着 Ink 来进行开发的。但对于 CLI 工具,useReducer 是更合适的选择,理由如下:
- 状态作用域单一:CLI 工具通常是单组件树结构,不存在跨页面、跨路由的状态共享需求,
- 无需 Middleware 生态:Redux 的强大之处在于中间件生态(redux-thunk、redux-saga、redux-observable),用于处理复杂的异步流程。但我们的场景不需要那么复杂。
- 依赖最小化:CLI 工具应该快速启动、轻量运行 。
useReducer内置于 React,不会引入额外依赖(当然 React 本身也是依赖,不过我的项目里本来就需要它)
总之,对这个场景来说 Redux 有点"过度设计"。
那咋整?
- Reducer:集中管理所有状态转换逻辑,纯函数易于测试
- Effect Map:状态到副作用的映射,统一处理异步操作
- 单一 Effect :一个
useEffect驱动整个流程
下面是完整的状态转换流程图,展示了所有可能的状态转换路径和条件分支:
注意 :Mermaid stateDiagram 中状态名不能包含连字符
-,这里使用 camelCase 命名。
类型定义
typescript
// 12 种状态覆盖完整流程
type UpdateStatus =
| "checking" // 检查 Git 状态
| "dirty-warning" // 工作区有未提交更改
| "backup-confirm" // 确认备份
| "backing-up" // 正在备份
| "fetching" // 获取更新
| "preview" // 显示更新预览
| "merging" // 合并中
| "installing" // 安装依赖
| "done" // 完成
| "conflict" // 有冲突
| "up-to-date" // 已是最新
| "error"; // 错误
// Action 驱动状态转换
type UpdateAction =
| { type: "GIT_CHECKED"; payload: GitStatusInfo }
| { type: "FETCHED"; payload: UpdateInfo }
| { type: "BACKUP_CONFIRM" | "BACKUP_SKIP" | "UPDATE_CONFIRM" | "INSTALLED" }
| { type: "BACKUP_DONE"; backupFile: string }
| { type: "MERGED"; payload: MergeResult }
| { type: "ERROR"; error: string };
Reducer 集中状态转换
所有状态转换逻辑集中在 reducer 中,每个 case 只处理当前状态下合法的 action:
typescript
function updateReducer(state: UpdateState, action: UpdateAction): UpdateState {
const { status, options } = state;
// 通用错误处理:任何状态都可以转到 error
if (action.type === "ERROR") {
return { ...state, status: "error", error: action.error };
}
switch (status) {
case "checking": {
if (action.type !== "GIT_CHECKED") return state;
const { payload: gitStatus } = action;
if (gitStatus.currentBranch !== "main") {
return {
...state,
status: "error",
error: "仅支持在 main 分支执行更新",
};
}
if (!gitStatus.isClean && !options.force) {
return { ...state, status: "dirty-warning", gitStatus };
}
return { ...state, status: "fetching", gitStatus };
}
case "fetching": {
if (action.type !== "FETCHED") return state;
const { payload: updateInfo } = action;
if (updateInfo.behindCount === 0) {
return { ...state, status: "up-to-date", updateInfo };
}
const nextStatus = options.skipBackup ? "preview" : "backup-confirm";
return { ...state, status: nextStatus, updateInfo };
}
// ... 其他状态处理
}
}
Effect Map:统一副作用处理
每个需要执行副作用的状态对应一个 effect 函数,可返回 cleanup 函数:
typescript
type EffectFn = (
state: UpdateState,
dispatch: Dispatch<UpdateAction>
) => (() => void) | undefined;
const statusEffects: Partial<Record<UpdateStatus, EffectFn>> = {
checking: (_state, dispatch) => {
const gitStatus = checkGitStatus();
ensureUpstreamRemote();
dispatch({ type: "GIT_CHECKED", payload: gitStatus });
return undefined;
},
fetching: (_state, dispatch) => {
fetchUpstream();
const info = getUpdateInfo();
dispatch({ type: "FETCHED", payload: info });
return undefined;
},
installing: (_state, dispatch) => {
let cancelled = false;
installDeps().then((result) => {
if (cancelled) return;
dispatch(
result.success
? { type: "INSTALLED" }
: { type: "ERROR", error: result.error }
);
});
return () => {
cancelled = true;
}; // cleanup
},
};
组件使用
组件中只需一个核心 useEffect 来驱动整个状态机:
typescript
function UpdateApp({ checkOnly, skipBackup, force }) {
const [state, dispatch] = useReducer(
updateReducer,
{ checkOnly, skipBackup, force },
createInitialState
);
// 核心:单一 effect 处理所有副作用
useEffect(() => {
const effect = statusEffects[state.status];
if (!effect) return;
return effect(state, dispatch);
}, [state.status, state]);
// UI 渲染基于 state.status
return <Box>...</Box>;
}
这种模式的优势:
- 可测试性:Reducer 是纯函数,可以独立测试状态转换
- 可维护性 :状态逻辑集中,不会分散在多个
useEffect中 - 可扩展性:添加新状态只需在 reducer 和 effect map 各加一个 case
用户交互设计
使用 React Ink 构建终端 UI,提供友好的交互体验:
预览更新
bash
发现 5 个新提交:
a1b2c3d feat: add dark mode (2 days ago)
e4f5g6h fix: responsive layout (3 days ago)
i7j8k9l docs: update readme (1 week ago)
... 还有 2 个提交
注意: 本地有 1 个未推送的提交
确认更新到最新版本? (Y/n)
处理冲突
bash
发现合并冲突
冲突文件:
- src/config.ts
- src/components/Header.tsx
你可以:
1. 手动解决冲突后运行: git add . && git commit
2. 中止合并恢复到更新前状态
备份文件: backup-2026-01-10-full.tar.gz
是否中止合并? (Y/n)
完整代码实现
Git 操作封装
typescript
import { execSync } from "node:child_process";
function git(args: string): string {
return execSync(`git ${args}`, {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
}).trim();
}
function gitSafe(args: string): string | null {
try {
return git(args);
} catch {
return null;
}
}
export function checkGitStatus(): GitStatusInfo {
const currentBranch = git("rev-parse --abbrev-ref HEAD");
const statusOutput = gitSafe("status --porcelain") || "";
const uncommittedFiles = statusOutput
.split("\n")
.filter((line) => line.trim());
return {
currentBranch,
isClean: uncommittedFiles.length === 0,
// 注:简化处理,完整兼容需更严格的 porcelain 解析
uncommittedFiles: uncommittedFiles.map((line) => line.slice(3).trim()),
};
}
export function getUpdateInfo(): UpdateInfo {
const revList =
gitSafe("rev-list --left-right --count HEAD...upstream/main") || "0\t0";
const [aheadStr, behindStr] = revList.split("\t");
const commitFormat = "%h|%s|%ar|%an";
const commitsOutput =
gitSafe(
`log HEAD..upstream/main --pretty=format:"${commitFormat}" --no-merges`
) || "";
const commits = commitsOutput
.split("\n")
.filter(Boolean)
.map((line) => {
const [hash, message, date, author] = line.split("|");
return { hash, message, date, author };
});
return {
behindCount: parseInt(behindStr, 10),
aheadCount: parseInt(aheadStr, 10),
commits,
};
}
export function mergeUpstream(): MergeResult {
try {
git("merge upstream/main --no-edit");
return { success: true, hasConflict: false, conflictFiles: [] };
} catch {
const conflictFiles = getConflictFiles();
return {
success: false,
hasConflict: conflictFiles.length > 0,
conflictFiles,
};
}
}
function getConflictFiles(): string[] {
const output = gitSafe("diff --name-only --diff-filter=U") || "";
return output.split("\n").filter(Boolean);
}
Git 命令速查表
| 命令 | 作用 | 场景 |
|---|---|---|
git rev-parse --abbrev-ref HEAD |
获取当前分支名 | 验证分支 |
git status --porcelain |
机器可读的状态输出 | 检查工作区 |
git remote get-url <name> |
获取 remote URL | 检查 remote |
git remote add <name> <url> |
添加 remote | 配置 upstream |
git fetch <remote> |
下载远程更新 | 获取更新 |
git rev-list --left-right --count A...B |
统计差异提交数 | 计算 ahead/behind |
git log A..B --pretty=format:"..." |
列出差异提交 | 预览更新 |
git show <ref>:<path> |
查看远程文件 | 获取版本号 |
git merge <branch> --no-edit |
自动合并 | 执行更新 |
git diff --name-only --diff-filter=U |
列出冲突文件 | 检测冲突 |
git merge --abort |
中止合并 | 回滚操作 |
Git 命令功能分类
为了更好地理解这些命令的用途,下面按功能将它们分类展示:
infographic
infographic hierarchy-structure
data
title Git 命令功能分类
desc 按操作类型组织的命令清单
items
- label 状态检查
icon mdi/information
children
- label git rev-parse
desc 获取当前分支名
- label git status --porcelain
desc 检查工作区状态
- label 远程管理
icon mdi/server-network
children
- label git remote get-url
desc 检查 remote 是否存在
- label git remote add
desc 添加 upstream remote
- label git fetch
desc 下载远程更新
- label 提交分析
icon mdi/source-commit
children
- label git rev-list
desc 统计提交差异
- label git log
desc 查看提交历史
- label git show
desc 查看远程文件内容
- label 合并操作
icon mdi/source-merge
children
- label git merge
desc 执行分支合并
- label git merge --abort
desc 中止合并恢复状态
- label 冲突检测
icon mdi/alert-octagon
children
- label git diff --diff-filter=U
desc 列出未解决冲突文件
备份还原功能实现
除了主题更新,CLI 还提供了完整的备份还原功能,确保用户数据安全。
备份和还原是两个互补的操作,下图展示了它们的完整工作流:
infographic
infographic compare-hierarchy-row-letter-card-compact-card
data
title 备份与还原流程对比
desc 两个互补操作的完整工作流
items
- label 备份流程
icon mdi/backup-restore
children
- label 检查配置
desc 确定备份类型和范围
- label 创建临时目录
desc 准备暂存空间
- label 复制文件
desc 按配置复制所需文件
- label 生成 manifest
desc 记录版本和元信息
- label 压缩打包
desc tar.gz 压缩存档
- label 清理临时目录
desc 删除暂存目录
- label 还原流程
icon mdi/restore
children
- label 选择备份
desc 读取 manifest 显示备份信息
- label 解压到临时目录
desc 提取归档内容(包含 manifest)
- label 读取 manifest.files
desc 获取实际备份成功的文件列表
- label 按映射复制文件
desc 使用自动生成的 RESTORE_MAP
- label 清理临时目录
desc 删除解压的暂存文件
备份项配置
备份系统采用配置驱动的方式,定义需要备份的文件和目录:
typescript
export interface BackupItem {
src: string; // 源路径(相对于项目根目录)
dest: string; // 备份内目标路径
label: string; // 显示标签
required: boolean; // 是否为必需项(basic 模式包含)
}
export const BACKUP_ITEMS: BackupItem[] = [
// 基础备份项(required: true)
{
src: "src/content/blog",
dest: "content/blog",
label: "博客文章",
required: true,
},
{
src: "config/site.yaml",
dest: "config/site.yaml",
label: "网站配置",
required: true,
},
{
src: "src/pages/about.md",
dest: "pages/about.md",
label: "关于页面",
required: true,
},
{
src: "public/img/avatar.webp",
dest: "img/avatar.webp",
label: "用户头像",
required: true,
},
{ src: ".env", dest: "env", label: "环境变量", required: true },
// 完整备份额外项目(required: false)
{ src: "public/img", dest: "img", label: "所有图片", required: false },
{
src: "src/assets/lqips.json",
dest: "assets/lqips.json",
label: "LQIP 数据",
required: false,
},
{
src: "src/assets/similarities.json",
dest: "assets/similarities.json",
label: "相似度数据",
required: false,
},
{
src: "src/assets/summaries.json",
dest: "assets/summaries.json",
label: "AI 摘要数据",
required: false,
},
];
备份流程
备份操作使用 tar.gz 格式压缩,并生成 manifest.json 记录元信息:
typescript
export function runBackup(
isFullBackup: boolean,
onProgress?: (results: BackupResult[]) => void
): BackupOutput {
// 1. 创建备份目录和临时目录
fs.mkdirSync(BACKUP_DIR, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const tempDir = path.join(BACKUP_DIR, `.tmp-backup-${timestamp}`);
// 2. 过滤备份项目(基础备份只包含 required: true 的项目)
const itemsToBackup = BACKUP_ITEMS.filter(
(item) => item.required || isFullBackup
);
// 3. 复制文件到临时目录
const results: BackupResult[] = [];
for (const item of itemsToBackup) {
const srcPath = path.join(PROJECT_ROOT, item.src);
const destPath = path.join(tempDir, item.dest);
if (fs.existsSync(srcPath)) {
fs.cpSync(srcPath, destPath, { recursive: true });
results.push({ item, success: true, skipped: false });
} else {
results.push({ item, success: false, skipped: true });
}
onProgress?.([...results]); // 进度回调
}
// 4. 生成 manifest.json
const manifest = {
name: "astro-koharu-backup",
version: getVersion(),
type: isFullBackup ? "full" : "basic",
timestamp,
created_at: new Date().toISOString(),
files: Object.fromEntries(results.map((r) => [r.item.dest, r.success])),
};
fs.writeFileSync(
path.join(tempDir, "manifest.json"),
JSON.stringify(manifest, null, 2)
);
// 5. 压缩并清理
tarCreate(backupFilePath, tempDir);
fs.rmSync(tempDir, { recursive: true, force: true });
return { results, backupFile: backupFilePath, fileSize, timestamp };
}
tar 操作封装
使用系统 tar 命令进行压缩和解压,并添加路径遍历安全检查:
typescript
// 安全验证:防止路径遍历攻击
function validateTarEntries(entries: string[], archivePath: string): void {
for (const entry of entries) {
if (entry.includes("\0")) {
throw new Error(`tar entry contains null byte`);
}
const normalized = path.posix.normalize(entry);
if (path.posix.isAbsolute(normalized)) {
throw new Error(`tar entry is absolute path: ${entry}`);
}
if (normalized.split("/").includes("..")) {
throw new Error(`tar entry contains parent traversal: ${entry}`);
}
}
}
// 创建压缩包
export function tarCreate(archivePath: string, sourceDir: string): void {
spawnSync("tar", ["-czf", archivePath, "-C", sourceDir, "."]);
}
// 解压到指定目录
export function tarExtract(archivePath: string, destDir: string): void {
listTarEntries(archivePath); // 先验证条目安全性
spawnSync("tar", ["-xzf", archivePath, "-C", destDir]);
}
// 读取 manifest(不解压整个文件)
export function tarExtractManifest(archivePath: string): string | null {
const result = spawnSync("tar", ["-xzf", archivePath, "-O", "manifest.json"]);
return result.status === 0 ? result.stdout : null;
}
还原流程
还原操作基于 manifest 驱动,确保只还原实际备份成功的文件:
typescript
// 路径映射:从备份项配置自动生成,确保一致性
export const RESTORE_MAP: Record<string, string> = Object.fromEntries(
BACKUP_ITEMS.map((item) => [item.dest, item.src])
);
export function restoreBackup(backupPath: string): RestoreResult {
// 1. 创建临时目录并解压
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "restore-"));
tarExtract(backupPath, tempDir);
// 2. 读取 manifest 获取实际备份的文件列表
const manifestPath = path.join(tempDir, "manifest.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
const restored: string[] = [];
const skipped: string[] = [];
// 3. 基于 manifest.files 还原(只还原成功备份的文件)
for (const [backupPath, success] of Object.entries(manifest.files)) {
// 跳过备份失败的文件
if (!success) {
skipped.push(backupPath);
continue;
}
const projectPath = RESTORE_MAP[backupPath];
if (!projectPath) {
console.warn(`未知的备份路径: ${backupPath},跳过`);
skipped.push(backupPath);
continue;
}
const srcPath = path.join(tempDir, backupPath);
const destPath = path.join(PROJECT_ROOT, projectPath);
if (fs.existsSync(srcPath)) {
fs.mkdirSync(path.dirname(destPath), { recursive: true });
fs.cpSync(srcPath, destPath, { recursive: true });
restored.push(projectPath);
} else {
skipped.push(backupPath);
}
}
// 4. 清理临时目录
fs.rmSync(tempDir, { recursive: true, force: true });
return {
restored,
skipped,
backupType: manifest.type,
version: manifest.version,
};
}
Dry-Run 模式详解
Dry-run(预演模式)是 CLI 工具中常见的安全特性,允许用户在实际执行前预览操作结果。本实现采用函数分离 + 条件渲染的模式。
下图展示了预览模式和实际执行模式的核心区别:
infographic
infographic compare-binary-horizontal-badge-card-arrow
data
title Dry-Run 模式与实际执行对比
desc 预览模式和实际还原的关键区别
items
- label 预览模式
desc 安全的只读预览
icon mdi/eye
children
- label 提取 manifest.json
desc 调用 tarExtractManifest 不解压整个归档
- label 读取 manifest.files
desc 获取实际备份的文件列表
- label 统计文件数量
desc 调用 tarList 计算每个路径的文件数
- label 不修改任何文件
desc 零副作用,可安全执行
- label 实际执行
desc 基于 manifest 的还原
icon mdi/content-save
children
- label 解压整个归档
desc 调用 tarExtract 提取所有文件
- label 读取 manifest.files
desc 获取实际备份成功的文件列表
- label 按 manifest 复制文件
desc 只还原 success: true 的文件
- label 显示跳过的文件
desc 报告 success: false 的文件
预览函数和执行函数
关键在于提供两个功能相似但副作用不同的函数:
typescript
// 预览函数:只读取 manifest,不解压不修改文件
export function getRestorePreview(backupPath: string): RestorePreviewItem[] {
// 只提取 manifest.json,不解压整个归档
const manifestContent = tarExtractManifest(backupPath);
if (!manifestContent) {
throw new Error("无法读取备份 manifest");
}
const manifest = JSON.parse(manifestContent);
const previewItems: RestorePreviewItem[] = [];
// 基于 manifest.files 生成预览
for (const [backupPath, success] of Object.entries(manifest.files)) {
if (!success) continue; // 跳过备份失败的文件
const projectPath = RESTORE_MAP[backupPath];
if (!projectPath) continue;
// 从归档中统计文件数量(不解压)
const files = tarList(backupPath);
const matchingFiles = files.filter(
(f) => f === backupPath || f.startsWith(`${backupPath}/`)
);
const fileCount = matchingFiles.length;
previewItems.push({
path: projectPath,
fileCount: fileCount || 1,
backupPath,
});
}
return previewItems;
}
// 执行函数:实际解压并复制文件
export function restoreBackup(backupPath: string): RestoreResult {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "restore-"));
tarExtract(backupPath, tempDir); // 实际解压
// 读取 manifest 驱动还原
const manifest = JSON.parse(
fs.readFileSync(path.join(tempDir, "manifest.json"), "utf-8")
);
const restored: string[] = [];
for (const [backupPath, success] of Object.entries(manifest.files)) {
if (!success) continue;
const projectPath = RESTORE_MAP[backupPath];
// ... 实际复制文件
fs.cpSync(srcPath, destPath, { recursive: true });
restored.push(projectPath);
}
return { restored, skipped: [], backupType: manifest.type };
}
两个函数的核心区别:
- 预览 :调用
tarExtractManifest()只提取 manifest,再用tarList()统计文件数量 - 执行 :调用
tarExtract()解压整个归档,基于 manifest.files 复制文件
组件层:条件分发
在 React 组件中,根据 dryRun 参数决定调用哪个函数:
typescript
interface RestoreAppProps {
dryRun?: boolean; // 是否为预览模式
force?: boolean; // 是否跳过确认
}
export function RestoreApp({ dryRun = false, force = false }: RestoreAppProps) {
const [result, setResult] = useState<{
items: RestorePreviewItem[] | string[];
backupType?: string;
skipped?: string[];
}>();
// 预览模式:只读取 manifest
const runDryRun = useCallback(() => {
const previewItems = getRestorePreview(selectedBackup);
setResult({ items: previewItems });
setStatus("done");
}, [selectedBackup]);
// 实际还原:基于 manifest 执行还原
const runRestore = useCallback(() => {
setStatus("restoring");
const { restored, skipped, backupType } = restoreBackup(selectedBackup);
setResult({ items: restored, backupType, skipped });
setStatus("done");
}, [selectedBackup]);
// 确认时根据模式分发
function handleConfirm() {
if (dryRun) {
runDryRun();
} else {
runRestore();
}
}
}
关键设计:
- 统一数据结构 :
result可以容纳预览和执行两种结果 - 类型区分 :预览返回
RestorePreviewItem[](含 fileCount),执行返回string[] - 额外信息 :执行模式返回
backupType和skipped,用于显示完整信息
UI 层:差异化展示
预览模式和实际执行模式在 UI 上有明确区分:
tsx
{
/* 确认提示:显示备份类型和文件数量 */
}
<Text color="yellow">
{dryRun ? "[预览模式] " : ""}
确认还原 {result?.backupType} 备份? 此操作将覆盖现有文件
</Text>;
{
/* 完成状态:根据模式显示不同标题 */
}
<Text bold color="green">
{dryRun ? "预览模式" : "还原完成"}
</Text>;
{
/* 结果展示:预览模式显示文件数量统计 */
}
{
result?.items.map((item) => {
const isPreviewItem = typeof item !== "string";
const filePath = isPreviewItem ? item.path : item;
const fileCount = isPreviewItem ? item.fileCount : 0;
return (
<Text key={filePath}>
<Text color="green">{" "}+ </Text>
<Text>{filePath}</Text>
{/* 预览模式额外显示文件数量 */}
{isPreviewItem && fileCount > 1 && (
<Text dimColor> ({fileCount} 文件)</Text>
)}
</Text>
);
});
}
{
/* 统计文案:使用 "将" vs "已" 区分 */
}
<Text>
{dryRun ? "将" : "已"}还原: <Text color="green">{result?.items.length}</Text>{" "}
项
</Text>;
{
/* 显示跳过的文件(仅实际执行模式) */
}
{
!dryRun && result?.skipped && result.skipped.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text color="yellow">跳过的文件:</Text>
{result.skipped.map((file) => (
<Text key={file} dimColor>
{" "}- {file}
</Text>
))}
</Box>
);
}
{
/* 预览模式特有提示 */
}
{
dryRun && <Text color="yellow">这是预览模式,没有文件被修改</Text>;
}
{
/* 实际执行模式:显示后续步骤 */
}
{
!dryRun && (
<Box flexDirection="column" marginTop={1}>
<Text dimColor>后续步骤:</Text>
<Text dimColor>{" "}1. pnpm install # 安装依赖</Text>
<Text dimColor>{" "}2. pnpm build # 构建项目</Text>
</Box>
);
}
命令行使用
bash
# 预览模式:查看将要还原的内容
pnpm koharu restore --dry-run
# 实际执行
pnpm koharu restore
# 跳过确认直接执行
pnpm koharu restore --force
# 还原最新备份(预览)
pnpm koharu restore --latest --dry-run
输出对比
预览模式输出(Full 备份):
bash
备份文件: backup-2026-01-10-12-30-00-full.tar.gz
备份类型: full
主题版本: 1.2.0
备份时间: 2026-01-10 12:30:00
[预览模式] 确认还原 full 备份? 此操作将覆盖现有文件 (Y/n)
预览模式
+ src/content/blog (42 文件)
+ config/site.yaml
+ src/pages/about.md
+ .env
+ public/img (128 文件)
+ src/assets/lqips.json
+ src/assets/similarities.json
+ src/assets/summaries.json
将还原: 8 项
这是预览模式,没有文件被修改
预览模式输出(Basic 备份):
bash
备份文件: backup-2026-01-10-12-30-00-basic.tar.gz
备份类型: basic
主题版本: 1.2.0
备份时间: 2026-01-10 12:30:00
[预览模式] 确认还原 basic 备份? 此操作将覆盖现有文件 (Y/n)
预览模式
+ src/content/blog (42 文件)
+ config/site.yaml
+ src/pages/about.md
+ .env
+ public/img/avatar.webp
将还原: 5 项
这是预览模式,没有文件被修改
实际执行输出(含跳过的文件):
bash
还原完成
+ src/content/blog
+ config/site.yaml
+ src/pages/about.md
+ .env
+ public/img
跳过的文件:
- src/assets/lqips.json (备份时不存在)
已还原: 5 项
后续步骤:
1. pnpm install # 安装依赖
2. pnpm build # 构建项目
3. pnpm dev # 启动开发服务器
写在最后
能看到这里,那很厉害了,觉得还挺喜欢的话,欢迎给我一个 star 呢~
自认为这次实现的这个 CLI 对于我自己的需求来说,相当好用,只恨没有早一些实践,如果你看到这篇文章,可以放心大胆的去构建。
相关链接如下
React Ink
- Ink - GitHub - React for interactive command-line apps,官方仓库
- Ink UI - Ink 的 UI 组件库,提供 TextInput、Spinner、ProgressBar 等组件
- Using Ink UI with React to build interactive, custom CLIs - LogRocket - Ink UI 使用教程
- Building a Coding CLI with React Ink - 实战教程,包含流式输出实现
- React + Ink CLI Tutorial - FreeCodeCamp - 入门教程
- Node.js CLI Apps Best Practices - GitHub - Node.js CLI 最佳实践清单
Git 同步 Fork
- Syncing a fork - GitHub Docs - 官方文档
- Git Upstreams and Forks - Atlassian - Atlassian 的详细教程
- How to Sync Your Fork with the Original Git Repository - FreeCodeCamp - 完整同步指南
状态机与 useReducer
- How to Use useReducer as a Finite State Machine - Kyle Shevlin - 将 useReducer 用作状态机的经典文章
- Turning your React Component into a Finite State Machine - DEV - 状态机实战教程