# dsh-git-sync 设计文档 > 用 git 实现 DSH 多端同步:工作区对话/文件双向同步、预设与插件清单同步。 > 复用系统 git 凭据(SSH key / Windows 凭据管理器 / PAT),**不存储任何密码**。 ## 1. 目标与场景 | 工作区类型 | 例子 | 同步方向 | 同步内容 | |---|---|---|---| | 本机强依赖(IAR 编译等) | `` | 单向(本机 push,他机查看) | 仅对话(`mode: conversations`) | | 纯开发(插件/预设生产) | `` | 双向续接 | 全部文件(`mode: all`) | 核心事实(已从 DSH rc.6 源码验证): - 一个会话 = `~/.dsh/sessions//session-/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 读写;不含密钥): ```yaml # 绑定(中央配置仓库:预设/插件清单,阶段3 启用) bind: centralRepo: "" # 例 git@github.com:/dsh-dotfiles.git centralBranch: main # 每个工作区(key = 工作区绝对路径;跨设备由逻辑名 `name` 关联镜像目录) workspaces: "": enabled: true mode: all # conversations | all remote: git@github.com:/.git branch: main "": enabled: true mode: conversations remote: git@github.com:/.git branch: main ``` 配置项说明: - `mode: conversations`:只同步 `~/.dsh/sessions//` 到仓库 `.dsh-sync/`,**不碰工作区文件** - `mode: all`:同步工作区全部文件(`git add -A`) - `remote` 为空 = 未绑定,卡片显示"未配置" - 凭据:系统 git 凭据(`git push`/`pull` 自动走),可选 PAT 配到系统凭据管理器,**插件不存** ### 2.2 仓库内约定目录 ``` / ├── .dsh-sync/ # 仅 conversations 模式使用 │ └── / # 目录名 = 会话归属(跨设备原样拷贝自洽) │ └── session-/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 流程 1. gate → `canonical`(工作区根,realpath) 2. 读配置:`mode`/`remote`/`branch`;`remote` 空 → 报"未配置" 3. 确保 `canonical` 本身是 git 仓库(无 `.git` 时 `git init`;**严格本层**,不向上找) 4. `mode === 'conversations'`: - `ctx.sessionPersistence.list()` → 按 `header.cwd` 过滤出 `cwd === canonical` 的会话 id - 复制 `~/.dsh/sessions//session-/session.jsonl.zstd` → `/.dsh-sync//session-/`(缺失才写) - `git add .dsh-sync` + `git commit -m "dsh-sync: N sessions"` + `git push` 5. `mode === 'all'`:`git add -A` + commit + push 6. 返回统计 ### 3.5 pull 流程 1. gate + 配置 2. `git pull`(未绑定 upstream 时用 `git pull `) 3. `mode === 'conversations'`: - 从 `/.dsh-sync//` 枚举会话 - 复制**缺失的**到 `~/.dsh/sessions//`(**不覆盖已存在文件**——防覆盖本机正在写的会话) - `requiresRestart: true`(DSH 需重启才扫描发现新会话) 4. `mode === 'all'`:直接 `git pull` 合入工作区文件 5. 返回统计 ### 3.6 编码工具 `projectKey(cwd)` 从 DSH 源码复制(行为必须一致): ```js 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) │ │ 工作区 状态 模式 │ │ ✓同步 [全部文件▼] │ │ [推送] [拉取] [状态] │ │ ⚠远程新 [仅对话▼] │ │ [推送] [拉取] [状态] │ └──────────────────────────────────────┘ ``` - 工作区列表来自 `/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. 风险与约束 1. **私有仓库**:对话含敏感内容,必须私有 2. **并发**:同一会话两端同时写会 git 冲突;conversations 模式靠"只补缺失"规避大部分;同一时刻仅一台续接 3. **路径一致性**:同会话续接要求设备间工作区路径一致;否则新设备=新起点、旧会话只读 4. **二进制进 git**:会话文件无 diff,但 zstd 压缩后很小,增量提交可接受 5. **版本脆弱性**:`projectKey` 编码/`sessions` 目录布局随 DSH 升级可能变(rc 阶段),升级后需回归验证 ## 7. 分阶段计划 | 阶段 | 内容 | |---|---| | MVP(本轮) | host 全路由 + 设置卡片 + conversations/all 模式 push/pull/status + 配置持久化 | | 阶段2 | `sidebar.footer.action` 快捷入口 + 远程更新徽标 + 启动时检查 | | 阶段3 | 中央配置仓库 + 预设/插件清单自动更新提示 |