Skip to content
Draft
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
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,50 @@ luarocks make
busted spec
```

Running Related Tests
--------------------

The `--related` option allows you to run only the tests that are affected by recent code changes. This is useful for faster feedback during development.

### Basic Usage

Run tests related to uncommitted git changes:

```bash
busted --related
```

Run tests related to specific files (skips git, works anywhere):

```bash
busted --related-files=src/mymodule.lua,src/utils.lua
```

Compare against a specific git ref instead of HEAD:

```bash
busted --related --related-base=main
```

### How It Works

1. **Detects changed files** - Either from git (staged, unstaged, and untracked) or from an explicit file list
2. **Builds a dependency graph** - Parses all Lua files to find `require()`, `loadfile()`, and `dofile()` statements
3. **Finds affected tests** - Uses transitive closure to find all test files that depend on the changed files

### Known Limitations

- **Dynamic requires are not detected** - Calls like `require(variable)` or `require("prefix" .. name)` cannot be statically analyzed
- **External modules are ignored** - Only project-local files are tracked in the dependency graph
- **Large files skipped** - Files over 1MB are skipped to avoid performance issues (likely generated/minified code)

### Performance Considerations

For large codebases:
- First run may take a few seconds to build the dependency graph
- Use `--verbose` to see dependency graph statistics
- Consider using `--related=files` with explicit file lists for faster execution in CI

Docker
------

Expand Down
6 changes: 6 additions & 0 deletions busted-scm-1.rockspec
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ build = {
['busted.modules.filter_loader'] = 'busted/modules/filter_loader.lua',
['busted.modules.cli'] = 'busted/modules/cli.lua',

['busted.modules.related'] = 'busted/modules/related/init.lua',
['busted.modules.related.dependency_graph'] = 'busted/modules/related/dependency_graph.lua',
['busted.modules.related.git_changes'] = 'busted/modules/related/git_changes.lua',
['busted.modules.related.path_resolver'] = 'busted/modules/related/path_resolver.lua',
['busted.modules.related.require_parser'] = 'busted/modules/related/require_parser.lua',

['busted.modules.files.lua'] = 'busted/modules/files/lua.lua',
['busted.modules.files.moonscript'] = 'busted/modules/files/moonscript.lua',
['busted.modules.files.terra'] = 'busted/modules/files/terra.lua',
Expand Down
3 changes: 3 additions & 0 deletions busted/modules/cli.lua
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ return function(options)
cli:flag('--[no-]sort-tests', 'sort test order within a file', processOption)
cli:flag('--[no-]suppress-pending', 'suppress `pending` test output', false, processOption)
cli:flag('--[no-]defer-print', 'defer print to when test suite is complete', false, processOption)
cli:flag('--related', 'only run tests related to uncommitted git changes. Note: dynamic requires like require(var) are not detected.', false, processOption)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The functionality directly baked into Busted should never assume Git is the VCS in charge.

cli:option('--related-files=FILES', 'run tests related to specified files (comma-separated). Skips git detection.', nil, processOption)
cli:option('--related-base=REF', 'git ref to compare against for --related (default: HEAD)', 'HEAD', processOption)

local function parse(args)
-- Parse the cli arguments
Expand Down
141 changes: 141 additions & 0 deletions busted/modules/related/dependency_graph.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
local RequireParser = require 'busted.modules.related.require_parser'

local DependencyGraph = {}
DependencyGraph.__index = DependencyGraph

function DependencyGraph.new()
local self = setmetatable({}, DependencyGraph)
self.forward = {}
self.reverse = {}
self.module_to_path = {}
return self
end

function DependencyGraph:build(files, path_resolver, options)
options = options or {}
local verbose = options.verbose

for _, filepath in ipairs(files) do
local parsed, err = RequireParser.parse_file(filepath)
if not parsed and verbose then
io.stderr:write('Warning: Failed to parse ' .. filepath .. ': ' .. (err or 'unknown error') .. '\n')
end
if parsed then
self.forward[filepath] = {}

for _, module_name in ipairs(parsed.requires) do
local resolved = self.module_to_path[module_name]
if not resolved then
resolved = path_resolver:resolve(module_name)
self.module_to_path[module_name] = resolved
end

if resolved then
self.forward[filepath][#self.forward[filepath] + 1] = resolved
end
end

for _, file_path in ipairs(parsed.loadfiles) do
local resolved = path_resolver:resolve_file(file_path)
if resolved then
self.forward[filepath][#self.forward[filepath] + 1] = resolved
end
end
end
end

for filepath, deps in pairs(self.forward) do
for _, dep in ipairs(deps) do
if not self.reverse[dep] then
self.reverse[dep] = {}
end
self.reverse[dep][#self.reverse[dep] + 1] = filepath
end
end
end

function DependencyGraph:get_direct_dependents(filepath)
return self.reverse[filepath] or {}
end

function DependencyGraph:get_direct_dependencies(filepath)
return self.forward[filepath] or {}
end

function DependencyGraph:get_affected_files(changed_files)
local affected = {}
local visited = {}
local queue = {}

for _, filepath in ipairs(changed_files) do
queue[#queue + 1] = filepath
end

local queue_start = 1
while queue_start <= #queue do
local current = queue[queue_start]
queue_start = queue_start + 1

if not visited[current] then
visited[current] = true
affected[current] = true

local dependents = self.reverse[current] or {}
for _, dependent in ipairs(dependents) do
if not visited[dependent] then
queue[#queue + 1] = dependent
end
end
end
end

return affected
end

function DependencyGraph:get_affected_tests(changed_files, test_files)
local affected = self:get_affected_files(changed_files)
local affected_tests = {}

for filepath in pairs(affected) do
if test_files[filepath] then
affected_tests[filepath] = true
end
end

return affected_tests
end

function DependencyGraph:stats()
local num_files = 0
local num_edges = 0

for _, deps in pairs(self.forward) do
num_files = num_files + 1
num_edges = num_edges + #deps
end

return {
files = num_files,
edges = num_edges,
}
end

function DependencyGraph:dump()
print("=== Forward edges (file -> dependencies) ===")
for filepath, deps in pairs(self.forward) do
print(filepath)
for _, dep in ipairs(deps) do
print(" -> " .. dep)
end
end

print("\n=== Reverse edges (file -> dependents) ===")
for filepath, deps in pairs(self.reverse) do
print(filepath)
for _, dep in ipairs(deps) do
print(" <- " .. dep)
end
end
end

return DependencyGraph
137 changes: 137 additions & 0 deletions busted/modules/related/git_changes.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
local path = require 'pl.path'

local GitChanges = {}

-- Normalize paths to forward slashes for cross-platform consistency
local function normalize(p)
return p:gsub('\\', '/')
end

local function default_run_command(cmd, cwd)
local full_cmd
if cwd then
full_cmd = string.format('cd %q && %s 2>&1', cwd, cmd)
else
full_cmd = cmd .. ' 2>&1'
end

local handle = io.popen(full_cmd)
if not handle then
return nil, 'Failed to execute command: ' .. cmd
end

local lines = {}
local has_error = false
for line in handle:lines() do
if line:match('^fatal:') or line:match('^error:') then
has_error = true
end
lines[#lines + 1] = line
end

local _, _, exit_code = handle:close()

if has_error or (exit_code and exit_code ~= 0) then
local err_msg = table.concat(lines, '\n')
return nil, err_msg ~= '' and err_msg or ('Git command failed: ' .. cmd)
end

return lines
end

-- Injectable for testing
GitChanges._run_command = default_run_command

local function run_git_command(cmd, cwd)
return GitChanges._run_command(cmd, cwd)
end

function GitChanges.is_git_repo(cwd)
local lines = run_git_command('git rev-parse --is-inside-work-tree', cwd)
return lines and #lines > 0 and lines[1] == 'true'
end

function GitChanges.get_git_root(cwd)
local lines = run_git_command('git rev-parse --show-toplevel', cwd)
return lines and lines[1]
end

function GitChanges.get_changed_files(cwd)
if not GitChanges.is_git_repo(cwd) then
return nil, 'Not a git repository'
end

local files = {}

local unstaged = run_git_command('git diff --name-only', cwd)
if unstaged then
for _, file in ipairs(unstaged) do
files[normalize(path.normpath(path.join(cwd, file)))] = true
end
end

local staged = run_git_command('git diff --cached --name-only', cwd)
if staged then
for _, file in ipairs(staged) do
files[normalize(path.normpath(path.join(cwd, file)))] = true
end
end

local untracked = run_git_command('git ls-files --others --exclude-standard', cwd)
if untracked then
for _, file in ipairs(untracked) do
files[normalize(path.normpath(path.join(cwd, file)))] = true
end
end

local result = {}
for file in pairs(files) do
result[#result + 1] = file
end
table.sort(result)

return result
end

function GitChanges.get_changes_since(cwd, base_ref)
if not GitChanges.is_git_repo(cwd) then
return nil, 'Not a git repository'
end

local files = {}

local cmd = string.format('git diff --name-only %q', base_ref)
local diff = run_git_command(cmd, cwd)
if diff then
for _, file in ipairs(diff) do
files[normalize(path.normpath(path.join(cwd, file)))] = true
end
end

local uncommitted = GitChanges.get_changed_files(cwd)
if uncommitted then
for _, file in ipairs(uncommitted) do
files[file] = true
end
end

local result = {}
for file in pairs(files) do
result[#result + 1] = file
end
table.sort(result)

return result
end

function GitChanges.filter_lua_files(files)
local result = {}
for _, file in ipairs(files) do
if file:match('%.lua$') then
result[#result + 1] = file
end
end
return result
end

return GitChanges
Loading