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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
217 changes: 217 additions & 0 deletions src/env/node/git/sub-providers/__tests__/stash.test.ts
Original file line number Diff line number Diff line change
@@ -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<GitStashCommit> {
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<string, GitStashCommit>();
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
});
});
82 changes: 79 additions & 3 deletions src/env/node/git/sub-providers/stash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,19 @@ 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';
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';
Expand Down Expand Up @@ -90,9 +94,62 @@ export class StashGitSubProvider implements GitStashSubProvider {
);

const stashes = new Map<string, GitStashCommit>();
const parentShas = new Set<string>();

// 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<string, { authorDate: number; committerDate: number }>();
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 };
Expand All @@ -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 },
Expand Down Expand Up @@ -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<GitStashCommit>): 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) ?? []),
);
});
}
12 changes: 12 additions & 0 deletions src/git/models/commit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -705,6 +709,7 @@ export class GitCommit implements GitRevisionReference {
this.tips,
this.stashName,
this.stashOnRef,
this.getChangedValue(changes.parentTimestamps, this.parentTimestamps),
) as T;
}

Expand Down Expand Up @@ -763,10 +768,17 @@ export interface GitCommitStats<Files extends number | GitDiffFileStats = number
readonly deletions: number;
}

export interface GitStashParentInfo {
readonly sha: string;
readonly authorDate?: number;
readonly committerDate?: number;
}

export interface GitStashCommit extends GitCommit {
readonly refType: GitStashReference['refType'];
readonly stashName: string;
readonly stashNumber: string;
readonly parentTimestamps?: GitStashParentInfo[];
}

export type GitCommitWithFullDetails = GitCommit & SomeNonNullable<GitCommit, 'message' | 'fileset'>;