diff --git a/CHANGELOG.md b/CHANGELOG.md index aafeeef882ad7..34fb35ad60966 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p ### Fixed +- Fixes stashes with parent commits older than the oldest stash not being visible on branches ([#4401](https://github.com/gitkraken/vscode-gitlens/issues/4401)) - Fixes editing search result in Search & Compare view failure ([#4431](https://github.com/gitkraken/vscode-gitlens/issues/4431)) - Fixes search results not paging properly on the _Commit Graph_ when the first page of results is contained within the already loaded commits diff --git a/src/env/node/git/sub-providers/__tests__/stash.test.ts b/src/env/node/git/sub-providers/__tests__/stash.test.ts new file mode 100644 index 0000000000000..8e5143d806149 --- /dev/null +++ b/src/env/node/git/sub-providers/__tests__/stash.test.ts @@ -0,0 +1,217 @@ +import * as assert from 'assert'; +import type { GitStashCommit, GitStashParentInfo } from '../../../../../git/models/commit'; +import { findOldestStashTimestamp } from '../stash'; + +suite('findOldestStashTimestamp Test Suite', () => { + function createMockStashCommit(date: Date, parentTimestamps?: GitStashParentInfo[]): Partial { + return { + date: date, + parentTimestamps: parentTimestamps, + }; + } + + test('should return Infinity for empty stashes collection', () => { + const result = findOldestStashTimestamp([]); + assert.strictEqual(result, Infinity); + }); + + test('should return stash date when no parent timestamps exist', () => { + const stashDate = new Date('2022-01-02T12:00:00Z'); + const stashes = [createMockStashCommit(stashDate)] as GitStashCommit[]; + + const result = findOldestStashTimestamp(stashes); + assert.strictEqual(result, stashDate.getTime()); + }); + + test('should return stash date when parent timestamps are empty', () => { + const stashDate = new Date('2022-01-02T12:00:00Z'); + const stashes = [createMockStashCommit(stashDate, [])] as GitStashCommit[]; + + const result = findOldestStashTimestamp(stashes); + assert.strictEqual(result, stashDate.getTime()); + }); + + test('should return oldest parent timestamp when parent is older than stash', () => { + const stashDate = new Date('2022-01-02T12:00:00Z'); + const oldest = 1640995200; // 2022-01-01 00:00:00 UTC + const parentTimestamps: GitStashParentInfo[] = [ + { + sha: 'parent1', + authorDate: oldest, + committerDate: 1640995260, + }, + ]; + const stashes = [createMockStashCommit(stashDate, parentTimestamps)] as GitStashCommit[]; + + const result = findOldestStashTimestamp(stashes); + const expectedOldest = oldest * 1000; // Convert to milliseconds + assert.strictEqual(result, expectedOldest); + }); + + test('should return stash date when stash is older than parents', () => { + const stashDate = new Date('2022-01-01T00:00:00Z'); // Older + const parentTimestamps: GitStashParentInfo[] = [ + { + sha: 'parent1', + authorDate: 1641081600, // 2022-01-02 00:00:00 UTC (newer) + committerDate: 1641081660, + }, + ]; + const stashes = [createMockStashCommit(stashDate, parentTimestamps)] as GitStashCommit[]; + + const result = findOldestStashTimestamp(stashes); + assert.strictEqual(result, stashDate.getTime()); + }); + + test('should handle multiple stashes and find the globally oldest timestamp', () => { + const stash1Date = new Date('2022-01-03T00:00:00Z'); + const oldest = 1640995200; // 2022-01-01 00:00:00 UTC (oldest overall) + const stash1Parents: GitStashParentInfo[] = [ + { + sha: 'parent1', + authorDate: oldest, + committerDate: 1640995260, + }, + ]; + + const stash2Date = new Date('2022-01-02T00:00:00Z'); + const stash2Parents: GitStashParentInfo[] = [ + { + sha: 'parent2', + authorDate: 1641081600, // 2022-01-02 00:00:00 UTC + committerDate: 1641081660, + }, + ]; + + const stashes = [ + createMockStashCommit(stash1Date, stash1Parents), + createMockStashCommit(stash2Date, stash2Parents), + ] as GitStashCommit[]; + + const result = findOldestStashTimestamp(stashes); + const expectedOldest = oldest * 1000; // parent1's authorDate + assert.strictEqual(result, expectedOldest); + }); + + test('should consider both authorDate and committerDate of parents', () => { + const stashDate = new Date('2022-01-02T00:00:00Z'); + const oldest = 1640995200; // 2022-01-01 00:00:00 UTC (older) + const parentTimestamps: GitStashParentInfo[] = [ + { + sha: 'parent1', + authorDate: 1641081600, // 2022-01-02 00:00:00 UTC + committerDate: oldest, + }, + ]; + const stashes = [createMockStashCommit(stashDate, parentTimestamps)] as GitStashCommit[]; + + const result = findOldestStashTimestamp(stashes); + const expectedOldest = oldest * 1000; // committerDate is older + assert.strictEqual(result, expectedOldest); + }); + + test('should handle null/undefined parent timestamps gracefully', () => { + const stashDate = new Date('2022-01-02T00:00:00Z'); + const oldest = 1640995200; // 2022-01-01 00:00:00 UTC + const parentTimestamps: GitStashParentInfo[] = [ + { + sha: 'parent1', + authorDate: undefined, + committerDate: null as any, + }, + { + sha: 'parent2', + authorDate: oldest, + committerDate: undefined, + }, + ]; + const stashes = [createMockStashCommit(stashDate, parentTimestamps)] as GitStashCommit[]; + + const result = findOldestStashTimestamp(stashes); + const expectedOldest = oldest * 1000; // Only valid timestamp + assert.strictEqual(result, expectedOldest); + }); + + test('should handle multiple parents per stash', () => { + const stashDate = new Date('2022-01-03T00:00:00Z'); + const oldest = 1640995200; // 2022-01-01 00:00:00 UTC (oldest) + const parentTimestamps: GitStashParentInfo[] = [ + { + sha: 'parent1', + authorDate: 1641081600, // 2022-01-02 00:00:00 UTC + committerDate: 1641081660, + }, + { + sha: 'parent2', + authorDate: oldest, + committerDate: 1640995260, + }, + { + sha: 'parent3', + authorDate: 1641168000, // 2022-01-03 00:00:00 UTC + committerDate: 1641168060, + }, + ]; + const stashes = [createMockStashCommit(stashDate, parentTimestamps)] as GitStashCommit[]; + + const result = findOldestStashTimestamp(stashes); + const expectedOldest = oldest * 1000; // parent2's authorDate + assert.strictEqual(result, expectedOldest); + }); + + test('should work with Map.values() as used in production code', () => { + const stashMap = new Map(); + const oldest = 1640995200; // 2022-01-01 00:00:00 UTC (oldest) + + const stash1Date = new Date('2022-01-02T00:00:00Z'); + const stash1Parents: GitStashParentInfo[] = [ + { + sha: 'parent1', + authorDate: oldest, + committerDate: 1640995260, + }, + ]; + + stashMap.set('stash1', createMockStashCommit(stash1Date, stash1Parents) as GitStashCommit); + + const result = findOldestStashTimestamp(stashMap.values()); + const expectedOldest = oldest * 1000; + assert.strictEqual(result, expectedOldest); + }); + + test('should handle edge case with only null parent timestamps', () => { + const stashDate = new Date('2022-01-02T00:00:00Z'); + const parentTimestamps: GitStashParentInfo[] = [ + { + sha: 'parent1', + authorDate: null as any, + committerDate: undefined, + }, + ]; + const stashes = [createMockStashCommit(stashDate, parentTimestamps)] as GitStashCommit[]; + + const result = findOldestStashTimestamp(stashes); + assert.strictEqual(result, stashDate.getTime()); // Falls back to stash date + }); + + test('should handle very large collections efficiently', () => { + const stashes: GitStashCommit[] = []; + const baseTime = new Date('2022-01-01T00:00:00Z').getTime(); + + // Create 1000 stashes with various timestamps + for (let i = 0; i < 1000; i++) { + const stashDate = new Date(baseTime + i * 60000); // Each stash 1 minute apart + const parentTimestamps: GitStashParentInfo[] = [ + { + sha: `parent${i}`, + authorDate: Math.floor((baseTime + i * 30000) / 1000), // 30 seconds apart + committerDate: Math.floor((baseTime + i * 45000) / 1000), // 45 seconds apart + }, + ]; + stashes.push(createMockStashCommit(stashDate, parentTimestamps) as GitStashCommit); + } + + const result = findOldestStashTimestamp(stashes); + assert.strictEqual(result, baseTime); // Should be the first parent's authorDate + }); +}); diff --git a/src/env/node/git/sub-providers/stash.ts b/src/env/node/git/sub-providers/stash.ts index 8ff74edd2bcb9..21c104b56dd5f 100644 --- a/src/env/node/git/sub-providers/stash.ts +++ b/src/env/node/git/sub-providers/stash.ts @@ -5,7 +5,7 @@ import type { GitCache } from '../../../../git/cache'; import { GitErrorHandling } from '../../../../git/commandOptions'; import { StashApplyError, StashApplyErrorReason } from '../../../../git/errors'; import type { GitStashSubProvider } from '../../../../git/gitProvider'; -import type { GitStashCommit } from '../../../../git/models/commit'; +import type { GitStashCommit, GitStashParentInfo } from '../../../../git/models/commit'; import { GitCommit, GitCommitIdentity } from '../../../../git/models/commit'; import { GitFileChange } from '../../../../git/models/fileChange'; import type { GitFileStatus } from '../../../../git/models/fileStatus'; @@ -13,7 +13,11 @@ import { GitFileWorkingTreeStatus } from '../../../../git/models/fileStatus'; import { RepositoryChange } from '../../../../git/models/repository'; import type { GitStash } from '../../../../git/models/stash'; import type { ParsedStash } from '../../../../git/parsers/logParser'; -import { getStashFilesOnlyLogParser, getStashLogParser } from '../../../../git/parsers/logParser'; +import { + getShaAndDatesLogParser, + getStashFilesOnlyLogParser, + getStashLogParser, +} from '../../../../git/parsers/logParser'; import { configuration } from '../../../../system/-webview/configuration'; import { splitPath } from '../../../../system/-webview/path'; import { countStringLength } from '../../../../system/array'; @@ -90,9 +94,62 @@ export class StashGitSubProvider implements GitStashSubProvider { ); const stashes = new Map(); + const parentShas = new Set(); + // First pass: create stashes and collect parent SHAs for (const s of parser.parse(result.stdout)) { stashes.set(s.sha, createStash(this.container, s, repoPath)); + // Collect all parent SHAs for timestamp lookup + if (s.parents) { + for (const parentSha of s.parents.split(' ')) { + if (parentSha.trim()) { + parentShas.add(parentSha.trim()); + } + } + } + } + + // Second pass: fetch parent timestamps if we have any parents + const parentTimestamps = new Map(); + if (parentShas.size > 0) { + try { + const datesParser = getShaAndDatesLogParser(); + const parentResult = await this.git.exec( + { + cwd: repoPath, + cancellation: cancellation, + stdin: Array.from(parentShas).join('\n'), + }, + 'log', + ...datesParser.arguments, + '--no-walk', + '--stdin', + ); + + for (const entry of datesParser.parse(parentResult.stdout)) { + parentTimestamps.set(entry.sha, { + authorDate: Number(entry.authorDate), + committerDate: Number(entry.committerDate), + }); + } + } catch (_ex) { + // If we can't get parent timestamps, continue without them + // This could happen if some parent commits are not available + } + } + + // Third pass: update stashes with parent timestamp information + for (const sha of stashes.keys()) { + const stash = stashes.get(sha); + if (stash?.parents.length) { + const parentsWithTimestamps: GitStashParentInfo[] = stash.parents.map(parentSha => ({ + sha: parentSha, + authorDate: parentTimestamps.get(parentSha)?.authorDate, + committerDate: parentTimestamps.get(parentSha)?.committerDate, + })); + // Store the parent timestamp information on the stash + stashes.set(sha, stash.with({ parentTimestamps: parentsWithTimestamps })); + } } return { repoPath: repoPath, stashes: stashes }; @@ -109,7 +166,7 @@ export class StashGitSubProvider implements GitStashSubProvider { // Create a copy because we are going to modify it and we don't want to mutate the cache const stashes = new Map(stash.stashes); - const oldestStashDate = new Date(min(stash.stashes.values(), c => c.date.getTime())).toISOString(); + const oldestStashDate = new Date(findOldestStashTimestamp(stash.stashes.values())).toISOString(); const result = await this.git.exec( { cwd: repoPath, cancellation: cancellation, errors: GitErrorHandling.Ignore }, @@ -424,3 +481,22 @@ function createStash(container: Container, s: ParsedStash, repoPath: string): Gi onRef, ) as GitStashCommit; } + +/** + * Finds the oldest timestamp among stash commits and their parent commits. + * This includes both the stash commit dates and all parent commit timestamps (author and committer dates). + * + * @param stashes - Collection of stash commits to analyze + * @returns The oldest timestamp in milliseconds, or Infinity if no stashes provided + */ +export function findOldestStashTimestamp(stashes: Iterable): number { + return min(stashes, c => { + return Math.min( + c.date.getTime(), + ...(c.parentTimestamps + ?.flatMap(p => [p.authorDate, p.committerDate]) + .filter((x): x is number => x != null) + .map(x => x * 1000) ?? []), + ); + }); +} diff --git a/src/git/models/commit.ts b/src/git/models/commit.ts index c1d7892b7725f..34163c3244852 100644 --- a/src/git/models/commit.ts +++ b/src/git/models/commit.ts @@ -66,6 +66,7 @@ export class GitCommit implements GitRevisionReference { readonly stashNumber: string | undefined; readonly stashOnRef: string | undefined; readonly tips: string[] | undefined; + readonly parentTimestamps?: GitStashParentInfo[] | undefined; constructor( private readonly container: Container, @@ -82,10 +83,12 @@ export class GitCommit implements GitRevisionReference { tips?: string[], stashName?: string | undefined, stashOnRef?: string | undefined, + parentTimestamps?: GitStashParentInfo[] | undefined, ) { this.ref = sha; this.shortSha = sha.substring(0, this.container.CommitShaFormatting.length); this.tips = tips; + this.parentTimestamps = parentTimestamps; if (stashName) { this.refType = 'stash'; @@ -689,6 +692,7 @@ export class GitCommit implements GitRevisionReference { fileset?: GitCommitFileset | null; lines?: GitCommitLine[] | null; stats?: GitCommitStats | null; + parentTimestamps?: GitStashParentInfo[] | null; }): T { return new GitCommit( this.container, @@ -705,6 +709,7 @@ export class GitCommit implements GitRevisionReference { this.tips, this.stashName, this.stashOnRef, + this.getChangedValue(changes.parentTimestamps, this.parentTimestamps), ) as T; } @@ -763,10 +768,17 @@ export interface GitCommitStats;