mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
fix(hooks): isolate lint-staged backups per worktree (#15388)
This commit is contained in:
@@ -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
@@ -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": {
|
||||
|
||||
Generated
+5
-2
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user