Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/.agents/issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 复用展示串(含分隔符)的实现均会在路径拼接处崩溃;以及任何「逐文件操作」误用「提交级 / 全局级」标志判定单文件形态的设计。


23 changes: 19 additions & 4 deletions src/adapter/commands.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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)}`);
}
}),
);

Expand Down
52 changes: 52 additions & 0 deletions src/adapter/diff-sides.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
}
26 changes: 17 additions & 9 deletions src/adapter/history-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)}`);
}
Expand Down
24 changes: 12 additions & 12 deletions src/adapter/webview/log-webview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down Expand Up @@ -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),
}));
Expand Down Expand Up @@ -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 '<div class="file" style="padding-left:' + (depth * DINDENT + 10) + 'px" data-hash="' + esc(hash) + '" data-path="' + esc(f.path) + '" data-hasparent="' + hasParent + '"><span class="dot" style="color:var(--vscode-' + f.themeColor.replace(/\\./g, '-') + ')">' + esc(f.statusLabel) + '</span><span class="nm">' + esc(label) + '</span></div>';
function detailLeafHtml(hash, f, depth, label) {
return '<div class="file" style="padding-left:' + (depth * DINDENT + 10) + 'px" data-hash="' + esc(hash) + '" data-path="' + esc(f.path) + '" data-oldpath="' + esc(f.oldPath || '') + '" data-status="' + esc(f.status) + '"><span class="dot" style="color:var(--vscode-' + f.themeColor.replace(/\\./g, '-') + ')">' + esc(f.statusLabel) + '</span><span class="nm">' + esc(label) + '</span></div>';
}
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('<div class="tree-dir" style="padding-left:' + (depth * DINDENT + 8) + 'px" data-dir="' + esc(node.path) + '"><span class="tree-twist">' + (isCol ? '\\u25B8' : '\\u25BE') + '</span><span class="tree-name">' + esc(node.name) + '</span></div>');
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) {
Expand All @@ -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 = '<div class="file" style="opacity:.6">No changed files (may be a root or merge commit)</div>'; 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');
}
Expand Down
53 changes: 53 additions & 0 deletions src/engine/diff/change-side.ts
Original file line number Diff line number Diff line change
@@ -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';
}
}
2 changes: 1 addition & 1 deletion src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }
Expand Down
Loading