From 63e2b7fe0cd7dca4ebf8e57e39deacb6afdff584 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Fri, 23 Oct 2026 18:17:49 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(Branches):=20=E5=88=86=E6=94=AF?= =?UTF-8?q?=E5=B0=B1=E5=9C=B0=E6=9B=B4=E6=96=B0=E2=80=94=E2=80=94=E4=B8=8D?= =?UTF-8?q?=20checkout=20=E5=8F=B3=E9=94=AE=20Update=20/=20=E6=A0=87?= =?UTF-8?q?=E9=A2=98=E6=A0=8F=E9=80=89=E5=8C=BA=E6=84=9F=E7=9F=A5=20/=20?= =?UTF-8?q?=E5=A4=9A=E9=80=89=E6=89=B9=E9=87=8F;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新命令 hyperGit.branchUpdate:目标解析链「右键实参 → 视图选区 → 无选区等价 Pull」; - git fetch :refs/heads/ 原生 fast-forward-only,diverged 安全拒绝; - 当前分支改走 repo.pull()(含冲突通道),无上游跳过,五桶对账汇总; - engine 纯逻辑 planBranchUpdates / formatBranchUpdateResult + 11 个单测; - Branches 标题栏 navigation@3 由全局 Pull 换为 branchUpdate(Commit 视图 Pull 不变)。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- CHANGELOG.md | 4 + package.json | 17 +++- src/adapter/branch-selection.ts | 2 +- src/adapter/remote-commands.ts | 114 +++++++++++++++++++++- src/engine/ref/branch-update.ts | 141 +++++++++++++++++++++++++++ src/extension.ts | 2 +- tests/suite/extension.test.js | 1 + tests/unit/branch-update.test.ts | 119 ++++++++++++++++++++++ tests/unit/branches-titlebar.test.ts | 36 +++++++ 9 files changed, 432 insertions(+), 4 deletions(-) create mode 100644 src/engine/ref/branch-update.ts create mode 100644 tests/unit/branch-update.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ea9426..d0a703b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ ## [Unreleased] +### Added + +- **Branches 视图分支就地更新(不 checkout)**:右键单个分支 **Update Branch**、多选批量更新、标题栏 Pull 按钮升级为选区感知(无选区保持原 Pull 语义,Commit 视图 Pull 不受影响)。底层原语 `git fetch :refs/heads/`——git 原生 fast-forward-only,diverged / ahead-only 分支被安全拒绝(附真实 stderr 原因),**永不改写本地提交**;当前分支自动改走 `repo.pull()`(含冲突处理通道);无上游分支透明跳过。五桶对账汇总(updated / already up to date / pulled / skipped / failed,选中数 = 各桶之和),up-to-date 经前后 `objectname` 短 sha 对比判定(fetch 静默 no-op 无输出)。upstream 解析不信任树节点数据(API 路径 `refToRaw` 不填 upstream/head),统一以一次 `for-each-ref` 取新鲜快照;`origin/x` 拆分复用 `resolveRemoteBranch` 最长前缀匹配(remote 名可含 `/`)。engine 纯逻辑 `planBranchUpdates` / `formatBranchUpdateResult` 落于 `engine/ref/branch-update.ts`,全量单测覆盖。 + ## [0.0.21] - 2026-10-23 — Filter by Author 候选建议 · 多选过滤 · 元字符作者修复 ### Added diff --git a/package.json b/package.json index 7211c72..3f66138 100644 --- a/package.json +++ b/package.json @@ -661,6 +661,12 @@ "category": "Hyper Git", "icon": "$(arrow-up)" }, + { + "command": "hyperGit.branchUpdate", + "title": "Update Branch", + "category": "Hyper Git", + "icon": "$(arrow-down)" + }, { "command": "hyperGit.resolveConflicts", "title": "Resolve Conflicts (3-way Merge Editor)…", @@ -972,7 +978,7 @@ "group": "navigation@2" }, { - "command": "hyperGit.pull", + "command": "hyperGit.branchUpdate", "when": "view == hyperGit.branches", "group": "navigation@3" }, @@ -1108,6 +1114,11 @@ "when": "view == hyperGit.branches && viewItem =~ /^(hyperGit.branch|hyperGit.remoteBranch)$/ && !listMultiSelection", "group": "1_branch@1.5" }, + { + "command": "hyperGit.branchUpdate", + "when": "view == hyperGit.branches && viewItem == hyperGit.branch", + "group": "1_branch@1.7" + }, { "command": "hyperGit.compareWithCurrent", "when": "view == hyperGit.branches && viewItem =~ /^(hyperGit.branch|hyperGit.remoteBranch|hyperGit.tag)$/ && !listMultiSelection", @@ -1448,6 +1459,10 @@ { "command": "hyperGit.pushBranch", "when": "false" + }, + { + "command": "hyperGit.branchUpdate", + "when": "false" } ] }, diff --git a/src/adapter/branch-selection.ts b/src/adapter/branch-selection.ts index 6593efb..da019d2 100644 --- a/src/adapter/branch-selection.ts +++ b/src/adapter/branch-selection.ts @@ -18,7 +18,7 @@ import type { BranchNode } from './tree/branches-tree'; */ export function selectedBranchRefs( clicked: BranchNode | undefined, - selection: BranchNode[] | undefined, + selection: readonly BranchNode[] | undefined, predicate: (ref: RawRef) => boolean, ): RawRef[] { const clickedRef = clicked?.kind === 'branch' ? clicked.ref : undefined; diff --git a/src/adapter/remote-commands.ts b/src/adapter/remote-commands.ts index 3f4c1f0..ac44006 100644 --- a/src/adapter/remote-commands.ts +++ b/src/adapter/remote-commands.ts @@ -15,21 +15,31 @@ import { type RemoteBranchTarget, type RemoteDeleteFailure, } from '../engine/ref/remote-ref'; +import { FOR_EACH_REF_FORMAT, parseForEachRef, type RawRef } from '../engine/ref/for-each-ref'; +import { + planBranchUpdates, + formatBranchUpdateResult, + type BranchUpdateAction, + type BranchUpdateFailure, +} from '../engine/ref/branch-update'; import type { GitRepositoryService } from './git-repository-service'; import { gitErrorText } from '../engine/git-state/git-error'; /** - * 注册远程同步对话框(Phase 3):Push 对话框 / Update Project / Merge 对话框。 + * 注册远程同步命令(Phase 3 起):Push 对话框 / Update Project / Merge 对话框 / 分支就地更新 / 删远程分支。 * * vscode.git 稳定 API 的 pull/push 缺策略/force/tags 选项,故经 execGit 实现: * - Push:force/force-with-lease/push tags(核心走 repo.push,tags 走 CLI)。 * - Update Project:pull --rebase / --no-rebase(策略)。 * - Merge:--ff-only / --no-ff / --squash + 自定义 message。 + * - Update Branch:`git fetch :refs/heads/`(原生 fast-forward-only, + * 不 checkout 就地更新本地分支;当前分支改走 repo.pull)。 */ export function registerRemoteCommands( service: GitRepositoryService, branchesTree: BranchesTreeProvider, logTree: LogFilterControl, + branchesView: vscode.TreeView, ): vscode.Disposable[] { const subs: vscode.Disposable[] = []; @@ -122,6 +132,108 @@ export function registerRemoteCommands( }), ); + subs.push( + vscode.commands.registerCommand('hyperGit.branchUpdate', async (node?: BranchNode, nodes?: BranchNode[]) => { + const repo = service.repo; + if (!repo) { + return; + } + // 目标解析链:右键实参 →(无实参的标题栏路径)视图选区 → 无选区等价 Pull(当前分支)。 + // 仅本地、非 tag(多选时自动过滤远程/标签节点)。 + const isLocal = (r: RawRef) => !r.isRemote && !r.isTag; + let targets = selectedBranchRefs(node, nodes, isLocal); + if (targets.length === 0 && !node) { + targets = selectedBranchRefs(undefined, branchesView.selection, isLocal); + } + if (targets.length === 0) { + if (node) { + // 点击项非本地分支(防御;菜单门控 viewItem == hyperGit.branch 下不可达)。 + return; + } + const headName = repo.state.HEAD?.name; + if (!headName) { + void vscode.window.showWarningMessage('Currently in detached HEAD; no branch to update'); + return; + } + // 无选区 → 语义兜底为 Pull 当前分支,复用既有命令(含冲突通道)。 + await vscode.commands.executeCommand('hyperGit.pull'); + return; + } + // 新鲜快照:树节点 ref.upstream/head 在 API 路径下为空(refToRaw 不填),禁止信任节点数据。 + let snapshot: ReadonlyMap; + try { + const out = await service.execGit(['for-each-ref', `--format=${FOR_EACH_REF_FORMAT}`, 'refs/heads']); + snapshot = new Map(parseForEachRef(out).map((r) => [r.shortName, r] as const)); + } catch (e) { + void showGitError(`Failed to read branch info: ${gitErrorText(e)}`); + return; + } + // 引擎纯逻辑排程 + 逐分支执行(per-item try/catch,三桶先例)。 + const remotes = repo.state.remotes.map((r) => r.name); + const headName = repo.state.HEAD?.name; + const plan = planBranchUpdates( + targets.map((r) => r.shortName), + snapshot, + headName, + remotes, + ); + const skipped = plan.filter((a): a is Extract => a.kind === 'skip-no-upstream').map((a) => a.name); + const updated: string[] = []; + const upToDate: string[] = []; + const pulled: string[] = []; + const failures: BranchUpdateFailure[] = []; + const failedNames = new Set(); + await runWithProgress('Updating branch(es)…', async () => { + for (const action of plan) { + if (action.kind === 'skip-no-upstream') { + continue; + } + try { + if (action.kind === 'pull-current') { + await repo.pull(); + pulled.push(action.name); + } else { + await service.execGit(['fetch', action.remote, `${action.upstreamBranch}:refs/heads/${action.name}`]); + } + } catch (e) { + if (action.kind === 'pull-current') { + // 冲突 UI 先行展示;仍按失败计数,维持「选中数 = 各桶之和」的对账诚实。 + await handleGitConflict(service, 'Pull'); + } + failedNames.add(action.name); + failures.push({ name: action.name, reason: gitErrorText(e) }); + } + } + }); + // 事后一次 for-each-ref:sha 对比分类 updated/upToDate(fetch 静默 no-op 无输出可解析)。 + try { + const out = await service.execGit(['for-each-ref', `--format=${FOR_EACH_REF_FORMAT}`, 'refs/heads']); + const after = new Map(parseForEachRef(out).map((r) => [r.shortName, r.objectname] as const)); + for (const a of plan) { + if (a.kind !== 'fetch' || failedNames.has(a.name)) { + continue; + } + (after.get(a.name) !== a.beforeSha ? updated : upToDate).push(a.name); + } + } catch { + // 分类不可得 → 保守计 updated(fetch 已成功,仅失判定粒度)。 + for (const a of plan) { + if (a.kind === 'fetch' && !failedNames.has(a.name)) { + updated.push(a.name); + } + } + } + branchesTree.refresh(); + logTree.refresh(); + const result = formatBranchUpdateResult({ updated, upToDate, pulled, skipped, failures }); + if (result.level === 'info') { + void vscode.window.showInformationMessage(result.message); + } else { + void showGitError(result.message); + } + }), + ); + subs.push( vscode.commands.registerCommand('hyperGit.mergeDialog', async () => { const repo = service.repo; diff --git a/src/engine/ref/branch-update.ts b/src/engine/ref/branch-update.ts new file mode 100644 index 0000000..d057892 --- /dev/null +++ b/src/engine/ref/branch-update.ts @@ -0,0 +1,141 @@ +/** + * 分支就地更新(不 checkout)的纯逻辑(零 vscode 依赖)。 + * + * 核心原语:`git fetch :refs/heads/`——git 原生 fast-forward-only, + * diverged / ahead-only 时拒绝回退(`! [rejected] ... (non-fast-forward)`),永不改写本地提交; + * 目标是当前分支(含被其他 worktree checkout)时 git 拒绝 + * (`refusing to fetch into branch '…' checked out at '…'`),须改走 `repo.pull()`。 + * 本模块提供: + * - {@link planBranchUpdates}:把选中本地分支短名 + for-each-ref 新鲜快照排程为逐分支动作 + * (pull-current / fetch / skip-no-upstream)。 + * - {@link formatBranchUpdateResult}:把逐分支结果五桶汇总为通知文案与级别。 + * + * 与同层模块的关系:upstream 短名(`origin/x`)拆分复用 {@link resolveRemoteBranch} + * (remote 名可含 `/`);汇总文案复用 {@link truncateNames};均以轻量指针保持单一事实源。 + */ + +import type { RawRef } from './for-each-ref'; +import { resolveRemoteBranch } from './remote-ref'; +import { truncateNames } from './cleanup'; + +/** 单分支更新动作({@link planBranchUpdates} 的产物)。 */ +export type BranchUpdateAction = + | { readonly kind: 'pull-current'; readonly name: string } + | { + readonly kind: 'fetch'; + readonly name: string; + readonly remote: string; + readonly upstreamBranch: string; + /** 更新前 objectname 短 sha(事后对比区分 updated / upToDate——fetch 静默 no-op 无输出)。 */ + readonly beforeSha: string; + } + | { readonly kind: 'skip-no-upstream'; readonly name: string }; + +/** + * 把选中的本地分支排程为逐分支更新动作(纯逻辑)。 + * + * 判定规则(顺序即优先级): + * 1. `name === headName` → pull-current:fetch refspec 对当前分支必被 git 拒绝,改走 repo.pull() + * (有无上游都交由 pull 报错,统一失败通道); + * 2. 快照缺失 / 无 upstream / upstream 无法归属任何已知 remote(陈旧 remote 场景)→ skip-no-upstream; + * 3. 其余 → fetch,携带 {remote, upstreamBranch, beforeSha}。 + * + * @param names 选中本地分支短名(调用方已谓词过滤/去重;此处对重复名再防御性去重) + * @param refByShort for-each-ref 新鲜快照(key=短名,含 upstream/objectname)。 + * 树节点 ref 不可作为事实源——API 路径的 refToRaw 不填 upstream/head。 + * @param headName 当前分支名(repo.state.HEAD?.name,权威判定;detached HEAD 时 undefined) + * @param remotes 已知 remote 名列表(repo.state.remotes,upstream 拆分的事实源) + */ +export function planBranchUpdates( + names: readonly string[], + refByShort: ReadonlyMap, + headName: string | undefined, + remotes: readonly string[], +): BranchUpdateAction[] { + const seen = new Set(); + const plan: BranchUpdateAction[] = []; + for (const name of names) { + if (seen.has(name)) { + continue; + } + seen.add(name); + if (name === headName) { + plan.push({ kind: 'pull-current', name }); + continue; + } + const ref = refByShort.get(name); + const target = ref?.upstream ? resolveRemoteBranch(ref.upstream, remotes) : null; + if (!ref || !target) { + plan.push({ kind: 'skip-no-upstream', name }); + continue; + } + plan.push({ kind: 'fetch', name, remote: target.remote, upstreamBranch: target.branch, beforeSha: ref.objectname }); + } + return plan; +} + +/** 单分支更新失败:分支短名 + 从 stderr/message 提取的原因。 */ +export interface BranchUpdateFailure { + readonly name: string; + /** 失败原因(stderr 优先),可能多行。 */ + readonly reason: string; +} + +/** 逐分支更新结果五桶(skip 在排程期即可定桶,其余在执行后归类)。 */ +export interface BranchUpdateBuckets { + /** fetch 后 sha 变化(真更新)。 */ + readonly updated: readonly string[]; + /** fetch 成功但 sha 不变(远程无新提交)。 */ + readonly upToDate: readonly string[]; + /** 当前分支经 repo.pull() 更新。 */ + readonly pulled: readonly string[]; + /** 无上游 / 上游无法归属已知 remote,未执行。 */ + readonly skipped: readonly string[]; + readonly failures: readonly BranchUpdateFailure[]; +} + +/** 通知级别:无失败走 info;存在失败走 error(联动 Show Output)。 */ +export type BranchUpdateResultLevel = 'info' | 'error'; + +/** 失败原因折叠为单行(换行→'; ')并截断,避免通知气泡过长。 */ +function collapseReason(reason: string, max = 160): string { + const line = reason.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).join('; '); + return line.length > max ? `${line.slice(0, max - 1)}…` : line; +} + +/** + * 把逐分支更新结果五桶汇总为通知文案与级别(纯逻辑)。 + * + * 诚实计数:各桶零计数段省略;失败项附真实原因(超 4 条截断标注); + * 选中数 = 五桶之和,保证用户可对账。 + */ +export function formatBranchUpdateResult(buckets: BranchUpdateBuckets): { + level: BranchUpdateResultLevel; + message: string; +} { + const { updated, upToDate, pulled, skipped, failures } = buckets; + const parts: string[] = []; + if (updated.length === 1) { + parts.push(`Updated branch ${updated[0]}`); + } else if (updated.length > 1) { + parts.push(`Updated ${updated.length} branches`); + } + if (upToDate.length === 1) { + parts.push(`${upToDate[0]} is already up to date`); + } else if (upToDate.length > 1) { + parts.push(`${upToDate.length} already up to date`); + } + if (pulled.length > 0) { + // head 至多一个,pull-current 桶恒 ≤1。 + parts.push(`Pulled current branch ${pulled[0]}`); + } + if (skipped.length > 0) { + parts.push(`${skipped.length} skipped (no upstream): ${truncateNames(skipped)}`); + } + if (failures.length === 0) { + return { level: 'info', message: parts.join('; ') }; + } + const entries = failures.map((f) => `${f.name} (${collapseReason(f.reason)})`); + parts.push(`${failures.length} failed: ${truncateNames(entries, 4)}`); + return { level: 'error', message: parts.join('; ') }; +} diff --git a/src/extension.ts b/src/extension.ts index ea8d1dc..4981348 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -220,7 +220,7 @@ export async function activate( ...registerGitCliCommands(service, branchesTree, logTree), ...registerPartialCommands(service, registry), ...registerAdvancedCommands(service, branchesTree), - ...registerRemoteCommands(service, branchesTree, logTree), + ...registerRemoteCommands(service, branchesTree, logTree, branchesView), ...registerMergeCommands(service), // 窗口 reload 后 rebase/merge 面板恢复(retainContextWhenHidden 仅保活不跨 reload,serializer 补齐)。 RebaseWebview.registerSerializer(service), diff --git a/tests/suite/extension.test.js b/tests/suite/extension.test.js index bd6d32a..ff1cdfb 100644 --- a/tests/suite/extension.test.js +++ b/tests/suite/extension.test.js @@ -51,6 +51,7 @@ suite('扩展冒烟测试', function () { 'hyperGit.pull', 'hyperGit.push', 'hyperGit.pushBranch', + 'hyperGit.branchUpdate', 'hyperGit.fetch', 'hyperGit.cherryPick', 'hyperGit.revertCommit', diff --git a/tests/unit/branch-update.test.ts b/tests/unit/branch-update.test.ts new file mode 100644 index 0000000..bffe3ba --- /dev/null +++ b/tests/unit/branch-update.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from 'vitest'; +import { planBranchUpdates, formatBranchUpdateResult } from '../../src/engine/ref/branch-update'; +import type { RawRef } from '../../src/engine/ref/for-each-ref'; + +/** 构造测试用本地分支 RawRef(for-each-ref 新鲜快照口径,upstream/head 可用)。 */ +function ref(shortName: string, opts: Partial = {}): RawRef { + return { + refname: `refs/heads/${shortName}`, + shortName, + objectname: 'abc1234', + head: false, + isRemote: false, + isTag: false, + ...opts, + }; +} + +describe('planBranchUpdates', () => { + it('head 命中 → pull-current(fetch refspec 对当前分支必被 git 拒绝),即使配置了上游', () => { + const snapshot = new Map([['dev', ref('dev', { upstream: 'origin/dev' })]]); + const plan = planBranchUpdates(['dev'], snapshot, 'dev', ['origin']); + expect(plan).toEqual([{ kind: 'pull-current', name: 'dev' }]); + }); + + it('有上游 → fetch 动作携带 remote/upstreamBranch/beforeSha', () => { + const snapshot = new Map([['feature/foo', ref('feature/foo', { upstream: 'origin/feature/foo', objectname: 'deadbee' })]]); + const plan = planBranchUpdates(['feature/foo'], snapshot, 'main', ['origin']); + expect(plan).toEqual([ + { kind: 'fetch', name: 'feature/foo', remote: 'origin', upstreamBranch: 'feature/foo', beforeSha: 'deadbee' }, + ]); + }); + + it('remote 名含 / :最长前缀拆分(myorg/repo/feat → remote=myorg/repo, upstreamBranch=feat)', () => { + const snapshot = new Map([['feat', ref('feat', { upstream: 'myorg/repo/feat' })]]); + const plan = planBranchUpdates(['feat'], snapshot, undefined, ['origin', 'myorg/repo']); + expect(plan).toEqual([{ kind: 'fetch', name: 'feat', remote: 'myorg/repo', upstreamBranch: 'feat', beforeSha: 'abc1234' }]); + }); + + it('无上游 → skip-no-upstream', () => { + const snapshot = new Map([['orphan', ref('orphan')]]); + const plan = planBranchUpdates(['orphan'], snapshot, 'main', ['origin']); + expect(plan).toEqual([{ kind: 'skip-no-upstream', name: 'orphan' }]); + }); + + it('上游指向已不存在的 remote(陈旧配置)→ skip-no-upstream', () => { + const snapshot = new Map([['stale', ref('stale', { upstream: 'gone/stale' })]]); + const plan = planBranchUpdates(['stale'], snapshot, 'main', ['origin']); + expect(plan).toEqual([{ kind: 'skip-no-upstream', name: 'stale' }]); + }); + + it('快照缺失(分支刚被并发删除)→ skip-no-upstream;重复名去重', () => { + const snapshot = new Map([['a', ref('a', { upstream: 'origin/a' })]]); + const plan = planBranchUpdates(['a', 'a', 'ghost'], snapshot, 'main', ['origin']); + expect(plan).toEqual([ + { kind: 'fetch', name: 'a', remote: 'origin', upstreamBranch: 'a', beforeSha: 'abc1234' }, + { kind: 'skip-no-upstream', name: 'ghost' }, + ]); + }); +}); + +describe('formatBranchUpdateResult', () => { + it('单 updated → 点名分支,级别 info', () => { + const result = formatBranchUpdateResult({ updated: ['main'], upToDate: [], pulled: [], skipped: [], failures: [] }); + expect(result).toEqual({ level: 'info', message: 'Updated branch main' }); + }); + + it('多 updated → 计数;零计数段省略(无 pulled/skipped 不出现)', () => { + const result = formatBranchUpdateResult({ + updated: ['a', 'b'], + upToDate: ['c', 'd'], + pulled: [], + skipped: [], + failures: [], + }); + expect(result).toEqual({ level: 'info', message: 'Updated 2 branches; 2 already up to date' }); + }); + + it('单 upToDate → 点名;pulled/skipped 段并存', () => { + const result = formatBranchUpdateResult({ + updated: [], + upToDate: ['c'], + pulled: ['dev'], + skipped: ['orphan'], + failures: [], + }); + expect(result).toEqual({ + level: 'info', + message: 'c is already up to date; Pulled current branch dev; 1 skipped (no upstream): orphan', + }); + }); + + it('有失败 → 级别 error,条目 name (reason) 且多行 reason 折叠单行', () => { + const result = formatBranchUpdateResult({ + updated: ['a'], + upToDate: [], + pulled: [], + skipped: [], + failures: [{ name: 'b', reason: '! [rejected] b -> b (non-fast-forward)\nhint: …' }], + }); + expect(result.level).toBe('error'); + expect(result.message).toBe( + 'Updated branch a; 1 failed: b (! [rejected] b -> b (non-fast-forward); hint: …)', + ); + }); + + it('仅失败(无任何成功段)→ 文案不以空段开头', () => { + const result = formatBranchUpdateResult({ + updated: [], + upToDate: [], + pulled: [], + skipped: [], + failures: [{ name: 'x', reason: 'fatal: refusing to fetch into branch … checked out at …' }], + }); + expect(result).toEqual({ + level: 'error', + message: '1 failed: x (fatal: refusing to fetch into branch … checked out at …)', + }); + }); +}); diff --git a/tests/unit/branches-titlebar.test.ts b/tests/unit/branches-titlebar.test.ts index c615963..9c00472 100644 --- a/tests/unit/branches-titlebar.test.ts +++ b/tests/unit/branches-titlebar.test.ts @@ -88,4 +88,40 @@ describe('branches-titlebar(Branches 标题栏/右键菜单声明护栏)', ( when: 'false', }); }); + + it('Update Branch 命令定义齐备:title "Update Branch" 与 $(arrow-down)(同全局 Pull 图标)', () => { + expect(commandById.get('hyperGit.branchUpdate')).toMatchObject({ + title: 'Update Branch', + icon: '$(arrow-down)', + }); + }); + + it('view/title:标题栏 Pull 位(navigation@3)换成 Update Branch(选区感知,无选区等价 Pull),不再挂全局 Pull', () => { + const branchesEntries = titleEntries.filter((e) => e.when === 'view == hyperGit.branches'); + const byCommand = new Map(branchesEntries.map((e) => [e.command, e])); + expect(byCommand.get('hyperGit.branchUpdate')).toEqual({ + command: 'hyperGit.branchUpdate', + when: 'view == hyperGit.branches', + group: 'navigation@3', + }); + // Commit 视图的 hyperGit.pull 保留;Branches 标题栏不再出现(选区语义由 branchUpdate 承载)。 + expect(byCommand.get('hyperGit.pull'), 'Branches 标题栏不应再挂全局 Pull').toBeUndefined(); + }); + + it('view/item/context:本地分支右键提供 Update Branch(多选可用:when 无 !listMultiSelection),palette 不泄漏', () => { + expect( + itemEntries.find( + (e) => e.command === 'hyperGit.branchUpdate' && e.when === 'view == hyperGit.branches && viewItem == hyperGit.branch', + ), + '分支右键缺 branchUpdate 条目', + ).toEqual({ + command: 'hyperGit.branchUpdate', + when: 'view == hyperGit.branches && viewItem == hyperGit.branch', + group: '1_branch@1.7', + }); + expect(paletteEntries.find((e) => e.command === 'hyperGit.branchUpdate')).toEqual({ + command: 'hyperGit.branchUpdate', + when: 'false', + }); + }); }); From d4c8bad1a44891bcb0680e8eb09a7a5e8dcb9b44 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Fri, 23 Oct 2026 18:17:58 +0800 Subject: [PATCH 2/4] =?UTF-8?q?docs(Branches):=20=E5=88=86=E6=94=AF?= =?UTF-8?q?=E5=B0=B1=E5=9C=B0=E6=9B=B4=E6=96=B0=E7=89=B9=E6=80=A7=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E4=B8=8E=E7=B4=A2=E5=BC=95=E7=99=BB=E8=AE=B0;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- docs/.agents/knowledge-map.md | 1 + docs/README.md | 1 + docs/features/branches-update-in-place.md | 42 +++++++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 docs/features/branches-update-in-place.md diff --git a/docs/.agents/knowledge-map.md b/docs/.agents/knowledge-map.md index 92334c2..dae9791 100644 --- a/docs/.agents/knowledge-map.md +++ b/docs/.agents/knowledge-map.md @@ -25,6 +25,7 @@ - [Claude Code 配置](../docs/features/claude-code-config.md) — Agentic Git 预置:`hyperGit.claudeCode.executablePath` 设置 + `~/.claude/settings.json` 快捷入口(原生设置 + 命令;纯路径逻辑沉淀 `engine/agent/`)。 - [Agentic Git 偏好配置](../docs/features/agentic-git-preferences.md) — Agentic Git 预置:`hyperGit.agent.*` 四项(baseBranch + commit/createPr/review Preferences;多行文本域 + 内置默认模板,Commit 模板中文规约已英译)。 - [多根工作区仓库切换](../docs/features/multi-root-repo-selection.md) — Graph 标题栏切换仓库图标按钮 + `hyperGit.selectRepository` 切换全局活跃仓库:七视图级联跟随、changelist/收藏/Shelf/最近消息按仓库隔离、三重顺序不变量与安全迁移。 +- [分支就地更新](../docs/features/branches-update-in-place.md) — Branches 视图不 checkout 更新本地分支:`git fetch :refs/heads/` 原生 fast-forward-only,右键/多选/标题栏选区感知三入口,五桶对账汇总,引擎排程 + 适配层执行。 - [实施状态总览](../docs/milestones/implementation-status.md) — M0-M5 交付记录 + API 限制 + M5 AI 设计 + 验证/发布(**实施看板**)。 - [工程实施方案](../docs/architecture/engineering-plan.md) — 路径 B 架构 + M0-M5 里程碑(**开发蓝图**)。 - [Git 功能矩阵](../docs/requirements/idea-feature-matrix.md) — 56 功能点 / 8 组(**验收基线**,参考 IDEA 等成熟实现)。 diff --git a/docs/README.md b/docs/README.md index d35d01d..a41f5d2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,7 @@ - [Log 提交详情面板](./features/log-commit-detail-panel.md) — 点击提交行于图右侧打开常驻详情面板(上半 Changed Files、下半提交信息);面板可见性 ⟺ 选中态,替代原悬停浮层。 - [Claude Code 配置](./features/claude-code-config.md) — Agentic Git 预置:Claude Code 可执行路径设置 + `~/.claude/settings.json` 快捷入口(原生设置 + 命令;M5 前置铺垫)。 - [Agentic Git 偏好配置](./features/agentic-git-preferences.md) — Agentic Git 预置:Base Branch + Commit / Create PR / Review 三项 agent 指令偏好(多行文本域 + 内置默认模板;M5 前置铺垫)。 +- [分支就地更新](./features/branches-update-in-place.md) — Branches 视图不 checkout 更新本地分支(fetch refspec 原生 fast-forward-only):右键 / 多选批量 / 标题栏选区感知三入口。 ## 发布说明 - [Release Notes 目录](./releases/README.md) — 各正式版发布说明(GitHub Release 正文单一事实源);最新:[v0.0.21 Filter by Author 候选建议 · 多选过滤 · 元字符作者修复](./releases/v0.0.21.md)。 diff --git a/docs/features/branches-update-in-place.md b/docs/features/branches-update-in-place.md new file mode 100644 index 0000000..87678ec --- /dev/null +++ b/docs/features/branches-update-in-place.md @@ -0,0 +1,42 @@ +# 分支就地更新(Update Branch Without Checkout) + +> Branches 视图中**不 checkout 即可把本地分支更新到其远程版本**:右键单个分支 Update、多选批量 Update、标题栏 Pull 按钮升级为选区感知(无选区时保持原 Pull 语义)。底层原语 `git fetch :refs/heads/`,git 原生 fast-forward-only,diverged / ahead-only 分支被安全拒绝,**永不改写本地提交**。 + +## 动机 + +对齐 IntelliJ IDEA 的 `GitUpdateSelectedBranchAction`:多分支并行开发时,让非当前分支跟上远程(如同步他人已合并 PR 的分支)此前必须 checkout → pull → checkout 回来;标题栏 Pull 也只作用于当前分支。本特性把「更新分支」从「必须身处该分支」中解耦。 + +## 核心原语:fetch refspec 而非 pull + +| 场景 | 行为 | +|---|---| +| 非当前分支、落后远程 | `git fetch origin feature/foo:refs/heads/feature/foo` → 本地 ref fast-forward,工作区零扰动 | +| 非当前分支、已同步 | fetch 静默 no-op(exit 0)→ 事后 sha 对比归入 `already up to date` 桶 | +| 非当前分支、diverged / ahead-only | git 拒绝(`! [rejected] ... (non-fast-forward)`)→ 失败桶,**无 force 通道** | +| 当前分支 | fetch refspec 必被 git 拒绝(`refusing to fetch into branch '…' checked out at '…'`)→ 排程为 `repo.pull()`(含冲突处理通道) | +| 分支被其他 worktree checkout | git 拒绝 → 失败桶附 stderr 原因 | + +副作用收益:带显式 refspec 的 fetch 同步刷新 remote-tracking ref,视图 ahead/behind 随之归真。 + +## 三入口与目标解析链 + +单一命令 `hyperGit.branchUpdate`("Update Branch",`$(arrow-down)`),目标解析链:**右键实参 →(无实参的标题栏路径)`branchesView.selection` → 无选区等价 Pull(委托既有 `hyperGit.pull`)**。 + +- **右键**(`view/item/context`,`viewItem == hyperGit.branch`,组 `1_branch@1.7`):单选作用于点击项;多选作用于整个选区(when 子句无 `!listMultiSelection`,与批量删除同契约),谓词过滤掉远程/标签节点,「点击在选区之外」以点击项为准(复用 `collectBranchRefs`)。当前分支节点同样显示 Update——路由到 pull,与 IDEA 行为一致。 +- **标题栏**(`view/title`,`navigation@3`,原全局 Pull 位):按钮读 Branches 视图选区;Commit 视图的 `hyperGit.pull` 保留原语义,跨视图零影响。 + +## 分层:引擎排程、适配层执行(单一事实源) + +- **引擎纯逻辑** [`engine/ref/branch-update.ts`](../../src/engine/ref/branch-update.ts)(零 vscode 依赖、Vitest 覆盖): + - `planBranchUpdates(names, refByShort, headName, remotes)` 把选中短名排程为逐分支动作——`pull-current`(head 命中,fetch 必败故改走 pull)/ `fetch`(携带 remote、upstreamBranch、beforeSha)/ `skip-no-upstream`(无上游或上游无法归属已知 remote)。upstream 短名(`origin/x`)拆分复用 [`resolveRemoteBranch`](../../src/engine/ref/remote-ref.ts) 最长前缀匹配(remote 名可含 `/`)。 + - `formatBranchUpdateResult` 把五桶(updated / upToDate / pulled / skipped / failed)汇总为通知文案与级别——零计数段省略、失败附真实原因(stderr 优先、折叠单行、超 4 条截断),保证「选中数 = 各桶之和」可对账。 +- **适配层** [`adapter/remote-commands.ts`](../../src/adapter/remote-commands.ts):命令编排。**不信任树节点数据**——API 路径的 `refToRaw` 不填 `upstream/head`,一律先以一次 `git for-each-ref`(`FOR_EACH_REF_FORMAT` + `parseForEachRef`)取全量新鲜快照再排程;逐分支执行(per-item try/catch,`branchDelete`/`deleteRemoteBranch` 批量范式),pull-current 失败先走 `handleGitConflict` 展示冲突 UI 但仍计入失败桶;事后一次 for-each-ref 以 `objectname` 前后对比区分 updated / upToDate(fetch 静默 no-op 无输出可解析;读取失败保守计 updated)。 + +逐分支而非合并 refspec(`git fetch origin b1:… b2:…`):合并执行丢失逐分支错误归因——任一 refspec 被拒即整命令失败且 stderr 混排,无法落桶。 + +## 边界 + +- **detached HEAD**:标题栏无选区兜底时警示(对齐 push 的 detached 提示),不盲跑。 +- **陈旧上游**:上游指向已删除的 remote → `resolveRemoteBranch` 返回 null → skip 桶透明提示 `no upstream`。 +- **快照竞态**:分支刚被并发删除 → 快照缺失 → skip 桶(不 crash、不误执行)。 +- **进度**:`runWithProgress`(Window 位置,非阻塞);execFile 无 kill 通道,不可取消(`task-progress.ts` 既有约束)。 From e35a3db36d9aec588a419ec84b6cd12690f76a5d Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Fri, 23 Oct 2026 21:06:53 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(Branches):=20=E5=88=86=E6=94=AF?= =?UTF-8?q?=E5=B0=B1=E5=9C=B0=E6=9B=B4=E6=96=B0=E5=AE=A1=E6=9F=A5=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E2=80=94=E2=80=94remotes=20=E7=A9=BA=E5=AE=88?= =?UTF-8?q?=E5=8D=AB=E3=80=81refspec=20=E5=85=A8=E9=99=90=E5=AE=9A?= =?UTF-8?q?=E9=98=B2=20force=20=E5=89=8D=E7=BC=80=E3=80=81=E7=9F=AD=20sha?= =?UTF-8?q?=20=E5=89=8D=E7=BC=80=E5=88=86=E7=B1=BB=E3=80=81Pull=20?= =?UTF-8?q?=E5=85=9C=E5=BA=95=E6=94=B6=E7=B4=A7=E3=80=81collapseReason=20S?= =?UTF-8?q?SOT=20=E6=94=B6=E6=95=9B;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- src/adapter/remote-commands.ts | 24 ++++++++++++++++-------- src/engine/ref/branch-update.ts | 25 ++++++++++++++++--------- src/engine/ref/cleanup.ts | 6 ++++++ src/engine/ref/remote-ref.ts | 10 ++-------- tests/unit/branch-update.test.ts | 16 +++++++++++++++- 5 files changed, 55 insertions(+), 26 deletions(-) diff --git a/src/adapter/remote-commands.ts b/src/adapter/remote-commands.ts index ac44006..a096c7b 100644 --- a/src/adapter/remote-commands.ts +++ b/src/adapter/remote-commands.ts @@ -19,6 +19,7 @@ import { FOR_EACH_REF_FORMAT, parseForEachRef, type RawRef } from '../engine/ref import { planBranchUpdates, formatBranchUpdateResult, + classifyFetchOutcome, type BranchUpdateAction, type BranchUpdateFailure, } from '../engine/ref/branch-update'; @@ -32,7 +33,7 @@ import { gitErrorText } from '../engine/git-state/git-error'; * - Push:force/force-with-lease/push tags(核心走 repo.push,tags 走 CLI)。 * - Update Project:pull --rebase / --no-rebase(策略)。 * - Merge:--ff-only / --no-ff / --squash + 自定义 message。 - * - Update Branch:`git fetch :refs/heads/`(原生 fast-forward-only, + * - Update Branch:`git fetch refs/heads/:refs/heads/`(原生 fast-forward-only, * 不 checkout 就地更新本地分支;当前分支改走 repo.pull)。 */ export function registerRemoteCommands( @@ -138,16 +139,17 @@ export function registerRemoteCommands( if (!repo) { return; } - // 目标解析链:右键实参 →(无实参的标题栏路径)视图选区 → 无选区等价 Pull(当前分支)。 - // 仅本地、非 tag(多选时自动过滤远程/标签节点)。 + // 目标解析链:右键实参 →(无实参的标题栏路径)视图选区 → 无选区等价 Pull(当前分支); + // 选区非空但不含本地分支时不动作(显式选区优先,不回退 Pull)。仅本地、非 tag。 const isLocal = (r: RawRef) => !r.isRemote && !r.isTag; let targets = selectedBranchRefs(node, nodes, isLocal); if (targets.length === 0 && !node) { targets = selectedBranchRefs(undefined, branchesView.selection, isLocal); } if (targets.length === 0) { - if (node) { - // 点击项非本地分支(防御;菜单门控 viewItem == hyperGit.branch 下不可达)。 + if (node || branchesView.selection.length > 0) { + // 点击项/选区不含本地分支(右键路径防御——菜单门控下不可达;标题栏路径为 + // 仅远程/标签/分组节点的显式选区)。显式选区优先,不回退 Pull,避免静默改写工作区。 return; } const headName = repo.state.HEAD?.name; @@ -159,6 +161,12 @@ export function registerRemoteCommands( await vscode.commands.executeCommand('hyperGit.pull'); return; } + // remotes 未配置/未就绪时不排程(姊妹命令同守卫):避免把有上游分支误报 no upstream。 + const remotes = repo.state.remotes.map((r) => r.name); + if (remotes.length === 0) { + void vscode.window.showWarningMessage('No remote repository configured'); + return; + } // 新鲜快照:树节点 ref.upstream/head 在 API 路径下为空(refToRaw 不填),禁止信任节点数据。 let snapshot: ReadonlyMap; try { @@ -169,7 +177,6 @@ export function registerRemoteCommands( return; } // 引擎纯逻辑排程 + 逐分支执行(per-item try/catch,三桶先例)。 - const remotes = repo.state.remotes.map((r) => r.name); const headName = repo.state.HEAD?.name; const plan = planBranchUpdates( targets.map((r) => r.shortName), @@ -193,7 +200,8 @@ export function registerRemoteCommands( await repo.pull(); pulled.push(action.name); } else { - await service.execGit(['fetch', action.remote, `${action.upstreamBranch}:refs/heads/${action.name}`]); + // src 侧全限定:`+` 开头的合法上游名裸写会被 git 解析为 force 前缀,绕过 fast-forward-only。 + await service.execGit(['fetch', action.remote, `refs/heads/${action.upstreamBranch}:refs/heads/${action.name}`]); } } catch (e) { if (action.kind === 'pull-current') { @@ -213,7 +221,7 @@ export function registerRemoteCommands( if (a.kind !== 'fetch' || failedNames.has(a.name)) { continue; } - (after.get(a.name) !== a.beforeSha ? updated : upToDate).push(a.name); + (classifyFetchOutcome(a.beforeSha, after.get(a.name)) === 'updated' ? updated : upToDate).push(a.name); } } catch { // 分类不可得 → 保守计 updated(fetch 已成功,仅失判定粒度)。 diff --git a/src/engine/ref/branch-update.ts b/src/engine/ref/branch-update.ts index d057892..dd84347 100644 --- a/src/engine/ref/branch-update.ts +++ b/src/engine/ref/branch-update.ts @@ -1,7 +1,7 @@ /** * 分支就地更新(不 checkout)的纯逻辑(零 vscode 依赖)。 * - * 核心原语:`git fetch :refs/heads/`——git 原生 fast-forward-only, + * 核心原语:`git fetch refs/heads/:refs/heads/`——git 原生 fast-forward-only, * diverged / ahead-only 时拒绝回退(`! [rejected] ... (non-fast-forward)`),永不改写本地提交; * 目标是当前分支(含被其他 worktree checkout)时 git 拒绝 * (`refusing to fetch into branch '…' checked out at '…'`),须改走 `repo.pull()`。 @@ -9,14 +9,15 @@ * - {@link planBranchUpdates}:把选中本地分支短名 + for-each-ref 新鲜快照排程为逐分支动作 * (pull-current / fetch / skip-no-upstream)。 * - {@link formatBranchUpdateResult}:把逐分支结果五桶汇总为通知文案与级别。 + * - {@link classifyFetchOutcome}:事后以短 sha 前缀语义把单个 fetch 动作归类 updated / upToDate。 * * 与同层模块的关系:upstream 短名(`origin/x`)拆分复用 {@link resolveRemoteBranch} - * (remote 名可含 `/`);汇总文案复用 {@link truncateNames};均以轻量指针保持单一事实源。 + * (remote 名可含 `/`);汇总文案复用 {@link truncateNames} 与 {@link collapseReason};均以轻量指针保持单一事实源。 */ import type { RawRef } from './for-each-ref'; import { resolveRemoteBranch } from './remote-ref'; -import { truncateNames } from './cleanup'; +import { collapseReason, truncateNames } from './cleanup'; /** 单分支更新动作({@link planBranchUpdates} 的产物)。 */ export type BranchUpdateAction = @@ -97,12 +98,6 @@ export interface BranchUpdateBuckets { /** 通知级别:无失败走 info;存在失败走 error(联动 Show Output)。 */ export type BranchUpdateResultLevel = 'info' | 'error'; -/** 失败原因折叠为单行(换行→'; ')并截断,避免通知气泡过长。 */ -function collapseReason(reason: string, max = 160): string { - const line = reason.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).join('; '); - return line.length > max ? `${line.slice(0, max - 1)}…` : line; -} - /** * 把逐分支更新结果五桶汇总为通知文案与级别(纯逻辑)。 * @@ -139,3 +134,15 @@ export function formatBranchUpdateResult(buckets: BranchUpdateBuckets): { parts.push(`${failures.length} failed: ${truncateNames(entries, 4)}`); return { level: 'error', message: parts.join('; ') }; } + +/** + * 事后把单个 fetch 动作归类为 updated / upToDate(纯逻辑)。 + * + * 前缀语义而非严格全等:`%(objectname:short)` 的缩写长度随仓库对象数自动伸缩, + * 批量 fetch 落库新对象可能使 after 短 sha 变长(如 7→8 位),同 commit 的前后 + * 短 sha 仍互为前缀;afterSha 缺失(分支被并发删除)保守计 updated——fetch 已 + * 成功,宁报更新不报无变化。 + */ +export function classifyFetchOutcome(beforeSha: string, afterSha: string | undefined): 'updated' | 'upToDate' { + return afterSha !== undefined && afterSha.startsWith(beforeSha) ? 'upToDate' : 'updated'; +} diff --git a/src/engine/ref/cleanup.ts b/src/engine/ref/cleanup.ts index 09cf993..10c7303 100644 --- a/src/engine/ref/cleanup.ts +++ b/src/engine/ref/cleanup.ts @@ -83,6 +83,12 @@ export function truncateNames(names: readonly string[], max = 8): string { return `${names.slice(0, max).join(', ')} … and ${names.length - max} more`; } +/** 失败原因折叠为单行(换行→'; ')并截断,避免通知气泡过长(单一事实源,勿在各结果格式化器内复制)。 */ +export function collapseReason(reason: string, max = 160): string { + const line = reason.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).join('; '); + return line.length > max ? `${line.slice(0, max - 1)}…` : line; +} + /** * 生成批量删除本地分支的确认文案与确认按钮文本(纯逻辑)。 * 诚实呈现强制删除风险:混合时分栏列出,确保 force 删除不被隐藏。 diff --git a/src/engine/ref/remote-ref.ts b/src/engine/ref/remote-ref.ts index cce4512..e48746c 100644 --- a/src/engine/ref/remote-ref.ts +++ b/src/engine/ref/remote-ref.ts @@ -11,10 +11,10 @@ * - {@link isRemoteRefMissingError}:识别「服务端已无此 ref」的 push 失败(本地 tracking ref 陈旧场景)。 * - {@link formatRemoteDeleteResult}:把逐分支删除结果三桶汇总为通知文案与级别。 * - * 与 cleanup.ts 的关系:复用其 `isProtectedBranch` / `truncateNames` 作为单一事实源(轻量指针,不复制定义)。 + * 与 cleanup.ts 的关系:复用其 `isProtectedBranch` / `truncateNames` / `collapseReason` 作为单一事实源(轻量指针,不复制定义)。 */ -import { isProtectedBranch, truncateNames } from './cleanup'; +import { collapseReason, isProtectedBranch, truncateNames } from './cleanup'; import { gitErrorText } from '../git-state/git-error'; /** 解析后的远程分支定位。 */ @@ -124,12 +124,6 @@ export function isRemoteRefMissingError(e: unknown): boolean { return text.length > 0 && REMOTE_REF_MISSING_PATTERNS.some((re) => re.test(text)); } -/** 失败原因折叠为单行(换行→'; '),并截断总长,避免通知气泡过长。 */ -function collapseReason(reason: string, max = 160): string { - const line = reason.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).join('; '); - return line.length > max ? `${line.slice(0, max - 1)}…` : line; -} - /** 通知级别:仅成功/已不存在走 info;存在真失败走 error(联动 Show Output)。 */ export type RemoteDeleteResultLevel = 'info' | 'error'; diff --git a/tests/unit/branch-update.test.ts b/tests/unit/branch-update.test.ts index bffe3ba..57e44ba 100644 --- a/tests/unit/branch-update.test.ts +++ b/tests/unit/branch-update.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { planBranchUpdates, formatBranchUpdateResult } from '../../src/engine/ref/branch-update'; +import { planBranchUpdates, formatBranchUpdateResult, classifyFetchOutcome } from '../../src/engine/ref/branch-update'; import type { RawRef } from '../../src/engine/ref/for-each-ref'; /** 构造测试用本地分支 RawRef(for-each-ref 新鲜快照口径,upstream/head 可用)。 */ @@ -117,3 +117,17 @@ describe('formatBranchUpdateResult', () => { }); }); }); + +describe('classifyFetchOutcome', () => { + it('同 commit 且缩写变长(对象数跨阈值,7→8 位)→ upToDate(前缀语义,非严格全等)', () => { + expect(classifyFetchOutcome('abc1234', 'abc12345')).toBe('upToDate'); + }); + + it('真更新(不同 commit)→ updated', () => { + expect(classifyFetchOutcome('abc1234', 'deadbee')).toBe('updated'); + }); + + it('afterSha 缺失(分支被并发删除)→ 保守 updated', () => { + expect(classifyFetchOutcome('abc1234', undefined)).toBe('updated'); + }); +}); From bdc1fd37ec16cb9fa3979c76c23ccace03f82ac1 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Fri, 23 Oct 2026 21:06:53 +0800 Subject: [PATCH 4/4] =?UTF-8?q?docs(Branches):=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E5=88=86=E6=94=AF=E5=B0=B1=E5=9C=B0=E6=9B=B4=E6=96=B0=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=E4=BF=AE=E5=A4=8D=E7=9A=84=E6=96=87=E6=A1=A3=E5=8F=A3?= =?UTF-8?q?=E5=BE=84=E4=B8=8E=E7=B4=A2=E5=BC=95=E9=93=BE=E6=8E=A5;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- CHANGELOG.md | 2 +- docs/.agents/knowledge-map.md | 2 +- docs/features/branches-update-in-place.md | 16 +++++++++------- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0a703b..318420d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### Added -- **Branches 视图分支就地更新(不 checkout)**:右键单个分支 **Update Branch**、多选批量更新、标题栏 Pull 按钮升级为选区感知(无选区保持原 Pull 语义,Commit 视图 Pull 不受影响)。底层原语 `git fetch :refs/heads/`——git 原生 fast-forward-only,diverged / ahead-only 分支被安全拒绝(附真实 stderr 原因),**永不改写本地提交**;当前分支自动改走 `repo.pull()`(含冲突处理通道);无上游分支透明跳过。五桶对账汇总(updated / already up to date / pulled / skipped / failed,选中数 = 各桶之和),up-to-date 经前后 `objectname` 短 sha 对比判定(fetch 静默 no-op 无输出)。upstream 解析不信任树节点数据(API 路径 `refToRaw` 不填 upstream/head),统一以一次 `for-each-ref` 取新鲜快照;`origin/x` 拆分复用 `resolveRemoteBranch` 最长前缀匹配(remote 名可含 `/`)。engine 纯逻辑 `planBranchUpdates` / `formatBranchUpdateResult` 落于 `engine/ref/branch-update.ts`,全量单测覆盖。 +- **Branches 视图分支就地更新(不 checkout)**:右键单个分支 **Update Branch**、多选批量更新、标题栏 Pull 按钮升级为选区感知(无选区保持原 Pull 语义,Commit 视图 Pull 不受影响)。底层原语 `git fetch refs/heads/:refs/heads/`——git 原生 fast-forward-only,diverged / ahead-only 分支被安全拒绝(附真实 stderr 原因),**永不改写本地提交**(src 侧全限定防 `+` 前缀上游名被解析为 force);当前分支自动改走 `repo.pull()`(含冲突处理通道);无上游分支透明跳过;无 remote 配置提示 `No remote repository configured` 提前返回(不误报 no upstream)。五桶对账汇总(updated / already up to date / pulled / skipped / failed,选中数 = 各桶之和),up-to-date 经前后 `objectname` 短 sha 前缀比对判定(fetch 静默 no-op 无输出;缩写长度随对象数伸缩,全等比对会误报)。upstream 解析不信任树节点数据(API 路径 `refToRaw` 不填 upstream/head),统一以一次 `for-each-ref` 取新鲜快照;`origin/x` 拆分复用 `resolveRemoteBranch` 最长前缀匹配(remote 名可含 `/`)。engine 纯逻辑 `planBranchUpdates` / `formatBranchUpdateResult` / `classifyFetchOutcome` 落于 `engine/ref/branch-update.ts`,全量单测覆盖。 ## [0.0.21] - 2026-10-23 — Filter by Author 候选建议 · 多选过滤 · 元字符作者修复 diff --git a/docs/.agents/knowledge-map.md b/docs/.agents/knowledge-map.md index dae9791..3c15677 100644 --- a/docs/.agents/knowledge-map.md +++ b/docs/.agents/knowledge-map.md @@ -25,7 +25,7 @@ - [Claude Code 配置](../docs/features/claude-code-config.md) — Agentic Git 预置:`hyperGit.claudeCode.executablePath` 设置 + `~/.claude/settings.json` 快捷入口(原生设置 + 命令;纯路径逻辑沉淀 `engine/agent/`)。 - [Agentic Git 偏好配置](../docs/features/agentic-git-preferences.md) — Agentic Git 预置:`hyperGit.agent.*` 四项(baseBranch + commit/createPr/review Preferences;多行文本域 + 内置默认模板,Commit 模板中文规约已英译)。 - [多根工作区仓库切换](../docs/features/multi-root-repo-selection.md) — Graph 标题栏切换仓库图标按钮 + `hyperGit.selectRepository` 切换全局活跃仓库:七视图级联跟随、changelist/收藏/Shelf/最近消息按仓库隔离、三重顺序不变量与安全迁移。 -- [分支就地更新](../docs/features/branches-update-in-place.md) — Branches 视图不 checkout 更新本地分支:`git fetch :refs/heads/` 原生 fast-forward-only,右键/多选/标题栏选区感知三入口,五桶对账汇总,引擎排程 + 适配层执行。 +- [分支就地更新](../features/branches-update-in-place.md) — Branches 视图不 checkout 更新本地分支:`git fetch refs/heads/:refs/heads/` 原生 fast-forward-only,右键/多选/标题栏选区感知三入口,五桶对账汇总,引擎排程 + 适配层执行。 - [实施状态总览](../docs/milestones/implementation-status.md) — M0-M5 交付记录 + API 限制 + M5 AI 设计 + 验证/发布(**实施看板**)。 - [工程实施方案](../docs/architecture/engineering-plan.md) — 路径 B 架构 + M0-M5 里程碑(**开发蓝图**)。 - [Git 功能矩阵](../docs/requirements/idea-feature-matrix.md) — 56 功能点 / 8 组(**验收基线**,参考 IDEA 等成熟实现)。 diff --git a/docs/features/branches-update-in-place.md b/docs/features/branches-update-in-place.md index 87678ec..4912a2e 100644 --- a/docs/features/branches-update-in-place.md +++ b/docs/features/branches-update-in-place.md @@ -1,6 +1,6 @@ # 分支就地更新(Update Branch Without Checkout) -> Branches 视图中**不 checkout 即可把本地分支更新到其远程版本**:右键单个分支 Update、多选批量 Update、标题栏 Pull 按钮升级为选区感知(无选区时保持原 Pull 语义)。底层原语 `git fetch :refs/heads/`,git 原生 fast-forward-only,diverged / ahead-only 分支被安全拒绝,**永不改写本地提交**。 +> Branches 视图中**不 checkout 即可把本地分支更新到其远程版本**:右键单个分支 Update、多选批量 Update、标题栏 Pull 按钮升级为选区感知(无选区时保持原 Pull 语义)。底层原语 `git fetch refs/heads/:refs/heads/`,git 原生 fast-forward-only,diverged / ahead-only 分支被安全拒绝,**永不改写本地提交**。 ## 动机 @@ -10,33 +10,35 @@ | 场景 | 行为 | |---|---| -| 非当前分支、落后远程 | `git fetch origin feature/foo:refs/heads/feature/foo` → 本地 ref fast-forward,工作区零扰动 | +| 非当前分支、落后远程 | `git fetch origin refs/heads/feature/foo:refs/heads/feature/foo` → 本地 ref fast-forward,工作区零扰动 | | 非当前分支、已同步 | fetch 静默 no-op(exit 0)→ 事后 sha 对比归入 `already up to date` 桶 | | 非当前分支、diverged / ahead-only | git 拒绝(`! [rejected] ... (non-fast-forward)`)→ 失败桶,**无 force 通道** | | 当前分支 | fetch refspec 必被 git 拒绝(`refusing to fetch into branch '…' checked out at '…'`)→ 排程为 `repo.pull()`(含冲突处理通道) | | 分支被其他 worktree checkout | git 拒绝 → 失败桶附 stderr 原因 | -副作用收益:带显式 refspec 的 fetch 同步刷新 remote-tracking ref,视图 ahead/behind 随之归真。 +副作用收益:带显式 refspec 的 fetch 同步刷新 remote-tracking ref,视图 ahead/behind 随之归真。refspec src 侧全限定 `refs/heads/`:`+` 开头的合法上游名(如 `origin/+foo`)裸写时首 `+` 会被 git 解析为 force 前缀,静默绕过 fast-forward-only。 ## 三入口与目标解析链 -单一命令 `hyperGit.branchUpdate`("Update Branch",`$(arrow-down)`),目标解析链:**右键实参 →(无实参的标题栏路径)`branchesView.selection` → 无选区等价 Pull(委托既有 `hyperGit.pull`)**。 +单一命令 `hyperGit.branchUpdate`("Update Branch",`$(arrow-down)`),目标解析链:**右键实参 →(无实参的标题栏路径)`branchesView.selection` → 无选区等价 Pull(委托既有 `hyperGit.pull`)**;选区非空但不含本地分支(仅远程/标签/分组节点)时不动作——显式选区优先,不回退 Pull。 - **右键**(`view/item/context`,`viewItem == hyperGit.branch`,组 `1_branch@1.7`):单选作用于点击项;多选作用于整个选区(when 子句无 `!listMultiSelection`,与批量删除同契约),谓词过滤掉远程/标签节点,「点击在选区之外」以点击项为准(复用 `collectBranchRefs`)。当前分支节点同样显示 Update——路由到 pull,与 IDEA 行为一致。 -- **标题栏**(`view/title`,`navigation@3`,原全局 Pull 位):按钮读 Branches 视图选区;Commit 视图的 `hyperGit.pull` 保留原语义,跨视图零影响。 +- **标题栏**(`view/title`,`navigation@3`,原全局 Pull 位):按钮读 Branches 视图选区(仅远程/标签/分组节点时不动作);Commit 视图的 `hyperGit.pull` 保留原语义,跨视图零影响。 ## 分层:引擎排程、适配层执行(单一事实源) - **引擎纯逻辑** [`engine/ref/branch-update.ts`](../../src/engine/ref/branch-update.ts)(零 vscode 依赖、Vitest 覆盖): - `planBranchUpdates(names, refByShort, headName, remotes)` 把选中短名排程为逐分支动作——`pull-current`(head 命中,fetch 必败故改走 pull)/ `fetch`(携带 remote、upstreamBranch、beforeSha)/ `skip-no-upstream`(无上游或上游无法归属已知 remote)。upstream 短名(`origin/x`)拆分复用 [`resolveRemoteBranch`](../../src/engine/ref/remote-ref.ts) 最长前缀匹配(remote 名可含 `/`)。 - `formatBranchUpdateResult` 把五桶(updated / upToDate / pulled / skipped / failed)汇总为通知文案与级别——零计数段省略、失败附真实原因(stderr 优先、折叠单行、超 4 条截断),保证「选中数 = 各桶之和」可对账。 -- **适配层** [`adapter/remote-commands.ts`](../../src/adapter/remote-commands.ts):命令编排。**不信任树节点数据**——API 路径的 `refToRaw` 不填 `upstream/head`,一律先以一次 `git for-each-ref`(`FOR_EACH_REF_FORMAT` + `parseForEachRef`)取全量新鲜快照再排程;逐分支执行(per-item try/catch,`branchDelete`/`deleteRemoteBranch` 批量范式),pull-current 失败先走 `handleGitConflict` 展示冲突 UI 但仍计入失败桶;事后一次 for-each-ref 以 `objectname` 前后对比区分 updated / upToDate(fetch 静默 no-op 无输出可解析;读取失败保守计 updated)。 + - `classifyFetchOutcome` 以前缀语义比对前后短 sha 归类 updated / upToDate——`%(objectname:short)` 缩写长度随仓库对象数自动伸缩,批量 fetch 落库新对象可能使缩写变长,严格全等会把 up-to-date 误报为 updated;after 缺失(并发删除)保守计 updated。 +- **适配层** [`adapter/remote-commands.ts`](../../src/adapter/remote-commands.ts):命令编排。**不信任树节点数据**——API 路径的 `refToRaw` 不填 `upstream/head`,一律先以一次 `git for-each-ref`(`FOR_EACH_REF_FORMAT` + `parseForEachRef`)取全量新鲜快照再排程;逐分支执行(per-item try/catch,`branchDelete`/`deleteRemoteBranch` 批量范式),pull-current 失败先走 `handleGitConflict` 展示冲突 UI 但仍计入失败桶;事后一次 for-each-ref 经 `classifyFetchOutcome` 以 `objectname` 前后对比区分 updated / upToDate(fetch 静默 no-op 无输出可解析;读取失败保守计 updated)。 逐分支而非合并 refspec(`git fetch origin b1:… b2:…`):合并执行丢失逐分支错误归因——任一 refspec 被拒即整命令失败且 stderr 混排,无法落桶。 ## 边界 - **detached HEAD**:标题栏无选区兜底时警示(对齐 push 的 detached 提示),不盲跑。 -- **陈旧上游**:上游指向已删除的 remote → `resolveRemoteBranch` 返回 null → skip 桶透明提示 `no upstream`。 +- **无 remote**:`remotes` 为空(未配置/元数据未就绪)→ 提示 `No remote repository configured` 提前返回(对齐 pushDialog / deleteRemoteBranch 先例),不把有上游分支误报 `no upstream`。 +- **陈旧上游**:上游指向已删除的 remote(remotes 非空时)→ `resolveRemoteBranch` 返回 null → skip 桶透明提示 `no upstream`。 - **快照竞态**:分支刚被并发删除 → 快照缺失 → skip 桶(不 crash、不误执行)。 - **进度**:`runWithProgress`(Window 位置,非阻塞);execFile 无 kill 通道,不可取消(`task-progress.ts` 既有约束)。