diff --git a/docs/config/experimental.md b/docs/config/experimental.md
index be80135dbac1..4d64cfb22d95 100644
--- a/docs/config/experimental.md
+++ b/docs/config/experimental.md
@@ -410,6 +410,67 @@ export default defineConfig({
If you are running tests in Deno, TypeScript files are processed by the runtime without any additional configurations.
:::
+## experimental.vcsProvider 4.1.1 {#experimental-vcsprovider}
+
+- **Type:** `VCSProvider | string`
+
+```ts
+interface VCSProvider {
+ findChangedFiles(options: VCSProviderOptions): Promise
+}
+
+interface VCSProviderOptions {
+ root: string
+ changedSince?: string | boolean
+}
+```
+
+- **Default:** `'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 paths of changed files
+ return []
+ },
+ },
+ },
+ },
+})
+```
+
+You can also pass a string path to a module with a default export that implements the `VCSProvider` interface:
+
+```js [vitest.config.js]
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ experimental: {
+ vcsProvider: './my-vcs-provider.js',
+ },
+ },
+})
+```
+
+```js [my-vcs-provider.js]
+export default {
+ async findChangedFiles({ root, changedSince }) {
+ // return paths of changed files
+ return []
+ },
+}
+```
+
## experimental.nodeLoader 4.1.0 {#experimental-nodeloader}
- **Type:** `boolean`
diff --git a/docs/guide/cli-generated.md b/docs/guide/cli-generated.md
index 7b880b2a2c42..ddf59427ce05 100644
--- a/docs/guide/cli-generated.md
+++ b/docs/guide/cli-generated.md
@@ -949,3 +949,10 @@ Control whether Vitest uses Vite's module runner to run the code or fallback to
- **Config:** [experimental.nodeLoader](/config/experimental#experimental-nodeloader)
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`)
+
+### experimental.vcsProvider
+
+- **CLI:** `--experimental.vcsProvider `
+- **Config:** [experimental.vcsProvider](/config/experimental#experimental-vcsprovider)
+
+Custom provider for detecting changed files. (default: `git`)
diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts
index 43afbe38082b..f1cb5cc3ee06 100644
--- a/packages/vitest/src/node/cli/cli-config.ts
+++ b/packages/vitest/src/node/cli/cli-config.ts
@@ -901,6 +901,11 @@ 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: {
+ argument: '',
+ description: 'Custom provider for detecting changed files. (default: `git`)',
+ subcommands: null,
+ },
},
},
// disable CLI options
diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts
index 26fe5673b6df..78964c1829c7 100644
--- a/packages/vitest/src/node/config/resolveConfig.ts
+++ b/packages/vitest/src/node/config/resolveConfig.ts
@@ -950,6 +950,10 @@ export function resolveConfig(
resolved.experimental.importDurations.thresholds.warn ??= 100
resolved.experimental.importDurations.thresholds.danger ??= 500
+ if (typeof resolved.experimental.vcsProvider === 'string' && resolved.experimental.vcsProvider !== 'git') {
+ resolved.experimental.vcsProvider = resolvePath(resolved.experimental.vcsProvider, resolved.root)
+ }
+
return resolved
}
diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts
index 269deee75c56..60ce250c24bb 100644
--- a/packages/vitest/src/node/core.ts
+++ b/packages/vitest/src/node/core.ts
@@ -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'
@@ -51,6 +52,7 @@ import { VitestSpecifications } from './specifications'
import { StateManager } from './state'
import { populateProjectsTags } from './tags'
import { TestRun } from './test-run'
+import { loadVCSProvider } from './vcs/vcs'
import { VitestWatcher } from './watcher'
const WATCHER_DEBOUNCE = 100
@@ -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 = {}
/** @internal */ filenamePattern?: string[]
@@ -263,6 +272,7 @@ export class Vitest {
configurable: true,
})
}
+ this.vcs = await loadVCSProvider(this.runner, resolved.experimental.vcsProvider)
if (this.config.watch) {
// hijack server restart
diff --git a/packages/vitest/src/node/coverage.ts b/packages/vitest/src/node/coverage.ts
index eebbf7d51314..60b507fea1f3 100644
--- a/packages/vitest/src/node/coverage.ts
+++ b/packages/vitest/src/node/coverage.ts
@@ -331,11 +331,17 @@ export class BaseCoverageProvider {
async onTestRunStart(): Promise {
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
diff --git a/packages/vitest/src/node/specifications.ts b/packages/vitest/src/node/specifications.ts
index 54fe67e7544b..222d271eab25 100644
--- a/packages/vitest/src/node/specifications.ts
+++ b/packages/vitest/src/node/specifications.ts
@@ -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()
@@ -121,15 +121,10 @@ export class VitestSpecifications {
private async filterTestsBySource(specs: TestSpecification[]): Promise {
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))
}
diff --git a/packages/vitest/src/node/types/config.ts b/packages/vitest/src/node/types/config.ts
index e530e30e3ad3..3f6ec9ece2db 100644
--- a/packages/vitest/src/node/types/config.ts
+++ b/packages/vitest/src/node/types/config.ts
@@ -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'
@@ -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 | string
}
/**
diff --git a/packages/vitest/src/node/git.ts b/packages/vitest/src/node/vcs/git.ts
similarity index 84%
rename from packages/vitest/src/node/git.ts
rename to packages/vitest/src/node/vcs/git.ts
index 3402fbafa7be..627b7d13c8fd 100644
--- a/packages/vitest/src/node/git.ts
+++ b/packages/vitest/src/node/vcs/git.ts
@@ -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 GitVCSProvider implements VCSProvider {
private root!: string
- constructor(private cwd: string) {}
-
private async resolveFilesWithGitCommand(args: string[]): Promise {
let result: Output
@@ -29,10 +25,10 @@ export class VitestGit {
.map(changedPath => resolve(this.root, changedPath))
}
- async findChangedFiles(options: GitOptions): Promise {
- const root = await this.getRoot(this.cwd)
+ async findChangedFiles(options: VCSProviderOptions): Promise {
+ const root = this.root || await this.getRoot(options.root)
if (!root) {
- return null
+ throw new GitNotFoundError()
}
this.root = root
diff --git a/packages/vitest/src/node/vcs/vcs.ts b/packages/vitest/src/node/vcs/vcs.ts
new file mode 100644
index 000000000000..a16f7683564b
--- /dev/null
+++ b/packages/vitest/src/node/vcs/vcs.ts
@@ -0,0 +1,36 @@
+import type { ModuleRunner } from 'vite/module-runner'
+import { resolve } from 'pathe'
+import { GitVCSProvider } from './git'
+
+export interface VCSProviderOptions {
+ root: string
+ changedSince?: string | boolean
+}
+
+export interface VCSProvider {
+ // eslint-disable-next-line ts/method-signature-style
+ findChangedFiles(options: VCSProviderOptions): Promise
+}
+
+export async function loadVCSProvider(runner: ModuleRunner, vcsProvider: string | VCSProvider | undefined): Promise {
+ if (typeof vcsProvider === 'object' && vcsProvider != null) {
+ return wrapVCSProvider(vcsProvider)
+ }
+ if (!vcsProvider || vcsProvider === 'git') {
+ return new GitVCSProvider()
+ }
+ const module = await runner.import(vcsProvider) as { default: VCSProvider }
+ if (!module.default || typeof module.default !== 'object' || typeof module.default.findChangedFiles !== 'function') {
+ throw new Error(`The vcsProvider module '${vcsProvider}' doesn't have a default export with \`findChangedFiles\` method.`)
+ }
+ return wrapVCSProvider(module.default)
+}
+
+function wrapVCSProvider(provider: VCSProvider): VCSProvider {
+ return {
+ async findChangedFiles(options) {
+ const changedFiles = await provider.findChangedFiles(options)
+ return changedFiles.map(file => resolve(options.root, file))
+ },
+ }
+}
diff --git a/test/cli/test/vcs-provider.test.ts b/test/cli/test/vcs-provider.test.ts
new file mode 100644
index 000000000000..1185978d2e2e
--- /dev/null
+++ b/test/cli/test/vcs-provider.test.ts
@@ -0,0 +1,195 @@
+import crypto from 'node:crypto'
+import { runInlineTests, useFS } from '#test-utils'
+import { resolve } from 'pathe'
+import { expect, onTestFinished, test, vi } from 'vitest'
+import { createVitest } from 'vitest/node'
+
+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",
+ },
+ }
+ `)
+})
+
+function createRoot(structure: Record) {
+ const root = resolve(process.cwd(), `vitest-test-${crypto.randomUUID()}`)
+ useFS(root, structure)
+ return root
+}
+
+async function vitest(config: Parameters[1]) {
+ const v = await createVitest('test', { ...config, watch: false, config: false }, {})
+ onTestFinished(() => v.close())
+ return v
+}
+
+test('vcsProvider defaults to GitVCSProvider when not specified', async () => {
+ const v = await vitest({})
+ expect(v.config.experimental.vcsProvider).toBeUndefined()
+ expect(v.vcs).toBeDefined()
+ expect(v.vcs.constructor.name).toBe('GitVCSProvider')
+})
+
+test('vcsProvider "git" resolves to GitVCSProvider', async () => {
+ const v = await vitest({
+ experimental: {
+ vcsProvider: 'git',
+ },
+ })
+ expect(v.config.experimental.vcsProvider).toBe('git')
+ expect(v.vcs.constructor.name).toBe('GitVCSProvider')
+})
+
+test('vcsProvider object is used directly', async () => {
+ const customProvider = {
+ findChangedFiles: vi.fn(async () => {
+ return []
+ }),
+ }
+ const v = await vitest({
+ experimental: {
+ vcsProvider: customProvider,
+ },
+ })
+ const files = await v.vcs.findChangedFiles({ root: v.config.root })
+ expect(files).toEqual([])
+ expect(customProvider.findChangedFiles).toHaveBeenCalledOnce()
+})
+
+test('vcsProvider string path is resolved to absolute path', async () => {
+ const root = createRoot({
+ 'my-vcs-provider.ts': `
+ export default {
+ async findChangedFiles() {
+ return []
+ },
+ }
+ `,
+ })
+ const v = await createVitest('test', { watch: false, config: false, root, experimental: { vcsProvider: './my-vcs-provider.ts' } }, {})
+ onTestFinished(() => v.close())
+ expect(v.config.experimental.vcsProvider).toBe(resolve(root, 'my-vcs-provider.ts'))
+ expect(v.vcs).toBeDefined()
+ expect(typeof v.vcs.findChangedFiles).toBe('function')
+})