Skip to content

Commit

Permalink
feat: add support for combining scripts (#664)
Browse files Browse the repository at this point in the history
* feat: add support for multiple scripts

* chore: update scripts doc

* chore: fix run test in Windows environment

* chore: update scripts doc

* chore: add test to verify that a script can call another script containing steps

* chore: run field as nullable

* chore: refactor execYaml is String conditional

* chore: add recursive script calls detection check

* chore: remove unnecessary todo reminder

* chore: refactor script fromYaml factory and make steps variable final

* chore: remove todo reminders and unnecessary comments
  • Loading branch information
jessicatarra authored Mar 19, 2024
1 parent c5ab053 commit aabf21c
Show file tree
Hide file tree
Showing 4 changed files with 632 additions and 63 deletions.
31 changes: 31 additions & 0 deletions docs/configuration/scripts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,37 @@ A short description, shown when using `melos run` with no argument.

The command to execute.

## steps

Enables the combination of multiple scripts within a single script definition.
In the example below, the 'pre-commit' is configured to sequentially invoke the
format and analyze scripts.

```yaml
scripts:
pre-commit:
description: pre-commit git hook script
steps:
- echo 'hello world'
- format
- analyze
format:
description: Format Dart code.
run: dart format .
exec:
concurrency: 5
analyze:
description: Analyze Dart code
run: dart analyze .
exec:
concurrency: 5
```

Note: When utilizing the `steps`, it's important to understand that options
related to exec — such as concurrency — or `packageFilters` cannot be directly
applied within the steps configuration. Instead, these options should be
configured in the individual scripts that are being called as part of the step.

## exec

Execute a script in multiple packages through `melos exec`.
Expand Down
171 changes: 171 additions & 0 deletions packages/melos/lib/src/commands/run.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,31 @@ mixin _RunMixin on _Melos {
);
}

if (script.steps != null && script.steps!.isNotEmpty) {
if (script.exec != null) {
throw ScriptExecOptionsException._(
scriptName,
);
}

_detectRecursiveScriptCalls(script);

await _runMultipleScripts(
script,
global: global,
noSelect: noSelect,
scripts: config.scripts,
steps: script.steps!,
);
return;
}

if (script.run == null && script.exec is! String) {
throw MissingScriptCommandException._(
scriptName,
);
}

final scriptSourceCode = targetStyle(
script.command(extraArgs).join(' ').withoutTrailing('\n'),
);
Expand All @@ -45,6 +70,36 @@ mixin _RunMixin on _Melos {
resultLogger.child(successLabel);
}

/// Detects recursive script calls within the provided [script].
///
/// This method recursively traverses the steps of the script to check
/// for any recursive calls. If a step calls another script that
/// eventually leads back to the original script, it indicates a
/// recursive script call, which can result in an infinite loop during
/// execution.
void _detectRecursiveScriptCalls(Script script) {
final visitedScripts = <String>{};

void traverseSteps(Script currentScript) {
visitedScripts.add(currentScript.name);

for (final step in currentScript.steps!) {
if (visitedScripts.contains(step)) {
throw RecursiveScriptCallException._(step);
}

final nestedScript = config.scripts[step];
if (nestedScript != null) {
traverseSteps(nestedScript);
}
}

visitedScripts.remove(currentScript.name);
}

traverseSteps(script);
}

Future<String> _pickScript(MelosWorkspaceConfig config) async {
// using toList as Maps may be unordered
final scripts = config.scripts.values.toList();
Expand Down Expand Up @@ -159,6 +214,82 @@ mixin _RunMixin on _Melos {
workingDirectory: config.path,
);
}

Future<void> _runMultipleScripts(
Script script, {
GlobalOptions? global,
bool noSelect = false,
required Scripts scripts,
required List<String> steps,
}) async {
final workspace = await createWorkspace(
global: global,
)
..validate();

final environment = {
EnvironmentVariableKey.melosRootPath: config.path,
if (workspace.sdkPath != null)
EnvironmentVariableKey.melosSdkPath: workspace.sdkPath!,
if (workspace.childProcessPath != null)
EnvironmentVariableKey.path: workspace.childProcessPath!,
...script.env,
};

await _executeScriptSteps(steps, scripts, script, environment);
}

Future<void> _executeScriptSteps(
List<String> steps,
Scripts scripts,
Script script,
Map<String, String> environment,
) async {
for (final step in steps) {
final scriptCommand =
scripts.containsKey(step) ? 'melos run $step' : step;

final scriptSourceCode = targetStyle(
step.withoutTrailing('\n'),
);

await _executeAndLogCommand(
script,
scriptSourceCode,
scriptCommand,
environment,
);
}
}

Future<void> _executeAndLogCommand(
Script script,
String scriptSourceCode,
String scriptCommand,
Map<String, String> environment,
) async {
logger.command('melos run ${script.name}');
logger.child(scriptSourceCode).child(runningLabel).newLine();

final exitCode = await startCommand(
[scriptCommand],
logger: logger,
environment: environment,
workingDirectory: config.path,
);

logger.newLine();
logger.command('melos run ${script.name}');
final resultLogger = logger.child(scriptSourceCode);

if (exitCode != 0) {
resultLogger.child(failedLabel);
throw ScriptException._(script.name);
} else {
resultLogger.child(successLabel);
}
logger.newLine();
}
}

class NoPackageFoundScriptException implements MelosException {
Expand Down Expand Up @@ -214,3 +345,43 @@ class ScriptException implements MelosException {
return 'ScriptException: The script $scriptName failed to execute.';
}
}

class ScriptExecOptionsException implements MelosException {
ScriptExecOptionsException._(this.scriptName);
final String scriptName;

@override
String toString() {
return 'ScriptExecOptionsException: Execution options are not supported '
'for the script "$scriptName". Ensure the script is designed to run '
'with the provided options or consult the documentation for supported '
'scripts.';
}
}

class MissingScriptCommandException implements MelosException {
MissingScriptCommandException._(this.scriptName);
final String scriptName;

@override
String toString() {
return 'MissingScriptCommandException: The script $scriptName failed '
'to execute. You must specify a script to run. '
'This can be done by filling "run" with a command, '
'defining a sequence of commands in the "steps", '
'or by providing a script execution definition in the "exec".';
}
}

class RecursiveScriptCallException implements MelosException {
RecursiveScriptCallException._(this.scriptName);

final String scriptName;

@override
String toString() {
return 'RecursiveScriptCallException: Detected a recursive call in script '
'execution. The script "$scriptName" calls itself or forms a recursive '
'loop.';
}
}
Loading

0 comments on commit aabf21c

Please sign in to comment.