fix(hooks): isolate lint-staged backups per worktree (#15388)

This commit is contained in:
Neil
2026-08-19 22:37:02 -07:00
committed by GitHub
parent d7a23c84a9
commit fdd4091ebd
5 changed files with 261 additions and 3 deletions
+125
View File
@@ -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,
@@ -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')
)
}
+2 -1
View File
@@ -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": {
+5 -2
View File
@@ -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
@@ -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()