Skip to content

Commit 6aaffa0

Browse files
committed
Avoids stashes mutation & cache pollution (#4427)
Improves stash loading performance with async caching
1 parent 5e668aa commit 6aaffa0

File tree

8 files changed

+93
-103
lines changed

8 files changed

+93
-103
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
1313
### Fixed
1414

1515
- Fixes disabled GitLens view tabs on clean install ([#4416](https://github.com/gitkraken/vscode-gitlens/issues/4416))
16+
- Fixes Stashes view errors w/ ID already exists ([#4427](https://github.com/gitkraken/vscode-gitlens/issues/4427))
1617

1718
## [17.2.0] - 2025-06-17
1819

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ export class GraphGitSubProvider implements GitGraphSubProvider {
136136

137137
// TODO@eamodio this is insanity -- there *HAS* to be a better way to get git log to return stashes
138138
const gitStash = getSettledValue(stashResult);
139-
const { stdin, stashes, remappedIds } = convertStashesToStdin(gitStash?.stashes);
139+
const { stdin, remappedIds } = convertStashesToStdin(gitStash?.stashes);
140140

141141
const useAvatars = configuration.get('graph.avatars', undefined, true);
142142

@@ -584,7 +584,7 @@ export class GraphGitSubProvider implements GitGraphSubProvider {
584584
branches: branchMap,
585585
remotes: remoteMap,
586586
downstreams: downstreamMap,
587-
stashes: stashes,
587+
stashes: gitStash?.stashes,
588588
worktrees: worktrees,
589589
worktreesByBranch: worktreesByBranch,
590590
rows: rows,

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

Lines changed: 78 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -73,80 +73,89 @@ export class StashGitSubProvider implements GitStashSubProvider {
7373
): Promise<GitStash | undefined> {
7474
if (repoPath == null) return undefined;
7575

76-
let stash = this.cache.stashes?.get(repoPath);
77-
if (stash == null) {
78-
const parser = getStashLogParser();
79-
const args = [...parser.arguments];
76+
let stashPromise = this.cache.stashes?.get(repoPath);
77+
if (stashPromise == null) {
78+
async function load(this: StashGitSubProvider): Promise<GitStash> {
79+
const parser = getStashLogParser();
80+
const args = [...parser.arguments];
81+
82+
const similarityThreshold = configuration.get('advanced.similarityThreshold');
83+
args.push(`-M${similarityThreshold == null ? '' : `${similarityThreshold}%`}`);
84+
85+
const result = await this.git.exec(
86+
{ cwd: repoPath, cancellation: cancellation },
87+
'stash',
88+
'list',
89+
...args,
90+
);
91+
92+
const stashes = new Map<string, GitStashCommit>();
93+
94+
for (const s of parser.parse(result.stdout)) {
95+
stashes.set(s.sha, createStash(this.container, s, repoPath));
96+
}
8097

81-
const similarityThreshold = configuration.get('advanced.similarityThreshold');
82-
args.push(`-M${similarityThreshold == null ? '' : `${similarityThreshold}%`}`);
98+
return { repoPath: repoPath, stashes: stashes };
99+
}
83100

84-
const result = await this.git.exec({ cwd: repoPath, cancellation: cancellation }, 'stash', 'list', ...args);
101+
stashPromise = load.call(this);
102+
this.cache.stashes?.set(repoPath, stashPromise);
103+
}
85104

86-
const stashes = new Map<string, GitStashCommit>();
105+
const stash = await stashPromise;
106+
if (!options?.reachableFrom || !stash?.stashes.size) return stash;
87107

88-
for (const s of parser.parse(result.stdout)) {
89-
stashes.set(s.sha, createStash(this.container, s, repoPath));
90-
}
108+
// Return only reachable stashes from the given ref
109+
// Create a copy because we are going to modify it and we don't want to mutate the cache
110+
const stashes = new Map(stash.stashes);
91111

92-
stash = { repoPath: repoPath, stashes: stashes };
112+
const oldestStashDate = new Date(min(stash.stashes.values(), c => c.date.getTime())).toISOString();
93113

94-
this.cache.stashes?.set(repoPath, stash);
95-
}
114+
const result = await this.git.exec(
115+
{ cwd: repoPath, cancellation: cancellation, errors: GitErrorHandling.Ignore },
116+
'rev-list',
117+
`--since="${oldestStashDate}"`,
118+
'--date-order',
119+
options.reachableFrom,
120+
'--',
121+
);
96122

97-
// Return only reachable stashes from the given ref
98-
if (options?.reachableFrom && stash?.stashes.size) {
99-
// Create a copy because we are going to modify it and we don't want to mutate the cache
100-
stash = { ...stash, stashes: new Map(stash.stashes) };
101-
102-
const oldestStashDate = new Date(min(stash.stashes.values(), c => c.date.getTime())).toISOString();
103-
104-
const result = await this.git.exec(
105-
{ cwd: repoPath, cancellation: cancellation, errors: GitErrorHandling.Ignore },
106-
'rev-list',
107-
`--since="${oldestStashDate}"`,
108-
'--date-order',
109-
options.reachableFrom,
110-
'--',
111-
);
123+
const ancestors = result.stdout.trim().split('\n');
124+
const reachableCommits =
125+
ancestors?.length && (ancestors.length !== 1 || ancestors[0]) ? new Set(ancestors) : undefined;
126+
if (reachableCommits?.size) {
127+
const reachableStashes = new Set<string>();
112128

113-
const ancestors = result.stdout.trim().split('\n');
114-
const reachableCommits =
115-
ancestors?.length && (ancestors.length !== 1 || ancestors[0]) ? new Set(ancestors) : undefined;
116-
if (reachableCommits?.size) {
117-
const reachableStashes = new Set<string>();
129+
// First pass: mark directly reachable stashes
130+
for (const [sha, s] of stash.stashes) {
131+
if (s.parents.some(p => p === options.reachableFrom || reachableCommits.has(p))) {
132+
reachableStashes.add(sha);
133+
}
134+
}
118135

119-
// First pass: mark directly reachable stashes
136+
// Second pass: mark stashes that build upon reachable stashes
137+
let changed;
138+
do {
139+
changed = false;
120140
for (const [sha, s] of stash.stashes) {
121-
if (s.parents.some(p => p === options.reachableFrom || reachableCommits.has(p))) {
141+
if (!reachableStashes.has(sha) && s.parents.some(p => reachableStashes.has(p))) {
122142
reachableStashes.add(sha);
143+
changed = true;
123144
}
124145
}
146+
} while (changed);
125147

126-
// Second pass: mark stashes that build upon reachable stashes
127-
let changed;
128-
do {
129-
changed = false;
130-
for (const [sha, s] of stash.stashes) {
131-
if (!reachableStashes.has(sha) && s.parents.some(p => reachableStashes.has(p))) {
132-
reachableStashes.add(sha);
133-
changed = true;
134-
}
135-
}
136-
} while (changed);
137-
138-
// Remove unreachable stashes
139-
for (const [sha] of stash.stashes) {
140-
if (!reachableStashes.has(sha)) {
141-
stash.stashes.delete(sha);
142-
}
148+
// Remove unreachable stashes
149+
for (const [sha] of stash.stashes) {
150+
if (!reachableStashes.has(sha)) {
151+
stashes.delete(sha);
143152
}
144-
} else {
145-
stash.stashes.clear();
146153
}
154+
} else {
155+
stashes.clear();
147156
}
148157

149-
return stash;
158+
return { ...stash, stashes: stashes };
150159
}
151160

152161
@log()
@@ -342,7 +351,7 @@ export class StashGitSubProvider implements GitStashSubProvider {
342351
}
343352
}
344353

345-
export function convertStashesToStdin(stashOrStashes: GitStash | Map<string, GitStashCommit> | undefined): {
354+
export function convertStashesToStdin(stashOrStashes: GitStash | ReadonlyMap<string, GitStashCommit> | undefined): {
346355
readonly stdin: string | undefined;
347356
readonly stashes: Map<string, GitStashCommit>;
348357
readonly remappedIds: Map<string, string>;
@@ -351,40 +360,23 @@ export function convertStashesToStdin(stashOrStashes: GitStash | Map<string, Git
351360
if (stashOrStashes == null) return { stdin: undefined, stashes: new Map(), remappedIds: remappedIds };
352361

353362
let stdin: string | undefined;
354-
let stashes: Map<string, GitStashCommit>;
355-
356-
if ('stashes' in stashOrStashes) {
357-
stashes = new Map(stashOrStashes.stashes);
358-
if (stashOrStashes.stashes.size) {
359-
stdin = '';
360-
for (const stash of stashOrStashes.stashes.values()) {
361-
stdin += `${stash.sha.substring(0, 9)}\n`;
362-
// Include the stash's 2nd (index files) and 3rd (untracked files) parents
363-
for (const p of skip(stash.parents, 1)) {
364-
remappedIds.set(p, stash.sha);
365-
363+
const original = 'stashes' in stashOrStashes ? stashOrStashes.stashes : stashOrStashes;
364+
const stashes = new Map<string, GitStashCommit>(original);
365+
366+
if (original.size) {
367+
stdin = '';
368+
for (const stash of original.values()) {
369+
stdin += `${stash.sha.substring(0, 9)}\n`;
370+
// Include the stash's 2nd (index files) and 3rd (untracked files) parents (if they aren't already in the map)
371+
for (const p of skip(stash.parents, 1)) {
372+
remappedIds.set(p, stash.sha);
373+
374+
if (!stashes.has(p)) {
366375
stashes.set(p, stash);
367376
stdin += `${p.substring(0, 9)}\n`;
368377
}
369378
}
370379
}
371-
} else {
372-
if (stashOrStashes.size) {
373-
stdin = '';
374-
for (const stash of stashOrStashes.values()) {
375-
stdin += `${stash.sha.substring(0, 9)}\n`;
376-
// Include the stash's 2nd (index files) and 3rd (untracked files) parents (if they aren't already in the map)
377-
for (const p of skip(stash.parents, 1)) {
378-
remappedIds.set(p, stash.sha);
379-
380-
if (!stashOrStashes.has(p)) {
381-
stashOrStashes.set(p, stash);
382-
stdin += `${p.substring(0, 9)}\n`;
383-
}
384-
}
385-
}
386-
}
387-
stashes = stashOrStashes;
388380
}
389381

390382
return { stdin: stdin || undefined, stashes: stashes, remappedIds: remappedIds };

src/git/cache.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,9 +116,9 @@ export class GitCache implements Disposable {
116116
return (this._repoInfoCache ??= new Map<RepoPath, RepositoryInfo>());
117117
}
118118

119-
private _stashesCache: Map<RepoPath, GitStash> | undefined;
120-
get stashes(): Map<RepoPath, GitStash> | undefined {
121-
return this.useCaching ? (this._stashesCache ??= new Map<RepoPath, GitStash>()) : undefined;
119+
private _stashesCache: Map<RepoPath, Promise<GitStash>> | undefined;
120+
get stashes(): Map<RepoPath, Promise<GitStash>> | undefined {
121+
return this.useCaching ? (this._stashesCache ??= new Map<RepoPath, Promise<GitStash>>()) : undefined;
122122
}
123123

124124
private _tagsCache: Map<RepoPath, Promise<PagedResult<GitTag>>> | undefined;

src/git/models/graph.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,12 @@ export interface GitGraph {
4343
/** A set of all "seen" commit ids */
4444
readonly ids: Set<string>;
4545
readonly includes: { stats?: boolean } | undefined;
46-
readonly branches: Map<string, GitBranch>;
47-
readonly remotes: Map<string, GitRemote>;
48-
readonly downstreams: Map<string, string[]>;
49-
readonly stashes: Map<string, GitStashCommit> | undefined;
50-
readonly worktrees: GitWorktree[] | undefined;
51-
readonly worktreesByBranch: Map<string, GitWorktree> | undefined;
46+
readonly branches: ReadonlyMap<string, GitBranch>;
47+
readonly remotes: ReadonlyMap<string, GitRemote>;
48+
readonly downstreams: ReadonlyMap<string, string[]>;
49+
readonly stashes: ReadonlyMap<string, GitStashCommit> | undefined;
50+
readonly worktrees: ReadonlyArray<GitWorktree> | undefined;
51+
readonly worktreesByBranch: ReadonlyMap<string, GitWorktree> | undefined;
5252

5353
/** The rows for the set of commits requested */
5454
readonly rows: GitGraphRow[];

src/git/models/stash.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@ import type { GitStashCommit } from './commit';
22

33
export interface GitStash {
44
readonly repoPath: string;
5-
readonly stashes: Map<string, GitStashCommit>;
5+
readonly stashes: ReadonlyMap<string, GitStashCommit>;
66
}

src/git/utils/branch.utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export function getBranchTrackingWithoutRemote(ref: GitBranchReference): string
4141

4242
export async function getLocalBranchByUpstream(
4343
remoteBranchName: string,
44-
branches: PageableResult<GitBranch> | Map<unknown, GitBranch>,
44+
branches: PageableResult<GitBranch> | ReadonlyMap<unknown, GitBranch>,
4545
): Promise<GitBranch | undefined> {
4646
let qualifiedRemoteBranchName;
4747
if (remoteBranchName.startsWith('remotes/')) {

src/views/stashesView.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,7 @@ import { registerViewCommand } from './viewCommands';
2222

2323
export class StashesRepositoryNode extends RepositoryFolderNode<StashesView, StashesNode> {
2424
async getChildren(): Promise<ViewNode[]> {
25-
if (this.child == null) {
26-
this.child = new StashesNode(this.uri, this.view, this, this.repo);
27-
}
28-
25+
this.child ??= new StashesNode(this.uri, this.view, this, this.repo);
2926
return this.child.getChildren();
3027
}
3128

0 commit comments

Comments
 (0)