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
97 changes: 92 additions & 5 deletions src/adapter/history-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { diffPrunedRefs, formatBranchDeleteConfirm, partitionByMerged, truncateN
import { diffShapeFromCode } from '../engine/diff/change-side';
import { resolveDiffSides } from './diff-sides';
import { gitErrorText } from '../engine/git-state/git-error';
import type { AuthorTerm } from '../engine/log/log-filter';
import { buildAuthorListArgs, parseShortlog, type AuthorStat } from '../engine/log/author-suggest';

/** 注册 Log/Branches/Blame/History/Tags 相关命令。 */
export function registerHistoryCommands(
Expand All @@ -29,11 +31,7 @@ export function registerHistoryCommands(
subs.push(vscode.commands.registerCommand('hyperGit.refreshBranches', () => branchesTree.refresh()));

subs.push(
vscode.commands.registerCommand('hyperGit.logFilterAuthor', async () => {
const author = await vscode.window.showInputBox({ prompt: 'Filter by Author', placeHolder: 'e.g. John' });
const f = logTree.getFilter();
logTree.setFilter({ ...f, author: author && author.trim() ? author.trim() : undefined });
}),
vscode.commands.registerCommand('hyperGit.logFilterAuthor', () => pickAuthors(service, logTree)),
);

subs.push(
Expand Down Expand Up @@ -736,6 +734,95 @@ export function registerHistoryCommands(
return subs;
}

/**
* 作者过滤选择器(仓库首个 createQuickPick 动态用法):候选来自 `git shortlog`(按当前视图 scope 取数、
* 按提交数降序),QuickPick 内建模糊过滤实现「输入前缀 → 建议下拉」;canSelectMany 多选(git 对多条
* `--author` 取 OR)。输入不等于任何作者姓名时头部动态插入「Use pattern」逃生口(原样正则透传,
* 保留旧 InputBox 的任意 pattern 能力);确认时空选 = 清除 author 维度,Esc = 不变。
*/
async function pickAuthors(service: GitRepositoryService, logTree: LogFilterControl): Promise<void> {
/** exact 项勾选键(同名异邮箱不串)。 */
const key = (t: { name: string; email: string }) => `${t.name} <${t.email}>`;
const qp = vscode.window.createQuickPick<AuthorPickItem>();
qp.canSelectMany = true;
qp.matchOnDescription = true;
qp.matchOnDetail = true;
qp.title = 'Filter by Author';
qp.placeholder = 'Type to search authors; check to filter (empty selection clears)';
// 先展示再取数:busy 遮盖等待;事件先于 await 注册,取数期间的键入即触发 rebuild。
qp.busy = true;
qp.items = [];
qp.show();

// 勾选自维护:重建 items 会重置 selectedItems,须记录并在重建后回写。
// 预勾选仅 exact 项(pattern 项随输入动态生成,无法预置);重建触发的程序化 selection
// 事件与用户点击不可区分,以时间窗抑制(程序化事件在赋值后即刻送达)。
let checked = new Set(logTree.getFilter().authors?.filter((t) => t.kind === 'exact').map(key) ?? []);
let patternOn = false;
let suppressUntil = 0;
let authors: readonly AuthorStat[] = [];
const rebuild = (value: string): void => {
const v = value.trim();
const patternItem: AuthorPickItem[] =
v && !authors.some((a) => a.name === v)
? [{ label: `Use pattern "${v}"`, alwaysShow: true, description: 'regular expression (OR-ed)', term: { kind: 'pattern', pattern: v } }]
: [];
suppressUntil = Date.now() + 100;
qp.items = [...patternItem, ...baseItems(authors)];
qp.selectedItems = qp.items.filter((i) => (i.term.kind === 'exact' ? checked.has(key(i.term)) : patternOn));
};
qp.onDidChangeValue(rebuild);
qp.onDidChangeSelection((sel) => {
if (Date.now() < suppressUntil) {
return;
}
patternOn = sel.some((i) => i.term.kind === 'pattern');
checked = new Set(sel.flatMap((i) => (i.term.kind === 'exact' ? [key(i.term)] : [])));
});

// settle 幂等守卫:accept 后 onDidHide 仍会触发;undefined = 取消不改,[] = 清除 author 维度。
let settled = false;
const settle = (terms: readonly AuthorTerm[] | undefined): void => {
if (settled) {
return;
}
settled = true;
qp.dispose();
if (terms) {
logTree.setFilter({ ...logTree.getFilter(), authors: terms.length ? terms : undefined });
}
};
qp.onDidAccept(() => settle(qp.selectedItems.map((i) => i.term)));
qp.onDidHide(() => settle(undefined));

// 候选按当前视图 scope 一次取数(非逐键);切库竞态守卫沿用 rootAtStart 范式;
// 空仓库 / unborn HEAD / 查询失败 → 空列表降级(仅 pattern 逃生口可用)。
const rootAtStart = service.repoRoot;
authors = parseShortlog(await service.execGit(['shortlog', ...buildAuthorListArgs(logTree.getScope())]).catch(() => ''));
if (service.repoRoot !== rootAtStart) {
qp.dispose();
return;
}
// 取数完成以 qp.value(而非 '')重建:纳入等待期间的键入。
rebuild(qp.value);
qp.busy = false;
}

/** shortlog 统计 → QuickPick 项(label=姓名、description=提交数、detail=email 供模糊匹配)。 */
function baseItems(authors: readonly AuthorStat[]): AuthorPickItem[] {
return authors.map((a) => ({
label: a.name,
description: a.count === 1 ? '1 commit' : `${a.count} commits`,
detail: a.email,
term: { kind: 'exact', name: a.name, email: a.email },
}));
}

/** 作者候选 QuickPick 项(label=姓名、description=提交数、detail=email,term 为过滤写回项)。 */
interface AuthorPickItem extends vscode.QuickPickItem {
readonly term: AuthorTerm;
}

/**
* 列出当前 `refs/remotes/*` 短名(如 `origin/master`),供 prune 前后做差集。
* 解析 `git for-each-ref --format=%(refname:short) refs/remotes`:逐行去空白、去空行。
Expand Down
6 changes: 6 additions & 0 deletions src/adapter/webview/log-webview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ export interface LogFilterControl extends vscode.Disposable {
setFilter(filter: LogFilter): void;
clearFilter(): void;
getFilter(): LogFilter;
/** 当前视图 scope(作者候选等按视图范围取数的消费方使用;视图状态,不入 LogFilter)。 */
getScope(): LogScope;
refresh(): void;
}

Expand Down Expand Up @@ -186,6 +188,10 @@ export class LogWebviewProvider implements vscode.WebviewViewProvider, LogFilter
return this.filter;
}

getScope(): LogScope {
return this.scope;
}

refresh(): void {
clearTimeout(this.refreshTimer);
this.refreshTimer = setTimeout(() => {
Expand Down
35 changes: 35 additions & 0 deletions src/engine/log/author-suggest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* 作者候选查询(Filter by Author 多选):`git shortlog` 取数参数 + 输出解析(纯逻辑,零 vscode 依赖)。
*
* shortlog 在 git 侧完成聚合计数与降序排序(巨库 stdout 仅数百行,优于全量 `log --format` 逐行回传),
* engine 仅保留一个容错解析函数;引用范围复用 {@link scopeRefs} 与 Graph 视图一致(单一事实源)。
*/

import { scopeRefs, type LogScope } from './log-query';

/** 作者统计(shortlog 一行:身份 + 提交数)。 */
export interface AuthorStat {
readonly name: string;
readonly email: string;
readonly count: number;
}

/** shortlog 参数:`-s` 仅计数、`-n` 按数降序、`-e` 含 email;引用范围随 scope。
* `current` 的 refs 为空时必须补显式 `HEAD`——shortlog 无 rev 时非 tty 下会等 stdin 而挂起。 */
export function buildAuthorListArgs(scope: LogScope): string[] {
const refs = scopeRefs(scope);
return ['-s', '-n', '-e', ...(refs.length > 0 ? refs : ['HEAD'])];
}

/** 解析 `git shortlog -se` 输出行(`<count>\t<Name> <email>`);坏行/空输入容错跳过。
* git ident 禁止 name/email 含 `<` `>` 或换行,`<email>` 段无切分歧义。 */
export function parseShortlog(output: string): AuthorStat[] {
const stats: AuthorStat[] = [];
for (const line of output.split('\n')) {
const m = line.match(/^\s*(\d+)\t(.*) <([^<>]*)>\s*$/);
if (m) {
stats.push({ count: Number(m[1]), name: m[2], email: m[3] });
}
}
return stats;
}
30 changes: 27 additions & 3 deletions src/engine/log/log-filter.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Log 客户端过滤器(纯逻辑,零 vscode 依赖)。
*
* author / path / grep 交给 `git log`(repo.log)服务端过滤;以下维度 git log 稳定 API 不支持或
* authors / path / grep 交给 `git log`(repo.log)服务端过滤;以下维度 git log 稳定 API 不支持或
* 表达不便,改在客户端对已取回的提交做过滤:合并模式(parents)/ 日期范围(authorDate)/ message 正则。
* engine 不依赖 vscode,故以 {@link FilterableCommit} 抽象 commit 所需字段,由 adapter 映射。
*/
Expand All @@ -25,11 +25,35 @@ export interface LogClientFilter {
}

/**
* Log 完整过滤器(单一事实源,engine 层):author/path/grep 交 git log 服务端;
* 作者过滤项(OR 语义,git 对多条 `--author` 取并集,可与 pattern 混选):
* - `exact`:从仓库作者列表选中的身份,经 {@link exactAuthorPattern} 锚定字面匹配(同名异邮箱不混淆);
* - `pattern`:手输正则原样透传(保留旧 InputBox 的任意 pattern 能力)。
*/
export type AuthorTerm =
| { readonly kind: 'exact'; readonly name: string; readonly email: string }
| { readonly kind: 'pattern'; readonly pattern: string };

/** BRE 特殊字符集:git `--author` 缺省 POSIX BRE(非 ERE),仅 `\ . [ * ^ $` 有特殊义;
* `+ ? | ( ) {` 在 BRE 中是普通字符,加反斜杠反成量词/分组操作符(实测 `\+` 使含 `+` 的邮箱归零命中),勿扩大转义集。 */
const BRE_SPECIAL = /[.*[\]^$\\]/g;

/** 按 BRE 语义转义字面串(`+ ? |` 等保持裸写,见 {@link BRE_SPECIAL})。 */
export function escapeBreLiteral(s: string): string {
return s.replace(BRE_SPECIAL, '\\$&');
}

/** exact 身份 → 锚定字面 pattern:`--author` 对 `Name <email>` 整行匹配,首尾锚定 + 全字面转义。 */
export function exactAuthorPattern(name: string, email: string): string {
return `^${escapeBreLiteral(`${name} <${email}>`)}$`;
}

/**
* Log 完整过滤器(单一事实源,engine 层):authors/path/grep 交 git log 服务端;
* mergeMode/dateFrom/dateTo/messageRegex 客户端(见 {@link toClientFilter})。
*/
export interface LogFilter {
readonly author?: string;
/** 作者过滤项集合(多选 OR),见 {@link AuthorTerm};空/缺省即不过滤。 */
readonly authors?: readonly AuthorTerm[];
readonly path?: string;
readonly grep?: string;
readonly mergeMode?: MergeMode;
Expand Down
37 changes: 25 additions & 12 deletions src/engine/log/log-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* (`row.authorDate`)对齐,肉眼所见与排列顺序永远一致。pathspec `-- <path>` 必须置于末尾。
*/

import type { LogFilter } from './log-filter';
import { exactAuthorPattern, type LogFilter } from './log-filter';
import { LOG_GRAPH_FORMAT } from './log-line';

/**
Expand All @@ -33,28 +33,41 @@ export interface LogQueryOptions {
readonly skip?: number;
}

/** scope → git 引用范围参数(单一事实源,{@link buildLogArgs} 与作者候选查询共享):
* `current` 为空数组 = git log 缺省 HEAD;shortlog 等调用方需自行补显式 HEAD。 */
export function scopeRefs(scope: LogScope): string[] {
if (scope === 'checkpointer') {
// 原始完整视图:遍历 refs/ 全部引用,含 conductor 等工具的 checkpoint 快照与归档头。
return ['--all'];
}
if (scope === 'all') {
// 干净视图:仅 heads/tags/remotes,排除 refs/conductor-* 等工具注入的内部引用,
// 以免其携带的游离/陈旧提交以游离泳道污染人类历史(`--all` 无法排除,prune 亦触不及)。
return ['--branches', '--tags', '--remotes'];
}
return [];
}

/**
* 构造 `git log` 参数向量(不含 `log` 字面量,host 拼接)。
* 顺序:`--author-date-order` → 范围 → 分页 → 服务端过滤 → `--format` → pathspec(末尾)。
*/
export function buildLogArgs(filter: LogFilter | undefined, scope: LogScope, opts: LogQueryOptions): string[] {
// 排序键须与视图日期列(row.authorDate)对齐;lane 算法仅依赖「子在父之上」不变量,
// --author-date-order 与 --topo-order 均满足,且额外按作者日期倒序、跨支正确交错(详见文件头)。
const args: string[] = ['--author-date-order'];
if (scope === 'checkpointer') {
// 原始完整视图:遍历 refs/ 全部引用,含 conductor 等工具的 checkpoint 快照与归档头。
args.push('--all');
} else if (scope === 'all') {
// 干净视图:仅 heads/tags/remotes,排除 refs/conductor-* 等工具注入的内部引用,
// 以免其携带的游离/陈旧提交以游离泳道污染人类历史(`--all` 无法排除,prune 亦触不及)。
args.push('--branches', '--tags', '--remotes');
}
const args: string[] = ['--author-date-order', ...scopeRefs(scope)];
args.push(`--max-count=${opts.maxCount}`);
if (opts.skip && opts.skip > 0) {
args.push(`--skip=${opts.skip}`);
}
if (filter?.author && filter.author.trim()) {
args.push(`--author=${filter.author.trim()}`);
// authors → 多条 `--author=`(git 取 OR);去重防重复 flag,空 pattern 跳过(对齐「空串不产 flag」)。
const seen = new Set<string>();
for (const term of filter?.authors ?? []) {
const v = term.kind === 'exact' ? exactAuthorPattern(term.name, term.email) : term.pattern.trim();
if (v && !seen.has(v)) {
seen.add(v);
args.push(`--author=${v}`);
}
}
if (filter?.grep && filter.grep.trim()) {
args.push(`--grep=${filter.grep.trim()}`);
Expand Down
54 changes: 54 additions & 0 deletions tests/unit/author-suggest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest';
import { buildAuthorListArgs, parseShortlog } from '../../src/engine/log/author-suggest';
import { scopeRefs } from '../../src/engine/log/log-query';
import { escapeBreLiteral, exactAuthorPattern } from '../../src/engine/log/log-filter';

describe('buildAuthorListArgs — shortlog 取数', () => {
it('三 scope 引用范围与 Graph 视图一致(scopeRefs 单一事实源)', () => {
expect(buildAuthorListArgs('all')).toEqual(['-s', '-n', '-e', '--branches', '--tags', '--remotes']);
expect(buildAuthorListArgs('checkpointer')).toEqual(['-s', '-n', '-e', '--all']);
// current 必须显式 HEAD:shortlog 无 rev 时非 tty 下等 stdin 会挂起
expect(buildAuthorListArgs('current')).toEqual(['-s', '-n', '-e', 'HEAD']);
});

it('scopeRefs 提取后行为不变(重构护栏)', () => {
expect(scopeRefs('all')).toEqual(['--branches', '--tags', '--remotes']);
expect(scopeRefs('checkpointer')).toEqual(['--all']);
expect(scopeRefs('current')).toEqual([]);
});
});

describe('parseShortlog — 容错解析', () => {
it('标准行(前导空格 + count<TAB>Name <email>),保留输出顺序(-n 已按数降序)', () => {
const out = [' 96\tAurelius Huang <threefish.ai@gmail.com>', ' 3\tThreeFish <a@b.com>'].join('\n');
expect(parseShortlog(out)).toEqual([
{ name: 'Aurelius Huang', email: 'threefish.ai@gmail.com', count: 96 },
{ name: 'ThreeFish', email: 'a@b.com', count: 3 },
]);
});

it('本仓库真实输出形态(含正则元字符作者名与 + 号邮箱)', () => {
const out = ' 36\tdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>';
expect(parseShortlog(out)).toEqual([{ name: 'dependabot[bot]', email: '49699333+dependabot[bot]@users.noreply.github.com', count: 36 }]);
});

it('坏行跳过、空输入返回空数组(空仓库/降级路径)', () => {
expect(parseShortlog('')).toEqual([]);
expect(parseShortlog('\n')).toEqual([]);
expect(parseShortlog('garbage line without tab\n12\tno-email-here\n')).toEqual([]);
});
});

describe('BRE 转义 — git --author 缺省 POSIX BRE(非 ERE)', () => {
it('仅转义 \\ . [ * ^ $;+ ? | ( ) { 裸写(加反斜杠反成量词/分组操作符)', () => {
expect(escapeBreLiteral('a.b[c]*^$\\d+?|({}')).toBe('a\\.b\\[c\\]\\*\\^\\$\\\\d+?|({}');
expect(escapeBreLiteral('plain')).toBe('plain');
});

it('exact 身份 → 锚定全行字面 pattern(--author 匹配 Name <email> 整行)', () => {
expect(exactAuthorPattern('Jane', 'jane@x.com')).toBe('^Jane <jane@x\\.com>$');
expect(exactAuthorPattern('dependabot[bot]', '49699333+nx@users.noreply.github.com')).toBe(
'^dependabot\\[bot\\] <49699333+nx@users\\.noreply\\.github\\.com>$',
);
});
});
Loading
Loading