Skip to content
38 changes: 38 additions & 0 deletions docs/config/experimental.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,44 @@ export default defineConfig({
If you are running tests in Deno, TypeScript files are processed by the runtime without any additional configurations.
:::

## experimental.vcsProvider <Version type="experimental">4.1.1</Version> {#experimental-vcsprovider}

- **Type:**

```ts
interface VCSProvider {
findChangedFiles(options: VCSProviderOptions): Promise<string[]>
}

interface VCSProviderOptions {
root: string
changedSince?: string | boolean
}
```

- **Default:** `undefined` (uses Git)

Custom provider for detecting changed files. Used with the [`--changed`](/guide/cli#changed) flag to determine which files have been modified.

By default, Vitest uses Git to detect changed files. You can provide a custom implementation of the `VCSProvider` interface to use a different version control system:

```ts [vitest.config.ts]
import { defineConfig } from 'vitest/config'

export default defineConfig({
test: {
experimental: {
vcsProvider: {
async findChangedFiles({ root, changedSince }) {
// return absolute paths of changed files
return []
},
},
},
},
})
```

## experimental.nodeLoader <Version type="experimental">4.1.0</Version> {#experimental-nodeloader}

- **Type:** `boolean`
Expand Down
1 change: 1 addition & 0 deletions packages/vitest/src/node/cli/cli-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -894,6 +894,7 @@ export const cliOptionsConfig: VitestCLIOptions = {
nodeLoader: {
description: 'Controls whether Vitest will use Node.js Loader API to process in-source or mocked files. This has no effect if `viteModuleRunner` is enabled. Disabling this can increase performance. (default: `true`)',
},
vcsProvider: null,
},
},
// disable CLI options
Expand Down
10 changes: 10 additions & 0 deletions packages/vitest/src/node/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { ResolvedConfig, TestProjectConfiguration, UserConfig, VitestRunMod
import type { CoverageProvider, ResolvedCoverageOptions } from './types/coverage'
import type { Reporter } from './types/reporter'
import type { TestRunResult } from './types/tests'
import type { VCSProvider } from './vcs/vcs'
import os, { tmpdir } from 'node:os'
import { getTasks, hasFailed, limitConcurrency } from '@vitest/runner/utils'
import { SnapshotManager } from '@vitest/snapshot/manager'
Expand Down Expand Up @@ -51,6 +52,7 @@ import { VitestSpecifications } from './specifications'
import { StateManager } from './state'
import { populateProjectsTags } from './tags'
import { TestRun } from './test-run'
import { VitestGit } from './vcs/git'
Comment thread
sheremet-va marked this conversation as resolved.
Outdated
import { VitestWatcher } from './watcher'

const WATCHER_DEBOUNCE = 100
Expand Down Expand Up @@ -101,6 +103,13 @@ export class Vitest {
* Vitest behaviour.
*/
public readonly watcher: VitestWatcher
/**
* The version control system provider used to detect changed files.
* This is used with the `--changed` flag to determine which test files to run.
* By default, Vitest uses Git. You can provide a custom implementation via
* `experimental.vcsProvider` in your config.
*/
public vcs!: VCSProvider

/** @internal */ configOverride: Partial<ResolvedConfig> = {}
/** @internal */ filenamePattern?: string[]
Expand Down Expand Up @@ -214,6 +223,7 @@ export class Vitest {
const resolved = resolveConfig(this, options, server.config)

this._config = resolved
this.vcs = resolved.experimental.vcsProvider || new VitestGit()
this._state = new StateManager({
onUnhandledError: resolved.onUnhandledError,
})
Expand Down
14 changes: 10 additions & 4 deletions packages/vitest/src/node/coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,11 +331,17 @@ export class BaseCoverageProvider<Options extends ResolvedCoverageOptions<'istan

async onTestRunStart(): Promise<void> {
if (this.options.changed) {
const { VitestGit } = await import('./git')
const vitestGit = new VitestGit(this.ctx.config.root)
const changedFiles = await vitestGit.findChangedFiles({ changedSince: this.options.changed })
try {
const changedFiles = await this.ctx.vcs.findChangedFiles({
root: this.ctx.config.root,
changedSince: this.options.changed,
})

this.changedFiles = changedFiles ?? undefined
this.changedFiles = changedFiles
}
catch {
this.changedFiles = undefined
}
}
else if (this.ctx.config.changed) {
this.changedFiles = this.ctx.config.related
Expand Down
11 changes: 3 additions & 8 deletions packages/vitest/src/node/specifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { join, relative, resolve } from 'pathe'
import pm from 'picomatch'
import { isWindows } from '../utils/env'
import { groupFilters, parseFilter } from './cli/filter'
import { GitNotFoundError, IncludeTaskLocationDisabledError, LocationFilterFileNotFoundError } from './errors'
import { IncludeTaskLocationDisabledError, LocationFilterFileNotFoundError } from './errors'

export class VitestSpecifications {
private readonly _cachedSpecs = new Map<string, TestSpecification[]>()
Expand Down Expand Up @@ -121,15 +121,10 @@ export class VitestSpecifications {

private async filterTestsBySource(specs: TestSpecification[]): Promise<TestSpecification[]> {
if (this.vitest.config.changed && !this.vitest.config.related) {
const { VitestGit } = await import('./git')
const vitestGit = new VitestGit(this.vitest.config.root)
const related = await vitestGit.findChangedFiles({
const related = await this.vitest.vcs.findChangedFiles({
root: this.vitest.config.root,
changedSince: this.vitest.config.changed,
})
if (!related) {
process.exitCode = 1
throw new GitNotFoundError()
}
this.vitest.config.related = Array.from(new Set(related))
}

Expand Down
10 changes: 10 additions & 0 deletions packages/vitest/src/node/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
} from '../reporters'
import type { TestCase, TestModule, TestSuite } from '../reporters/reported-tasks'
import type { TestSequencerConstructor } from '../sequencers/types'
import type { VCSProvider } from '../vcs/vcs'
import type { WatcherTriggerPattern } from '../watcher'
import type { BenchmarkUserOptions } from './benchmark'
import type { BrowserConfigOptions, ResolvedBrowserOptions } from './browser'
Expand Down Expand Up @@ -937,6 +938,15 @@ export interface InlineConfig {
* This option only affects `loader.load` method, Vitest always defines a `loader.resolve` to populate the module graph.
*/
nodeLoader?: boolean

/**
* Custom provider for detecting changed files. Used with the `--changed` flag
* to determine which files have been modified.
*
* By default, Vitest uses Git to detect changed files. You can provide a custom
* implementation of the `VCSProvider` interface to use a different version control system.
*/
vcsProvider?: VCSProvider
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
import type { Output } from 'tinyexec'
import type { VCSProvider, VCSProviderOptions } from './vcs'
import { resolve } from 'pathe'
import { x } from 'tinyexec'
import { GitNotFoundError } from '../errors'

export interface GitOptions {
changedSince?: string | boolean
}

export class VitestGit {
export class VitestGit implements VCSProvider {
private root!: string

constructor(private cwd: string) {}

private async resolveFilesWithGitCommand(args: string[]): Promise<string[]> {
let result: Output

Expand All @@ -29,10 +25,10 @@ export class VitestGit {
.map(changedPath => resolve(this.root, changedPath))
}

async findChangedFiles(options: GitOptions): Promise<string[] | null> {
const root = await this.getRoot(this.cwd)
async findChangedFiles(options: VCSProviderOptions): Promise<string[]> {
const root = this.root || await this.getRoot(options.root)
if (!root) {
return null
throw new GitNotFoundError()
}

this.root = root
Expand Down
9 changes: 9 additions & 0 deletions packages/vitest/src/node/vcs/vcs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export interface VCSProviderOptions {
root: string
changedSince?: string | boolean
}

export interface VCSProvider {
// eslint-disable-next-line ts/method-signature-style
findChangedFiles(options: VCSProviderOptions): Promise<string[]>
}
130 changes: 130 additions & 0 deletions test/cli/test/vcs-provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { runInlineTests } from '#test-utils'
import { expect, test } from 'vitest'

test('custom vcsProvider that returns specific files runs only matching tests', async () => {
const { testTree, stderr } = await runInlineTests({
'vitest.config.js': `
import path from 'node:path'
export default {
test: {
experimental: {
vcsProvider: {
async findChangedFiles({ root }) {
return [path.resolve(root, 'src/changed.ts')]
},
},
},
},
}
`,
'src/changed.ts': 'export const a = 1',
'src/not-changed.ts': 'export const b = 2',
'related.test.ts': `
import { a } from './src/changed.ts'
import { test, expect } from 'vitest'
test('related test', () => {
expect(a).toBe(1)
})
`,
'not-related.test.ts': `
import { b } from './src/not-changed.ts'
import { test, expect } from 'vitest'
test('not related test', () => {
expect(b).toBe(2)
})
`,
}, {
changed: true,
})

expect(stderr).toBe('')
expect(testTree()).toMatchInlineSnapshot(`
{
"related.test.ts": {
"related test": "passed",
},
}
`)
})

test('custom vcsProvider that returns no files runs no tests', async () => {
const { testTree, stdout } = await runInlineTests({
'vitest.config.js': `
export default {
test: {
passWithNoTests: true,
experimental: {
vcsProvider: {
async findChangedFiles() {
return []
},
},
},
},
}
`,
'basic.test.ts': `
import { test, expect } from 'vitest'
test('should not run', () => {
expect(1).toBe(1)
})
`,
}, {
changed: true,
})

expect(stdout).toContain(`No test files found, exiting with code 0`)
expect(testTree()).toMatchInlineSnapshot('{}')
})

test('custom vcsProvider that returns all files runs all tests', async () => {
const { testTree, stderr } = await runInlineTests({
'vitest.config.js': `
import path from 'node:path'
export default {
test: {
experimental: {
vcsProvider: {
async findChangedFiles({ root }) {
return [
path.resolve(root, 'src/a.ts'),
path.resolve(root, 'src/b.ts'),
]
},
},
},
},
}
`,
'src/a.ts': 'export const a = 1',
'src/b.ts': 'export const b = 2',
'first.test.ts': `
import { a } from './src/a.ts'
import { test, expect } from 'vitest'
test('first test', () => {
expect(a).toBe(1)
})
`,
'second.test.ts': `
import { b } from './src/b.ts'
import { test, expect } from 'vitest'
test('second test', () => {
expect(b).toBe(2)
})
`,
}, {
changed: true,
})

expect(stderr).toBe('')
expect(testTree()).toMatchInlineSnapshot(`
{
"first.test.ts": {
"first test": "passed",
},
"second.test.ts": {
"second test": "passed",
},
}
`)
})
Loading