本文地址:https://blog.cosine.ren/post/interactive-git-update-cli
本文图表、伪代码等由 AI 辅助编写
# 背景
当你 fork 了一个开源项目作为自己的博客主题,如何优雅地从上游仓库同步更新?手动敲一串 Git 命令既繁琐又容易出错;但直接点 Fork 的 Sync 按钮,又可能覆盖你的自定义配置和内容。
很多人因此在「保持更新」和「保留修改」之间左右为难:要么干脆二开后不再同步,要么每次更新都提心吊胆。
这也是为什么不少项目会像 @fumadocs/cli 一样,提供专门的 CLI 来完成更新等相关操作。
本文将介绍如何简单地构建一个交互式 CLI 工具,把 fork 同步的流程自动化起来。
这个工具的核心目标是:
- 安全:更新前检查工作区状态,必要时可备份
- 透明:预览所有变更,让用户决定是否更新
- 友好:出现冲突时给出明确指引
具体的代码可以看这个 PR:
https://github.com/cosZone/astro-koharu/pull/43
不过这个 PR 只是最初的版本,后面又缝缝补补了不少东西,整体流程是我研究一个周末后摸索出的,如有不足,那一定是我考虑不周,欢迎指出~
在这个 PR 里,我基于 Ink 构建了一个交互式 TUI 工具,提供了博客内容备份 / 还原、主题更新、内容生成、备份管理等功能:
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 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. 检查当前分支
git rev-parse --abbrev-ref HEAD |
作用:获取当前所在分支的名称。
参数解析:
rev-parse:解析 Git 引用--abbrev-ref:输出简短的引用名称(如main),而不是完整的 SHA
使用场景:确保用户在正确的分支(如 main )上执行更新,避免在 feature 分支上意外合并上游代码。
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD") | |
.toString() | |
.trim(); | |
if (currentBranch !== "main") { | |
throw new Error(`仅支持在 main 分支执行更新,当前分支: ${currentBranch}`); | |
} |
# 2. 检查工作区状态
git status --porcelain |
作用:以机器可读的格式输出工作区状态。
参数解析:
--porcelain:输出稳定、易于解析的格式,不受 Git 版本和语言设置影响
输出格式:
M modified-file.ts # 已暂存的修改 | |
M unstaged-file.ts # 未暂存的修改 | |
?? untracked-file.ts # 未跟踪的文件 | |
A new-file.ts # 新添加的文件 | |
D deleted-file.ts # 删除的文件 |
前两个字符分别表示暂存区和工作区的状态。
const statusOutput = execSync("git status --porcelain").toString(); | |
const uncommittedFiles = statusOutput.split("\n").filter((line) => line.trim()); | |
const isClean = uncommittedFiles.length === 0; |
# 3. 管理远程仓库
# 检查 remote 是否存在
git remote get-url upstream |
作用:获取指定 remote 的 URL,如果不存在会报错。
# 添加 upstream remote
# 将 URL 替换为你的上游仓库地址 | |
git remote add upstream https://github.com/original/repo.git |
作用:添加一个名为 upstream 的远程仓库,指向原始项目。
为什么需要 upstream?
当你 fork 一个项目后,你的 origin 指向你自己的 fork,而 upstream 指向原始项目。这样可以:
- 从
upstream拉取原项目的更新 - 向
origin推送你的修改
// 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. 获取远程更新
git fetch upstream |
作用:从 upstream 远程仓库下载所有分支的最新提交,但不会自动合并到本地分支。
与 git pull 的区别:
fetch只下载数据,不修改本地代码pull=fetch+merge,会自动合并
使用 fetch 可以让我们先预览变更,再决定是否合并。
# 5. 计算提交差异
git rev-list --left-right --count HEAD...upstream/main |
作用:计算本地分支与 upstream/main 之间的提交差异。
参数解析:
rev-list:列出提交记录--left-right:区分左侧(本地)和右侧(远程)的提交--count:只输出计数,不列出具体提交HEAD...upstream/main:三个点表示对称差集
输出示例:
2 5 |
表示本地有 2 个提交不在 upstream 上(ahead),upstream 有 5 个提交不在本地(behind)。
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. 查看待合并的提交
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
输出示例:
a1b2c3d|feat: add dark mode|2 days ago|Author Name | |
e4f5g6h|fix: typo in readme|3 days ago|Author Name |
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. 查看远程文件内容
git show upstream/main:package.json |
作用:直接查看远程分支上某个文件的内容,无需切换分支或合并。
使用场景:获取上游仓库的版本号,用于显示 "将更新到 x.x.x 版本"。
const packageJson = execSync("git show upstream/main:package.json").toString(); | |
const { version } = JSON.parse(packageJson); | |
console.log(`最新版本: ${version}`); |
# 8. 执行合并
git merge upstream/main --no-edit |
作用:将 upstream/main 分支合并到当前分支。
参数解析:
--no-edit:使用自动生成的合并提交信息,不打开编辑器
合并策略:Git 会自动选择合适的合并策略:
- Fast-forward:如果本地没有新提交,直接移动指针
- Three-way merge:如果有分叉,创建一个合并提交
注意:本工具采用 merge 同步上游,保留本地历史。如果你的需求是 "强制与上游一致"(丢弃本地修改),需要使用 rebase 或 reset 方案,不在本文讨论范围。
# 9. 检测合并冲突
git diff --name-only --diff-filter=U |
作用:列出所有未解决冲突的文件。
参数解析:
--name-only:只输出文件名--diff-filter=U:只显示 Unmerged(未合并 / 冲突)的文件
另一种方式是解析 git status --porcelain 的输出,查找冲突标记:
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. 中止合并
git merge --abort |
作用:中止当前的合并操作,恢复到合并前的状态。
使用场景:当用户遇到冲突但不想手动解决时,可以选择中止合并。
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 命名。
stateDiagram-v2 | |
[*] --> checking: 开始更新 | |
checking --> error: 不在 main 分支 | |
checking --> dirtyWarning: 工作区不干净 && !force | |
checking --> fetching: 工作区干净 || force | |
dirtyWarning --> [*]: 用户取消 | |
dirtyWarning --> fetching: 用户继续 | |
fetching --> upToDate: behindCount = 0 | |
fetching --> backupConfirm: behindCount > 0 && !skipBackup | |
fetching --> preview: behindCount > 0 && skipBackup | |
backupConfirm --> backingUp: 用户确认备份 | |
backupConfirm --> preview: 用户跳过备份 | |
backingUp --> preview: 备份完成 | |
backingUp --> error: 备份失败 | |
preview --> [*]: checkOnly 模式 | |
preview --> merging: 用户确认更新 | |
preview --> [*]: 用户取消 | |
merging --> conflict: 合并冲突 | |
merging --> installing: 合并成功 | |
conflict --> [*]: 用户处理冲突 | |
installing --> done: 依赖安装成功 | |
installing --> error: 依赖安装失败 | |
done --> [*] | |
error --> [*] | |
upToDate --> [*] |
# 类型定义
// 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:
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 函数:
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 来驱动整个状态机:
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,提供友好的交互体验:
# 预览更新
发现 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) |
# 处理冲突
发现合并冲突 | |
冲突文件: | |
- src/config.ts | |
- src/components/Header.tsx | |
你可以: | |
1. 手动解决冲突后运行: git add . && git commit | |
2. 中止合并恢复到更新前状态 | |
备份文件: backup-2026-01-10-full.tar.gz | |
是否中止合并? (Y/n) |
# 完整代码实现
# Git 操作封装
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 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 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 删除解压的暂存文件
# 备份项配置
备份系统采用配置驱动的方式,定义需要备份的文件和目录:
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 记录元信息:
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 命令进行压缩和解压,并添加路径遍历安全检查:
// 安全验证:防止路径遍历攻击 | |
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 驱动,确保只还原实际备份成功的文件:
// 路径映射:从备份项配置自动生成,确保一致性 | |
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 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 的文件
# 预览函数和执行函数
关键在于提供两个功能相似但副作用不同的函数:
// 预览函数:只读取 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 参数决定调用哪个函数:
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 上有明确区分:
{ | |
/* 确认提示:显示备份类型和文件数量 */ | |
} | |
<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> | |
); | |
} |
# 命令行使用
# 预览模式:查看将要还原的内容 | |
pnpm koharu restore --dry-run | |
# 实际执行 | |
pnpm koharu restore | |
# 跳过确认直接执行 | |
pnpm koharu restore --force | |
# 还原最新备份(预览) | |
pnpm koharu restore --latest --dry-run |
# 输出对比
预览模式输出(Full 备份):
备份文件: 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 备份):
备份文件: 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 项 | |
这是预览模式,没有文件被修改 |
实际执行输出(含跳过的文件):
还原完成 | |
+ 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 呢~
https://github.com/cosZone/astro-koharu
自认为这次实现的这个 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 - 状态机实战教程