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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

## [Unreleased]

### Added

- **Branches 视图分支就地更新(不 checkout)**:右键单个分支 **Update Branch**、多选批量更新、标题栏 Pull 按钮升级为选区感知(无选区保持原 Pull 语义,Commit 视图 Pull 不受影响)。底层原语 `git fetch <remote> refs/heads/<upstream>:refs/heads/<branch>`——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 候选建议 · 多选过滤 · 元字符作者修复

### Added
Expand Down
1 change: 1 addition & 0 deletions docs/.agents/knowledge-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/最近消息按仓库隔离、三重顺序不变量与安全迁移。
- [分支就地更新](../features/branches-update-in-place.md) — Branches 视图不 checkout 更新本地分支:`git fetch <remote> refs/heads/<upstream>:refs/heads/<branch>` 原生 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 等成熟实现)。
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)。
Expand Down
44 changes: 44 additions & 0 deletions docs/features/branches-update-in-place.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# 分支就地更新(Update Branch Without Checkout)

> Branches 视图中**不 checkout 即可把本地分支更新到其远程版本**:右键单个分支 Update、多选批量 Update、标题栏 Pull 按钮升级为选区感知(无选区时保持原 Pull 语义)。底层原语 `git fetch <remote> refs/heads/<upstream>:refs/heads/<branch>`,git 原生 fast-forward-only,diverged / ahead-only 分支被安全拒绝,**永不改写本地提交**。

## 动机

对齐 IntelliJ IDEA 的 `GitUpdateSelectedBranchAction`:多分支并行开发时,让非当前分支跟上远程(如同步他人已合并 PR 的分支)此前必须 checkout → pull → checkout 回来;标题栏 Pull 也只作用于当前分支。本特性把「更新分支」从「必须身处该分支」中解耦。

## 核心原语:fetch refspec 而非 pull

| 场景 | 行为 |
|---|---|
| 非当前分支、落后远程 | `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 src 侧全限定 `refs/heads/<upstream>`:`+` 开头的合法上游名(如 `origin/+foo`)裸写时首 `+` 会被 git 解析为 force 前缀,静默绕过 fast-forward-only。

## 三入口与目标解析链

单一命令 `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` 保留原语义,跨视图零影响。

## 分层:引擎排程、适配层执行(单一事实源)

- **引擎纯逻辑** [`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 条截断),保证「选中数 = 各桶之和」可对账。
- `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**:`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` 既有约束)。
17 changes: 16 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)…",
Expand Down Expand Up @@ -972,7 +978,7 @@
"group": "navigation@2"
},
{
"command": "hyperGit.pull",
"command": "hyperGit.branchUpdate",
"when": "view == hyperGit.branches",
"group": "navigation@3"
},
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -1448,6 +1459,10 @@
{
"command": "hyperGit.pushBranch",
"when": "false"
},
{
"command": "hyperGit.branchUpdate",
"when": "false"
}
]
},
Expand Down
2 changes: 1 addition & 1 deletion src/adapter/branch-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
122 changes: 121 additions & 1 deletion src/adapter/remote-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,32 @@ 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,
classifyFetchOutcome,
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 <remote> refs/heads/<upstream>:refs/heads/<branch>`(原生 fast-forward-only,
* 不 checkout 就地更新本地分支;当前分支改走 repo.pull)。
*/
export function registerRemoteCommands(
service: GitRepositoryService,
branchesTree: BranchesTreeProvider,
logTree: LogFilterControl,
branchesView: vscode.TreeView<BranchNode>,
): vscode.Disposable[] {
const subs: vscode.Disposable[] = [];

Expand Down Expand Up @@ -122,6 +133,115 @@ export function registerRemoteCommands(
}),
);

subs.push(
vscode.commands.registerCommand('hyperGit.branchUpdate', async (node?: BranchNode, nodes?: BranchNode[]) => {
const repo = service.repo;
if (!repo) {
return;
}
// 目标解析链:右键实参 →(无实参的标题栏路径)视图选区 → 无选区等价 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 || branchesView.selection.length > 0) {
// 点击项/选区不含本地分支(右键路径防御——菜单门控下不可达;标题栏路径为
// 仅远程/标签/分组节点的显式选区)。显式选区优先,不回退 Pull,避免静默改写工作区。
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;
}
// 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<string, RawRef>;
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 headName = repo.state.HEAD?.name;
const plan = planBranchUpdates(
targets.map((r) => r.shortName),
snapshot,
headName,
remotes,
);
const skipped = plan.filter((a): a is Extract<BranchUpdateAction, { kind: 'skip-no-upstream' }> => 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<string>();
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 {
// 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') {
// 冲突 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;
}
(classifyFetchOutcome(a.beforeSha, after.get(a.name)) === 'updated' ? 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;
Expand Down
Loading
Loading