diff --git a/docs/.agents/issue.md b/docs/.agents/issue.md index f918195..31de2b1 100644 --- a/docs/.agents/issue.md +++ b/docs/.agents/issue.md @@ -123,3 +123,12 @@ - **同类问题影响**:所有用 `git log --topo-order` 取数、自计算或直接渲染提交图、且 UI 暴露「按时间浏览」预期的 Git GUI;凡把「lane 算法要求」误读为「必须 `--topo-order`」(实为「子在父之上」即可)的实现均会复现日期回跳;以及排序键与显示日期列不一致(author date vs committer date)导致的「列内看似乱序」类二次 bug。 +## #15 新增/删除/重命名文件在 Commit 视图与 Graph 视图无法打开差异(git 空树 ref 兜底缺失端) + +- **表因**:用户反馈在 **Commit 视图**与 **Graph 视图**点击一个**新增文件**时差异视图直接打不开(预期应展示「全绿新增」对比);删除、重命名文件同样失败;Graph 视图重命名文件点击更是彻底无效。 +- **根因**:两处「打开差异」都为差异的「缺失端」构造了指向**不存在对象**的 git URI:Commit 视图 `commands.ts` 的 `openDiff` 取 `toGitUri(change.uri, 'HEAD')`(新增文件在 HEAD 不存在);Graph 视图 `history-commands.ts` 的 `openCommitFileDiff` 取 `toGitUri(uri, `${hash}^`)`(新增文件在父提交不存在)。VS Code 的 git `GitFileSystemProvider.readFile` 行为随版本演进——**1.85**(本扩展 `engines.vscode` 下限)`catch` 吞掉一切取对象错误返空(容错),**当前主线**改为对不存在对象**抛 `FileNotFound`**、仅当 `ref === repository.getEmptyTree()`(空树)时才回空。故 `'HEAD'` / `${hash}^` 这类具名 ref 在新版会抛错致差异打不开(旧版恰好容错掩盖了缺陷)。附带:Graph 视图 `detailLeafHtml` 的 `data-path` 取的是展示串 `"old → new"`(`sendCommitFiles` 把 rename 拼进 `path`),`joinPath` 得伪路径致重命名彻底崩溃;点击仅回传提交级 `hasParent` 而无逐文件 `status`,宿主无法区分 A/D/M/R。 +- **处理方式**:缺失端统一改用 **git 空树 ref**(`4b825dc642cb6eb9a060e54bf8d69288fbee4904`)构造 URI——旧版容错、新版空树逃逸,两版皆稳定解析为空内容(这正是 VS Code 官方 git 扩展现今为「新增文件」左端的做法,复用非自造)。正交分解:① 纯状态分类器 `engine/diff/change-side.ts`(`diffShapeFromStatus` / `diffShapeFromCode` → `added`/`deleted`/`renamed`/`modified`);② adapter 层 `diff-sides.ts`(`GIT_EMPTY_TREE` + `resolveDiffSides` 按形态把缺失端置空树);③ `openDiff` / `openCommitFileDiff` 改为按 `status` 选端(Commit 视图签名不变;Graph 视图签名 `(hash, filePath, status?, oldPath?)`,移除 `hasParent`,由 `status` 取代);④ 协议 `log/openFile` payload 增 `status`/`oldPath`、去 `hasParent`;⑤ `sendCommitFiles` 的 `path` 改回干净新路径,展示串由 webview 端用 `oldPath` 拼出(数据与展示分离)。新增/根提交均不再依赖 `^`。同步补 `tests/unit/diff-change-side.test.ts`(分类器)与 `tests/suite/diff-open.test.js`(空树 URI 解析为空 + A/D/R/工作区新增打开差异)。 +- **后续防范**:① 为差异的「缺失端」构造 URI 时,**一律用 git 空树 ref**,不要对不存在对象取 `'HEAD'` / `${hash}^` 这类具名 ref——它们只在旧版 VS Code(容错 readFile)上侥幸可用,新版必抛 FileNotFound。② VS Code git 扩展内部行为(如 readFile 容错性)随版本变化,复用其 `toGitUri` 时须确认跨 `engines.vscode` 下限到当前主线的兼容矩阵;空树 ref 是少数有契约保障的「稳定回空」途径。③ webview 的 `data-*` 属性应承载**数据**(机器可用的稳定 key/路径),展示串(含 `"old → new"` 这类人为拼接)只放在可见标签文本里——二者混用会导致 `joinPath` 之类以数据为输入的下游崩溃。④ 逐文件级语义(status)须端到端透传到决策点(host 命令),勿用提交级布尔(`hasParent`)模糊替代——后者无法区分单文件是 A/D/M/R。⑤ 测试断言「差异已打开」时勿按标签计数(VS Code `{preview:true}` 会复用预览槽替换而非新增),应先 `closeAllEditors` 再按差异标题(含文件名)匹配标签。 +- **同类问题影响**:所有消费 vscode.git `toGitUri` 自建差异打开逻辑的扩展,凡为缺失端取具名 ref 的均在新版 VS Code 复现;凡 webview `data-path` 复用展示串(含分隔符)的实现均会在路径拼接处崩溃;以及任何「逐文件操作」误用「提交级 / 全局级」标志判定单文件形态的设计。 + + diff --git a/src/adapter/commands.ts b/src/adapter/commands.ts index 4131ef3..4a22f2d 100644 --- a/src/adapter/commands.ts +++ b/src/adapter/commands.ts @@ -1,6 +1,8 @@ import * as path from 'path'; import * as vscode from 'vscode'; import { FileStatus } from '../engine/model'; +import { diffShapeFromStatus } from '../engine/diff/change-side'; +import { resolveDiffSides } from './diff-sides'; import type { ChangelistRegistry } from './changelist-registry'; import type { ChangeItem, GitRepositoryService } from './git-repository-service'; @@ -121,11 +123,24 @@ export function registerChangesCommands( if (!repo || !change) { return; } - // 空仓库(无 HEAD)时用 originalUri 兜底,避免 git scheme 解析失败 - const left = repo.state.HEAD ? service.toGitUri(change.uri, 'HEAD') : change.originalUri; - const right = change.uri; + // 按变更形态选择差异端点:新增置空旧端、删除置空新端、重命名旧端取原路径;缺失端统一走 git 空树 ref + // (跨 VS Code 版本稳定回空),避免对不存在对象取 'HEAD' 致新版差异打不开。空树在无 HEAD 的空仓库下亦成立。 + const shape = diffShapeFromStatus(change.status); + const oldUri = shape === 'renamed' ? change.originalUri : change.uri; + const { left, right } = resolveDiffSides( + service, + shape, + oldUri, + service.toGitUri(oldUri, 'HEAD'), + change.uri, + change.uri, + ); const title = `${path.basename(change.relativePath)} (HEAD ↔ Working)`; - await vscode.commands.executeCommand('vscode.diff', left, right, title); + try { + await vscode.commands.executeCommand('vscode.diff', left, right, title); + } catch (e) { + void vscode.window.showErrorMessage(`Failed to open diff: ${e instanceof Error ? e.message : String(e)}`); + } }), ); diff --git a/src/adapter/diff-sides.ts b/src/adapter/diff-sides.ts new file mode 100644 index 0000000..24d21a5 --- /dev/null +++ b/src/adapter/diff-sides.ts @@ -0,0 +1,52 @@ +/** + * 差异左右端点解析(adapter 层,复用 vscode.git 的 `toGitUri` 生成 git scheme 资源)。 + * + * 缺失端(新增文件的旧端 / 删除文件的新端)统一指向 git 空树 ref,以跨 VS Code 版本稳定地解析为空内容: + * - 旧版(≤1.85 等)git FileSystemProvider.readFile 对任意取不到的对象容错回空; + * - 新版对不存在对象抛 FileNotFound,仅当 ref 等于仓库空树时才回空(空树逃逸)。 + * 二者叠加使「空树 ref」成为两版皆稳的空内容来源;而具名 ref('HEAD' / `${hash}^`)对不存在对象在新版会抛错, + * 正是「新增/删除文件差异打不开」的根因(详见 docs/.agents/issue.md)。 + */ + +import type * as vscode from 'vscode'; +import type { GitRepositoryService } from './git-repository-service'; +import type { DiffShape } from '../engine/diff/change-side'; + +/** + * git 空树对象哈希(SHA-1:`git hash-object -t tree /dev/null` 的恒定值)。 + * 缺失端指向它 → 旧版容错回空、新版空树逃逸回空,均稳定得到空文档。 + * 注:SHA-256 仓库空树哈希不同,新版空树逃逸不匹配(极少见,暂不覆盖)。 + */ +export const GIT_EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; + +/** 缺失端资源 Uri:对目标路径取 git 空树 ref(稳定解析为空内容)。 */ +export function emptyTreeUri(service: GitRepositoryService, uri: vscode.Uri): vscode.Uri { + return service.toGitUri(uri, GIT_EMPTY_TREE); +} + +/** + * 依差异形态选择 [left, right]。缺失端置空树,其余端用调用方已算好的「真实内容端」。 + * + * @param oldUri 旧端路径(重命名时为源路径),置空树时以其定位 + * @param oldSide 旧端真实内容(如 `toGitUri(oldUri, 'HEAD' | `${hash}^`)`) + * @param newUri 新端路径,置空树时以其定位 + * @param newSide 新端真实内容(工作区 `change.uri` 或 `toGitUri(newUri, hash)`) + */ +export function resolveDiffSides( + service: GitRepositoryService, + shape: DiffShape, + oldUri: vscode.Uri, + oldSide: vscode.Uri, + newUri: vscode.Uri, + newSide: vscode.Uri, +): { left: vscode.Uri; right: vscode.Uri } { + switch (shape) { + case 'added': + return { left: emptyTreeUri(service, newUri), right: newSide }; + case 'deleted': + return { left: oldSide, right: emptyTreeUri(service, oldUri) }; + default: + // renamed / modified:两端均取真实内容(重命名为异路径,修改为同路径)。 + return { left: oldSide, right: newSide }; + } +} diff --git a/src/adapter/history-commands.ts b/src/adapter/history-commands.ts index 902bdea..ac392dc 100644 --- a/src/adapter/history-commands.ts +++ b/src/adapter/history-commands.ts @@ -9,6 +9,8 @@ import { handleGitConflict } from './conflict-ui'; import type { MergeMode } from '../engine/log/log-filter'; import { selectedBranchRefs } from './branch-selection'; import { diffPrunedRefs, formatBranchDeleteConfirm, partitionByMerged, truncateNames } from '../engine/ref/cleanup'; +import { diffShapeFromCode } from '../engine/diff/change-side'; +import { resolveDiffSides } from './diff-sides'; /** 注册 Log/Branches/Blame/History/Tags 相关命令。 */ export function registerHistoryCommands( @@ -504,20 +506,26 @@ export function registerHistoryCommands( // —— Log 增强(Phase 2):高级过滤 + 提交详情 diff + per-commit 操作 —— subs.push( - vscode.commands.registerCommand('hyperGit.openCommitFileDiff', async (hash: string, filePath: string, hasParent: boolean) => { + vscode.commands.registerCommand('hyperGit.openCommitFileDiff', async (hash: string, filePath: string, status?: string, oldPath?: string) => { const repo = service.repo; if (!repo) { return; } - const uri = vscode.Uri.joinPath(repo.rootUri, filePath); - const right = service.toGitUri(uri, hash); + // 按变更形态选择差异端点:新增置空旧端、删除置空新端、重命名/复制两端取异路径。缺失端统一走 git 空树 ref + // (跨 VS Code 版本稳定回空),避免对父提交不存在的对象取 `${hash}^` 致新版差异打不开。新增/根提交均不依赖 `^`。 + const shape = diffShapeFromCode(status ?? 'M'); + const newUri = vscode.Uri.joinPath(repo.rootUri, filePath); + const oldUri = oldPath ? vscode.Uri.joinPath(repo.rootUri, oldPath) : newUri; + const { left, right } = resolveDiffSides( + service, + shape, + oldUri, + service.toGitUri(oldUri, `${hash}^`), + newUri, + service.toGitUri(newUri, hash), + ); try { - if (hasParent) { - const left = service.toGitUri(uri, `${hash}^`); - await vscode.commands.executeCommand('vscode.diff', left, right, `${filePath} · ${hash.slice(0, 7)} (commit diff)`, { preview: true }); - } else { - await vscode.commands.executeCommand('vscode.open', right); - } + await vscode.commands.executeCommand('vscode.diff', left, right, `${filePath} · ${hash.slice(0, 7)} (commit diff)`, { preview: true }); } catch (e) { void vscode.window.showErrorMessage(`Failed to open diff: ${errMsg(e)}`); } diff --git a/src/adapter/webview/log-webview.ts b/src/adapter/webview/log-webview.ts index 9b702bf..9b671e9 100644 --- a/src/adapter/webview/log-webview.ts +++ b/src/adapter/webview/log-webview.ts @@ -168,7 +168,8 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider, LogFilter 'hyperGit.openCommitFileDiff', msg.payload.hash, msg.payload.path, - msg.payload.hasParent, + msg.payload.status, + msg.payload.oldPath, ); break; case 'log/setScope': @@ -421,10 +422,11 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider, LogFilter // 复用 Log 既有逻辑:diff-tree 取变更文件。 const out = await this.service.execGit(['diff-tree', '--no-commit-id', '--name-status', '-r', '--root', hash]); const changes = parseNameStatus(out); + // path 取干净新路径(供 data-path/建树/端点定位);rename/copy 的 "old → new" 展示由 webview 端用 oldPath 拼出。 const files: LogCommitFileItem[] = changes.map((c) => ({ status: c.status, statusLabel: statusLabel(c.status), - path: c.oldPath ? `${c.oldPath} → ${c.path}` : c.path, + path: c.path, oldPath: c.oldPath, themeColor: fileIconColor(c.status), })); @@ -1082,20 +1084,20 @@ detailsList.addEventListener('click', function (e) { const d = e.target.closest('.tree-dir'); if (d) { toggleDetailCollapse(d.getAttribute('data-dir')); return; } const f = e.target.closest('.file'); if (!f) return; - vscode.postMessage({ type: 'log/openFile', payload: { hash: f.getAttribute('data-hash'), path: f.getAttribute('data-path'), hasParent: f.getAttribute('data-hasparent') === '1' } }); + vscode.postMessage({ type: 'log/openFile', payload: { hash: f.getAttribute('data-hash'), path: f.getAttribute('data-path'), status: f.getAttribute('data-status'), oldPath: f.getAttribute('data-oldpath') || undefined } }); }); const DINDENT = 14; -function detailLeafHtml(hash, f, hasParent, depth, label) { - return '
' + esc(f.statusLabel) + '' + esc(label) + '
'; +function detailLeafHtml(hash, f, depth, label) { + return '
' + esc(f.statusLabel) + '' + esc(label) + '
'; } -function renderDetailNode(node, depth, hash, hasParent, files, out) { +function renderDetailNode(node, depth, hash, files, out) { if (node.dir) { const isCol = dcollapsed.has(node.path); out.push('
' + (isCol ? '\\u25B8' : '\\u25BE') + '' + esc(node.name) + '
'); - if (!isCol) { for (const c of node.children) renderDetailNode(c, depth + 1, hash, hasParent, files, out); } + if (!isCol) { for (const c of node.children) renderDetailNode(c, depth + 1, hash, files, out); } } else { - out.push(detailLeafHtml(hash, files[node.fileIndex], hasParent, depth, node.name)); + out.push(detailLeafHtml(hash, files[node.fileIndex], depth, node.name)); } } function pruneDetailCollapsed(tree) { @@ -1122,14 +1124,12 @@ function renderDetails(hash, files, tree) { if (!hash) { detailsEl.classList.remove('show'); return; } curDetailHash = hash; curDetailFiles = files || []; curDetailTree = tree || []; updateDetailModeButtons(); - const row = model.rows.find(function (r) { return r.hash === hash; }); - const hasParent = row && row.parents && row.parents.length > 0 ? '1' : '0'; detailsTitleEl.textContent = 'Changed Files (' + curDetailFiles.length + ') · ' + hash.slice(0, 7); if (curDetailFiles.length === 0) { detailsList.innerHTML = '
No changed files (may be a root or merge commit)
'; detailsEl.classList.add('show'); return; } pruneDetailCollapsed(curDetailTree); const out = []; - if (detailsMode === 'tree') { for (const n of curDetailTree) renderDetailNode(n, 0, hash, hasParent, curDetailFiles, out); } - else { for (const f of curDetailFiles) out.push(detailLeafHtml(hash, f, hasParent, 0, f.path)); } + if (detailsMode === 'tree') { for (const n of curDetailTree) renderDetailNode(n, 0, hash, curDetailFiles, out); } + else { for (const f of curDetailFiles) out.push(detailLeafHtml(hash, f, 0, (f.oldPath ? f.oldPath + ' → ' + f.path : f.path))); } detailsList.innerHTML = out.join(''); detailsEl.classList.add('show'); } diff --git a/src/engine/diff/change-side.ts b/src/engine/diff/change-side.ts new file mode 100644 index 0000000..3f1d9c7 --- /dev/null +++ b/src/engine/diff/change-side.ts @@ -0,0 +1,53 @@ +/** + * 变更文件 → 差异端点形态(纯逻辑,零 vscode 依赖,可被 Vitest 与未来 CLI 双复用)。 + * + * 「打开差异」需为左(旧)/右(新)两端各取一个内容源。当一端在对应 ref 下不存在(新增文件无旧端、 + * 删除文件无新端)时,若仍对不存在对象取具名 ref('HEAD' / `${hash}^`),较新 VS Code 的 git + * FileSystemProvider.readFile 会抛 FileNotFound 致差异打不开(见 issue.md)。故先把「变更状态」正交地 + * 归约为 DiffShape,再由 adapter 层据此决定哪一端置空(git 空树 ref)。 + */ + +import { FileStatus } from '../model'; + +/** 差异端点形态:决定左右两端如何取(新增置空旧端 / 删除置空新端 / 重命名两端异路径 / 修改两端同路径)。 */ +export type DiffShape = 'added' | 'deleted' | 'renamed' | 'modified'; + +/** + * 领域模型状态(Commit 视图,来自 vscode.git 映射)→ 差异形态。 + * Copied 归为 added:工作区 copy 的源路径不可靠,置空旧端最稳妥(回落为「全新增」对比)。 + */ +export function diffShapeFromStatus(status: FileStatus): DiffShape { + switch (status) { + case FileStatus.Added: + case FileStatus.Untracked: + case FileStatus.Copied: + return 'added'; + case FileStatus.Deleted: + return 'deleted'; + case FileStatus.Renamed: + return 'renamed'; + default: + // Modified / Conflict / Ignored 等:两端同路径取真实内容对比。 + return 'modified'; + } +} + +/** + * git diff-tree 字母状态码(Graph 视图,形如 A / M / D / R100 / C90 / T / U)→ 差异形态。 + * 取首字母判定;C(copy)在 diff-tree 输出里携带可靠 oldPath,故按 renamed 处理(源↔目标内容对比)。 + */ +export function diffShapeFromCode(code: string): DiffShape { + switch (code[0]) { + case 'A': + case 'U': + return 'added'; + case 'D': + return 'deleted'; + case 'R': + case 'C': + return 'renamed'; + default: + // M(modified)/ T(type-changed)/ 其它:两端同路径取真实内容对比。 + return 'modified'; + } +} diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index c6b2f2d..6f35e84 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -231,7 +231,7 @@ export type LogWebviewToHostMessage = | { readonly type: 'log/selectCommit'; readonly payload: { readonly hash: string } } | { readonly type: 'log/commitAction'; readonly payload: { readonly op: LogCommitOp; readonly hash: string } } | { readonly type: 'log/setScope'; readonly payload: { readonly scope: LogScope } } - | { readonly type: 'log/openFile'; readonly payload: { readonly hash: string; readonly path: string; readonly hasParent: boolean } } + | { readonly type: 'log/openFile'; readonly payload: { readonly hash: string; readonly path: string; readonly status: string; readonly oldPath?: string } } | { readonly type: 'log/requestCi'; readonly payload: { readonly hashes: readonly string[] } } | { readonly type: 'log/ciSignIn' } | { readonly type: 'log/openExternal'; readonly payload: { readonly url: string } } diff --git a/tests/suite/diff-open.test.js b/tests/suite/diff-open.test.js new file mode 100644 index 0000000..237aef6 --- /dev/null +++ b/tests/suite/diff-open.test.js @@ -0,0 +1,130 @@ +const assert = require('assert'); +const vscode = require('vscode'); +const fs = require('fs'); +const path = require('path'); +const cp = require('child_process'); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const GIT_EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; + +/** + * 「打开差异」集成测试:覆盖新增/删除/重命名/根提交文件在两个视图均能打开原生差异编辑器。 + * 直接对核心修复点(缺失端走 git 空树 ref)做端到端验证: + * 1. 空树 URI 经 vscode.git 解析得到空文档(否则新增/删除文件差异在新版 VS Code 会 FileNotFound)。 + * 2. `hyperGit.openCommitFileDiff`(Graph 视图)对 A/D/R 不抛错并打开差异。 + * 3. `hyperGit.openDiff`(Commit 视图)对工作区未跟踪新增文件不抛错并打开差异。 + * + * 注:VS Code 对 `{preview:true}` 的差异会复用预览标签页(后续差异替换而非新增), + * 故按「标签数自增」断言不可靠;这里每例先关闭全部编辑器,再按差异标题(含文件名)匹配标签。 + */ +suite('打开差异(新增/删除/重命名/根提交)', function () { + this.timeout(60000); + + let api; + let repo; + let root; + + suiteSetup(async function () { + const gitExt = vscode.extensions.getExtension('vscode.git'); + assert.ok(gitExt, 'vscode.git 扩展未找到'); + if (!gitExt.isActive) { + await gitExt.activate(); + } + api = gitExt.exports.getAPI(1); + for (let i = 0; i < 40; i++) { + repo = api.repositories[0]; + if (repo) { + try { + await repo.status(); + } catch { + /* ignore */ + } + if (repo.state.workingTreeChanges.length > 0 || repo.state.untrackedChanges.length > 0) { + break; + } + } + await sleep(500); + } + assert.ok(repo, '未发现 git 仓库(fixture 未被 vscode.git 打开)'); + root = repo.rootUri.fsPath; + }); + + async function waitFor(predicate, attempts = 50) { + for (let i = 0; i < attempts; i++) { + if (await predicate()) { + return true; + } + await sleep(200); + } + return false; + } + + async function closeAllEditors() { + await vscode.commands.executeCommand('workbench.action.closeAllEditors'); + await sleep(300); + } + + /** 等待出现标签标题包含 nameContains 的标签(差异标题含文件名 + hash)。 */ + async function waitForDiffTab(nameContains) { + return waitFor(() => + vscode.window.tabGroups.all.some((g) => g.tabs.some((t) => (t.label || '').includes(nameContains))), + ); + } + + test('空树 URI 解析为空文档(缺失端锚点)', async function () { + // 任意路径 + 空树 ref 都应稳定得到空文档(与该文件是否存在/跟踪无关)。 + const emptyUri = api.toGitUri(vscode.Uri.joinPath(repo.rootUri, 'any/file.txt'), GIT_EMPTY_TREE); + const doc = await vscode.workspace.openTextDocument(emptyUri); + assert.strictEqual(doc.getText(), '', '空树 ref 应解析为空文档'); + }); + + test('Graph 视图:新增文件打开差异不抛错', async function () { + await closeAllEditors(); + fs.writeFileSync(path.join(root, 'added.txt'), '新增内容\n'); + cp.execFileSync('git', ['add', 'added.txt'], { cwd: root }); + cp.execFileSync('git', ['commit', '-q', '-m', 'feat: 新增 added.txt'], { cwd: root }); + const hash = cp.execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root }).toString().trim(); + + await vscode.commands.executeCommand('hyperGit.openCommitFileDiff', hash, 'added.txt', 'A'); + assert.ok(await waitForDiffTab('added.txt'), '应打开 added.txt 差异'); + }); + + test('Graph 视图:删除文件打开差异不抛错', async function () { + await closeAllEditors(); + cp.execFileSync('git', ['rm', '-q', 'added.txt'], { cwd: root }); + cp.execFileSync('git', ['commit', '-q', '-m', 'chore: 删除 added.txt'], { cwd: root }); + const hash = cp.execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root }).toString().trim(); + + await vscode.commands.executeCommand('hyperGit.openCommitFileDiff', hash, 'added.txt', 'D'); + assert.ok(await waitForDiffTab('added.txt'), '应打开 added.txt 差异'); + }); + + test('Graph 视图:重命名文件打开差异不抛错', async function () { + await closeAllEditors(); + // README.md 由 fixture 初始提交落库,git mv 制造可靠 rename(含 oldPath)。 + cp.execFileSync('git', ['mv', 'README.md', 'renamed.md'], { cwd: root }); + cp.execFileSync('git', ['commit', '-q', '-m', 'refactor: README 改名'], { cwd: root }); + const hash = cp.execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root }).toString().trim(); + + // status=R100、oldPath=README.md:命中「重命名」分支,左端取旧路径@父、右端取新路径@本提交。 + await vscode.commands.executeCommand('hyperGit.openCommitFileDiff', hash, 'renamed.md', 'R100', 'README.md'); + assert.ok(await waitForDiffTab('renamed.md'), '应打开 renamed.md 差异'); + }); + + test('Commit 视图:工作区未跟踪新增文件打开差异不抛错', async function () { + await closeAllEditors(); + const file = path.join(root, 'untracked.txt'); + fs.writeFileSync(file, '未跟踪\n'); + + // 模拟 Commit 视图点击:构造 ChangeItem(status='A' 即 FileStatus.Added)触发 openDiff。 + const change = { + relativePath: 'untracked.txt', + uri: vscode.Uri.file(file), + originalUri: vscode.Uri.file(file), + status: 'A', + staged: false, + }; + await vscode.commands.executeCommand('hyperGit.openDiff', change); + assert.ok(await waitForDiffTab('untracked.txt'), '应打开 untracked.txt 差异'); + }); +}); diff --git a/tests/suite/index.js b/tests/suite/index.js index 9fa5dc7..69f019e 100644 --- a/tests/suite/index.js +++ b/tests/suite/index.js @@ -9,6 +9,7 @@ async function run() { const mocha = new Mocha({ ui: 'tdd', color: true, timeout: 60000 }); mocha.addFile(path.resolve(__dirname, 'extension.test.js')); mocha.addFile(path.resolve(__dirname, 'commit-flow.test.js')); + mocha.addFile(path.resolve(__dirname, 'diff-open.test.js')); return new Promise((resolve, reject) => { mocha.run((failures) => { diff --git a/tests/unit/diff-change-side.test.ts b/tests/unit/diff-change-side.test.ts new file mode 100644 index 0000000..dae7c3b --- /dev/null +++ b/tests/unit/diff-change-side.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { diffShapeFromStatus, diffShapeFromCode } from '../../src/engine/diff/change-side'; +import { FileStatus } from '../../src/engine/model'; + +describe('diffShapeFromStatus(Commit 视图领域模型状态 → 差异形态)', () => { + it('新增类(Added/Untracked/Copied)→ added(旧端置空)', () => { + expect(diffShapeFromStatus(FileStatus.Added)).toBe('added'); + expect(diffShapeFromStatus(FileStatus.Untracked)).toBe('added'); + expect(diffShapeFromStatus(FileStatus.Copied)).toBe('added'); + }); + + it('Deleted → deleted(新端置空)', () => { + expect(diffShapeFromStatus(FileStatus.Deleted)).toBe('deleted'); + }); + + it('Renamed → renamed(两端异路径)', () => { + expect(diffShapeFromStatus(FileStatus.Renamed)).toBe('renamed'); + }); + + it('其余(Modified/Conflict/Ignored)→ modified(两端同路径真实内容)', () => { + expect(diffShapeFromStatus(FileStatus.Modified)).toBe('modified'); + expect(diffShapeFromStatus(FileStatus.Conflict)).toBe('modified'); + expect(diffShapeFromStatus(FileStatus.Ignored)).toBe('modified'); + }); +}); + +describe('diffShapeFromCode(Graph 视图 git diff-tree 字母码 → 差异形态)', () => { + it('A/U → added', () => { + expect(diffShapeFromCode('A')).toBe('added'); + expect(diffShapeFromCode('U')).toBe('added'); + }); + + it('D → deleted', () => { + expect(diffShapeFromCode('D')).toBe('deleted'); + }); + + it('R/C(含相似度后缀 R100/C90)→ renamed', () => { + expect(diffShapeFromCode('R')).toBe('renamed'); + expect(diffShapeFromCode('R100')).toBe('renamed'); + expect(diffShapeFromCode('C')).toBe('renamed'); + expect(diffShapeFromCode('C90')).toBe('renamed'); + }); + + it('M/T 及未知码 → modified(默认回落)', () => { + expect(diffShapeFromCode('M')).toBe('modified'); + expect(diffShapeFromCode('T')).toBe('modified'); + expect(diffShapeFromCode('X')).toBe('modified'); + }); +});