Skip to content
44 changes: 23 additions & 21 deletions nf_core/pipelines/lint/container_configs.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import logging
from pathlib import Path

import git

from nf_core.pipelines.containers_utils import ContainerConfigs

log = logging.getLogger(__name__)
Expand All @@ -13,8 +11,8 @@ def container_configs(self):

Scans all ``meta.yml`` files under ``modules/`` that contain a ``containers``
key, reads the process name from the sibling ``main.nf``, and regenerates
the container configuration files in ``conf/``. Uses ``git diff`` to detect
changes. If not in ``--fix`` mode the working tree is restored to its
the container configuration files in ``conf/``. Uses direct content comparison
to detect changes. If not in ``--fix`` mode the working tree is restored to its
original state afterwards.

Can be skipped by adding the following to the ``.nf-core.yml`` file:
Expand All @@ -31,7 +29,11 @@ def container_configs(self):
could_fix = False

conf_dir = Path(self.wf_path) / "conf"
repo = git.Repo(self.wf_path)

# Snapshot the content of existing container config files before generation
snapshot: dict[str, str] = {}
for path in conf_dir.glob("containers_*"):
snapshot[path.name] = path.read_text()

try:
generated = ContainerConfigs(self.wf_path).generate_container_configs()
Expand All @@ -41,20 +43,20 @@ def container_configs(self):

log.debug(f"Generated {len(generated)} container config file(s): {', '.join(sorted(generated)) or 'none'}")

# Files modified in the working tree (tracked and changed by generation)
modified = {
Path(d.a_path).name
for d in repo.index.diff(None)
if d.a_path and Path(d.a_path).parent.name == "conf" and Path(d.a_path).name.startswith("containers_")
}
# Newly created files (generated but not previously tracked)
new = {
Path(f).name
for f in repo.untracked_files
if Path(f).parent.name == "conf" and Path(f).name.startswith("containers_")
}
# Already-correct files: generated, tracked, and unchanged
correct = generated - modified - new
# Compare generated content to pre-generation snapshot
modified: set[str] = set()
new: set[str] = set()
correct: set[str] = set()

for name in generated:
new_content = (conf_dir / name).read_text() if (conf_dir / name).exists() else ""
old_content = snapshot.get(name)
if old_content is None:
new.add(name)
elif new_content != old_content:
modified.add(name)
else:
correct.add(name)

log.debug(f"Container config status — correct: {len(correct)}, modified: {len(modified)}, new: {len(new)}")

Expand All @@ -76,10 +78,10 @@ def container_configs(self):
could_fix = True

if not fixing:
# Restore working tree: reset modified tracked files and delete new untracked ones
# Restore working tree: write back original content for modified files, remove new files
log.debug(f"Restoring working tree: resetting {len(modified)} modified, removing {len(new)} new file(s)")
for name in modified:
repo.git.restore(str(conf_dir / name))
(conf_dir / name).write_text(snapshot[name])
for name in new:
(conf_dir / name).unlink(missing_ok=True)

Expand Down
122 changes: 113 additions & 9 deletions tests/pipelines/lint/test_container_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def _commit_container_cfg(self, name: str, content: str) -> Path:
def test_container_configs_up_to_date(self):
"""Linting passes when generated configs match what is on disk."""
content = "process { withName: 'FASTQC' { container = 'docker.io/biocontainers/fastqc:0.12.1' } }\n"
self._commit_container_cfg("containers_docker_amd64.config", content)
self._write_container_cfg("containers_docker_amd64.config", content)

def generate(cc_self):
(cc_self.workflow_directory / "conf" / "containers_docker_amd64.config").write_text(content)
Expand All @@ -62,7 +62,7 @@ def test_container_configs_out_of_date(self):
"""Linting fails when generated configs differ from what is on disk."""
old = "process { withName: 'FASTQC' { container = 'old_image' } }\n"
new = "process { withName: 'FASTQC' { container = 'new_image' } }\n"
self._commit_container_cfg("containers_docker_amd64.config", old)
self._write_container_cfg("containers_docker_amd64.config", old)

def generate(cc_self):
(cc_self.workflow_directory / "conf" / "containers_docker_amd64.config").write_text(new)
Expand All @@ -78,16 +78,16 @@ def generate(cc_self):
assert any("out of date" in f for f in result["failed"])

def test_container_configs_missing_file(self):
"""Linting fails when generate produces a config that has never been committed to the repo."""
"""Linting fails when generate produces a config that did not exist on disk before."""
content = "process { withName: 'FASTQC' { container = 'docker.io/biocontainers/fastqc:0.12.1' } }\n"

repo = git.Repo(self.new_pipeline)
repo.index.remove(["conf/containers_docker_amd64.config"], working_tree=True)
repo.index.commit("remove container config to simulate missing file")
# Ensure the file doesn't exist before generation
target = Path(self.new_pipeline) / "conf" / "containers_new_platform.config"
target.unlink(missing_ok=True)

def generate(cc_self):
(cc_self.workflow_directory / "conf" / "containers_docker_amd64.config").write_text(content)
return {"containers_docker_amd64.config"}
(cc_self.workflow_directory / "conf" / "containers_new_platform.config").write_text(content)
return {"containers_new_platform.config"}

with patch(
"nf_core.pipelines.containers_utils.ContainerConfigs.generate_container_configs",
Expand All @@ -102,7 +102,7 @@ def test_container_configs_fix_overwrites_files(self):
"""--fix overwrites out-of-date container config files and reports them as fixed."""
old = "process { withName: 'FASTQC' { container = 'old_image' } }\n"
new = "process { withName: 'FASTQC' { container = 'new_image' } }\n"
cfg = self._commit_container_cfg("containers_docker_amd64.config", old)
cfg = self._write_container_cfg("containers_docker_amd64.config", old)

def generate(cc_self):
(cc_self.workflow_directory / "conf" / "containers_docker_amd64.config").write_text(new)
Expand Down Expand Up @@ -132,3 +132,107 @@ def test_container_configs_user_warning_warns(self):

assert len(result["failed"]) == 0
assert any("Nextflow version too low" in w for w in result["warned"])

def test_container_configs_uncommitted_but_correct_passes(self):
"""Lint passes when configs were already regenerated (e.g. by modules update) but not committed.

This is the key scenario that broke with the old git-diff approach: if modules update
regenerated configs without committing, repo.index.diff(None) would show them as
modified vs HEAD, causing false 'out of date' failures. The content-comparison approach
correctly identifies these as up-to-date since the on-disk content matches what generation
would produce.
"""
content = "process { withName: 'FASTQC' { container = 'docker.io/biocontainers/fastqc:0.12.1' } }\n"
# Write the file but do NOT commit it — simulates modules update having regenerated it
self._write_container_cfg("containers_docker_amd64.config", content)

def generate(cc_self):
# Generation writes the exact same content
(cc_self.workflow_directory / "conf" / "containers_docker_amd64.config").write_text(content)
return {"containers_docker_amd64.config"}

with patch(
"nf_core.pipelines.containers_utils.ContainerConfigs.generate_container_configs",
autospec=True,
side_effect=generate,
):
result = self._lint()

assert len(result["failed"]) == 0
assert any("up to date" in p for p in result["passed"])

def test_container_configs_working_tree_restored_after_lint(self):
"""After lint (without --fix), the working tree is restored to its pre-lint state.

This verifies the cleanup/restore logic works correctly: modified files are written
back to their original content, and newly created files are removed.
"""
old = "process { withName: 'FASTQC' { container = 'old_image' } }\n"
new = "process { withName: 'FASTQC' { container = 'new_image' } }\n"
cfg_path = self._write_container_cfg("containers_docker_amd64.config", old)

# Also ensure a file that will be "new" doesn't exist
new_path = Path(self.new_pipeline) / "conf" / "containers_new_platform.config"
new_path.unlink(missing_ok=True)

def generate(cc_self):
# Modify existing file
(cc_self.workflow_directory / "conf" / "containers_docker_amd64.config").write_text(new)
# Create a new file
(cc_self.workflow_directory / "conf" / "containers_new_platform.config").write_text("new platform\n")
return {"containers_docker_amd64.config", "containers_new_platform.config"}

with patch(
"nf_core.pipelines.containers_utils.ContainerConfigs.generate_container_configs",
autospec=True,
side_effect=generate,
):
result = self._lint()

# Lint should detect issues
assert len(result["failed"]) > 0

# But the working tree must be restored
assert cfg_path.read_text() == old, "Modified file should be restored to original content"
assert not new_path.exists(), "Newly created file should be removed after lint"

def test_container_configs_restore_with_relative_wf_path(self):
"""Verify working tree restore works correctly when wf_path is relative.

This tests the fix for the relative path bug: the old implementation called
git restore with paths relative to wf_path, which would fail when wf_path
wasn't the repo root. The new implementation directly writes back snapshotted
content, so it works regardless of whether wf_path is absolute or relative.
"""
import os

old = "process { withName: 'FASTQC' { container = 'old_image' } }\n"
new = "process { withName: 'FASTQC' { container = 'new_image' } }\n"
cfg_path = self._write_container_cfg("containers_docker_amd64.config", old)

def generate(cc_self):
(cc_self.workflow_directory / "conf" / "containers_docker_amd64.config").write_text(new)
return {"containers_docker_amd64.config"}

with patch(
"nf_core.pipelines.containers_utils.ContainerConfigs.generate_container_configs",
autospec=True,
side_effect=generate,
):
# Save original cwd and change to parent directory
original_cwd = Path.cwd()
try:
os.chdir(Path(self.new_pipeline).parent)
# Use relative path (just the basename) instead of absolute path
relative_pipeline_path = Path(self.new_pipeline).name
lint_obj = nf_core.pipelines.lint.PipelineLint(str(relative_pipeline_path))
lint_obj._load()
result = lint_obj.container_configs()
finally:
os.chdir(original_cwd)

# Lint should detect the modification as "out of date"
assert any("out of date" in f for f in result["failed"])

# But the working tree must still be restored correctly even with relative path
assert cfg_path.read_text() == old, "Modified file should be restored even when using relative wf_path"
Loading