Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
a
b
c
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "command-staged-restore-conflict",
"private": true,
"type": "module"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Removing the changed line makes both attempts to apply the hidden diff fail.
require('node:fs').writeFileSync('file.txt', 'a\nc\n');
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[[case]]
name = "command_staged_restore_conflict"
vp = ["local", "global"]
comment = "A restore conflict in a linked worktree reports successful rollback without naming a deleted patch."
steps = [
{ argv = ["git", "init"], snapshot = false },
{ argv = ["git", "add", "-A"], snapshot = false },
{ argv = ["git", "-c", "user.name=Vite Plus", "-c", "user.email=vite-plus@example.com", "commit", "-m", "init"], snapshot = false },
{ argv = ["git", "worktree", "add", "--detach", "linked", "HEAD"], snapshot = false },
{ argv = ["vpt", "write-file", "linked/file.txt", "a\nstaged\nc\n"], snapshot = false },
{ argv = ["git", "-C", "linked", "add", "file.txt"], snapshot = false },
{ argv = ["vpt", "write-file", "linked/file.txt", "a\nunstaged\nc\n"], snapshot = false },
{ argv = ["vp", "staged", "--cwd", "linked"], continue-on-failure = true },
{ argv = ["vpt", "print-file", "linked/file.txt"], comment = "The working tree retains the unstaged change." },
{ argv = ["git", "-C", "linked", "show", ":file.txt"], comment = "The index retains the staged change." },
{ argv = ["vpt", "list-dir", ".git/worktrees/linked/lint-staged"], comment = "The temporary recovery directory is empty." },
{ argv = ["git", "stash", "list"], comment = "The backup stash was cleaned up after successful rollback." },
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# command_staged_restore_conflict

A restore conflict in a linked worktree reports successful rollback without naming a deleted patch.

## `git init`


## `git add -A`


## `git -c 'user.name=Vite Plus' -c user.email=vite-plus@example.com commit -m init`


## `git worktree add --detach linked HEAD`


## `vpt write-file linked/file.txt 'a
staged
c
'`


## `git -C linked add file.txt`


## `vpt write-file linked/file.txt 'a
unstaged
c
'`


## `vp staged --cwd linked`

**Exit code:** 1

```
✔ node reformat.cjs
✖ Failed to restore unstaged changes!
✔ Done reverting to original state!
Original state was restored from the backup stash.
```

## `vpt print-file linked/file.txt`

The working tree retains the unstaged change.

```
a
unstaged
c
```

## `git -C linked show :file.txt`

The index retains the staged change.

```
a
staged
c
```

## `vpt list-dir .git/worktrees/linked/lint-staged`

The temporary recovery directory is empty.

```
```

## `git stash list`

The backup stash was cleaned up after successful rollback.

```
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# command_staged_restore_conflict

A restore conflict in a linked worktree reports successful rollback without naming a deleted patch.

## `git init`


## `git add -A`


## `git -c 'user.name=Vite Plus' -c user.email=vite-plus@example.com commit -m init`


## `git worktree add --detach linked HEAD`


## `vpt write-file linked/file.txt 'a
staged
c
'`


## `git -C linked add file.txt`


## `vpt write-file linked/file.txt 'a
unstaged
c
'`


## `vp staged --cwd linked`

**Exit code:** 1

```
✔ node reformat.cjs
✖ Failed to restore unstaged changes!
✔ Done reverting to original state!
Original state was restored from the backup stash.
```

## `vpt print-file linked/file.txt`

The working tree retains the unstaged change.

```
a
unstaged
c
```

## `git -C linked show :file.txt`

The index retains the staged change.

```
a
staged
c
```

## `vpt list-dir .git/worktrees/linked/lint-staged`

The temporary recovery directory is empty.

```
```

## `git stash list`

The backup stash was cleaned up after successful rollback.

```
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default {
staged: {
'*.txt': 'node reformat.cjs',
},
};
128 changes: 128 additions & 0 deletions packages/cli/src/staged/recovery.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

import lintStaged from 'lint-staged';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test';

import { createStagedLogger } from './logger.ts';

const restoredMessage = 'Original state was restored from the backup stash.';
const patchMessage = 'Unstaged changes have been kept back in a patch file:';

function createLogger() {
return { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
}

describe('staged recovery', () => {
let cwd: string;
const git = (...args: string[]) => execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();

beforeEach(() => {
cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-staged-recovery-'));
git('init', '--quiet');
git('config', 'user.name', 'Vite Plus');
git('config', 'user.email', 'vite-plus@example.com');
git('config', 'commit.gpgsign', 'false');
git('config', 'core.autocrlf', 'false');
fs.writeFileSync(path.join(cwd, 'file.txt'), 'a\nb\nc\n');
fs.writeFileSync(
path.join(cwd, 'task.cjs'),
"require('node:fs').writeFileSync('file.txt', 'a\\nc\\n');\n",
);
git('add', '.');
git('-c', 'core.hooksPath=', 'commit', '--quiet', '-m', 'init');
fs.writeFileSync(path.join(cwd, 'file.txt'), 'a\nstaged\nc\n');
git('add', 'file.txt');
fs.writeFileSync(path.join(cwd, 'file.txt'), 'a\nunstaged\nc\n');
});

afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(cwd, { recursive: true, force: true });
});

function expectOriginalState() {
expect(git('show', ':file.txt')).toBe('a\nstaged\nc');
expect(fs.readFileSync(path.join(cwd, 'file.txt'), 'utf8')).toBe('a\nunstaged\nc\n');
}

it.each([false, true])('reports successful rollback with debug=%s', async (debug) => {
const logger = createLogger();
const success = await lintStaged(
{ cwd, config: { '*.txt': 'node task.cjs' }, debug, color: false },
debug ? logger : createStagedLogger(logger),
);

expect(success).toBe(false);
expect(logger.warn.mock.calls).toEqual([[restoredMessage]]);
expectOriginalState();
expect(git('stash', 'list')).toBe('');
});

it.each([{ revert: false }, { stash: false }])(
'keeps the recovery patch with %o',
async (options) => {
const logger = createLogger();
const success = await lintStaged(
{ cwd, config: { '*.txt': 'node task.cjs' }, color: false, ...options },
createStagedLogger(logger),
);

expect(success).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(patchMessage);
expect(logger.warn).not.toHaveBeenCalledWith(restoredMessage);
const patchPath = logger.warn.mock.calls.at(-1)![0] as string;
expect(fs.readFileSync(patchPath, 'utf8')).toContain('+unstaged');
},
);

it('keeps the recovery patch and backup stash when rollback fails', async () => {
// Fail only the rollback's stash apply; all other Git operations are real.
const { GitWorkflow } = await import(
new URL('./gitWorkflow.js', import.meta.resolve('lint-staged')).href
);
const restoreOriginalState = GitWorkflow.prototype.restoreOriginalState;
vi.spyOn(GitWorkflow.prototype, 'restoreOriginalState').mockImplementation(async function (
this: { execGit: (args: string[]) => Promise<string> },
ctx: unknown,
) {
const execGit = this.execGit;
this.execGit = async (args) => {
if (args[0] === 'stash' && args[1] === 'apply') {
throw new Error('Simulated stash apply failure');
}
return execGit(args);
};
await restoreOriginalState.call(this, ctx);
});
const logger = createLogger();
const success = await lintStaged(
{ cwd, config: { '*.txt': 'node task.cjs' }, color: false },
createStagedLogger(logger),
);

expect(success).toBe(false);
expect(logger.error).toHaveBeenCalledWith('✖ Failed to revert to original state!');
expect(logger.warn).toHaveBeenCalledWith(patchMessage);
expect(logger.warn).not.toHaveBeenCalledWith(restoredMessage);
const patchPath = logger.warn.mock.calls.at(-1)![0] as string;
expect(fs.readFileSync(patchPath, 'utf8')).toContain('+unstaged');
expect(git('stash', 'list')).toContain('lint-staged automatic backup');
git('stash', 'apply', '--index');
expectOriginalState();
});

it('preserves both diffs without recovery warnings when tasks succeed', async () => {
const logger = createLogger();
const success = await lintStaged(
{ cwd, config: { '*.txt': 'node --version' }, color: false },
createStagedLogger(logger),
);

expect(success).toBe(true);
expect(logger.warn).not.toHaveBeenCalled();
expectOriginalState();
});
});
43 changes: 43 additions & 0 deletions patches/lint-staged@17.6.0.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
diff --git a/lib/gitWorkflow.js b/lib/gitWorkflow.js
index 0d0fad3e894dd4dc0574168deff118928da0c957..de869658c27a64ff3bc543ba037970fb50fd6b9a 100644
--- a/lib/gitWorkflow.js
+++ b/lib/gitWorkflow.js
@@ -535,6 +535,7 @@ export class GitWorkflow {
await this.execGit(['stash', 'apply', '--quiet', '--index', ctx.backupHash])
await this.restoreMergeStatus(ctx)
await removeDir(this.hiddenPath)
+ ctx.originalStateRestored = true
this.logger.log(`${figures.done()} Done reverting to original state!`)
} catch (error) {
ctx.errors.add(GitError)
diff --git a/lib/index.js b/lib/index.js
index 9256d5cdf57ec59605215b998097a50e49c0ab9e..f9b3135cb6a8f99ea6ad7a15b2d2383cdff6ae68 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -109,8 +109,12 @@ const lintStaged = async (options = {}, logger = console) => {
logger.warn(preventedTaskModifications() + '\n')
logger.warn(restoreStashExample(ctx.backupHash))
} else if (ctx.errors.has(RestoreUnstagedChangesError)) {
- logger.warn(unstagedChangesBackupStashLocation())
- logger.warn(ctx.unstagedPatch)
+ if (ctx.originalStateRestored) {
+ logger.warn('Original state was restored from the backup stash.')
+ } else {
+ logger.warn(unstagedChangesBackupStashLocation())
+ logger.warn(ctx.unstagedPatch)
+ }
} else if (ctx.errors.has(GitError) || cleanupSkipped(ctx)) {
logger.error(gitError())
if (ctx.shouldBackup) {
diff --git a/lib/state.js b/lib/state.js
index 3ae30ff4a882a52c87c698b59704f6ef8fdf5064..b76620e400b17a0ea552c3674eb52b4a47a2e393 100644
--- a/lib/state.js
+++ b/lib/state.js
@@ -23,6 +23,7 @@ export const getInitialState = ({
shouldFailOnChanges: failOnChanges,
hasFilesToHide: null,
output: [],
+ originalStateRestored: false,
quiet,
runId: crypto.randomBytes(4).toString('hex'),
shouldBackup: null,
7 changes: 4 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading