Skip to content

Commit 8990683

Browse files
committed
feat(test): abort search on exit code 127
1 parent 84dcc33 commit 8990683

File tree

3 files changed

+264
-63
lines changed

3 files changed

+264
-63
lines changed

git-branchless-test/src/lib.rs

Lines changed: 148 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,14 @@ lazy_static! {
8787
/// source control systems as well.
8888
const INDETERMINATE_EXIT_CODE: i32 = 125;
8989

90+
/// Similarly to `INDETERMINATE_EXIT_CODE`, this exit code is used officially by
91+
/// `git-bisect` and others to abort the process. It's also typically raised by
92+
/// the shell when the command is not found, so it's technically ambiguous
93+
/// whether the command existed or not. Nonetheless, it's intuitive for a
94+
/// failure to run a given command to abort the process altogether, so it
95+
/// shouldn't be too confusing in practice.
96+
const ABORT_EXIT_CODE: i32 = 127;
97+
9098
/// How verbose of output to produce.
9199
#[derive(Clone, Copy, Debug, Ord, PartialOrd, Eq, PartialEq)]
92100
enum Verbosity {
@@ -818,7 +826,7 @@ struct TestOutput {
818826
}
819827

820828
/// The possible results of attempting to run a test.
821-
#[derive(Debug)]
829+
#[derive(Clone, Debug)]
822830
enum TestStatus {
823831
/// Attempting to set up the working directory for the repository failed.
824832
CheckoutFailed,
@@ -840,6 +848,9 @@ enum TestStatus {
840848
/// The test command indicated that the commit should be skipped for testing.
841849
Indeterminate { exit_code: i32 },
842850

851+
/// The test command indicated that the process should be aborted entirely.
852+
Abort { exit_code: i32 },
853+
843854
/// The test failed and returned the provided (non-zero) exit code.
844855
Failed {
845856
/// Whether or not the result was cached (indicating that we didn't
@@ -881,7 +892,7 @@ impl TestStatus {
881892
| TestStatus::ReadCacheFailed(_)
882893
| TestStatus::TerminatedBySignal
883894
| TestStatus::Indeterminate { .. } => icons::EXCLAMATION,
884-
TestStatus::Failed { .. } => icons::CROSS,
895+
TestStatus::Failed { .. } | TestStatus::Abort { .. } => icons::CROSS,
885896
TestStatus::Passed { .. } => icons::CHECKMARK,
886897
}
887898
}
@@ -895,12 +906,18 @@ impl TestStatus {
895906
| TestStatus::ReadCacheFailed(_)
896907
| TestStatus::TerminatedBySignal
897908
| TestStatus::Indeterminate { .. } => *STYLE_SKIPPED,
898-
TestStatus::Failed { .. } => *STYLE_FAILURE,
909+
TestStatus::Failed { .. } | TestStatus::Abort { .. } => *STYLE_FAILURE,
899910
TestStatus::Passed { .. } => *STYLE_SUCCESS,
900911
}
901912
}
902913
}
903914

915+
#[derive(Debug)]
916+
struct TestingAbortedError {
917+
commit_oid: NonZeroOid,
918+
exit_code: i32,
919+
}
920+
904921
#[derive(Debug)]
905922
struct SerializedNonZeroOid(NonZeroOid);
906923

@@ -969,6 +986,14 @@ fn make_test_status_description(
969986
.append(commit.friendly_describe(glyphs)?)
970987
.build(),
971988

989+
TestStatus::Abort { exit_code } => StyledStringBuilder::new()
990+
.append_styled(
991+
format!("Exit code indicated to abort testing (exit code {exit_code}): "),
992+
*STYLE_FAILURE,
993+
)
994+
.append(commit.friendly_describe(glyphs)?)
995+
.build(),
996+
972997
TestStatus::Failed {
973998
cached,
974999
interactive,
@@ -1085,7 +1110,8 @@ impl TestOutput {
10851110
| TestStatus::TerminatedBySignal
10861111
| TestStatus::AlreadyInProgress
10871112
| TestStatus::ReadCacheFailed(_)
1088-
| TestStatus::Indeterminate { .. } => false,
1113+
| TestStatus::Indeterminate { .. }
1114+
| TestStatus::Abort { .. } => false,
10891115
TestStatus::Failed { interactive, .. } | TestStatus::Passed { interactive, .. } => {
10901116
interactive
10911117
}
@@ -1190,6 +1216,7 @@ impl<'a> search::SearchGraph for SearchGraph<'a> {
11901216
struct TestResults {
11911217
search_bounds: search::Bounds<NonZeroOid>,
11921218
test_outputs: IndexMap<NonZeroOid, TestOutput>,
1219+
testing_aborted_error: Option<TestingAbortedError>,
11931220
}
11941221

11951222
#[instrument]
@@ -1264,7 +1291,11 @@ fn run_tests<'a>(
12641291
Some(TestSearchStrategy::Binary) => Some(search::Strategy::Binary),
12651292
};
12661293

1267-
let (search, test_outputs_unordered) = {
1294+
let EventLoopOutput {
1295+
search,
1296+
test_outputs: test_outputs_unordered,
1297+
testing_aborted_error,
1298+
} = {
12681299
let (effects, progress) =
12691300
effects.start_operation(OperationType::RunTests(Arc::new(command.clone())));
12701301
progress.notify_progress(0, commits.len());
@@ -1376,8 +1407,12 @@ fn run_tests<'a>(
13761407
result_rx,
13771408
)?;
13781409

1379-
debug!("Waiting for workers");
13801410
work_queue.close();
1411+
if test_results.testing_aborted_error.is_some() {
1412+
return Ok(test_results);
1413+
}
1414+
1415+
debug!("Waiting for workers");
13811416
progress.notify_status(OperationIcon::InProgress, "Waiting for workers");
13821417
if search_strategy.is_none() {
13831418
for (worker_id, worker) in workers {
@@ -1406,7 +1441,7 @@ fn run_tests<'a>(
14061441
test_outputs_ordered.insert(commit_oid, result);
14071442
}
14081443
None => {
1409-
if search_strategy.is_none() {
1444+
if search_strategy.is_none() && testing_aborted_error.is_none() {
14101445
warn!(?commit_oid, "No result was returned for commit");
14111446
}
14121447
}
@@ -1428,18 +1463,26 @@ fn run_tests<'a>(
14281463
Some(search_strategy) => search.search(search_strategy)?.bounds,
14291464
},
14301465
test_outputs: test_outputs_ordered,
1466+
testing_aborted_error,
14311467
}))
14321468
}
14331469

1470+
struct EventLoopOutput<'a> {
1471+
search: search::Search<SearchGraph<'a>>,
1472+
test_outputs: HashMap<NonZeroOid, TestOutput>,
1473+
testing_aborted_error: Option<TestingAbortedError>,
1474+
}
1475+
14341476
fn event_loop(
14351477
commit_jobs: IndexMap<NonZeroOid, TestJob>,
14361478
mut search: search::Search<SearchGraph>,
14371479
search_strategy: Option<search::Strategy>,
14381480
num_jobs: usize,
14391481
work_queue: WorkQueue<TestJob>,
14401482
result_rx: Receiver<JobResult<TestJob, TestOutput>>,
1441-
) -> eyre::Result<(search::Search<SearchGraph>, HashMap<NonZeroOid, TestOutput>)> {
1483+
) -> eyre::Result<EventLoopOutput> {
14421484
let mut test_outputs = HashMap::new();
1485+
let mut testing_aborted_error = None;
14431486
while let Ok(message) = {
14441487
debug!("Main thread waiting for new job result");
14451488
let result = if commit_jobs.is_empty() {
@@ -1456,32 +1499,42 @@ fn event_loop(
14561499
commit_oid,
14571500
operation_type: _,
14581501
} = job;
1459-
search.notify(
1460-
commit_oid,
1461-
match &test_output.test_status {
1462-
TestStatus::CheckoutFailed
1463-
| TestStatus::SpawnTestFailed(_)
1464-
| TestStatus::TerminatedBySignal
1465-
| TestStatus::AlreadyInProgress
1466-
| TestStatus::ReadCacheFailed(_)
1467-
| TestStatus::Indeterminate { .. } => search::Status::Indeterminate,
1468-
1469-
TestStatus::Failed {
1470-
cached: _,
1471-
interactive: _,
1472-
exit_code: _,
1473-
} => search::Status::Failure,
1474-
1475-
TestStatus::Passed {
1476-
cached: _,
1477-
interactive: _,
1478-
fixed_tree_oid: _,
1479-
} => search::Status::Success,
1480-
},
1481-
)?;
1502+
let (maybe_testing_aborted_error, search_status) = match &test_output.test_status {
1503+
TestStatus::CheckoutFailed
1504+
| TestStatus::SpawnTestFailed(_)
1505+
| TestStatus::TerminatedBySignal
1506+
| TestStatus::AlreadyInProgress
1507+
| TestStatus::ReadCacheFailed(_)
1508+
| TestStatus::Indeterminate { .. } => (None, search::Status::Indeterminate),
1509+
1510+
TestStatus::Abort { exit_code } => (
1511+
Some(TestingAbortedError {
1512+
commit_oid,
1513+
exit_code: *exit_code,
1514+
}),
1515+
search::Status::Indeterminate,
1516+
),
1517+
1518+
TestStatus::Failed {
1519+
cached: _,
1520+
interactive: _,
1521+
exit_code: _,
1522+
} => (None, search::Status::Failure),
1523+
1524+
TestStatus::Passed {
1525+
cached: _,
1526+
interactive: _,
1527+
fixed_tree_oid: _,
1528+
} => (None, search::Status::Success),
1529+
};
1530+
search.notify(commit_oid, search_status)?;
14821531
test_outputs.insert(commit_oid, test_output);
1532+
if let Some(err) = maybe_testing_aborted_error {
1533+
testing_aborted_error = Some(err);
1534+
break;
1535+
}
14831536

1484-
let should_exit = match search_strategy {
1537+
let search_completed = match search_strategy {
14851538
None => test_outputs.len() == commit_jobs.len(),
14861539
Some(search_strategy) => {
14871540
let solution = search.search(search_strategy)?;
@@ -1490,13 +1543,12 @@ fn event_loop(
14901543
.take(num_jobs)
14911544
.map(|commit_oid| commit_jobs[&commit_oid].clone())
14921545
.collect_vec();
1493-
let should_exit = next_to_search.is_empty();
1546+
let search_completed = next_to_search.is_empty();
14941547
work_queue.set(next_to_search);
1495-
should_exit
1548+
search_completed
14961549
}
14971550
};
1498-
if should_exit {
1499-
drop(result_rx);
1551+
if search_completed {
15001552
break;
15011553
}
15021554
}
@@ -1510,7 +1562,11 @@ fn event_loop(
15101562
}
15111563
}
15121564

1513-
Ok((search, test_outputs))
1565+
Ok(EventLoopOutput {
1566+
search,
1567+
test_outputs,
1568+
testing_aborted_error,
1569+
})
15141570
}
15151571

15161572
#[instrument]
@@ -1545,6 +1601,9 @@ fn print_summary(
15451601
| TestStatus::TerminatedBySignal
15461602
| TestStatus::Indeterminate { .. } => num_skipped += 1,
15471603

1604+
TestStatus::Abort { .. } => {
1605+
num_failed += 1;
1606+
}
15481607
TestStatus::Failed {
15491608
cached,
15501609
exit_code: _,
@@ -1682,6 +1741,23 @@ fn print_summary(
16821741
print_hint_suppression_notice(effects, Hint::CleanCachedTestResults)?;
16831742
}
16841743

1744+
if let Some(testing_aborted_error) = &test_results.testing_aborted_error {
1745+
let TestingAbortedError {
1746+
commit_oid,
1747+
exit_code,
1748+
} = testing_aborted_error;
1749+
let commit = repo.find_commit_or_fail(*commit_oid)?;
1750+
writeln!(
1751+
effects.get_output_stream(),
1752+
"Aborted testing with exit code {} at commit: {}",
1753+
exit_code,
1754+
effects
1755+
.get_glyphs()
1756+
.render(commit.friendly_describe(effects.get_glyphs())?)?
1757+
)?;
1758+
return Ok(ExitCode(1));
1759+
}
1760+
16851761
if is_search {
16861762
Ok(ExitCode(0))
16871763
} else if num_failed > 0 || num_skipped > 0 {
@@ -1725,7 +1801,8 @@ fn apply_fixes(
17251801
| TestStatus::AlreadyInProgress
17261802
| TestStatus::ReadCacheFailed(_)
17271803
| TestStatus::Indeterminate { .. }
1728-
| TestStatus::Failed { .. } => None,
1804+
| TestStatus::Failed { .. }
1805+
| TestStatus::Abort { .. } => None,
17291806
})
17301807
.collect();
17311808

@@ -2064,7 +2141,9 @@ fn run_test(
20642141
| TestStatus::ReadCacheFailed(_)
20652142
| TestStatus::Indeterminate { .. } => OperationIcon::Warning,
20662143

2067-
TestStatus::TerminatedBySignal | TestStatus::Failed { .. } => OperationIcon::Failure,
2144+
TestStatus::TerminatedBySignal
2145+
| TestStatus::Failed { .. }
2146+
| TestStatus::Abort { .. } => OperationIcon::Failure,
20682147

20692148
TestStatus::Passed { .. } => OperationIcon::Success,
20702149
},
@@ -2155,6 +2234,13 @@ fn make_test_files(
21552234
TestStatus::Indeterminate { exit_code }
21562235
}
21572236

2237+
Ok(SerializedTestResult {
2238+
command: _,
2239+
exit_code,
2240+
fixed_tree_oid: _,
2241+
interactive: _,
2242+
}) if exit_code == ABORT_EXIT_CODE => TestStatus::Abort { exit_code },
2243+
21582244
Ok(SerializedTestResult {
21592245
command: _,
21602246
exit_code,
@@ -2371,17 +2457,35 @@ fn test_commit(
23712457
let exit125 = effects
23722458
.get_glyphs()
23732459
.render(StyledString::styled("exit 125", *STYLE_SKIPPED))?;
2460+
let exit127 = effects
2461+
.get_glyphs()
2462+
.render(StyledString::styled("exit 127", *STYLE_FAILURE))?;
23742463

23752464
// NB: use `println` here instead of
23762465
// `writeln!(effects.get_output_stream(), ...)` because the effects are
23772466
// suppressed in interactive mode.
23782467
println!(
23792468
"\
23802469
You are now at: {commit_desc}
2381-
To mark this commit as {passed}, run: {exit0}
2382-
To mark this commit as {failed}, run: {exit1}
2383-
To mark this commit as {skipped}, run: {exit125}",
2470+
To mark this commit as {passed},run: {exit0}
2471+
To mark this commit as {failed}, run: {exit1}
2472+
To mark this commit as {skipped}, run: {exit125}
2473+
To abort testing entirely, run: {exit127}",
23842474
);
2475+
match options.execution_strategy {
2476+
TestExecutionStrategy::WorkingCopy => {}
2477+
TestExecutionStrategy::Worktree => {
2478+
let warning = effects
2479+
.get_glyphs()
2480+
.render(StyledString::styled(
2481+
"Warning: You are in a worktree. Your changes will not be propagated between the worktree and the main repository.",
2482+
*STYLE_SKIPPED
2483+
))?;
2484+
println!("{warning}");
2485+
println!("To save your changes, create a new branch or note the commit hash.");
2486+
println!("To incorporate the changes from the main repository, switch to the main repository's current commit or branch.");
2487+
}
2488+
}
23852489
} else {
23862490
command
23872491
.stdin(Stdio::null())
@@ -2463,6 +2567,7 @@ To mark this commit as {skipped}, run: {exit125}",
24632567
}
24642568

24652569
exit_code @ INDETERMINATE_EXIT_CODE => TestStatus::Indeterminate { exit_code },
2570+
exit_code @ ABORT_EXIT_CODE => TestStatus::Abort { exit_code },
24662571

24672572
exit_code => TestStatus::Failed {
24682573
cached: false,
@@ -2486,6 +2591,7 @@ To mark this commit as {skipped}, run: {exit125}",
24862591
| TestStatus::AlreadyInProgress
24872592
| TestStatus::ReadCacheFailed(_)
24882593
| TestStatus::Failed { .. }
2594+
| TestStatus::Abort { .. }
24892595
| TestStatus::Indeterminate { .. } => None,
24902596
},
24912597
interactive: options.interactive,

0 commit comments

Comments
 (0)