diff --git a/CHANGES.md b/CHANGES.md
index 92c6642ff11..c4847b84a78 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -78,6 +78,10 @@ Remove obsolete Cylc 7 `[scheduling]spawn to max active cycle points` config.
safer by preventing cleaning of dirs that contain more than one workflow
run dir (use `--force` to override this safeguard).
+[#4362](https://github.com/cylc/cylc-flow/pull/4362) -
+When using `cylc clean` on a sequential run directory, remove the `runN` symlink
+if it points to the removed directory.
+
-------------------------------------------------------------------------------
## __cylc-8.0b2 (Released 2021-07-28)__
diff --git a/cylc/flow/workflow_files.py b/cylc/flow/workflow_files.py
index f1874e8ab0a..27c33698df1 100644
--- a/cylc/flow/workflow_files.py
+++ b/cylc/flow/workflow_files.py
@@ -747,6 +747,15 @@ def clean(reg: str, run_dir: Path, rm_dirs: Optional[Set[str]] = None) -> None:
# Remove empty parents of symlink target up to /cylc-run/
remove_empty_parents(target, Path(reg, symlink))
+ # Remove `runN` symlink if it's now broken
+ runN = run_dir.parent / WorkflowFiles.RUN_N
+ if (
+ runN.is_symlink() and
+ not run_dir.exists() and
+ os.readlink(str(runN)) == run_dir.name
+ ):
+ runN.unlink()
+
def get_symlink_dirs(reg: str, run_dir: Union[Path, str]) -> Dict[str, Path]:
"""Return the standard symlink dirs and their targets if they exist in
diff --git a/tests/unit/test_workflow_files.py b/tests/unit/test_workflow_files.py
index 07accb518b2..0e34c10a133 100644
--- a/tests/unit/test_workflow_files.py
+++ b/tests/unit/test_workflow_files.py
@@ -19,6 +19,7 @@
import os
from pathlib import Path
import pytest
+import re
import shutil
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Type, Union
from unittest import mock
@@ -40,6 +41,7 @@
_remote_clean_cmd,
check_flow_file,
check_nested_run_dirs,
+ clean,
get_rsync_rund_cmd,
get_symlink_dirs,
is_installed,
@@ -1523,3 +1525,32 @@ def test_get_rsync_rund_cmd(tmp_run_dir: Callable):
'--exclude=log', '--exclude=work', '--exclude=share',
'--exclude=_cylc-install', '--exclude=.service',
'blah/', f'{cylc_run_dir}/']
+
+
+@pytest.mark.parametrize(
+ 'expect, dirs',
+ [
+ (['run1'], ['run1', 'run2']),
+ (['run1', 'run11'], ['run1', 'run11', 'run2']),
+ (['run1200'], ['run1200', 'run1201']),
+ (['foo'], ['foo', 'bar']),
+ ]
+)
+def test_delete_runN(tmp_path, expect, dirs):
+ """It deletes the runN symlink.
+ """
+ for dir_ in dirs:
+ (tmp_path / dir_).mkdir()
+ if re.findall('run\d*', dirs[-1]):
+ (Path(tmp_path / 'runN')).symlink_to(dirs[-1])
+ clean(str(tmp_path.name) + '/' + dirs[-1], tmp_path / dirs[-1])
+ assert sorted([i.stem for i in tmp_path.glob('*')]) == sorted(expect)
+
+
+def test_delete_runN_skipif_cleanedrun_not_runN(tmp_path):
+ """It doesn't delete the symlink dir to be cleaned is not runN"""
+ for folder in ['run1', 'run2']:
+ (tmp_path / folder).mkdir()
+ (tmp_path / 'runN').symlink_to(tmp_path / 'run2')
+ clean(str(tmp_path.name) + '/' + 'run1', tmp_path / 'run1')
+ assert sorted([i.stem for i in tmp_path.glob('*')]) == ['run2', 'runN']