diff --git a/config/patches/lint-staged@16.4.0.patch b/config/patches/lint-staged@16.4.0.patch new file mode 100644 index 00000000000..cd5bcf57740 --- /dev/null +++ b/config/patches/lint-staged@16.4.0.patch @@ -0,0 +1,125 @@ +diff --git a/lib/gitWorkflow.js b/lib/gitWorkflow.js +index 3a32d6a9cd65c7ecb6b3cbccfce5a15c96530435..363513128bf7302eab73ea775102bb8a633a3b8b 100644 +--- a/lib/gitWorkflow.js ++++ b/lib/gitWorkflow.js +@@ -50,6 +50,9 @@ export const STASH = 'lint-staged automatic backup' + + const PATCH_UNSTAGED = 'lint-staged_unstaged.patch' + ++const BACKUP_REF_PREFIX = 'refs/worktree/lint-staged-backups' ++const ZERO_OID = '0000000000000000000000000000000000000000' ++ + const GIT_DIFF_ARGS = [ + '--binary', // support binary files + '--unified=0', // do not add lines around diff for consistent behaviour +@@ -100,6 +103,8 @@ export class GitWorkflow { + /** @type {import('./getStagedFiles.js').StagedFile[][]} */ + this.matchedFileChunks = matchedFileChunks + this.topLevelDir = topLevelDir ++ this.backupOid = null ++ this.backupRef = null + + /** + * These three files hold state about an ongoing git merge +@@ -122,6 +127,17 @@ export class GitWorkflow { + * Get name of backup stash + */ + async getBackupStash(ctx) { ++ if (this.backupRef) { ++ const backupOid = await this.execGit(['rev-parse', '--verify', this.backupRef]) ++ ++ if (backupOid !== this.backupOid) { ++ ctx.errors.add(GetBackupStashError) ++ throw new Error('lint-staged automatic backup is missing!') ++ } ++ ++ return this.backupRef ++ } ++ + /** Print stash list with short hash and subject */ + const stashes = await this.execGit(['stash', 'list', '--format="%h %s"', '-z']) + .then(parseGitZOutput) +@@ -270,11 +286,14 @@ export class GitWorkflow { + } else { + /** Save stash of all changes, keeping all files as-is */ + const stashHash = await this.execGit(['stash', 'create']) ++ this.backupOid = stashHash ++ this.backupRef = `${BACKUP_REF_PREFIX}/${crypto.randomUUID()}` + ctx.backupHash = await this.execGit(['rev-parse', '--short', stashHash]) +- await this.execGit(['stash', 'store', '--quiet', '--message', STASH, ctx.backupHash]) ++ await this.execGit(['update-ref', this.backupRef, this.backupOid, ZERO_OID]) ++ ctx.backupRef = this.backupRef + } + +- task.title = `Backed up original state in git stash (${ctx.backupHash})` ++ task.title = `Backed up original state (${ctx.backupHash})` + debugLog(task.title) + } + } catch (error) { +@@ -425,7 +444,11 @@ export class GitWorkflow { + async cleanup(ctx) { + try { + debugLog('Dropping backup stash...') +- await this.execGit(['stash', 'drop', '--quiet', await this.getBackupStash(ctx)]) ++ if (this.backupRef) { ++ await this.execGit(['update-ref', '-d', this.backupRef, this.backupOid]) ++ } else { ++ await this.execGit(['stash', 'drop', '--quiet', await this.getBackupStash(ctx)]) ++ } + debugLog('Done dropping backup stash!') + } catch (error) { + handleError(error, ctx) +diff --git a/lib/index.js b/lib/index.js +index 75eeacee48759ef90249df5524639d61bce10918..24b4852eba9b14e05e10c3df8a2b2dae75af19f3 100644 +--- a/lib/index.js ++++ b/lib/index.js +@@ -157,7 +157,7 @@ const lintStaged = async ( + logger.warn(PREVENTED_EMPTY_COMMIT) + } else if (ctx.errors.has(FailOnChangesError)) { + logger.warn(PREVENTED_TASK_MODIFICATIONS + '\n') +- logger.warn(restoreStashExample(ctx.backupHash)) ++ logger.warn(restoreStashExample(ctx.backupHash, ctx.backupRef)) + } else if (ctx.errors.has(RestoreUnstagedChangesError)) { + logger.warn(UNSTAGED_CHANGES_BACKUP_STASH_LOCATION) + logger.warn(ctx.unstagedPatch) +@@ -168,7 +168,7 @@ const lintStaged = async ( + logger.error(GIT_ERROR) + if (ctx.shouldBackup) { + // No sense to show this if the backup stash itself is missing. +- logger.error(restoreStashExample(ctx.backupHash) + '\n') ++ logger.error(restoreStashExample(ctx.backupHash, ctx.backupRef) + '\n') + } + } + +diff --git a/lib/messages.js b/lib/messages.js +index 993f8d81cac48132b5ae2ebdc8ca452c7217caad..2dc2291bc82451eacc5babfc8ce408051e1101e3 100644 +--- a/lib/messages.js ++++ b/lib/messages.js +@@ -66,9 +66,12 @@ export const PREVENTED_EMPTY_COMMIT = ` + Use the --allow-empty option to continue, or check your task configuration`)} + ` + +-export const restoreStashExample = ( +- hash = 'h0a0s0h0' +-) => `Any lost modifications can be restored from a git stash: ++export const restoreStashExample = (hash = 'h0a0s0h0', backupRef) => ++ backupRef ++ ? `Any lost modifications can be restored from the worktree backup: ++ ++ > git stash apply --index ${backupRef}` ++ : `Any lost modifications can be restored from a git stash: + + > git stash list --format="%h %s" + ${hash} On main: lint-staged automatic backup +diff --git a/lib/state.js b/lib/state.js +index da30e6f639d31307ebf503691aa991bb93948f8a..4a38799fa7773a8e8629c7c3a6b72287e6b22f4b 100644 +--- a/lib/state.js ++++ b/lib/state.js +@@ -15,6 +15,7 @@ export const getInitialState = ({ + revert = true, + } = {}) => ({ + backupHash: null, ++ backupRef: null, + errors: new Set([]), + shouldFailOnChanges: failOnChanges, + hasFilesToHide: null, diff --git a/config/scripts/lint-staged-worktree-backup.test.mjs b/config/scripts/lint-staged-worktree-backup.test.mjs new file mode 100644 index 00000000000..97ee56b7295 --- /dev/null +++ b/config/scripts/lint-staged-worktree-backup.test.mjs @@ -0,0 +1,97 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import lintStaged from 'lint-staged' +import { expect, it } from 'vitest' + +const BACKUP_REFS = 'refs/worktree/lint-staged-backups' +const silentLogger = { error() {}, log() {}, warn() {} } + +it('keeps lint-staged backups isolated to the current worktree', async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-lint-staged-worktree-')) + + try { + const repo = join(root, 'repo') + const worktree = join(root, 'linked worktree') + const trackedFile = join(worktree, 'tracked.txt') + mkdirSync(repo) + initializeRepo(repo) + + writeFileSync(join(repo, 'tracked.txt'), 'base-one\nbase-two\n') + git(repo, ['add', 'tracked.txt']) + git(repo, ['commit', '--quiet', '-m', 'initial']) + writeFileSync(join(repo, 'tracked.txt'), 'user stash\nbase-two\n') + git(repo, ['stash', 'push', '--quiet', '--message', 'user backup']) + git(repo, ['worktree', 'add', '--quiet', '-b', 'linked', worktree]) + + writeFileSync(trackedFile, 'staged-change\nbase-two\n') + git(worktree, ['add', 'tracked.txt']) + writeFileSync(trackedFile, 'staged-change\nunstaged-change\n') + + const expectedStash = gitTrim(worktree, ['rev-parse', 'refs/stash']) + const stashBefore = git(worktree, ['stash', 'list', '--format=%H%x00%gs']) + const stagedBefore = git(worktree, ['diff', '--cached', '--binary']) + const unstagedBefore = git(worktree, ['diff', '--binary']) + const contentBefore = readFileSync(trackedFile, 'utf8') + const observation = join(root, 'task-observation.json') + const probe = join(root, 'failing-task.cjs') + writeProbe(probe) + + const task = [process.execPath, probe, expectedStash, observation].map(quote).join(' ') + const passed = await lintStaged( + { config: { '*.txt': task }, cwd: worktree, quiet: true }, + silentLogger + ) + + expect(passed).toBe(false) + expect(JSON.parse(readFileSync(observation, 'utf8'))).toEqual({ + backupRefs: [expect.stringMatching(`^${BACKUP_REFS}/`)], + sharedStash: expectedStash + }) + expect(git(worktree, ['for-each-ref', '--format=%(refname)', BACKUP_REFS])).toBe('') + expect(git(worktree, ['stash', 'list', '--format=%H%x00%gs'])).toBe(stashBefore) + expect(git(worktree, ['diff', '--cached', '--binary'])).toBe(stagedBefore) + expect(git(worktree, ['diff', '--binary'])).toBe(unstagedBefore) + expect(readFileSync(trackedFile, 'utf8')).toBe(contentBefore) + expect(git(worktree, ['ls-files', '--unmerged'])).toBe('') + } finally { + rmSync(root, { force: true, recursive: true }) + } +}) + +function initializeRepo(repo) { + git(repo, ['init', '--quiet']) + git(repo, ['config', 'user.email', 'test@example.invalid']) + git(repo, ['config', 'user.name', 'Test']) + git(repo, ['config', 'core.autocrlf', 'false']) + git(repo, ['config', 'core.hooksPath', join(repo, '.git', 'no-hooks')]) + git(repo, ['config', 'commit.gpgsign', 'false']) +} + +function git(cwd, args) { + return execFileSync('git', args, { cwd, encoding: 'utf8' }) +} + +function gitTrim(cwd, args) { + return git(cwd, args).trim() +} + +function quote(value) { + return JSON.stringify(value) +} + +function writeProbe(path) { + writeFileSync( + path, + [ + "const { execFileSync } = require('node:child_process')", + "const { writeFileSync } = require('node:fs')", + "const git = (args) => execFileSync('git', args, { encoding: 'utf8' }).trim()", + "const backupRefs = git(['for-each-ref', '--format=%(refname)', 'refs/worktree/lint-staged-backups'])", + "writeFileSync(process.argv[3], JSON.stringify({ backupRefs: backupRefs.split('\\n').filter(Boolean), sharedStash: git(['rev-parse', 'refs/stash']) }))", + "writeFileSync(process.argv[4], 'task-output\\n')", + 'process.exit(1)' + ].join('\n') + ) +} diff --git a/package.json b/package.json index aabf6ab08d6..ceac925f0b2 100644 --- a/package.json +++ b/package.json @@ -313,7 +313,8 @@ "@xterm/addon-ligatures@0.11.0-beta.287": "config/patches/@xterm__addon-ligatures@0.11.0-beta.287.patch", "@xterm/addon-webgl@0.20.0-beta.286": "config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch", "@xterm/addon-serialize@0.15.0-beta.287": "config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch", - "@xterm/xterm@6.1.0-beta.287": "config/patches/@xterm__xterm@6.1.0-beta.287.patch" + "@xterm/xterm@6.1.0-beta.287": "config/patches/@xterm__xterm@6.1.0-beta.287.patch", + "lint-staged@16.4.0": "config/patches/lint-staged@16.4.0.patch" } }, "reactDoctor": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac78a681963..1c0d6b0d31b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ patchedDependencies: '@xterm/xterm@6.1.0-beta.287': hash: 46796c152f3b73e28238f44499eaf5a867a863809bc7b470b159526a41e354f7 path: config/patches/@xterm__xterm@6.1.0-beta.287.patch + lint-staged@16.4.0: + hash: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673 + path: config/patches/lint-staged@16.4.0.patch node-pty@1.1.0: hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8 path: config/patches/node-pty@1.1.0.patch @@ -274,7 +277,7 @@ importers: version: 0.16.45 lint-staged: specifier: ^16.4.0 - version: 16.4.0 + version: 16.4.0(patch_hash=7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673) lowlight: specifier: ^3.3.0 version: 3.3.0 @@ -11374,7 +11377,7 @@ snapshots: linkifyjs@4.3.2: {} - lint-staged@16.4.0: + lint-staged@16.4.0(patch_hash=7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673): dependencies: commander: 14.0.3 listr2: 9.0.5 diff --git a/src/shared/git-binary-compatibility.test.ts b/src/shared/git-binary-compatibility.test.ts index c8fe65d53bb..a33adcd7d76 100644 --- a/src/shared/git-binary-compatibility.test.ts +++ b/src/shared/git-binary-compatibility.test.ts @@ -200,6 +200,38 @@ describeBinaryCompatibility('real Git binary compatibility', () => { }) }) + it('supports isolated worktree backup refs', async () => { + const worktree = 'compat-lint-staged' + const backupRef = 'refs/worktree/lint-staged-backups/compat' + await runGit(['worktree', 'add', '-b', 'compat-lint-staged', worktree]) + await writeFile(join(repoPath, worktree, 'tracked.txt'), 'staged\n') + await runGit(['-C', worktree, 'add', 'tracked.txt']) + await writeFile(join(repoPath, worktree, 'tracked.txt'), 'staged\nunstaged\n') + + const backupOid = (await runGit(['-C', worktree, 'stash', 'create'])).stdout.trim() + await runGit([ + '-C', + worktree, + 'update-ref', + backupRef, + backupOid, + '0000000000000000000000000000000000000000' + ]) + await expect( + runGit(['-C', worktree, 'rev-parse', '--verify', backupRef]) + ).resolves.toMatchObject({ stdout: `${backupOid}\n` }) + await expect(runGit(['rev-parse', '--verify', backupRef])).rejects.toBeDefined() + + await runGit(['-C', worktree, 'reset', '--hard', 'HEAD']) + await expect( + runGit(['-C', worktree, 'stash', 'apply', '--quiet', '--index', backupRef]) + ).resolves.toBeDefined() + await expect(runGit(['-C', worktree, 'status', '--short'])).resolves.toMatchObject({ + stdout: 'MM tracked.txt\n' + }) + await runGit(['-C', worktree, 'update-ref', '-d', backupRef, backupOid]) + }) + it('degrades indexed credential config safely at the Git 2.31 boundary', async () => { const guardEnv = gitCredentialPromptGuardEnv({}, 'linux') await expect(runGit(['status', '--short'], guardEnv)).resolves.toBeDefined()