diff --git a/common/changes/@microsoft/rush/fix-change-file-timestamp-seconds_2026-06-03-00-00.json b/common/changes/@microsoft/rush/fix-change-file-timestamp-seconds_2026-06-03-00-00.json new file mode 100644 index 0000000000..c77ac43a30 --- /dev/null +++ b/common/changes/@microsoft/rush/fix-change-file-timestamp-seconds_2026-06-03-00-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Include seconds in the generated change file name so that running `rush change` more than once in the same minute no longer silently overwrites the previously generated change file.", + "type": "minor", + "packageName": "@microsoft/rush" + } + ], + "packageName": "@microsoft/rush", + "email": "LeSingh1@users.noreply.github.com" +} diff --git a/libraries/rush-lib/src/api/ChangeFile.ts b/libraries/rush-lib/src/api/ChangeFile.ts index d2022c7e90..1de74ec139 100644 --- a/libraries/rush-lib/src/api/ChangeFile.ts +++ b/libraries/rush-lib/src/api/ChangeFile.ts @@ -84,10 +84,15 @@ export class ChangeFile { console.log('Could not automatically detect git branch name, using timestamp instead.'); } - // example filename: yourbranchname_2017-05-01-20-20.json + // The timestamp includes seconds so that running "rush change" more than once + // in the same minute produces distinct filenames. Without this, the "--overwrite" + // flag rarely had any effect, and a second invocation would silently clobber the + // change file written by the first one. See GitHub issue #2195. + // example filename: yourbranchname_2017-05-01-20-20-30.json + const timestamp: string | undefined = this._getTimestamp(true); const filename: string = branch - ? this._escapeFilename(`${branch}_${this._getTimestamp()}.json`) - : `${this._getTimestamp()}.json`; + ? this._escapeFilename(`${branch}_${timestamp}.json`) + : `${timestamp}.json`; const filePath: string = path.join( this._rushConfiguration.changesFolder, ...this._changeFileData.packageName.split('/'), @@ -98,7 +103,7 @@ export class ChangeFile { /** * Gets the current time, formatted as YYYY-MM-DD-HH-MM - * Optionally will include seconds + * When useSeconds is true, the seconds are appended as well: YYYY-MM-DD-HH-MM-SS */ private _getTimestamp(useSeconds: boolean = false): string | undefined { // Create a date string with the current time @@ -120,7 +125,7 @@ export class ChangeFile { let formattedTime: string; if (useSeconds) { // formattedTime === "22-47-49" - formattedTime = matches[2].replace(':', '-'); + formattedTime = matches[2].replace(/:/g, '-'); } else { // formattedTime === "22-47" const timeParts: string[] = matches[2].split(':'); diff --git a/libraries/rush-lib/src/api/test/ChangeFile.test.ts b/libraries/rush-lib/src/api/test/ChangeFile.test.ts index bbe1b9797f..8df693df8b 100644 --- a/libraries/rush-lib/src/api/test/ChangeFile.test.ts +++ b/libraries/rush-lib/src/api/test/ChangeFile.test.ts @@ -1,11 +1,42 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import type { GitRepoInfo } from 'git-repo-info'; + import { ChangeFile } from '../ChangeFile'; import { RushConfiguration } from '../RushConfiguration'; import { ChangeType } from '../ChangeManagement'; +import { Git } from '../../logic/Git'; describe(ChangeFile.name, () => { + it('generates a path that includes seconds so repeated invocations do not collide', () => { + const rushFilename: string = `${__dirname}/repo/rush-npm.json`; + const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(rushFilename); + + // Pin the branch name so the generated filename is deterministic. + jest.spyOn(Git.prototype, 'getGitInfo').mockReturnValue({ branch: 'my-branch' } as Readonly); + + // Pin the clock to 2017-05-01 20:20:30 UTC so the timestamp is deterministic. + jest.useFakeTimers({ + now: new Date('2017-05-01T20:20:30.000Z').getTime() + }); + + const changeFile: ChangeFile = new ChangeFile( + { + packageName: 'a', + changes: [], + email: 'fake@example.com' + }, + rushConfiguration + ); + + const generatedPath: string = changeFile.generatePath(); + // The seconds must be present and the filename must be fully dash-separated + // (no leftover colons from the time portion). + // Check toContain on the forward-slash-normalised path so it works on Windows too. + expect(generatedPath.replace(/\\/g, '/').endsWith('my-branch_2017-05-01-20-20-30.json')).toBe(true); + }); + it('can add a change', () => { const rushFilename: string = `${__dirname}/repo/rush-npm.json`; const rushConfiguration: RushConfiguration = RushConfiguration.loadFromConfigurationFile(rushFilename);