dsh-git-sync 设计文档
August 16, 2026 · View on GitHub
用 git 实现 DSH 多端同步:工作区对话/文件双向同步、预设与插件清单同步。 复用系统 git 凭据(SSH key / Windows 凭据管理器 / PAT),不存储任何密码。
1. 目标与场景
| 工作区类型 | 例子 | 同步方向 | 同步内容 |
|---|---|---|---|
| 本机强依赖(IAR 编译等) | <WORKSPACE_B> | 单向(本机 push,他机查看) | 仅对话(mode: conversations) |
| 纯开发(插件/预设生产) | <WORKSPACE_A> | 双向续接 | 全部文件(mode: all) |
核心事实(已从 DSH rc.6 源码验证):
- 一个会话 =
~/.dsh/sessions/<projectKey(cwd)>/session-<uuid>/session.jsonl.zstd(zstd 多帧) - 会话按工作区(header 里的
cwd绝对路径)分目录;目录名projectKey(cwd)= 分隔符(\/://)合并为-、非安全字符转~XXXX、--包裹 - DSH 启动时扫描 sessions 目录自动发现/收养未注册会话(
dsh-workspace.bootstrap),无需改workspace.json workspace.json/session_projcache.json是派生索引,不必同步- 会话 header 硬编码
cwd,跨设备同会话续接要求路径一致;路径不同则旧会话以"虚拟工作区"只读可查
2. 插件架构
dsh-git-sync/
├── package.json # dsh-plugin 元数据(bundles 里注册)
├── docs/design.md # 本文档
├── lib/index.js # host 半部:git 服务 + 配置 + /dsh-git-sync/* 路由
├── lib/client.js # client 半部:settings.plugin.item 卡片(+ 阶段2 快捷入口)
└── README.md
2.1 配置存储
全局配置文件 ~/.dsh/.git-sync.yaml(与 .credentials.yaml 同级,host 读写;不含密钥):
# 绑定(中央配置仓库:预设/插件清单,阶段3 启用)
bind:
centralRepo: "" # 例 git@github.com:<ACCOUNT>/dsh-dotfiles.git
centralBranch: main
# 每个工作区(key = 工作区绝对路径;跨设备由逻辑名 `name` 关联镜像目录)
workspaces:
"<WORKSPACE_A_ABS_PATH>":
enabled: true
mode: all # conversations | all
remote: git@github.com:<ACCOUNT>/<WORKSPACE_A_REPO>.git
branch: main
"<WORKSPACE_B_ABS_PATH>":
enabled: true
mode: conversations
remote: git@github.com:<ACCOUNT>/<WORKSPACE_B_REPO>.git
branch: main
配置项说明:
mode: conversations:只同步~/.dsh/sessions/<projectKey(cwd)>/到仓库.dsh-sync/,不碰工作区文件mode: all:同步工作区全部文件(git add -A)remote为空 = 未绑定,卡片显示"未配置"- 凭据:系统 git 凭据(
git push/pull自动走),可选 PAT 配到系统凭据管理器,插件不存
2.2 仓库内约定目录
<workspace>/
├── .dsh-sync/ # 仅 conversations 模式使用
│ └── <projectKey(cwd)>/ # 目录名 = 会话归属(跨设备原样拷贝自洽)
│ └── session-<uuid>/session.jsonl.zstd
├── (工作区文件 …)
└── .git/
.dsh-sync/进 git(.gitignore不得排除)- 会话文件是 zstd 压缩的追加日志,增量提交即可
3. host 半部(lib/index.js)
3.1 依赖注入
inject = ['webServer', 'subprocess', 'workspaceRegistry', 'sessionPersistence']
ctx.webServer.register({kind:'prefix', path:'/dsh-git-sync', handler})ctx.subprocess.spawn(spec)跑 git(复用 layout 插件的subprocessRunner模式)ctx.workspaceRegistry.list()→ 工作区记录(path/title/sessionIds)ctx.sessionPersistence.list()→ 所有持久化会话 header(id/cwd)
3.2 安全边界(gate)
复用 layout 插件的 workspace gate:/dsh-git-sync/* 的 {path} 必须 realpath 后落在注册工作区内;remote/branch 字符串做白名单校验(禁 - 开头、空白、..)。配置读写路由允许任意工作区 path(从 workspaceRegistry 校验存在)。
3.3 路由
| 路由 | 入参 | 返回 | 说明 |
|---|---|---|---|
GET 不支持;全 POST | 与 layout 一致 | ||
/dsh-git-sync/config | {} | 完整配置 | 读 ~/.dsh/.git-sync.yaml |
/dsh-git-sync/config | {workspacePath, patch} | 更新后的配置 | 写单个工作区方案或 bind |
/dsh-git-sync/workspaces | {} | [{path,title,mode,remote,enabled, gitRoot, branch, localChanged, remoteAhead}] | 工作区列表 + 同步状态 |
/dsh-git-sync/status | {path} | {gitRoot,branch,changed,sessions:[{id,size,updatedAt}],remoteAhead,remoteBehind} | 本地/远程差异 |
/dsh-git-sync/push | {path, message?} | {pushed, sessions, files} | 上传(见 3.4) |
/dsh-git-sync/pull | {path} | {pulled, sessions, files, requiresRestart} | 拉取(见 3.5) |
3.4 push 流程
- gate →
canonical(工作区根,realpath) - 读配置:
mode/remote/branch;remote空 → 报"未配置" - 确保
canonical本身是 git 仓库(无.git时git init;严格本层,不向上找) mode === 'conversations':ctx.sessionPersistence.list()→ 按header.cwd过滤出cwd === canonical的会话 id- 复制
~/.dsh/sessions/<projectKey(cwd)>/session-<id>/session.jsonl.zstd→<canonical>/.dsh-sync/<projectKey(cwd)>/session-<id>/(缺失才写) git add .dsh-sync+git commit -m "dsh-sync: N sessions"+git push
mode === 'all':git add -A+ commit + push- 返回统计
3.5 pull 流程
- gate + 配置
git pull(未绑定 upstream 时用git pull <remote> <branch>)mode === 'conversations':- 从
<canonical>/.dsh-sync/<projectKey(cwd)>/枚举会话 - 复制缺失的到
~/.dsh/sessions/<projectKey(cwd)>/(不覆盖已存在文件——防覆盖本机正在写的会话) requiresRestart: true(DSH 需重启才扫描发现新会话)
- 从
mode === 'all':直接git pull合入工作区文件- 返回统计
3.6 编码工具
projectKey(cwd) 从 DSH 源码复制(行为必须一致):
function projectKey(cwd) {
let readable = "";
let separatorRun = false;
for (let i = 0; i < cwd.length; i++) {
const code = cwd.charCodeAt(i);
const ch = String.fromCharCode(code);
if (ch === "/" || ch === "\\" || ch === ":") {
if (!separatorRun) readable += "-";
separatorRun = true;
} else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) {
readable += ch; separatorRun = false;
} else {
readable += "~" + code.toString(16).toUpperCase().padStart(4, "0"); separatorRun = false;
}
}
return `--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--`;
}
sessionsRoot = join(DSH_HOME, 'sessions')(DSH_HOME = ~/.dsh,兼容 $DSH_HOME)。
4. client 半部(lib/client.js)
4.1 设置卡片(MVP 主入口)
注册 settings.plugin.item(kind:'list',scope:'root',apply 顶层注册,与 layout 插件一致):
┌─ Git 同步 ────────────────────────────┐
│ 绑定中央仓库: [URL] [分支] (阶段3) │
│ 工作区 状态 模式 │
│ <WORKSPACE_A> ✓同步 [全部文件▼] │
│ [推送] [拉取] [状态] │
│ <WORKSPACE_B> ⚠远程新 [仅对话▼] │
│ [推送] [拉取] [状态] │
└──────────────────────────────────────┘
- 工作区列表来自
/dsh-git-sync/workspaces - 每个工作区:
mode下拉(仅对话/全部文件)+ remote 输入 + 推送/拉取按钮 + 状态徽标 - 状态徽标:
未配置(无 remote)/✓ 同步/↑ 本地有新/↓ 远程有新/⚠ 冲突或错误 - 操作结果用 Toast 提示;pull 后提示"重启 DSH 后新会话出现"
- 配置保存走
/dsh-git-sync/config
4.2 快捷入口(阶段2,可选)
sidebar.footer.action(kind:'list')注册图标按钮;有远程更新时亮徽标,点击打开设置卡片同款面板(用官方 Modal)。
5. 中央配置仓库(阶段3)
bind.centralRepo 指向私有仓库,内容为声明文件(非安装产物):
presets.yml:[{id, repo, revision}]→ 各设备git pull预设仓库plugins.yml:[{id, source: github:...}]→ 各设备dsh plugin add清单- 启动时
git pull中央仓库 → 对比 revision → 提示"预设/插件有更新"
禁止同步
~/.dsh/profiles/(pnpm 安装产物)与.credentials.yaml。
6. 风险与约束
- 私有仓库:对话含敏感内容,必须私有
- 并发:同一会话两端同时写会 git 冲突;conversations 模式靠"只补缺失"规避大部分;同一时刻仅一台续接
- 路径一致性:同会话续接要求设备间工作区路径一致;否则新设备=新起点、旧会话只读
- 二进制进 git:会话文件无 diff,但 zstd 压缩后很小,增量提交可接受
- 版本脆弱性:
projectKey编码/sessions目录布局随 DSH 升级可能变(rc 阶段),升级后需回归验证
7. 分阶段计划
| 阶段 | 内容 |
|---|---|
| MVP(本轮) | host 全路由 + 设置卡片 + conversations/all 模式 push/pull/status + 配置持久化 |
| 阶段2 | sidebar.footer.action 快捷入口 + 远程更新徽标 + 启动时检查 |
| 阶段3 | 中央配置仓库 + 预设/插件清单自动更新提示 |