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
17 changes: 15 additions & 2 deletions docs/docs/commands/doctor.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@ dependency subtree.

By default, any provided fix's will be run. If you don't want to run fixes add `--fix=false` to disable fixing issues.

`--parallel` runs up to two independent groups at once. A group's actions stay in order,
and a dependent group starts only after its prerequisites succeed. Groups with a shared
[`exclusiveResources` declaration](../models/ScopeDoctorGroup.mdx#exclusive-resources)
never overlap. On a fail-fast result, already-running groups finish and no new groups start.
Failed or user-denied prerequisites block their dependents, including when the failed action is
optional or `--fix=false` leaves a check unresolved. Those groups are reported as skipped.
Scope waits for running child commands before showing a fix confirmation; config authors should
give commands that prompt on their own a shared
`terminal` resource so those commands do not compete for input.
Parallel runs reject cycles and references to unknown prerequisites before reporting success.
The default run remains sequential.

When using a [ScopeDoctorGroup](../models/ScopeDoctorGroup.mdx), the checksum of files are stored on disk. If you need to disable caching, add `--no-cache`.

```text
Expand All @@ -49,7 +61,8 @@ Options:
--skip-only <SKIP_ONLY> When set, only the named groups are removed from the run; their dependencies still run
-f, --fix <FIX> When set, if a fix is specified it will also run [default: true] [possible values: true, false]
-n, --no-cache When set cache will be disabled, forcing all file based checks to run
--yolo Automatically approve all fix prompts without asking
-y, --yolo Automatically approve all fix prompts without asking
--parallel Run independent groups concurrently (up to two); failed prerequisites block dependents
(excluded default args)
```

Expand All @@ -64,4 +77,4 @@ Will print out all doctor checks available, in the order `run` will execute.
INFO - ScopeDoctorGroup/path-exists-fix-in-scope-dir Check your shell for basic functionality .scope/doctor-group-in-scope-dir.yaml
INFO - ScopeDoctorGroup/path-exists Check your shell for basic functionality .scope/doctor-group-path-exists.yaml
INFO - ScopeDoctorGroup/group-1 Check your shell for basic functionality .scope/doctor-group-1.yaml
```
```
15 changes: 15 additions & 0 deletions docs/docs/models/ScopeDoctorGroup.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,21 @@ dependency that isn't also required by another group still in the run. A shared
also required by a non-skipped group) is kept. The skipped group is still reported in the run
summary; its dependencies that get trimmed along with it are not.

## Exclusive resources

Set `spec.exclusiveResources` to a list of names for operations that must not
overlap, such as Homebrew package changes. During `doctor run --parallel`, Scope
holds those names for the full group execution. Two groups sharing a name run
one after the other; unrelated groups may still run together.
Use a shared `terminal` name for groups whose commands can read from the terminal.

```yaml
spec:
exclusiveResources:
- homebrew
actions: []
```

## Commands

A command can either be relative, or use the PATH.
Expand Down
8 changes: 8 additions & 0 deletions schema/merged.json
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,14 @@
"$ref": "#/$defs/DoctorGroupActionSpec"
}
},
"exclusiveResources": {
"description": "Resources that this group must hold exclusively while its actions run.",
"type": "array",
"default": [],
"items": {
"type": "string"
}
},
"include": {
"description": "Change how a group is handled when building the dependency task graph.\nWhen set to `when-required`, the group will be ignored unless it's required by another\ndependency.",
"$ref": "#/$defs/DoctorInclude",
Expand Down
8 changes: 8 additions & 0 deletions schema/v1alpha.com.github.scope.ScopeDoctorGroup.json
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,14 @@
"$ref": "#/$defs/DoctorGroupActionSpec"
}
},
"exclusiveResources": {
"description": "Resources that this group must hold exclusively while its actions run.",
"type": "array",
"default": [],
"items": {
"type": "string"
}
},
"include": {
"description": "Change how a group is handled when building the dependency task graph.\nWhen set to `when-required`, the group will be ignored unless it's required by another\ndependency.",
"$ref": "#/$defs/DoctorInclude",
Expand Down
8 changes: 8 additions & 0 deletions schema/v1alpha.com.github.scope.ScopeKnownError.json
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,14 @@
"$ref": "#/$defs/DoctorGroupActionSpec"
}
},
"exclusiveResources": {
"description": "Resources that this group must hold exclusively while its actions run.",
"type": "array",
"default": [],
"items": {
"type": "string"
}
},
"include": {
"description": "Change how a group is handled when building the dependency task graph.\nWhen set to `when-required`, the group will be ignored unless it's required by another\ndependency.",
"$ref": "#/$defs/DoctorInclude",
Expand Down
8 changes: 8 additions & 0 deletions schema/v1alpha.com.github.scope.ScopeReportLocation.json
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,14 @@
"$ref": "#/$defs/DoctorGroupActionSpec"
}
},
"exclusiveResources": {
"description": "Resources that this group must hold exclusively while its actions run.",
"type": "array",
"default": [],
"items": {
"type": "string"
}
},
"include": {
"description": "Change how a group is handled when building the dependency task graph.\nWhen set to `when-required`, the group will be ignored unless it's required by another\ndependency.",
"$ref": "#/$defs/DoctorInclude",
Expand Down
23 changes: 23 additions & 0 deletions src/doctor/check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1677,6 +1677,29 @@ pub(crate) mod tests {
Ok(())
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn known_error_prompt_waits_for_running_terminal_command() -> Result<()> {
let action = build_run_fail_fix_succeed_action();
let exec_runner = MockExecutionProvider::new();
let glob_walker = MockGlobWalker::new();
let mut run = setup_test(vec![action], exec_runner, glob_walker);
run.yolo = false;
run.known_errors.insert(
"ScopeKnownError/test-error".to_string(),
make_known_error("error-pattern", true),
);
let report = task_report("error-pattern found in output");
let command = crate::shared::TERMINAL_ACCESS.read().await;
let prompt = tokio::spawn(async move { run.analyze_known_errors(&[report]).await });
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
assert!(!prompt.is_finished());
drop(command);
let status =
tokio::time::timeout(std::time::Duration::from_secs(2), prompt).await???;
assert!(matches!(status, AnalyzeStatus::KnownErrorFoundUserDenied));
Ok(())
}

#[tokio::test]
async fn no_pattern_match_returns_no_known_errors() -> Result<()> {
let action = build_run_fail_fix_succeed_action();
Expand Down
74 changes: 73 additions & 1 deletion src/doctor/commands/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ pub struct DoctorRunArgs {
/// Automatically approve all fix prompts without asking
#[arg(long, short = 'y', default_value = "false")]
pub yolo: bool,
/// Run independent groups concurrently (up to two); failed prerequisites block dependents.
#[arg(long, action = clap::ArgAction::SetTrue, overrides_with = "parallel")]
pub parallel: bool,
}

fn get_cache(args: &DoctorRunArgs) -> Arc<dyn FileCache> {
Expand Down Expand Up @@ -192,6 +195,19 @@ pub async fn doctor_run(found_config: &FoundConfig, args: &DoctorRunArgs) -> Res
warn!(target: "user", "Could not find any tasks to execute");
}

if args.parallel {
for name in &all_paths {
let Some(group) = found_config.doctor_group.get(name) else {
continue;
};
for dependency in &group.requires {
if !found_config.doctor_group.contains_key(dependency) {
anyhow::bail!("group {name} needs unknown group {dependency}");
}
}
}
}

let graph = RunGraph::new(&all_paths, &found_config.doctor_group);
let run_groups = RunGroups {
group_actions: transform.groups,
Expand All @@ -201,7 +217,11 @@ pub async fn doctor_run(found_config: &FoundConfig, args: &DoctorRunArgs) -> Res
yolo: args.yolo,
};

let result = run_groups.execute().await?;
let result = if args.parallel {
run_groups.execute_parallel().await?
} else {
run_groups.execute().await?
};
report_stdout!("Summary: {}", result);

if let Err(e) = transform.file_cache.persist().await {
Expand Down Expand Up @@ -329,6 +349,7 @@ fn transform_inputs(found_config: &FoundConfig, args: &DoctorRunArgs) -> RunTran
#[cfg(test)]
mod test {
use super::*;
use clap::Parser;
use std::collections::BTreeSet;
use std::path::PathBuf;

Expand All @@ -339,6 +360,57 @@ mod test {
DoctorGroup, FoundConfig, MockExecutionProvider, OutputCaptureBuilder, SkipSpec,
};

#[test]
fn parallel_flag_is_opt_in_and_idempotent() {
let default = DoctorRunArgs::try_parse_from(["run"]).unwrap();
assert!(!default.parallel);
let enabled = DoctorRunArgs::try_parse_from(["run", "--parallel", "--parallel"]).unwrap();
assert!(enabled.parallel);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn parallel_run_rejects_unknown_dependency() {
let mut config = FoundConfig::empty(PathBuf::from("/tmp"));
config.doctor_group.insert(
"group".to_string(),
make_root_model_additional(
vec![],
|meta| meta.name("group"),
|group| group.requires(vec!["missing".to_string()]),
),
);
let args = DoctorRunArgs {
parallel: true,
no_cache: true,
..Default::default()
};
let error = doctor_run(&config, &args).await.unwrap_err();
assert!(
error
.to_string()
.contains("group group needs unknown group missing")
);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn parallel_doctor_run_completes_successfully() {
let mut config = FoundConfig::empty(std::env::temp_dir());
config.doctor_group.insert(
"first".to_string(),
make_root_model_additional(vec![], |meta| meta.name("first"), group_noop),
);
config.doctor_group.insert(
"second".to_string(),
make_root_model_additional(vec![], |meta| meta.name("second"), group_noop),
);
let args = DoctorRunArgs {
parallel: true,
no_cache: true,
..Default::default()
};
assert_eq!(doctor_run(&config, &args).await.unwrap(), 0);
}

#[test]
fn test_will_include_by_default() {
let mut fc = FoundConfig::empty(PathBuf::from("/tmp"));
Expand Down
Loading
Loading