diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2bb885f2..133186ce 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,9 @@ +Release 0.14.0 (unreleased) +=========================== + +* Allow ``dfetch freeze`` to accept project names to freeze only specific projects (#1063) +* Edit manifest in-place when freezing inside a git or SVN superproject, preserving comments and layout (#1063) + Release 0.13.0 (released 2026-03-30) ==================================== diff --git a/dfetch/commands/freeze.py b/dfetch/commands/freeze.py index d057c488..db61b4b1 100644 --- a/dfetch/commands/freeze.py +++ b/dfetch/commands/freeze.py @@ -18,7 +18,11 @@ When your project becomes stable and you want to rely on a specific version of ``mymodule`` you can run ``dfetch freeze``. -First *DFetch* will rename your old manifest (appended with ``.backup``). +When the manifest lives inside a git or SVN super-project, *DFetch* edits the +manifest file **in-place** so that comments, blank lines and indentation are +preserved. Only the version fields that changed are touched. + +Otherwise *DFetch* first renames your old manifest (appended with ``.backup``). After that a new manifest is generated with all the projects as in your original manifest, but each with the specific version as it currently is on disk. @@ -34,8 +38,16 @@ url: http://git.mycompany.local/mycompany/mymodule tag: v1.0.0 +You can also freeze a subset of projects by listing their names: + +.. code-block:: sh + + dfetch freeze mymodule + .. scenario-include:: ../features/freeze-projects.feature +.. scenario-include:: ../features/freeze-specific-projects.feature + For archive projects, ``dfetch freeze`` adds the hash under the nested ``integrity.hash`` key (e.g. ``integrity.hash: sha256:``) to pin the exact archive content used. This value acts as the version identifier: @@ -44,6 +56,8 @@ .. scenario-include:: ../features/freeze-archive.feature +.. scenario-include:: ../features/freeze-inplace.feature + """ import argparse @@ -55,9 +69,9 @@ import dfetch.project from dfetch import DEFAULT_MANIFEST_NAME from dfetch.log import get_logger -from dfetch.manifest.manifest import Manifest -from dfetch.manifest.project import ProjectEntry +from dfetch.manifest.manifest import Manifest, update_project_in_manifest_file from dfetch.project import create_super_project +from dfetch.project.superproject import NoVcsSuperProject from dfetch.util.util import catch_runtime_exceptions, in_directory logger = get_logger(__name__) @@ -67,24 +81,30 @@ class Freeze(dfetch.commands.command.Command): """Freeze your projects versions in the manifest as they are on disk. Generate a new manifest that has all version as they are on disk. + Optionally pass one or more project names to freeze only those projects. """ @staticmethod def create_menu(subparsers: dfetch.commands.command.SubparserActionType) -> None: """Add the parser menu for this action.""" - dfetch.commands.command.Command.parser(subparsers, Freeze) + parser = dfetch.commands.command.Command.parser(subparsers, Freeze) + parser.add_argument( + "projects", + metavar="", + type=str, + nargs="*", + help="Specific project(s) to freeze (default: all projects in manifest)", + ) def __call__(self, args: argparse.Namespace) -> None: """Perform the freeze.""" - del args # unused - superproject = create_super_project() + use_inplace = not isinstance(superproject, NoVcsSuperProject) exceptions: list[str] = [] - projects: list[ProjectEntry] = [] with in_directory(superproject.root_directory): - for project in superproject.manifest.projects: + for project in superproject.manifest.selected_projects(args.projects): with catch_runtime_exceptions(exceptions) as exceptions: sub_project = dfetch.project.create_sub_project(project) on_disk_version = sub_project.on_disk_version() @@ -106,18 +126,23 @@ def __call__(self, args: argparse.Namespace) -> None: project.name, f"Frozen on version {new_version}", ) + if use_inplace: + update_project_in_manifest_file( + project, superproject.manifest.path + ) - projects.append(project) - - manifest = Manifest( - { - "version": "0.0", - "remotes": superproject.manifest.remotes, - "projects": projects, - } - ) - - shutil.move(DEFAULT_MANIFEST_NAME, DEFAULT_MANIFEST_NAME + ".backup") - - manifest.dump(DEFAULT_MANIFEST_NAME) - logger.info(f"Updated manifest ({DEFAULT_MANIFEST_NAME}) in {os.getcwd()}") + if not use_inplace: + manifest = Manifest( + { + "version": "0.0", + "remotes": superproject.manifest.remotes, + "projects": superproject.manifest.projects, + } + ) + + shutil.move(DEFAULT_MANIFEST_NAME, DEFAULT_MANIFEST_NAME + ".backup") + + manifest.dump(DEFAULT_MANIFEST_NAME) + logger.info( + f"Updated manifest ({DEFAULT_MANIFEST_NAME}) in {os.getcwd()}" + ) diff --git a/dfetch/manifest/manifest.py b/dfetch/manifest/manifest.py index aff40963..5d1fae6e 100644 --- a/dfetch/manifest/manifest.py +++ b/dfetch/manifest/manifest.py @@ -33,6 +33,7 @@ from dfetch.log import get_logger from dfetch.manifest.project import ProjectEntry, ProjectEntryDict from dfetch.manifest.remote import Remote, RemoteDict +from dfetch.util.yaml import append_field, find_field, update_value, yaml_scalar logger = get_logger(__name__) @@ -358,18 +359,16 @@ def find_name_in_manifest(self, name: str) -> ManifestEntryLocation: if not self.__text: raise FileNotFoundError("No manifest text available") - for line_nr, line in enumerate(self.__text.splitlines(), start=1): - match = re.search( - rf"^\s+-\s*name:\s*(?P{re.escape(name)})\s*#?.*$", line - ) + result = _locate_project_name_line(self.__text.splitlines(), name) + if result is None: + raise RuntimeError(f"{name} was not found in the manifest!") - if match: - return ManifestEntryLocation( - line_number=line_nr, - start=int(match.start("name")) + 1, - end=int(match.end("name")), - ) - raise RuntimeError(f"{name} was not found in the manifest!") + line_idx, _, name_start, name_end = result + return ManifestEntryLocation( + line_number=line_idx + 1, + start=name_start, + end=name_end, + ) # Characters not allowed in a project name (YAML special chars). _UNSAFE_NAME_RE = re.compile(r"[\x00-\x1F\x7F-\x9F:#\[\]{}&*!|>'\"%@`]") @@ -483,3 +482,268 @@ def append_entry_manifest_file( manifest_file.write("\n") for line in new_entry.splitlines(): manifest_file.write(f" {line}\n") + + +def update_project_in_manifest_file( + project: ProjectEntry, + manifest_path: str | Path, +) -> None: + """Update a project's version fields in the manifest file, preserving layout and comments. + + This is used when the manifest is in a version-controlled superproject: instead of + creating a backup and regenerating the file from scratch, the existing file is edited + in-place so that comments and formatting are retained. + + Args: + project: The ``ProjectEntry`` whose version fields have already been updated by + ``freeze_project()``. + manifest_path: Path to the manifest file to update. + """ + path = Path(manifest_path) + text = path.read_text(encoding="utf-8") + updated = _update_project_version_in_text(text, project) + path.write_text(updated, encoding="utf-8") + + +# --------------------------------------------------------------------------- +# Private aliases – kept for internal callers; the canonical implementations +# live in dfetch.util.yaml. +# --------------------------------------------------------------------------- +_yaml_scalar = yaml_scalar +_find_field = find_field +_update_value = update_value +_append_field = append_field + + +def _locate_project_name_line( + lines: Sequence[str], project_name: str +) -> tuple[int, int, int, int] | None: + """Scan *lines* for the ``- name: `` entry. + + Returns ``(line_idx, item_indent, name_col_start, name_col_end)`` or + ``None`` when the project is not found. + + - ``line_idx``: 0-based index into *lines* + - ``item_indent``: column of the ``-`` character + - ``name_col_start``: 1-based column of the first character of the name value + (compatible with :class:`ManifestEntryLocation`) + - ``name_col_end``: 0-based exclusive end column of the name value + """ + pattern = re.compile( + r"^(?P\s*)-\s*name:\s*(?P" + + re.escape(project_name) + + r")\s*(?:#.*)?$" + ) + for i, line in enumerate(lines): + m = pattern.match(line.rstrip("\n\r")) + if m: + return i, len(m.group("indent")), m.start("name") + 1, m.end("name") + return None + + +def _find_project_block( + lines: Sequence[str], project_name: str +) -> tuple[int, int, int]: + """Return ``(start, end, item_indent)`` for the named project's YAML block. + + *start* is the index of the ``- name: `` line. + *end* is the exclusive end index (first line that belongs to the next block). + *item_indent* is the column of the ``-`` character. + + Raises: + RuntimeError: if the project name is not found. + """ + result = _locate_project_name_line(lines, project_name) + if result is None: + raise RuntimeError(f"Project '{project_name}' not found in manifest text") + + start, item_indent, _, _ = result + + end = len(lines) + for i in range(start + 1, len(lines)): + line = lines[i] + if not line.strip(): + continue + if line.lstrip().startswith("#"): + continue + indent = len(line) - len(line.lstrip()) + if indent <= item_indent: + end = i + break + + return start, end, item_indent + + +def _set_simple_field_in_block( + block: Sequence[str], field_indent: int, field_name: str, yaml_value: str +) -> list[str]: + """Update an existing ``field_name:`` line in *block*, or insert one after the first line.""" + idx = _find_field(block, field_name, field_indent) + if idx is not None: + return _update_value(block, idx, field_name, yaml_value) + return _append_field(block, field_name, yaml_value, field_indent, after=1) + + +def _update_hash_in_existing_integrity( + block: Sequence[str], integrity_line: int, field_indent: int, hash_value: str +) -> list[str]: + """Update or insert ``hash:`` inside an already-located ``integrity:`` block.""" + sub_end = len(block) + for i in range(integrity_line + 1, len(block)): + line = block[i] + if not line.strip() or line.lstrip().startswith("#"): + continue + if len(line) - len(line.lstrip()) <= field_indent: + sub_end = i + break + hash_idx = _find_field( + block, "hash", field_indent + 2, start=integrity_line + 1, end=sub_end + ) + if hash_idx is not None: + return _update_value(block, hash_idx, "hash", hash_value) + return _append_field( + block, "hash", hash_value, field_indent + 2, after=integrity_line + 1 + ) + + +def _append_integrity_block( + block: Sequence[str], field_indent: int, hash_value: str +) -> list[str]: + r"""Append a new ``integrity:\\n hash:`` block before any trailing blank lines.""" + insert_at = len(block) + for i in range(len(block) - 1, -1, -1): + if block[i].strip(): + insert_at = i + 1 + break + block = _append_field(block, "integrity", "", field_indent, after=insert_at) + return _append_field( + block, "hash", hash_value, field_indent + 2, after=insert_at + 1 + ) + + +def _set_integrity_hash_in_block( + block: Sequence[str], field_indent: int, hash_value: str +) -> list[str]: + """Update or insert ``integrity.hash`` inside *block*.""" + block = list(block) + integrity_line = _find_field(block, "integrity", field_indent) + if integrity_line is not None: + return _update_hash_in_existing_integrity( + block, integrity_line, field_indent, hash_value + ) + return _append_integrity_block(block, field_indent, hash_value) + + +_VERSION_KEYS: tuple[str, ...] = ("revision", "tag", "branch") + + +def _remove_integrity_block(block: Sequence[str], field_indent: int) -> list[str]: + """Remove the ``integrity:`` mapping and all its children from *block*.""" + result = list(block) + integrity_idx = _find_field(result, "integrity", field_indent) + if integrity_idx is None: + return result + sub_end = len(result) + for i in range(integrity_idx + 1, len(result)): + line = result[i] + if not line.strip() or line.lstrip().startswith("#"): + continue + if len(line) - len(line.lstrip()) <= field_indent: + sub_end = i + break + del result[integrity_idx:sub_end] + return result + + +def _remove_stale_version_fields( + block: Sequence[str], + field_indent: int, + keys_to_keep: set[str], + keep_integrity: bool, +) -> list[str]: + """Delete version-related keys from *block* that are absent from *keys_to_keep*.""" + result = list(block) + for key in _VERSION_KEYS: + if key not in keys_to_keep: + idx = _find_field(result, key, field_indent) + if idx is not None: + del result[idx] + if not keep_integrity: + result = _remove_integrity_block(result, field_indent) + return result + + +def _collect_version_fields( + project_yaml: dict[str, Any], +) -> tuple[list[tuple[str, str]], str]: + """Extract version-related fields from *project_yaml*. + + Returns: + A ``(fields_to_set, integrity_hash)`` pair. *fields_to_set* is a list + of ``(field_name, yaml_scalar)`` tuples for ``revision``, ``tag`` and + ``branch``; *integrity_hash* is the raw hash string or ``""`` when absent. + """ + fields: list[tuple[str, str]] = [] + for field in ("revision", "tag", "branch"): + value = project_yaml.get(field) + if value: + fields.append((field, _yaml_scalar(str(value)))) + + integrity = project_yaml.get("integrity") + integrity_hash: str = ( + integrity["hash"] + if isinstance(integrity, dict) and integrity.get("hash") + else "" + ) + return fields, integrity_hash + + +def _apply_block_updates( + block: Sequence[str], + field_indent: int, + fields_to_set: list[tuple[str, str]], + integrity_hash: str, +) -> list[str]: + """Apply version-field updates to *block* and return the modified block. + + Stale version keys (those no longer present in *fields_to_set* / + *integrity_hash*) are deleted so the in-place result matches what the + backup-and-regenerate path would produce. + """ + keys_to_keep = {name for name, _ in fields_to_set} + block = _remove_stale_version_fields( + block, field_indent, keys_to_keep, bool(integrity_hash) + ) + + inserted: list[tuple[str, str]] = [] + for field_name, yaml_value in fields_to_set: + idx = _find_field(block, field_name, field_indent) + if idx is not None: + block = _update_value(block, idx, field_name, yaml_value) + else: + inserted.append((field_name, yaml_value)) + + for pos, (field_name, yaml_value) in enumerate(inserted, start=1): + block = _append_field(block, field_name, yaml_value, field_indent, after=pos) + + if integrity_hash: + block = _set_integrity_hash_in_block(block, field_indent, integrity_hash) + + return block + + +def _update_project_version_in_text(text: str, project: ProjectEntry) -> str: + """Return *text* with the version fields for *project* updated in-place. + + Version-related fields (``revision``, ``tag``, ``branch``, + ``integrity.hash``) are added, updated, or removed to match the project's + current state. All other content — including comments and indentation — + is preserved verbatim. + """ + fields_to_set, integrity_hash = _collect_version_fields(project.as_yaml()) + lines = text.splitlines(keepends=True) + start, end, item_indent = _find_project_block(lines, project.name) + block = _apply_block_updates( + list(lines[start:end]), item_indent + 2, fields_to_set, integrity_hash + ) + return "".join(lines[:start] + block + lines[end:]) diff --git a/dfetch/util/yaml.py b/dfetch/util/yaml.py new file mode 100644 index 00000000..7ea4b2ca --- /dev/null +++ b/dfetch/util/yaml.py @@ -0,0 +1,106 @@ +"""Generic YAML text-editing utilities. + +These helpers operate on plain lists of text lines and know nothing about +dfetch-specific concepts. They are used by :mod:`dfetch.manifest.manifest` +to edit manifest files in-place while preserving comments and layout. +""" + +import re +from collections.abc import Sequence + +import yaml + + +def yaml_scalar(value: str) -> str: + """Return the YAML inline representation of a scalar string value. + + Strings that look like integers (e.g. SVN revision ``'176'``) are quoted + so that YAML round-trips them back as strings. + """ + dumped: str = yaml.dump(value, default_flow_style=None, allow_unicode=True) + return dumped.splitlines()[0] + + +def _line_eol(line: str) -> str: + """Return the line-ending sequence of *line*, or ``""`` if it has none.""" + if line.endswith("\r\n"): + return "\r\n" + if line.endswith("\n"): + return "\n" + return "" + + +def _detect_eol(block: Sequence[str]) -> str: + r"""Return the line-ending style used in *block*, defaulting to ``\\n``.""" + for line in block: + eol = _line_eol(line) + if eol: + return eol + return "\n" + + +def find_field( + block: Sequence[str], + field_name: str, + indent: int, + start: int = 0, + end: int | None = None, +) -> int | None: + """Return the index in *block* of ``field_name:`` at exactly *indent* spaces. + + Searches ``block[start:end]``. Commented-out lines (where the first + non-whitespace character is ``#``) are skipped and never matched. + Returns ``None`` when not found. + """ + bound = end if end is not None else len(block) + prefix = " " * indent + field_name + ":" + for i in range(start, bound): + stripped = block[i].rstrip("\n\r") + if stripped.lstrip().startswith("#"): + continue + if stripped.startswith(prefix) and ( + len(stripped) == len(prefix) or stripped[len(prefix)] in (" ", "\t") + ): + return i + return None + + +def update_value( + block: Sequence[str], line_idx: int, field_name: str, yaml_value: str +) -> list[str]: + """Return a copy of *block* with the value on *line_idx* replaced by *yaml_value*. + + The indentation, line ending (LF or CRLF), and any trailing comment of + the original line are all preserved so that in-place edits do not destroy + annotations or alter the file's line-ending convention. + """ + result = list(block) + line = result[line_idx] + indent = len(line) - len(line.lstrip()) + eol = _line_eol(line) + stripped = line.rstrip("\n\r") + comment_match = re.search(r"(\s+#.*)$", stripped) + comment = comment_match.group(1) if comment_match else "" + result[line_idx] = " " * indent + field_name + ": " + yaml_value + comment + eol + return result + + +def append_field( + block: Sequence[str], + field_name: str, + yaml_value: str, + indent: int, + after: int, +) -> list[str]: + """Return a copy of *block* with ``field_name: yaml_value`` inserted at *after*. + + When *yaml_value* is empty the line is written as ``field_name:`` (no + value), which is the correct YAML form for a mapping key whose children + follow on subsequent lines. The line-ending style (LF or CRLF) is inferred + from the existing lines in *block*. + """ + result = list(block) + value_part = ": " + yaml_value if yaml_value else ":" + eol = _detect_eol(result) + result.insert(after, " " * indent + field_name + value_part + eol) + return result diff --git a/doc/conf.py b/doc/conf.py index e295ab66..2a933c0f 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -250,7 +250,13 @@ # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, f"dfetch-{__version__}.tex", "Dfetch Documentation", "Dfetch", "manual"), + ( + master_doc, + f"dfetch-{__version__}.tex", + "Dfetch Documentation", + "Dfetch", + "manual", + ), ] diff --git a/features/freeze-inplace.feature b/features/freeze-inplace.feature new file mode 100644 index 00000000..1c39112e --- /dev/null +++ b/features/freeze-inplace.feature @@ -0,0 +1,96 @@ +Feature: Freeze manifest in-place inside a version-controlled superproject + + When the manifest lives inside a git or SVN superproject, ``dfetch freeze`` + edits the manifest file **in-place** instead of creating a ``.backup`` copy. + Comments, blank lines, and the original indentation are all preserved. + + Scenario: Git projects are frozen in-place preserving layout + Given a local git repo "superproject" with the manifest + """ + manifest: + version: '0.0' + + projects: + - name: ext/test-repo-tag + url: https://github.com/dfetch-org/test-repo + branch: main + + """ + And all projects are updated in superproject + When I run "dfetch freeze" in superproject + Then the manifest 'dfetch.yaml' in superproject is replaced with + """ + manifest: + version: '0.0' + + projects: + - name: ext/test-repo-tag + revision: e1fda19a57b873eb8e6ae37780594cbb77b70f1a + url: https://github.com/dfetch-org/test-repo + branch: main + + """ + And no file 'dfetch.yaml.backup' exists in superproject + + Scenario: Inline comments on fields are preserved after freeze + Given a local git repo "superproject3" with the manifest + """ + manifest: + version: '0.0' + + projects: + - name: ext/test-repo-tag + url: https://github.com/dfetch-org/test-repo # source mirror + branch: main # track the integration branch + + """ + And all projects are updated in superproject3 + When I run "dfetch freeze" in superproject3 + Then the manifest 'dfetch.yaml' in superproject3 is replaced with + """ + manifest: + version: '0.0' + + projects: + - name: ext/test-repo-tag + revision: e1fda19a57b873eb8e6ae37780594cbb77b70f1a + url: https://github.com/dfetch-org/test-repo # source mirror + branch: main # track the integration branch + + """ + And no file 'dfetch.yaml.backup' exists in superproject3 + + Scenario: Only selected project is frozen in-place + Given a local git repo "superproject2" with the manifest + """ + manifest: + version: '0.0' + + projects: + - name: ext/test-repo-tag + url: https://github.com/dfetch-org/test-repo + branch: main + + - name: ext/test-repo-tag2 + url: https://github.com/dfetch-org/test-repo + branch: main + + """ + And all projects are updated in superproject2 + When I run "dfetch freeze ext/test-repo-tag" in superproject2 + Then the manifest 'dfetch.yaml' in superproject2 is replaced with + """ + manifest: + version: '0.0' + + projects: + - name: ext/test-repo-tag + revision: e1fda19a57b873eb8e6ae37780594cbb77b70f1a + url: https://github.com/dfetch-org/test-repo + branch: main + + - name: ext/test-repo-tag2 + url: https://github.com/dfetch-org/test-repo + branch: main + + """ diff --git a/features/freeze-specific-projects.feature b/features/freeze-specific-projects.feature new file mode 100644 index 00000000..20416596 --- /dev/null +++ b/features/freeze-specific-projects.feature @@ -0,0 +1,39 @@ +Feature: Freeze specific projects + + *DFetch* can freeze specific projects by name, leaving other projects untouched. + This is useful when only some dependencies need to be pinned. + + Scenario: Single project is frozen while another is skipped + Given the manifest 'dfetch.yaml' + """ + manifest: + version: '0.0' + + projects: + - name: ext/test-repo-tag + url: https://github.com/dfetch-org/test-repo + branch: main + + - name: ext/test-repo-tag2 + url: https://github.com/dfetch-org/test-repo + branch: main + + """ + And all projects are updated + When I run "dfetch freeze ext/test-repo-tag" + Then the manifest 'dfetch.yaml' is replaced with + """ + manifest: + version: '0.0' + + projects: + - name: ext/test-repo-tag + revision: e1fda19a57b873eb8e6ae37780594cbb77b70f1a + url: https://github.com/dfetch-org/test-repo + branch: main + + - name: ext/test-repo-tag2 + url: https://github.com/dfetch-org/test-repo + branch: main + + """ diff --git a/features/steps/manifest_steps.py b/features/steps/manifest_steps.py index 30d0c2f4..5afa41a7 100644 --- a/features/steps/manifest_steps.py +++ b/features/steps/manifest_steps.py @@ -52,6 +52,22 @@ def step_impl(context, name): check_file(name, apply_manifest_substitutions(context, context.text)) +@then("the manifest '{name}' in {directory} is replaced with") +def step_impl(context, name, directory): + """Check a manifest located in a subdirectory.""" + check_file( + os.path.join(directory, name), + apply_manifest_substitutions(context, context.text), + ) + + +@then("no file '{name}' exists in {directory}") +def step_impl(_, name, directory): + """Assert that a file does not exist in the given directory.""" + path = os.path.join(directory, name) + assert not os.path.exists(path), f"Expected '{path}' to not exist, but it does!" + + @given("the manifest '{name}' with the projects:") def step_impl(context, name): projects = "\n".join(f" - name: {row['name']}" for row in context.table) diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 87d7ea57..c2faf51d 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -14,9 +14,17 @@ ManifestDict, ManifestEntryLocation, RequestedProjectNotFoundError, + _find_project_block, + _locate_project_name_line, + _set_integrity_hash_in_block, + _set_simple_field_in_block, + _update_project_version_in_text, ) from dfetch.manifest.parse import find_manifest, get_submanifests from dfetch.manifest.project import ProjectEntry +from dfetch.util.yaml import append_field as _append_field +from dfetch.util.yaml import find_field as _find_field +from dfetch.util.yaml import update_value as _update_value BASIC_MANIFEST = """ manifest: @@ -245,3 +253,467 @@ def test_validate_destination_rejects_dotdot() -> None: def test_validate_destination_accepts_relative() -> None: Manifest.validate_destination("external/mylib") # must NOT raise + + +# --------------------------------------------------------------------------- +# In-place manifest text editing helpers +# --------------------------------------------------------------------------- + +_SIMPLE_MANIFEST = """\ +manifest: + version: '0.0' + + projects: + - name: myproject + url: https://example.com/myproject + branch: main +""" + +_TWO_PROJECT_MANIFEST = """\ +manifest: + version: '0.0' + + projects: + - name: first + url: https://example.com/first + branch: main + + - name: second + url: https://example.com/second + branch: develop +""" + + +# --- _locate_project_name_line --------------------------------------------- + + +def test_locate_project_name_line_found() -> None: + lines = _SIMPLE_MANIFEST.splitlines() + result = _locate_project_name_line(lines, "myproject") + assert result is not None + line_idx, item_indent, name_start, name_end = result + assert item_indent == 4 + assert lines[line_idx][name_start - 1 : name_end] == "myproject" + + +def test_locate_project_name_line_not_found() -> None: + lines = _SIMPLE_MANIFEST.splitlines() + assert _locate_project_name_line(lines, "nonexistent") is None + + +def test_locate_is_shared_by_find_name_and_find_block() -> None: + """find_name_in_manifest and _find_project_block agree on the same line.""" + text = _SIMPLE_MANIFEST + manifest = Manifest( + {"version": "0.0", "projects": [{"name": "myproject", "url": "https://x.com"}]}, + text=text, + ) + location = manifest.find_name_in_manifest("myproject") + + lines = text.splitlines(keepends=True) + start, _end, _indent = _find_project_block(lines, "myproject") + + # Both should point to the same line (1-based vs 0-based). + assert location.line_number == start + 1 + + +# --- _find_project_block --------------------------------------------------- + + +def test_find_project_block_single() -> None: + lines = _SIMPLE_MANIFEST.splitlines(keepends=True) + start, end, item_indent = _find_project_block(lines, "myproject") + assert item_indent == 4 + assert lines[start].startswith(" - name: myproject") + # end should point past the block + assert end == len(lines) + + +def test_find_project_block_first_of_two() -> None: + lines = _TWO_PROJECT_MANIFEST.splitlines(keepends=True) + start, end, item_indent = _find_project_block(lines, "first") + assert item_indent == 4 + assert lines[start].startswith(" - name: first") + # The blank line between projects is excluded from the block + block_text = "".join(lines[start:end]) + assert "second" not in block_text + + +def test_find_project_block_second_of_two() -> None: + lines = _TWO_PROJECT_MANIFEST.splitlines(keepends=True) + start, _end, _indent = _find_project_block(lines, "second") + assert lines[start].startswith(" - name: second") + + +def test_find_project_block_not_found() -> None: + lines = _SIMPLE_MANIFEST.splitlines(keepends=True) + with pytest.raises(RuntimeError, match="not found"): + _find_project_block(lines, "nonexistent") + + +def test_find_project_block_comment_at_item_indent_does_not_end_block() -> None: + """A comment at the same indent level as '- name:' must not split the block.""" + manifest = ( + " - name: myproject\n" + " url: https://example.com\n" + " # revision: old-rev <- comment at item-indent\n" + " branch: main\n" + ) + lines = manifest.splitlines(keepends=True) + start, end, item_indent = _find_project_block(lines, "myproject") + block_text = "".join(lines[start:end]) + assert "branch: main" in block_text + + +# --- _set_simple_field_in_block -------------------------------------------- + + +def test_set_simple_field_updates_existing() -> None: + block = [ + " - name: myproject\n", + " revision: oldrev\n", + " url: https://example.com\n", + ] + result = _set_simple_field_in_block(block, 6, "revision", "newrev") + assert any("revision: newrev" in l for l in result) + assert not any("oldrev" in l for l in result) + + +def test_set_simple_field_inserts_after_name() -> None: + block = [ + " - name: myproject\n", + " url: https://example.com\n", + ] + result = _set_simple_field_in_block(block, 6, "revision", "abc123") + assert result[1] == " revision: abc123\n" + assert result[2] == " url: https://example.com\n" + + +# --- _find_field ----------------------------------------------------------- + + +def test_find_field_returns_index_when_present() -> None: + block = [ + " - name: myproject\n", + " revision: abc\n", + " url: https://example.com\n", + ] + assert _find_field(block, "revision", 6) == 1 + + +def test_find_field_returns_none_when_absent() -> None: + block = [ + " - name: myproject\n", + " url: https://example.com\n", + ] + assert _find_field(block, "revision", 6) is None + + +def test_find_field_respects_start_end_bounds() -> None: + block = [ + " - name: myproject\n", + " revision: abc\n", + " url: https://example.com\n", + ] + # revision is at index 1, but we start searching at index 2 — should not find it + assert _find_field(block, "revision", 6, start=2) is None + # and with end=1 it is also excluded + assert _find_field(block, "revision", 6, start=0, end=1) is None + + +def test_find_field_ignores_wrong_indent() -> None: + block = [ + " - name: myproject\n", + " revision: abc\n", # indent 4, not 6 + " url: https://example.com\n", + ] + assert _find_field(block, "revision", 6) is None + + +def test_find_field_skips_commented_out_field() -> None: + """A '# field: value' line must not be matched — field is considered absent.""" + block = [ + " - name: myproject\n", + " # branch: main\n", # commented-out + " url: https://example.com\n", + ] + assert _find_field(block, "branch", 6) is None + + +def test_find_field_skips_commented_field_no_space() -> None: + """'#field: value' (no space after #) must also not be matched.""" + block = [ + " - name: myproject\n", + " #branch: main\n", + " url: https://example.com\n", + ] + assert _find_field(block, "branch", 6) is None + + +def test_find_field_finds_real_field_past_commented_one() -> None: + """The live field after a commented-out duplicate is matched.""" + block = [ + " - name: myproject\n", + " # branch: old\n", + " branch: new\n", + ] + assert _find_field(block, "branch", 6) == 2 + + +# --- _update_value --------------------------------------------------------- + + +def test_update_value_replaces_inline_value() -> None: + block = [ + " - name: myproject\n", + " revision: old\n", + ] + result = _update_value(block, 1, "revision", "new") + assert result[1] == " revision: new\n" + assert result[0] == block[0] # untouched + + +def test_update_value_preserves_indent() -> None: + block = [" revision: old\n"] + result = _update_value(block, 0, "revision", "newrev") + assert result[0].startswith(" revision:") + + +def test_update_value_preserves_trailing_comment() -> None: + block = [" branch: main # track the integration branch\n"] + result = _update_value(block, 0, "branch", "main") + assert result[0] == " branch: main # track the integration branch\n" + + +# --- _append_field --------------------------------------------------------- + + +def test_append_field_inserts_at_position() -> None: + block = [ + " - name: myproject\n", + " url: https://example.com\n", + ] + result = _append_field(block, "revision", "abc123", 6, after=1) + assert result[1] == " revision: abc123\n" + assert result[2] == " url: https://example.com\n" + + +def test_append_field_empty_value_omits_value_part() -> None: + block = [" - name: myproject\n"] + result = _append_field(block, "integrity", "", 6, after=1) + assert result[1] == " integrity:\n" + + +# --- _set_integrity_hash_in_block ------------------------------------------ + + +def test_set_integrity_hash_inserts_when_absent() -> None: + block = [ + " - name: myproject\n", + " url: https://example.com/archive.tar.gz\n", + " vcs: archive\n", + ] + result = _set_integrity_hash_in_block(block, 6, "sha256:abc123") + joined = "".join(result) + assert "integrity:" in joined + assert "hash: sha256:abc123" in joined + + +def test_set_integrity_hash_updates_existing_hash() -> None: + block = [ + " - name: myproject\n", + " url: https://example.com/archive.tar.gz\n", + " vcs: archive\n", + " integrity:\n", + " hash: sha256:old\n", + ] + result = _set_integrity_hash_in_block(block, 6, "sha256:new") + joined = "".join(result) + assert "hash: sha256:new" in joined + assert "sha256:old" not in joined + + +# --- _update_project_version_in_text --------------------------------------- + + +def _make_project(name: str, **kwargs) -> ProjectEntry: + """Helper: build a ProjectEntry with the given fields.""" + data = {"name": name} + data.update(kwargs) + return ProjectEntry(data) # type: ignore[arg-type] + + +def test_update_adds_revision_preserves_layout() -> None: + text = _SIMPLE_MANIFEST + project = _make_project("myproject", revision="deadbeef" * 5, branch="main") + result = _update_project_version_in_text(text, project) + # The layout (4-space indent for "- name:") is preserved. + assert " - name: myproject" in result + # The revision is inserted. + assert "revision:" in result + # Original url line is still there. + assert "url: https://example.com/myproject" in result + + +def test_update_second_project_does_not_touch_first() -> None: + text = _TWO_PROJECT_MANIFEST + project = _make_project( + "second", revision="abc123def456" * 3 + "abcd", branch="develop" + ) + result = _update_project_version_in_text(text, project) + + # Verify the "first" block is unchanged by re-parsing and checking project count + # with "revision" in the result. + assert "revision:" in result # second project got a revision + + # The "first" project block should have no revision field: find its block boundaries + lines = result.splitlines(keepends=True) + first_start, first_end, _ = _find_project_block(lines, "first") + first_block_text = "".join(lines[first_start:first_end]) + assert "revision" not in first_block_text + + +def test_update_stale_version_keys_removed_when_project_has_none() -> None: + """Stale version keys in the manifest are removed when the project carries none.""" + text = _SIMPLE_MANIFEST # contains branch: main + project = _make_project("myproject") # no version fields at all + result = _update_project_version_in_text(text, project) + assert "branch:" not in result + assert "revision:" not in result + assert "tag:" not in result + # Non-version fields are untouched + assert "url:" in result + + +def test_update_integer_like_revision_is_quoted() -> None: + """SVN revisions look like integers and must be YAML-quoted.""" + text = _SIMPLE_MANIFEST + project = _make_project("myproject", revision="176", branch="trunk") + result = _update_project_version_in_text(text, project) + # The scalar '176' must be quoted in YAML to round-trip as a string. + assert "revision: '176'" in result + + +def test_update_preserves_inline_comments_on_fields() -> None: + """Inline comments on existing fields survive an in-place freeze.""" + text = ( + "manifest:\n" + " version: '0.0'\n" + " projects:\n" + " - name: myproject\n" + " url: https://example.com # source mirror\n" + " branch: main # track the integration branch\n" + ) + project = _make_project("myproject", revision="deadbeef" * 5, branch="main") + result = _update_project_version_in_text(text, project) + assert "url: https://example.com # source mirror" in result + assert "branch: main # track the integration branch" in result + assert "revision:" in result + + +def test_update_commented_out_field_is_appended_not_matched() -> None: + """A commented-out version field must be treated as absent; the real value is appended.""" + text = ( + "manifest:\n" + " version: '0.0'\n" + " projects:\n" + " - name: myproject\n" + " url: https://example.com\n" + " # branch: old-branch\n" + " branch: main\n" + ) + project = _make_project("myproject", revision="deadbeef" * 5, branch="main") + result = _update_project_version_in_text(text, project) + # Commented-out line must survive unchanged + assert " # branch: old-branch" in result + # The live branch line keeps its comment-free value + assert " branch: main" in result + # revision is inserted as a new field, not used to update the comment + assert result.count("branch:") == 2 # comment + live field + assert "revision:" in result + + +def test_update_comment_at_item_indent_does_not_break_block() -> None: + """A comment at item-indent level inside a block must not end the block early.""" + text = ( + "manifest:\n" + " version: '0.0'\n" + " projects:\n" + " - name: myproject\n" + " url: https://example.com\n" + " # old pinned version\n" + " branch: main\n" + ) + project = _make_project("myproject", revision="deadbeef" * 5, branch="main") + result = _update_project_version_in_text(text, project) + # The comment at item-indent is preserved verbatim + assert " # old pinned version" in result + # branch is updated in-place, not duplicated + assert result.count("branch:") == 1 + assert "revision:" in result + + +def test_update_stale_revision_removed_when_project_switches_to_tag() -> None: + """When a project changes from revision to tag, the stale revision key is deleted.""" + text = ( + "manifest:\n" + " version: '0.0'\n" + " projects:\n" + " - name: myproject\n" + " url: https://example.com\n" + " revision: deadbeefdeadbeef\n" + " branch: main\n" + ) + project = _make_project("myproject", tag="v1.0.0") + result = _update_project_version_in_text(text, project) + assert "tag: v1.0.0" in result + assert "revision:" not in result + assert "branch:" not in result + + +# --------------------------------------------------------------------------- +# EOL preservation +# --------------------------------------------------------------------------- + + +def test_update_value_preserves_crlf() -> None: + block = [" revision: old\r\n"] + result = _update_value(block, 0, "revision", "new") + assert result[0].endswith("\r\n") + assert result[0] == " revision: new\r\n" + + +def test_append_field_inherits_crlf_from_block() -> None: + block = [ + " - name: myproject\r\n", + " url: https://example.com\r\n", + ] + result = _append_field(block, "revision", "abc", 6, after=1) + assert result[1].endswith("\r\n"), "inserted line should use CRLF to match block" + + +def test_append_field_defaults_to_lf_on_empty_block() -> None: + result = _append_field([], "revision", "abc", 6, after=0) + assert result[0].endswith("\n") + assert not result[0].endswith("\r\n") + + +# --------------------------------------------------------------------------- +# integrity sub_end comment fix +# --------------------------------------------------------------------------- + + +def test_integrity_hash_updated_past_comment_in_integrity_block() -> None: + """A comment inside an integrity: block must not cut off the hash: search.""" + block = [ + " - name: myproject\n", + " integrity:\n", + " # checksum added by CI\n", + " hash: sha256:oldvalue\n", + " url: https://example.com\n", + ] + result = _set_integrity_hash_in_block(block, 6, "sha256:newvalue") + joined = "".join(result) + assert "hash: sha256:newvalue" in joined + assert "sha256:oldvalue" not in joined + assert "# checksum added by CI" in joined