Skip to content

Commit 62da554

Browse files
committed
Closes #4737 adds reachability to detail views
- Adds ability to see which branches and tags contain a specific commit
1 parent de0476d commit 62da554

File tree

12 files changed

+634
-37
lines changed

12 files changed

+634
-37
lines changed

docs/telemetry-events.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -885,6 +885,29 @@ or
885885
}
886886
```
887887

888+
### commitDetails/reachability/failed
889+
890+
> Sent when commit reachability fails to load
891+
892+
```typescript
893+
{
894+
'duration': number,
895+
'failed.error': string,
896+
'failed.reason': 'unknown' | 'git-error' | 'timeout'
897+
}
898+
```
899+
900+
### commitDetails/reachability/loaded
901+
902+
> Sent when commit reachability is successfully loaded
903+
904+
```typescript
905+
{
906+
'duration': number,
907+
'refs.count': number
908+
}
909+
```
910+
888911
### commitDetails/showAborted
889912

890913
```typescript
@@ -2264,6 +2287,29 @@ or
22642287
}
22652288
```
22662289

2290+
### graphDetails/reachability/failed
2291+
2292+
> Sent when commit reachability fails to load in Graph Details
2293+
2294+
```typescript
2295+
{
2296+
'duration': number,
2297+
'failed.error': string,
2298+
'failed.reason': 'unknown' | 'git-error' | 'timeout'
2299+
}
2300+
```
2301+
2302+
### graphDetails/reachability/loaded
2303+
2304+
> Sent when commit reachability is successfully loaded in Graph Details
2305+
2306+
```typescript
2307+
{
2308+
'duration': number,
2309+
'refs.count': number
2310+
}
2311+
```
2312+
22672313
### graphDetails/showAborted
22682314

22692315
```typescript

src/constants.telemetry.ts

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,13 @@ export interface TelemetryEvents extends WebviewShowAbortedEvents, WebviewShownE
133133
'command/core': CoreCommandEvent;
134134

135135
/** Sent when the Inspect view is shown */
136-
'commitDetails/shown': CommitDetailsShownEvent;
136+
'commitDetails/shown': DetailsShownEvent;
137137
/** Sent when the user changes the selected tab (mode) on the Graph Details view */
138-
'commitDetails/mode/changed': CommitDetailsModeChangedEvent;
138+
'commitDetails/mode/changed': DetailsModeChangedEvent;
139+
/** Sent when commit reachability is successfully loaded */
140+
'commitDetails/reachability/loaded': DetailsReachabilityLoadedEvent;
141+
/** Sent when commit reachability fails to load */
142+
'commitDetails/reachability/failed': DetailsReachabilityFailedEvent;
139143

140144
/** Sent when the Commit Composer is first loaded with repo data */
141145
'composer/loaded': ComposerEvent;
@@ -203,9 +207,13 @@ export interface TelemetryEvents extends WebviewShowAbortedEvents, WebviewShownE
203207
'graph/searched': GraphSearchedEvent;
204208

205209
/** Sent when the Graph Details view is shown */
206-
'graphDetails/shown': GraphDetailsShownEvent;
210+
'graphDetails/shown': DetailsShownEvent;
207211
/** Sent when the user changes the selected tab (mode) on the Graph Details view */
208-
'graphDetails/mode/changed': GraphDetailsModeChangedEvent;
212+
'graphDetails/mode/changed': DetailsModeChangedEvent;
213+
/** Sent when commit reachability is successfully loaded in Graph Details */
214+
'graphDetails/reachability/loaded': DetailsReachabilityLoadedEvent;
215+
/** Sent when commit reachability fails to load in Graph Details */
216+
'graphDetails/reachability/failed': DetailsReachabilityFailedEvent;
209217

210218
/** Sent when a Home command is executed */
211219
'home/command': CommandEventData;
@@ -646,13 +654,24 @@ interface CoreCommandEvent {
646654
command: string;
647655
}
648656

649-
type CommitDetailsShownEvent = WebviewShownEventData & InspectShownEventData;
657+
type DetailsShownEvent = WebviewShownEventData & InspectShownEventData;
650658

651-
type CommitDetailsModeChangedEvent = InspectContextEventData & {
659+
type DetailsModeChangedEvent = InspectContextEventData & {
652660
'mode.old': 'wip' | 'commit';
653661
'mode.new': 'wip' | 'commit';
654662
};
655663

664+
interface DetailsReachabilityLoadedEvent {
665+
'refs.count': number;
666+
duration: number;
667+
}
668+
669+
interface DetailsReachabilityFailedEvent {
670+
duration: number;
671+
'failed.reason': 'git-error' | 'timeout' | 'unknown';
672+
'failed.error'?: string;
673+
}
674+
656675
export type FeaturePreviewDayEventData = Record<`day.${number}.startedOn`, string>;
657676
export type FeaturePreviewEventData = {
658677
feature: FeaturePreviews;
@@ -729,12 +748,6 @@ interface GraphSearchedEvent extends GraphContextEventData {
729748
'failed.error.detail'?: string;
730749
}
731750

732-
type GraphDetailsShownEvent = WebviewShownEventData & InspectShownEventData;
733-
type GraphDetailsModeChangedEvent = InspectContextEventData & {
734-
'mode.old': 'wip' | 'commit';
735-
'mode.new': 'wip' | 'commit';
736-
};
737-
738751
export type HomeTelemetryContext = WebviewTelemetryContext & {
739752
'context.preview': string | undefined;
740753
};

src/env/node/git/sub-providers/commits.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { GitCache } from '../../../../git/cache';
77
import type { GitCommandOptions } from '../../../../git/commandOptions';
88
import { GitErrorHandling } from '../../../../git/commandOptions';
99
import type {
10+
GitCommitReachability,
1011
GitCommitsSubProvider,
1112
GitLogForPathOptions,
1213
GitLogOptions,
@@ -53,6 +54,9 @@ import { filterMap, first, join, last, some } from '../../../../system/iterable'
5354
import { Logger } from '../../../../system/logger';
5455
import { getLogScope } from '../../../../system/logger.scope';
5556
import { isFolderGlob, stripFolderGlob } from '../../../../system/path';
57+
import { wait } from '../../../../system/promise';
58+
import type { Cancellable } from '../../../../system/promiseCache';
59+
import { PromiseCache } from '../../../../system/promiseCache';
5660
import { maybeStopWatch } from '../../../../system/stopwatch';
5761
import type { CachedLog, TrackedGitDocument } from '../../../../trackers/trackedDocument';
5862
import { GitDocumentState } from '../../../../trackers/trackedDocument';
@@ -174,6 +178,104 @@ export class CommitsGitSubProvider implements GitCommitsSubProvider {
174178
}
175179
}
176180

181+
@log()
182+
async getCommitReachability(
183+
repoPath: string,
184+
rev: string,
185+
cancellation?: CancellationToken,
186+
): Promise<GitCommitReachability | undefined> {
187+
if (repoPath == null || isUncommitted(rev)) return undefined;
188+
189+
const scope = getLogScope();
190+
191+
const getCore = async (cancellable?: Cancellable) => {
192+
try {
193+
// Use for-each-ref with %(HEAD) to mark current branch with *
194+
const result = await this.git.exec(
195+
{ cwd: repoPath, cancellation: cancellation, errors: GitErrorHandling.Ignore },
196+
'for-each-ref',
197+
'--contains',
198+
rev,
199+
'--format=%(HEAD)%(refname)',
200+
'--sort=-version:refname',
201+
'--sort=-committerdate',
202+
'--sort=-HEAD',
203+
'refs/heads/',
204+
'refs/remotes/',
205+
'refs/tags/',
206+
);
207+
if (cancellation?.isCancellationRequested) throw new CancellationError();
208+
209+
const refs: GitCommitReachability['refs'] = [];
210+
211+
// Parse branches from refs/heads/ and refs/remotes/
212+
if (result?.stdout) {
213+
const lines = result.stdout.split('\n');
214+
215+
for (let line of lines) {
216+
line = line.trim();
217+
if (!line) continue;
218+
219+
// %(HEAD) outputs '*' for current branch, ' ' for others
220+
const isCurrent = line.startsWith('*');
221+
const refname = isCurrent ? line.substring(1) : line; // Skip the HEAD marker
222+
223+
// Skip HEADs
224+
if (refname.endsWith('/HEAD')) continue;
225+
226+
if (refname.startsWith('refs/heads/')) {
227+
// Remove 'refs/heads/'
228+
const name = refname.substring(11);
229+
refs.push({
230+
refType: 'branch',
231+
name: name,
232+
remote: false,
233+
current: isCurrent,
234+
});
235+
} else if (refname.startsWith('refs/remotes/')) {
236+
// Remove 'refs/remotes/'
237+
refs.push({ refType: 'branch', name: refname.substring(13), remote: true });
238+
} else if (refname.startsWith('refs/tags/')) {
239+
// Remove 'refs/tags/'
240+
refs.push({ refType: 'tag', name: refname.substring(10) });
241+
}
242+
}
243+
}
244+
245+
// Sort to move tags to the end, preserving order within each type
246+
refs.sort((a, b) => (a.refType !== b.refType ? (a.refType === 'tag' ? 1 : -1) : 0));
247+
248+
await wait(20000);
249+
250+
return { refs: refs };
251+
} catch (ex) {
252+
cancellable?.cancelled();
253+
debugger;
254+
if (isCancellationError(ex)) throw ex;
255+
256+
Logger.error(ex, scope);
257+
258+
return undefined;
259+
}
260+
};
261+
262+
const cache = this.cache.reachability;
263+
if (cache == null) return getCore();
264+
265+
let reachabilityCache = cache.get(repoPath);
266+
if (reachabilityCache == null) {
267+
cache.set(
268+
repoPath,
269+
(reachabilityCache = new PromiseCache<string, GitCommitReachability | undefined>({
270+
accessTTL: 1000 * 60 * 60, // 60 minutes
271+
capacity: 25, // Limit to 25 commits per repo
272+
})),
273+
);
274+
}
275+
276+
return reachabilityCache.get(rev, getCore);
277+
}
278+
177279
@log()
178280
async getIncomingActivity(
179281
repoPath: string,

src/git/cache.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { PromiseOrValue } from '../system/promise';
66
import type { PromiseCache } from '../system/promiseCache';
77
import { PromiseMap } from '../system/promiseCache';
88
import { PathTrie } from '../system/trie';
9-
import type { CachedGitTypes, GitContributorsResult, GitDir, PagedResult } from './gitProvider';
9+
import type { CachedGitTypes, GitCommitReachability, GitContributorsResult, GitDir, PagedResult } from './gitProvider';
1010
import type { GitBranch } from './models/branch';
1111
import type { GitContributor } from './models/contributor';
1212
import type { GitPausedOperationStatus } from './models/pausedOperationStatus';
@@ -105,6 +105,13 @@ export class GitCache implements Disposable {
105105
: undefined;
106106
}
107107

108+
private _reachabilityCache: Map<RepoPath, PromiseCache<string, GitCommitReachability | undefined>> | undefined;
109+
get reachability(): Map<RepoPath, PromiseCache<string, GitCommitReachability | undefined>> | undefined {
110+
return this.useCaching
111+
? (this._reachabilityCache ??= new Map<RepoPath, PromiseCache<string, GitCommitReachability | undefined>>())
112+
: undefined;
113+
}
114+
108115
private _remotesCache: PromiseMap<RepoPath, GitRemote[]> | undefined;
109116
get remotes(): PromiseMap<RepoPath, GitRemote[]> | undefined {
110117
return this.useCaching ? (this._remotesCache ??= new PromiseMap<RepoPath, GitRemote[]>()) : undefined;
@@ -145,6 +152,7 @@ export class GitCache implements Disposable {
145152
cachesToClear.add(this._branchCache);
146153
cachesToClear.add(this._branchesCache);
147154
cachesToClear.add(this._defaultBranchNameCache);
155+
cachesToClear.add(this._reachabilityCache);
148156
}
149157

150158
if (!types.length || types.includes('contributors')) {
@@ -204,6 +212,8 @@ export class GitCache implements Disposable {
204212
this._contributorsLiteCache = undefined;
205213
this._pausedOperationStatusCache?.clear();
206214
this._pausedOperationStatusCache = undefined;
215+
this._reachabilityCache?.clear();
216+
this._reachabilityCache = undefined;
207217
this._remotesCache?.clear();
208218
this._remotesCache = undefined;
209219
this._stashesCache?.clear();

src/git/gitProvider.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,13 @@ export interface IncomingActivityOptions extends GitLogOptionsBase {
306306
skip?: number;
307307
}
308308

309+
export interface GitCommitReachability {
310+
readonly refs: (
311+
| { readonly refType: 'branch'; readonly name: string; readonly remote: boolean; readonly current?: boolean }
312+
| { readonly refType: 'tag'; readonly name: string; readonly current?: never }
313+
)[];
314+
}
315+
309316
export interface GitCommitsSubProvider {
310317
getCommit(repoPath: string, rev: string, cancellation?: CancellationToken): Promise<GitCommit | undefined>;
311318
getCommitCount(repoPath: string, rev: string, cancellation?: CancellationToken): Promise<number | undefined>;
@@ -362,6 +369,11 @@ export interface GitCommitsSubProvider {
362369
options?: GitSearchCommitsOptions,
363370
cancellation?: CancellationToken,
364371
): Promise<SearchCommitsResult>;
372+
getCommitReachability?(
373+
repoPath: string,
374+
rev: string,
375+
cancellation?: CancellationToken,
376+
): Promise<GitCommitReachability | undefined>;
365377
}
366378

367379
export interface GitOperationsSubProvider {

src/system/promiseCache.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ interface PromiseCacheOptions {
1818
accessTTL?: number;
1919
/** Whether to expire the entry if the promise fails (default: true) */
2020
expireOnError?: boolean;
21+
/** Maximum number of entries in the cache (LRU eviction when exceeded) */
22+
capacity?: number;
2123
}
2224

2325
export class PromiseCache<K, V> {
@@ -72,9 +74,9 @@ export class PromiseCache<K, V> {
7274
};
7375
this.cache.set(key, entry);
7476

75-
// Clean up other expired entries
77+
// Clean up expired entries and enforce capacity limit in one pass
7678
if (this.cache.size > 1) {
77-
queueMicrotask(() => this.cleanupExpired());
79+
queueMicrotask(() => this.cleanup());
7880
}
7981

8082
if (options?.expireOnError ?? true) {
@@ -84,14 +86,39 @@ export class PromiseCache<K, V> {
8486
return promise;
8587
}
8688

87-
private cleanupExpired(): void {
89+
private cleanup(): void {
8890
const now = Date.now();
91+
const capacity = this.options.capacity;
8992

93+
// If no capacity limit, just remove expired entries
94+
if (capacity == null) {
95+
for (const [key, entry] of this.cache.entries()) {
96+
if (this.expired(entry, now)) {
97+
this.cache.delete(key);
98+
}
99+
}
100+
return;
101+
}
102+
103+
// Single pass: collect non-expired entries and find LRU candidates
104+
const entries: Array<[K, CacheEntry<V>]> = [];
90105
for (const [key, entry] of this.cache.entries()) {
91-
if (this.expired(entry, now)) {
106+
if (!this.expired(entry, now)) {
107+
entries.push([key, entry]);
108+
} else {
92109
this.cache.delete(key);
93110
}
94111
}
112+
113+
// If still over capacity, remove LRU entries
114+
const excess = entries.length - capacity;
115+
if (excess > 0) {
116+
// Sort by accessed time (oldest first) and remove the excess
117+
entries.sort((a, b) => a[1].accessed - b[1].accessed);
118+
for (let i = 0; i < excess; i++) {
119+
this.cache.delete(entries[i][0]);
120+
}
121+
}
95122
}
96123

97124
private expired(entry: CacheEntry<V>, now: number): boolean {

0 commit comments

Comments
 (0)