From 9e8634d48eb2518bf6d3db2b45af4ee6c65fadd0 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 22 Feb 2026 17:05:49 +0000 Subject: [PATCH 01/29] dftech add command Fixes #25 --- CHANGELOG.rst | 1 + dfetch/__main__.py | 2 + dfetch/commands/add.py | 154 +++++++++++++++++++++++++++ dfetch/log.py | 7 ++ dfetch/manifest/manifest.py | 19 ++++ doc/asciicasts/add.cast | 81 ++++++++++++++ doc/generate-casts/add-demo.sh | 24 +++++ doc/generate-casts/generate-casts.sh | 1 + doc/manual.rst | 12 +++ 9 files changed, 301 insertions(+) create mode 100644 dfetch/commands/add.py create mode 100644 doc/asciicasts/add.cast create mode 100755 doc/generate-casts/add-demo.sh diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b3391a48..db9f4ac0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -12,6 +12,7 @@ Release 0.13.0 (unreleased) * Make ``dfetch report`` output more yaml-like (#1017) * Don't break when importing submodules with space in path (#1017) * Warn when ``src:`` glob pattern matches multiple directories (#1017) +* Introduce new ``add`` command with optional interactive mode (``-i``) (#25) Release 0.12.1 (released 2026-02-24) ==================================== diff --git a/dfetch/__main__.py b/dfetch/__main__.py index 8edfd8c4..0eb9984b 100644 --- a/dfetch/__main__.py +++ b/dfetch/__main__.py @@ -9,6 +9,7 @@ from rich.console import Console +import dfetch.commands.add import dfetch.commands.check import dfetch.commands.diff import dfetch.commands.environment @@ -43,6 +44,7 @@ def create_parser() -> argparse.ArgumentParser: parser.set_defaults(func=_help) subparsers = parser.add_subparsers(help="commands") + dfetch.commands.add.Add.create_menu(subparsers) dfetch.commands.check.Check.create_menu(subparsers) dfetch.commands.diff.Diff.create_menu(subparsers) dfetch.commands.environment.Environment.create_menu(subparsers) diff --git a/dfetch/commands/add.py b/dfetch/commands/add.py new file mode 100644 index 00000000..72ca6c60 --- /dev/null +++ b/dfetch/commands/add.py @@ -0,0 +1,154 @@ +"""*Dfetch* can add projects through the cli to the manifest. + +Sometimes you want to add a project to your manifest, but you don't want to +edit the manifest by hand. With ``dfetch add`` you can add a project to your manifest +through the command line. This will add the project to your manifest and fetch it +to your disk. You can also specify a version to add, or it will be added with the +latest version available. + +""" + +import argparse +import os +from collections.abc import Sequence +from pathlib import Path + +from rich.prompt import Prompt + +import dfetch.commands.command +import dfetch.manifest.project +import dfetch.project +from dfetch.log import get_logger +from dfetch.manifest.manifest import append_entry_manifest_file +from dfetch.manifest.project import ProjectEntry, ProjectEntryDict +from dfetch.manifest.remote import Remote +from dfetch.project import create_sub_project, create_super_project +from dfetch.util.purl import remote_url_to_purl + +logger = get_logger(__name__) + + +class Add(dfetch.commands.command.Command): + """Add a new project to the manifest. + + Add a new project to the manifest. + """ + + @staticmethod + def create_menu(subparsers: dfetch.commands.command.SubparserActionType) -> None: + """Add the parser menu for this action.""" + parser = dfetch.commands.command.Command.parser(subparsers, Add) + + parser.add_argument( + "remote_url", + metavar="", + type=str, + nargs=1, + help="Remote URL of project to add", + ) + + parser.add_argument( + "-f", + "--force", + action="store_true", + help="Always perform addition.", + ) + + def __call__(self, args: argparse.Namespace) -> None: + """Perform the add.""" + superproject = create_super_project() + + purl = remote_url_to_purl(args.remote_url[0]) + project_entry = ProjectEntry( + ProjectEntryDict(name=purl.name, url=args.remote_url[0]) + ) + + # Determines VCS type tries to reach remote + subproject = create_sub_project(project_entry) + + if project_entry.name in [ + project.name for project in superproject.manifest.projects + ]: + raise RuntimeError( + f"Project with name {project_entry.name} already exists in manifest!" + ) + + destination = _guess_destination( + project_entry.name, superproject.manifest.projects + ) + + if remote_to_use := _determine_remote( + superproject.manifest.remotes, project_entry.remote_url + ): + logger.debug( + f"Remote URL {project_entry.remote_url} matches remote {remote_to_use.name}" + ) + + project_entry = ProjectEntry( + ProjectEntryDict( + name=project_entry.name, + url=(project_entry.remote_url), + branch=subproject.get_default_branch(), + dst=destination, + ), + ) + if remote_to_use: + project_entry.set_remote(remote_to_use) + + logger.print_overview( + project_entry.name, + "Will add following entry to manifest:", + project_entry.as_yaml(), + ) + + if not args.force and not confirm(): + logger.print_warning_line(project_entry.name, "Aborting add of project") + return + + append_entry_manifest_file( + (superproject.root_directory / superproject.manifest.path).absolute(), + project_entry, + ) + + logger.print_info_line(project_entry.name, "Added project to manifest") + + +def confirm() -> bool: + """Show a confirmation prompt to the user before adding the project.""" + return ( + Prompt.ask("Add project to manifest?", choices=["y", "n"], default="y") == "y" + ) + + +def _check_name_uniqueness( + project_name: str, manifest_projects: Sequence[ProjectEntry] +) -> None: + """Validate that the project name is not already used in the manifest.""" + if project_name in [project.name for project in manifest_projects]: + raise RuntimeError( + f"Project with name {project_name} already exists in manifest!" + ) + + +def _guess_destination( + project_name: str, manifest_projects: Sequence[ProjectEntry] +) -> str: + """Guess the destination of the project based on the remote URL and existing projects.""" + if len(manifest_projects) <= 1: + return "" + + common_path = os.path.commonpath( + [project.destination for project in manifest_projects] + ) + + if common_path and common_path != os.path.sep: + return (Path(common_path) / project_name).as_posix() + return "" + + +def _determine_remote(remotes: Sequence[Remote], remote_url: str) -> Remote | None: + """Determine if the remote URL matches any of the remotes in the manifest.""" + for remote in remotes: + if remote_url.startswith(remote.url): + return remote + return None diff --git a/dfetch/log.py b/dfetch/log.py index 77a92061..6d9e05d8 100644 --- a/dfetch/log.py +++ b/dfetch/log.py @@ -103,6 +103,13 @@ def print_warning_line(self, name: str, info: str) -> None: line = markup_escape(info).replace("\n", "\n ") self.info(f" [bold bright_yellow]> {line}[/bold bright_yellow]") + def print_overview(self, name: str, title: str, info: dict[str, Any]) -> None: + """Print an overview of fields.""" + self.print_info_line(name, title) + for key, value in info.items(): + key += ":" + self.info(f" [blue]{key:20s}[/blue][white] {value}[/white]") + def print_title(self) -> None: """Print the DFetch tool title and version.""" self.info(f"[bold blue]Dfetch ({__version__})[/bold blue]") diff --git a/dfetch/manifest/manifest.py b/dfetch/manifest/manifest.py index ec084c28..dd06ebad 100644 --- a/dfetch/manifest/manifest.py +++ b/dfetch/manifest/manifest.py @@ -24,6 +24,7 @@ import re from collections.abc import Sequence from dataclasses import dataclass +from pathlib import Path from typing import IO, Any import yaml @@ -391,3 +392,21 @@ def write_line_break(self, data: Any = None) -> None: super().write_line_break() # type: ignore[unused-ignore, no-untyped-call] self._last_additional_break = len(self.indents) + + +def append_entry_manifest_file( + manifest_path: str | Path, + project_entry: ProjectEntry, +) -> None: + """Add the project entry to the manifest file.""" + with Path(manifest_path).open("a", encoding="utf-8") as manifest_file: + + new_entry = yaml.dump( + [project_entry.as_yaml()], + sort_keys=False, + line_break=os.linesep, + indent=2, + ) + manifest_file.write("\n") + for line in new_entry.splitlines(): + manifest_file.write(f" {line}\n") diff --git a/doc/asciicasts/add.cast b/doc/asciicasts/add.cast new file mode 100644 index 00000000..01f24fe1 --- /dev/null +++ b/doc/asciicasts/add.cast @@ -0,0 +1,81 @@ +{"version": 2, "width": 175, "height": 16, "timestamp": 1771797309, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[0.494902, "o", "\u001b[H\u001b[2J\u001b[3J"] +[0.497658, "o", "$ "] +[1.500235, "o", "\u001b["] +[1.680644, "o", "1m"] +[1.770828, "o", "ca"] +[1.860948, "o", "t "] +[1.951108, "o", "dfe"] +[2.041269, "o", "tc"] +[2.131404, "o", "h."] +[2.221555, "o", "ya"] +[2.311663, "o", "ml"] +[2.401896, "o", "\u001b[0"] +[2.582125, "o", "m"] +[3.583662, "o", "\r\n"] +[3.585601, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v3.4 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n"] +[3.589043, "o", "$ "] +[4.591589, "o", "\u001b"] +[4.771858, "o", "[1"] +[4.861983, "o", "md"] +[4.966335, "o", "fe"] +[5.043363, "o", "tc"] +[5.133493, "o", "h "] +[5.223631, "o", "ad"] +[5.31377, "o", "d "] +[5.403904, "o", "-f"] +[5.494126, "o", " h"] +[5.674549, "o", "t"] +[5.764721, "o", "tp"] +[5.854808, "o", "s:"] +[5.94494, "o", "//"] +[6.035324, "o", "gi"] +[6.125438, "o", "th"] +[6.215568, "o", "ub"] +[6.305716, "o", ".c"] +[6.39586, "o", "om"] +[6.576041, "o", "/d"] +[6.666201, "o", "f"] +[6.756456, "o", "et"] +[6.846865, "o", "ch"] +[6.936987, "o", "-o"] +[7.02721, "o", "rg"] +[7.11736, "o", "/d"] +[7.20751, "o", "fe"] +[7.297833, "o", "tc"] +[7.47816, "o", "h."] +[7.56839, "o", "gi"] +[7.658512, "o", "t"] +[7.748652, "o", "\u001b["] +[7.839, "o", "0m"] +[8.840408, "o", "\r\n"] +[9.321142, "o", "\u001b[1;34mDfetch (0.12.0)\u001b[0m \r\n"] +[9.632322, "o", " \u001b[1;92mdfetch:\u001b[0m \r\n"] +[9.632906, "o", " \u001b[1;34m> Will add following entry to manifest:\u001b[0m \r\n"] +[9.633466, "o", " \u001b[34mname: \u001b[0m\u001b[37m dfetch\u001b[0m \r\n"] +[9.633962, "o", " \u001b[34mremote: \u001b[0m\u001b[37m github\u001b[0m \r\n"] +[9.63445, "o", " \u001b[34mbranch: \u001b[0m\u001b[37m main\u001b[0m \r\n"] +[9.634929, "o", " \u001b[34mrepo-path: \u001b[0m\u001b[37m dfetch-org/dfetch.git\u001b[0m \r\n"] +[9.63603, "o", " \u001b[1;34m> Added project to manifest\u001b[0m \r\n"] +[9.698037, "o", "$ "] +[10.700827, "o", "\u001b["] +[10.881118, "o", "1m"] +[10.971276, "o", "ca"] +[11.061415, "o", "t "] +[11.151604, "o", "df"] +[11.241877, "o", "et"] +[11.332175, "o", "ch"] +[11.422206, "o", ".y"] +[11.512344, "o", "am"] +[11.60248, "o", "l\u001b"] +[11.782771, "o", "[0"] +[11.872931, "o", "m"] +[12.874505, "o", "\r\n"] +[12.87646, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v3.4 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n\r\n - name: dfetch\r\n remote: github\r\n branch: main\r\n repo-path: dfetch-org/dfetch.git\r\n"] +[15.8913, "o", "$ "] +[15.891782, "o", "\u001b["] +[16.071982, "o", "1m"] +[16.162128, "o", "\u001b["] +[16.252276, "o", "0m"] +[16.252759, "o", "\r\n"] +[16.254621, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] diff --git a/doc/generate-casts/add-demo.sh b/doc/generate-casts/add-demo.sh new file mode 100755 index 00000000..e67e2822 --- /dev/null +++ b/doc/generate-casts/add-demo.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +source ./demo-magic/demo-magic.sh + +PROMPT_TIMEOUT=1 + +# Copy example manifest +mkdir add +pushd add +dfetch init +clear + +# Run the command +pe "cat dfetch.yaml" +pe "dfetch add -f https://github.com/dfetch-org/dfetch.git" +pe "cat dfetch.yaml" + +PROMPT_TIMEOUT=3 +wait + +pei "" + +popd +rm -rf add diff --git a/doc/generate-casts/generate-casts.sh b/doc/generate-casts/generate-casts.sh index 1d2d615b..bdc1ee24 100755 --- a/doc/generate-casts/generate-casts.sh +++ b/doc/generate-casts/generate-casts.sh @@ -13,6 +13,7 @@ rm -rf ../asciicasts/* asciinema rec --overwrite -c "./basic-demo.sh" ../asciicasts/basic.cast asciinema rec --overwrite -c "./init-demo.sh" ../asciicasts/init.cast +asciinema rec --overwrite -c "./add-demo.sh" ../asciicasts/add.cast asciinema rec --overwrite -c "./environment-demo.sh" ../asciicasts/environment.cast asciinema rec --overwrite -c "./validate-demo.sh" ../asciicasts/validate.cast asciinema rec --overwrite -c "./check-demo.sh" ../asciicasts/check.cast diff --git a/doc/manual.rst b/doc/manual.rst index 46a80ecc..28233537 100644 --- a/doc/manual.rst +++ b/doc/manual.rst @@ -173,6 +173,18 @@ Validate .. automodule:: dfetch.commands.validate +Add +--- +.. argparse:: + :module: dfetch.__main__ + :func: create_parser + :prog: dfetch + :path: add + +.. asciinema:: asciicasts/add.cast + +.. automodule:: dfetch.commands.add + CLI Cheatsheet -------------- From b123c89f5d74a249f4d04a7cc52e5312c0669629 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 11:27:57 +0000 Subject: [PATCH 02/29] Add interactive manifest mode (-i/--interactive) to dfetch add command Implements the -i/--interactive flag for `dfetch add` that guides the user step-by-step through each manifest field: - name (default: repo name from URL) - dst (default: guessed from common prefix of existing projects) - version type: branch / tag / revision (with list of available branches/tags) - src (optional sub-path or glob) Also includes: - list_of_branches() method added to GitRemote, GitSubProject and SubProject base - list_of_tags() public wrapper on SubProject base class - Smarter destination guessing: works with a single existing project too - Comprehensive behave feature tests (6 scenarios, including interactive flow) - 21 pytest unit tests covering all code paths - Updated user documentation in manual.rst and CLI cheatsheet https://claude.ai/code/session_01XnrSn9ar6cLpL6Y2qDDGXd --- dfetch/commands/add.py | 314 +++++++++++++--- dfetch/project/gitsubproject.py | 4 + dfetch/project/subproject.py | 8 + dfetch/vcs/git.py | 10 + doc/manual.rst | 12 + features/add-project-through-cli.feature | 138 +++++++ features/steps/add_steps.py | 78 ++++ tests/test_add.py | 458 +++++++++++++++++++++++ 8 files changed, 975 insertions(+), 47 deletions(-) create mode 100644 features/add-project-through-cli.feature create mode 100644 features/steps/add_steps.py create mode 100644 tests/test_add.py diff --git a/dfetch/commands/add.py b/dfetch/commands/add.py index 72ca6c60..2c361203 100644 --- a/dfetch/commands/add.py +++ b/dfetch/commands/add.py @@ -1,11 +1,48 @@ -"""*Dfetch* can add projects through the cli to the manifest. +"""*Dfetch* can add projects to the manifest through the CLI. Sometimes you want to add a project to your manifest, but you don't want to -edit the manifest by hand. With ``dfetch add`` you can add a project to your manifest -through the command line. This will add the project to your manifest and fetch it -to your disk. You can also specify a version to add, or it will be added with the -latest version available. +edit the manifest by hand. With ``dfetch add`` you can add a project to your +manifest through the command line. +Non-interactive mode +-------------------- +In the simplest form you just provide the URL:: + + dfetch add https://github.com/some-org/some-repo.git + +Dfetch will fetch the remote repository metadata (branches and tags), pick +the default branch, guess a sensible destination path based on where your +existing projects live, and append the new entry to ``dfetch.yaml``. + +A confirmation prompt is shown before writing. Pass ``--force`` (or ``-f``) +to skip it:: + + dfetch add -f https://github.com/some-org/some-repo.git + +Interactive mode +---------------- +With ``--interactive`` (or ``-i``) dfetch guides you through every manifest +field step by step:: + + dfetch add -i https://github.com/some-org/some-repo.git + +You will be prompted for: + +* **name** – a human-readable project name (default: repository name from URL) +* **dst** – local destination directory (default: guessed from existing + projects) +* **branch / tag / revision** – version to fetch (default: default branch of + the remote) +* **src** – sub-path or glob inside the remote to copy (optional) + +All prompts show a sensible default so you can just press *Enter* to accept +it. When a list of choices is available (e.g. branches or tags) the list is +displayed so you can easily pick one. + +The entry is appended at the end of the manifest and *not* fetched to disk; +run ``dfetch update`` afterwards to materialise the dependency. + +.. scenario-include:: ../features/add-project-through-cli.feature """ import argparse @@ -23,7 +60,8 @@ from dfetch.manifest.project import ProjectEntry, ProjectEntryDict from dfetch.manifest.remote import Remote from dfetch.project import create_sub_project, create_super_project -from dfetch.util.purl import remote_url_to_purl +from dfetch.project.subproject import SubProject +from dfetch.util.purl import vcs_url_to_purl logger = get_logger(__name__) @@ -31,7 +69,8 @@ class Add(dfetch.commands.command.Command): """Add a new project to the manifest. - Add a new project to the manifest. + Append a project entry to the manifest without fetching. + Use -i/--interactive to be guided through each field. """ @staticmethod @@ -44,56 +83,79 @@ def create_menu(subparsers: dfetch.commands.command.SubparserActionType) -> None metavar="", type=str, nargs=1, - help="Remote URL of project to add", + help="Remote URL of the repository to add.", ) parser.add_argument( "-f", "--force", action="store_true", - help="Always perform addition.", + help="Skip the confirmation prompt.", + ) + + parser.add_argument( + "-i", + "--interactive", + action="store_true", + help=( + "Interactively guide through each manifest field. " + "Dfetch will fetch the remote branch/tag list and " + "let you confirm or override every value." + ), ) def __call__(self, args: argparse.Namespace) -> None: """Perform the add.""" superproject = create_super_project() - purl = remote_url_to_purl(args.remote_url[0]) - project_entry = ProjectEntry( - ProjectEntryDict(name=purl.name, url=args.remote_url[0]) - ) + remote_url: str = args.remote_url[0] + purl = vcs_url_to_purl(remote_url) - # Determines VCS type tries to reach remote - subproject = create_sub_project(project_entry) + # Build a minimal entry so we can probe the remote. + probe_entry = ProjectEntry(ProjectEntryDict(name=purl.name, url=remote_url)) - if project_entry.name in [ - project.name for project in superproject.manifest.projects - ]: - raise RuntimeError( - f"Project with name {project_entry.name} already exists in manifest!" - ) + # Determines VCS type, tries to reach remote. + subproject = create_sub_project(probe_entry) - destination = _guess_destination( - project_entry.name, superproject.manifest.projects - ) + _check_name_uniqueness(probe_entry.name, superproject.manifest.projects) - if remote_to_use := _determine_remote( - superproject.manifest.remotes, project_entry.remote_url - ): + remote_to_use = _determine_remote( + superproject.manifest.remotes, probe_entry.remote_url + ) + if remote_to_use: logger.debug( - f"Remote URL {project_entry.remote_url} matches remote {remote_to_use.name}" + f"Remote URL {probe_entry.remote_url} matches remote {remote_to_use.name}" ) - project_entry = ProjectEntry( - ProjectEntryDict( - name=project_entry.name, - url=(project_entry.remote_url), - branch=subproject.get_default_branch(), - dst=destination, - ), + guessed_dst = _guess_destination( + probe_entry.name, superproject.manifest.projects ) - if remote_to_use: - project_entry.set_remote(remote_to_use) + default_branch = subproject.get_default_branch() + + if args.interactive: + project_entry = _interactive_flow( + remote_url=remote_url, + default_name=probe_entry.name, + default_dst=guessed_dst, + default_branch=default_branch, + subproject=subproject, + remote_to_use=remote_to_use, + existing_projects=superproject.manifest.projects, + ) + else: + project_entry = ProjectEntry( + ProjectEntryDict( + name=probe_entry.name, + url=remote_url, + branch=default_branch, + dst=guessed_dst, + ), + ) + if remote_to_use: + project_entry.set_remote(remote_to_use) + + if project_entry is None: + return logger.print_overview( project_entry.name, @@ -101,7 +163,7 @@ def __call__(self, args: argparse.Namespace) -> None: project_entry.as_yaml(), ) - if not args.force and not confirm(): + if not args.force and not _confirm(): logger.print_warning_line(project_entry.name, "Aborting add of project") return @@ -113,7 +175,155 @@ def __call__(self, args: argparse.Namespace) -> None: logger.print_info_line(project_entry.name, "Added project to manifest") -def confirm() -> bool: +# --------------------------------------------------------------------------- +# Interactive flow +# --------------------------------------------------------------------------- + + +def _interactive_flow( + remote_url: str, + default_name: str, + default_dst: str, + default_branch: str, + subproject: SubProject, + remote_to_use: Remote | None, + existing_projects: Sequence[ProjectEntry], +) -> ProjectEntry | None: + """Guide the user through every manifest field and return a ``ProjectEntry``. + + Returns ``None`` when the user aborts the wizard. + """ + logger.info("[bold blue]--- Interactive add wizard ---[/bold blue]") + + # --- name --- + name = _ask_name(default_name, existing_projects) + + # --- dst --- + dst = _ask_dst(name, default_dst) + + # --- version: branch / tag / revision --- + branches = subproject.list_of_branches() + tags = subproject.list_of_tags() + + version_type, version_value = _ask_version(default_branch, branches, tags) + + # --- src (optional) --- + src = _ask_src() + + # Build the entry dict. + entry_dict: ProjectEntryDict = ProjectEntryDict( + name=name, + url=remote_url, + dst=dst, + ) + + if version_type == "branch": + entry_dict["branch"] = version_value + elif version_type == "tag": + entry_dict["tag"] = version_value + elif version_type == "revision": + entry_dict["revision"] = version_value + + if src: + entry_dict["src"] = src + + project_entry = ProjectEntry(entry_dict) + if remote_to_use: + project_entry.set_remote(remote_to_use) + + return project_entry + + +def _ask_name(default: str, existing_projects: Sequence[ProjectEntry]) -> str: + """Prompt for the project name, re-asking if the name already exists.""" + existing_names = {p.name for p in existing_projects} + while True: + name = Prompt.ask( + " [bold]Project name[/bold]", + default=default, + ) + if name in existing_names: + logger.warning( + f"A project named '{name}' already exists in the manifest. " + "Please choose a different name." + ) + else: + return name + + +def _ask_dst(name: str, default: str) -> str: + """Prompt for the destination path.""" + suggested = default or name + dst = Prompt.ask( + " [bold]Destination path[/bold] (relative to manifest)", + default=suggested, + ) + return dst + + +def _ask_version( + default_branch: str, + branches: list[str], + tags: list[str], +) -> tuple[str, str]: + """Prompt for branch, tag, or revision. + + Returns a ``(type, value)`` tuple where *type* is one of ``"branch"``, + ``"tag"``, or ``"revision"``. + """ + if branches: + logger.info( + " [blue]Available branches:[/blue] " + + ", ".join(f"[green]{b}[/green]" for b in branches[:10]) + + (" …" if len(branches) > 10 else "") + ) + if tags: + logger.info( + " [blue]Available tags:[/blue] " + + ", ".join(f"[green]{t}[/green]" for t in tags[:10]) + + (" …" if len(tags) > 10 else "") + ) + + version_type = Prompt.ask( + " [bold]Version type[/bold]", + choices=["branch", "tag", "revision"], + default="branch", + ) + + if version_type == "branch": + value = Prompt.ask( + " [bold]Branch[/bold]", + default=default_branch, + ) + elif version_type == "tag": + default_tag = tags[0] if tags else "" + value = Prompt.ask( + " [bold]Tag[/bold]", + default=default_tag, + ) + else: + value = Prompt.ask( + " [bold]Revision (full SHA)[/bold]", + ) + + return version_type, value + + +def _ask_src() -> str: + """Prompt for an optional ``src:`` sub-path or glob.""" + src = Prompt.ask( + " [bold]Source sub-path or glob[/bold] (leave empty to fetch entire repo)", + default="", + ) + return src.strip() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _confirm() -> bool: """Show a confirmation prompt to the user before adding the project.""" return ( Prompt.ask("Add project to manifest?", choices=["y", "n"], default="y") == "y" @@ -123,31 +333,41 @@ def confirm() -> bool: def _check_name_uniqueness( project_name: str, manifest_projects: Sequence[ProjectEntry] ) -> None: - """Validate that the project name is not already used in the manifest.""" + """Raise if *project_name* is already used in the manifest.""" if project_name in [project.name for project in manifest_projects]: raise RuntimeError( - f"Project with name {project_name} already exists in manifest!" + f"Project with name '{project_name}' already exists in manifest!" ) def _guess_destination( project_name: str, manifest_projects: Sequence[ProjectEntry] ) -> str: - """Guess the destination of the project based on the remote URL and existing projects.""" - if len(manifest_projects) <= 1: + """Guess the destination based on the common prefix of existing projects. + + With two or more existing projects the common parent directory is used. + With a single existing project its parent directory is used (if any). + """ + destinations = [p.destination for p in manifest_projects if p.destination] + if not destinations: return "" - common_path = os.path.commonpath( - [project.destination for project in manifest_projects] - ) + common_path = os.path.commonpath(destinations) if common_path and common_path != os.path.sep: + # For a single project whose name *is* the destination the common_path + # equals that destination, so we take its parent directory instead. + if len(destinations) == 1: + parent = str(Path(common_path).parent) + if parent and parent != ".": + return (Path(parent) / project_name).as_posix() + return "" return (Path(common_path) / project_name).as_posix() return "" def _determine_remote(remotes: Sequence[Remote], remote_url: str) -> Remote | None: - """Determine if the remote URL matches any of the remotes in the manifest.""" + """Return the first remote whose base URL is a prefix of *remote_url*.""" for remote in remotes: if remote_url.startswith(remote.url): return remote diff --git a/dfetch/project/gitsubproject.py b/dfetch/project/gitsubproject.py index 21ee85d3..3b4428c4 100644 --- a/dfetch/project/gitsubproject.py +++ b/dfetch/project/gitsubproject.py @@ -40,6 +40,10 @@ def _list_of_tags(self) -> list[str]: """Get list of all available tags.""" return [str(tag) for tag in self._remote_repo.list_of_tags()] + def list_of_branches(self) -> list[str]: + """Get list of all available branches.""" + return [str(branch) for branch in self._remote_repo.list_of_branches()] + @staticmethod def revision_is_enough() -> bool: """See if this VCS can uniquely distinguish branch with revision only.""" diff --git a/dfetch/project/subproject.py b/dfetch/project/subproject.py index 36ccff3e..0812d264 100644 --- a/dfetch/project/subproject.py +++ b/dfetch/project/subproject.py @@ -400,6 +400,14 @@ def _fetch_impl(self, version: Version) -> tuple[Version, list[Dependency]]: def get_default_branch(self) -> str: """Get the default branch of this repository.""" + def list_of_branches(self) -> list[str]: + """Get list of all available branches. Override in VCS-specific subclasses.""" + return [] + + def list_of_tags(self) -> list[str]: + """Get list of all available tags (public wrapper around ``_list_of_tags``).""" + return self._list_of_tags() + def freeze_project(self, project: ProjectEntry) -> str | None: """Freeze *project* to its current on-disk version. diff --git a/dfetch/vcs/git.py b/dfetch/vcs/git.py index a9be2869..47d0236a 100644 --- a/dfetch/vcs/git.py +++ b/dfetch/vcs/git.py @@ -148,6 +148,16 @@ def list_of_tags(self) -> list[str]: if reference.startswith("refs/tags/") ] + def list_of_branches(self) -> list[str]: + """Get list of all available branches.""" + info = self._ls_remote(self._remote) + + return [ + reference.replace("refs/heads/", "") + for reference, _ in info.items() + if reference.startswith("refs/heads/") + ] + def get_default_branch(self) -> str: """Try to get the default branch or fallback to master.""" try: diff --git a/doc/manual.rst b/doc/manual.rst index 28233537..5e447e0c 100644 --- a/doc/manual.rst +++ b/doc/manual.rst @@ -199,6 +199,18 @@ Also called vendoring. More info: ` + + or non-interactively (auto-accept defaults, skip confirmation): + + .. code-block:: console + + dfetch add -f + - Generate a manifest from existing git submodules or svn externals: .. code-block:: console diff --git a/features/add-project-through-cli.feature b/features/add-project-through-cli.feature new file mode 100644 index 00000000..285cdedd --- /dev/null +++ b/features/add-project-through-cli.feature @@ -0,0 +1,138 @@ +Feature: Add a project to the manifest via the CLI + + *DFetch* can add a new project entry to the manifest without requiring + manual YAML editing. ``dfetch add `` inspects the remote repository, + fills in sensible defaults (name, destination, default branch), shows a + preview, and appends the entry to ``dfetch.yaml`` after confirmation. + + Pass ``--force`` / ``-f`` to skip the confirmation prompt. + Pass ``--interactive`` / ``-i`` to be guided step-by-step through every + manifest field (name, destination, branch/tag/revision, optional src). + + Background: + Given a git repository "MyLib.git" + + Scenario: Adding a project appends it to the manifest + Given the manifest 'dfetch.yaml' + """ + manifest: + version: '0.0' + projects: + - name: ext/existing + url: some-remote-server/existing.git + """ + When I add "some-remote-server/MyLib.git" with force + Then the manifest 'dfetch.yaml' contains entry + """ + - name: MyLib + url: some-remote-server/MyLib.git + branch: master + dst: ext/MyLib + """ + + Scenario: Duplicate project name is rejected + Given the manifest 'dfetch.yaml' + """ + manifest: + version: '0.0' + projects: + - name: MyLib + url: some-remote-server/MyLib.git + """ + When I add "some-remote-server/MyLib.git" with force + Then the command fails with "already exists in manifest" + + Scenario: Destination is guessed from common prefix of existing projects + Given the manifest 'dfetch.yaml' + """ + manifest: + version: '0.0' + projects: + - name: ext/lib-a + url: some-remote-server/lib-a.git + - name: ext/lib-b + url: some-remote-server/lib-b.git + """ + When I add "some-remote-server/MyLib.git" with force + Then the manifest 'dfetch.yaml' contains entry + """ + - name: MyLib + url: some-remote-server/MyLib.git + branch: master + dst: ext/MyLib + """ + + Scenario: Interactive add guides through each field + Given the manifest 'dfetch.yaml' + """ + manifest: + version: '0.0' + projects: + - name: ext/existing + url: some-remote-server/existing.git + """ + When I interactively add "some-remote-server/MyLib.git" with inputs + | prompt_contains | answer | + | Project name | my-lib | + | Destination path | libs/my | + | Version type | branch | + | Branch | master | + | Source sub-path or glob | | + | Add project to manifest? | y | + Then the manifest 'dfetch.yaml' contains entry + """ + - name: my-lib + url: some-remote-server/MyLib.git + branch: master + dst: libs/my + """ + + Scenario: Interactive add with tag version + Given the manifest 'dfetch.yaml' + """ + manifest: + version: '0.0' + projects: + - name: existing + url: some-remote-server/existing.git + """ + When I interactively add "some-remote-server/MyLib.git" with inputs + | prompt_contains | answer | + | Project name | my-lib | + | Destination path | my-lib | + | Version type | tag | + | Tag | v1 | + | Source sub-path or glob | | + | Add project to manifest? | y | + Then the manifest 'dfetch.yaml' contains entry + """ + - name: my-lib + url: some-remote-server/MyLib.git + tag: v1 + """ + + Scenario: Interactive add with abort does not modify manifest + Given the manifest 'dfetch.yaml' + """ + manifest: + version: '0.0' + projects: + - name: existing + url: some-remote-server/existing.git + """ + When I interactively add "some-remote-server/MyLib.git" with inputs + | prompt_contains | answer | + | Project name | MyLib | + | Destination path | MyLib | + | Version type | branch | + | Branch | master | + | Source sub-path or glob | | + | Add project to manifest? | n | + Then the manifest 'dfetch.yaml' is replaced with + """ + manifest: + version: '0.0' + projects: + - name: existing + url: some-remote-server/existing.git + """ diff --git a/features/steps/add_steps.py b/features/steps/add_steps.py new file mode 100644 index 00000000..0b207f08 --- /dev/null +++ b/features/steps/add_steps.py @@ -0,0 +1,78 @@ +"""Steps for the 'dfetch add' feature tests.""" + +# pylint: disable=function-redefined, missing-function-docstring, not-callable +# pyright: reportRedeclaration=false, reportAttributeAccessIssue=false, reportCallIssue=false + +from collections import deque +from unittest.mock import patch + +from behave import then, when # pylint: disable=no-name-in-module + +from features.steps.generic_steps import call_command, remote_server_path +from features.steps.manifest_steps import apply_manifest_substitutions + + +def _resolve_url(url: str, context) -> str: + """Replace 'some-remote-server' with the actual temp file:// URL.""" + return url.replace("some-remote-server", f"file:///{remote_server_path(context)}") + + +@when('I add "{remote_url}" with force') +def step_impl(context, remote_url): + url = _resolve_url(remote_url, context) + call_command(context, ["add", "--force", url]) + + +@when('I add "{remote_url}"') +def step_impl(context, remote_url): + url = _resolve_url(remote_url, context) + call_command(context, ["add", url]) + + +@when('I interactively add "{remote_url}" with inputs') +def step_impl(context, remote_url): + url = _resolve_url(remote_url, context) + + # Build a FIFO queue of answers in the order prompts will arrive. + answers: deque[str] = deque(row["answer"] for row in context.table) + + def _auto_answer(prompt: str, **kwargs) -> str: # type: ignore[return] + """Return the next pre-defined answer, ignoring the actual prompt text.""" + if answers: + return answers.popleft() + # Fallback: use default if provided. + default = kwargs.get("default", "") + return str(default) + + with patch("dfetch.commands.add.Prompt.ask", side_effect=_auto_answer): + call_command(context, ["add", "--interactive", url]) + + +@then("the manifest '{name}' contains entry") +def step_impl(context, name): + expected = apply_manifest_substitutions(context, context.text) + with open(name, "r", encoding="utf-8") as fh: + actual = fh.read() + + # Check that every line of the expected snippet is present somewhere in + # the manifest (order-insensitive substring check per line). + missing = [] + for line in expected.splitlines(): + stripped = line.strip() + if stripped and stripped not in actual: + missing.append(line) + + if missing: + print("Actual manifest:") + print(actual) + assert not missing, "Expected lines not found in manifest:\n" + "\n".join( + missing + ) + + +@then('the command fails with "{message}"') +def step_impl(context, message): + assert context.cmd_returncode != 0, "Expected command to fail, but it succeeded" + assert ( + message in context.cmd_output + ), f"Expected error message '{message}' not found in output:\n{context.cmd_output}" diff --git a/tests/test_add.py b/tests/test_add.py new file mode 100644 index 00000000..93fbea1e --- /dev/null +++ b/tests/test_add.py @@ -0,0 +1,458 @@ +"""Tests for the ``dfetch add`` command.""" + +# mypy: ignore-errors +# flake8: noqa + +import argparse +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch, call + +import pytest + +from dfetch.commands.add import ( + Add, + _check_name_uniqueness, + _determine_remote, + _guess_destination, +) +from dfetch.manifest.project import ProjectEntry, ProjectEntryDict +from dfetch.manifest.remote import Remote +from tests.manifest_mock import mock_manifest + +# --------------------------------------------------------------------------- +# Helper factories +# --------------------------------------------------------------------------- + + +def _make_project(name: str, destination: str = "") -> Mock: + p = Mock(spec=ProjectEntry) + p.name = name + p.destination = destination or name + return p + + +def _make_remote(name: str, url: str) -> Mock: + r = Mock(spec=Remote) + r.name = name + r.url = url + return r + + +def _make_args( + remote_url: str, + force: bool = False, + interactive: bool = False, +) -> argparse.Namespace: + return argparse.Namespace( + remote_url=[remote_url], + force=force, + interactive=interactive, + ) + + +# --------------------------------------------------------------------------- +# _check_name_uniqueness +# --------------------------------------------------------------------------- + + +def test_check_name_uniqueness_raises_when_duplicate(): + projects = [_make_project("foo"), _make_project("bar")] + with pytest.raises(RuntimeError, match="already exists"): + _check_name_uniqueness("foo", projects) + + +def test_check_name_uniqueness_passes_for_new_name(): + projects = [_make_project("foo")] + _check_name_uniqueness("bar", projects) # should not raise + + +def test_check_name_uniqueness_passes_for_empty_manifest(): + _check_name_uniqueness("anything", []) + + +# --------------------------------------------------------------------------- +# _guess_destination +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "project_name, existing, expected", + [ + # No existing projects → empty string + ("new", [], ""), + # Single existing project in a subdirectory → use parent dir + ("new", [("ext/a", "ext/a")], "ext/new"), + # Two projects under ext/ → ext/new + ("new", [("ext/a", "ext/a"), ("ext/b", "ext/b")], "ext/new"), + # Projects with no common prefix → empty string + ("new", [("a/x", "a/x"), ("b/y", "b/y")], ""), + ], +) +def test_guess_destination(project_name, existing, expected): + projects = [_make_project(name, dst) for name, dst in existing] + assert _guess_destination(project_name, projects) == expected + + +# --------------------------------------------------------------------------- +# _determine_remote +# --------------------------------------------------------------------------- + + +def test_determine_remote_returns_matching_remote(): + remotes = [ + _make_remote("github", "https://github.com/"), + _make_remote("gitlab", "https://gitlab.com/"), + ] + result = _determine_remote(remotes, "https://github.com/myorg/myrepo.git") + assert result is not None + assert result.name == "github" + + +def test_determine_remote_returns_none_when_no_match(): + remotes = [_make_remote("github", "https://github.com/")] + result = _determine_remote(remotes, "https://bitbucket.org/myorg/myrepo.git") + assert result is None + + +def test_determine_remote_returns_none_for_empty_remotes(): + result = _determine_remote([], "https://github.com/myorg/myrepo.git") + assert result is None + + +# --------------------------------------------------------------------------- +# Add command – non-interactive (force) +# --------------------------------------------------------------------------- + + +def test_add_command_force_appends_entry(): + """With --force the entry is appended without any prompts.""" + fake_superproject = Mock() + fake_superproject.manifest = mock_manifest( + [{"name": "ext/existing"}], path="/some/dfetch.yaml" + ) + fake_superproject.manifest.remotes = [] + fake_superproject.root_directory = Path("/some") + + fake_subproject = Mock() + fake_subproject.get_default_branch.return_value = "main" + fake_subproject.list_of_branches.return_value = ["main"] + fake_subproject.list_of_tags.return_value = ["v1.0"] + + with patch( + "dfetch.commands.add.create_super_project", return_value=fake_superproject + ): + with patch( + "dfetch.commands.add.create_sub_project", return_value=fake_subproject + ): + with patch("dfetch.commands.add.append_entry_manifest_file") as mock_append: + Add()(_make_args("https://github.com/org/myrepo.git", force=True)) + + mock_append.assert_called_once() + entry: ProjectEntry = mock_append.call_args[0][1] + assert entry.name == "myrepo" + assert entry.branch == "main" + + +def test_add_command_user_confirms(): + """Without --force the user is prompted; 'y' proceeds.""" + fake_superproject = Mock() + fake_superproject.manifest = mock_manifest([], path="/some/dfetch.yaml") + fake_superproject.manifest.remotes = [] + fake_superproject.root_directory = Path("/some") + + fake_subproject = Mock() + fake_subproject.get_default_branch.return_value = "main" + fake_subproject.list_of_branches.return_value = ["main"] + fake_subproject.list_of_tags.return_value = [] + + with patch( + "dfetch.commands.add.create_super_project", return_value=fake_superproject + ): + with patch( + "dfetch.commands.add.create_sub_project", return_value=fake_subproject + ): + with patch("dfetch.commands.add.Prompt.ask", return_value="y"): + with patch( + "dfetch.commands.add.append_entry_manifest_file" + ) as mock_append: + Add()(_make_args("https://github.com/org/myrepo.git")) + + mock_append.assert_called_once() + + +def test_add_command_user_aborts(): + """Without --force the user can abort; no manifest modification.""" + fake_superproject = Mock() + fake_superproject.manifest = mock_manifest([], path="/some/dfetch.yaml") + fake_superproject.manifest.remotes = [] + fake_superproject.root_directory = Path("/some") + + fake_subproject = Mock() + fake_subproject.get_default_branch.return_value = "main" + fake_subproject.list_of_branches.return_value = ["main"] + fake_subproject.list_of_tags.return_value = [] + + with patch( + "dfetch.commands.add.create_super_project", return_value=fake_superproject + ): + with patch( + "dfetch.commands.add.create_sub_project", return_value=fake_subproject + ): + with patch("dfetch.commands.add.Prompt.ask", return_value="n"): + with patch( + "dfetch.commands.add.append_entry_manifest_file" + ) as mock_append: + Add()(_make_args("https://github.com/org/myrepo.git")) + + mock_append.assert_not_called() + + +def test_add_command_raises_on_duplicate_name(): + """Trying to add a project whose name already exists must raise.""" + fake_superproject = Mock() + fake_superproject.manifest = mock_manifest( + [{"name": "myrepo"}], path="/some/dfetch.yaml" + ) + fake_superproject.manifest.remotes = [] + fake_superproject.root_directory = Path("/some") + + fake_subproject = Mock() + fake_subproject.get_default_branch.return_value = "main" + fake_subproject.list_of_branches.return_value = [] + fake_subproject.list_of_tags.return_value = [] + + with patch( + "dfetch.commands.add.create_super_project", return_value=fake_superproject + ): + with patch( + "dfetch.commands.add.create_sub_project", return_value=fake_subproject + ): + with pytest.raises(RuntimeError, match="already exists"): + Add()(_make_args("https://github.com/org/myrepo.git", force=True)) + + +# --------------------------------------------------------------------------- +# Add command – interactive mode +# --------------------------------------------------------------------------- + + +def test_add_command_interactive_branch(): + """Interactive mode with branch selection appends correct entry.""" + fake_superproject = Mock() + fake_superproject.manifest = mock_manifest([], path="/some/dfetch.yaml") + fake_superproject.manifest.remotes = [] + fake_superproject.root_directory = Path("/some") + + fake_subproject = Mock() + fake_subproject.get_default_branch.return_value = "main" + fake_subproject.list_of_branches.return_value = ["main", "dev"] + fake_subproject.list_of_tags.return_value = ["v1.0"] + + # Answers in order: name, dst, version_type, branch, src, confirm + answers = iter(["myrepo", "libs/myrepo", "branch", "dev", "", "y"]) + + with patch( + "dfetch.commands.add.create_super_project", return_value=fake_superproject + ): + with patch( + "dfetch.commands.add.create_sub_project", return_value=fake_subproject + ): + with patch( + "dfetch.commands.add.Prompt.ask", + side_effect=lambda *a, **kw: next(answers), + ): + with patch( + "dfetch.commands.add.append_entry_manifest_file" + ) as mock_append: + Add()( + _make_args( + "https://github.com/org/myrepo.git", + interactive=True, + ) + ) + + mock_append.assert_called_once() + entry: ProjectEntry = mock_append.call_args[0][1] + assert entry.name == "myrepo" + assert entry.branch == "dev" + assert entry.destination == "libs/myrepo" + + +def test_add_command_interactive_tag(): + """Interactive mode with tag selection appends entry with tag set.""" + fake_superproject = Mock() + fake_superproject.manifest = mock_manifest([], path="/some/dfetch.yaml") + fake_superproject.manifest.remotes = [] + fake_superproject.root_directory = Path("/some") + + fake_subproject = Mock() + fake_subproject.get_default_branch.return_value = "main" + fake_subproject.list_of_branches.return_value = ["main"] + fake_subproject.list_of_tags.return_value = ["v1.0", "v2.0"] + + answers = iter(["myrepo", "myrepo", "tag", "v2.0", "", "y"]) + + with patch( + "dfetch.commands.add.create_super_project", return_value=fake_superproject + ): + with patch( + "dfetch.commands.add.create_sub_project", return_value=fake_subproject + ): + with patch( + "dfetch.commands.add.Prompt.ask", + side_effect=lambda *a, **kw: next(answers), + ): + with patch( + "dfetch.commands.add.append_entry_manifest_file" + ) as mock_append: + Add()( + _make_args( + "https://github.com/org/myrepo.git", + interactive=True, + ) + ) + + mock_append.assert_called_once() + entry: ProjectEntry = mock_append.call_args[0][1] + assert entry.tag == "v2.0" + assert entry.branch == "" + + +def test_add_command_interactive_abort(): + """Interactive mode: answering 'n' at confirmation does not append.""" + fake_superproject = Mock() + fake_superproject.manifest = mock_manifest([], path="/some/dfetch.yaml") + fake_superproject.manifest.remotes = [] + fake_superproject.root_directory = Path("/some") + + fake_subproject = Mock() + fake_subproject.get_default_branch.return_value = "main" + fake_subproject.list_of_branches.return_value = ["main"] + fake_subproject.list_of_tags.return_value = [] + + answers = iter(["myrepo", "myrepo", "branch", "main", "", "n"]) + + with patch( + "dfetch.commands.add.create_super_project", return_value=fake_superproject + ): + with patch( + "dfetch.commands.add.create_sub_project", return_value=fake_subproject + ): + with patch( + "dfetch.commands.add.Prompt.ask", + side_effect=lambda *a, **kw: next(answers), + ): + with patch( + "dfetch.commands.add.append_entry_manifest_file" + ) as mock_append: + Add()( + _make_args( + "https://github.com/org/myrepo.git", + interactive=True, + ) + ) + + mock_append.assert_not_called() + + +def test_add_command_interactive_with_src(): + """Interactive mode: providing a src path includes it in the entry.""" + fake_superproject = Mock() + fake_superproject.manifest = mock_manifest([], path="/some/dfetch.yaml") + fake_superproject.manifest.remotes = [] + fake_superproject.root_directory = Path("/some") + + fake_subproject = Mock() + fake_subproject.get_default_branch.return_value = "main" + fake_subproject.list_of_branches.return_value = ["main"] + fake_subproject.list_of_tags.return_value = [] + + answers = iter(["myrepo", "myrepo", "branch", "main", "include/", "y"]) + + with patch( + "dfetch.commands.add.create_super_project", return_value=fake_superproject + ): + with patch( + "dfetch.commands.add.create_sub_project", return_value=fake_subproject + ): + with patch( + "dfetch.commands.add.Prompt.ask", + side_effect=lambda *a, **kw: next(answers), + ): + with patch( + "dfetch.commands.add.append_entry_manifest_file" + ) as mock_append: + Add()( + _make_args( + "https://github.com/org/myrepo.git", + interactive=True, + ) + ) + + mock_append.assert_called_once() + entry: ProjectEntry = mock_append.call_args[0][1] + assert entry.source == "include/" + + +# --------------------------------------------------------------------------- +# Add command – remote matching +# --------------------------------------------------------------------------- + + +def test_add_command_matches_existing_remote(): + """When the URL matches a known remote, the entry uses repo-path.""" + fake_remote = MagicMock() + fake_remote.name = "github" + fake_remote.url = "https://github.com/" + + fake_superproject = Mock() + fake_superproject.manifest = mock_manifest([], path="/some/dfetch.yaml") + fake_superproject.manifest.remotes = [fake_remote] + fake_superproject.root_directory = Path("/some") + + fake_subproject = Mock() + fake_subproject.get_default_branch.return_value = "main" + fake_subproject.list_of_branches.return_value = ["main"] + fake_subproject.list_of_tags.return_value = [] + + with patch( + "dfetch.commands.add.create_super_project", return_value=fake_superproject + ): + with patch( + "dfetch.commands.add.create_sub_project", return_value=fake_subproject + ): + with patch("dfetch.commands.add.append_entry_manifest_file") as mock_append: + Add()( + _make_args( + "https://github.com/org/myrepo.git", + force=True, + ) + ) + + mock_append.assert_called_once() + + +# --------------------------------------------------------------------------- +# CLI menu creation +# --------------------------------------------------------------------------- + + +def test_add_create_menu(): + import argparse + + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers() + Add.create_menu(subparsers) + parsed = parser.parse_args(["add", "-f", "https://example.com/repo.git"]) + assert parsed.force is True + assert parsed.remote_url == ["https://example.com/repo.git"] + + +def test_add_create_menu_interactive_flag(): + import argparse + + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers() + Add.create_menu(subparsers) + parsed = parser.parse_args(["add", "-i", "https://example.com/repo.git"]) + assert parsed.interactive is True From ff9adced60cb7e6f672815e3e7654837dc661e60 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 12:24:36 +0000 Subject: [PATCH 03/29] Rework interactive add: numbered pick-list, Confirm, input validation UX improvements inspired by inquirer.js / carbon-cli style: - Version selection is now a single step: show a short numbered list (max 5 branches + 5 tags) instead of the previous two-step "choose type then value" flow. User can pick by number, type a known branch/tag name, or enter any SHA revision directly. - Branches are listed default-first; tags are sorted newest semver first with non-semver tags appended, so the most likely choices appear at top. - Confirmation uses rich.prompt.Confirm (y/n keys) instead of the old Prompt.ask("y/n") text prompt. - Name and destination inputs now validate on entry: - Name: rejects YAML-unsafe characters (# : [ ] { } & * etc.) - Destination: rejects path traversal (.. components) - Add semver import for tag ordering. - Update 22 unit tests and 6 behave scenarios to match new interface. - Add test_add_command_interactive_branch_by_number covering numeric pick. https://claude.ai/code/session_01XnrSn9ar6cLpL6Y2qDDGXd --- dfetch/commands/add.py | 245 +++++++++++++++-------- features/add-project-through-cli.feature | 15 +- features/steps/add_steps.py | 33 +-- tests/test_add.py | 139 ++++++++----- 4 files changed, 279 insertions(+), 153 deletions(-) diff --git a/dfetch/commands/add.py b/dfetch/commands/add.py index 2c361203..3ab26606 100644 --- a/dfetch/commands/add.py +++ b/dfetch/commands/add.py @@ -10,47 +10,43 @@ dfetch add https://github.com/some-org/some-repo.git -Dfetch will fetch the remote repository metadata (branches and tags), pick -the default branch, guess a sensible destination path based on where your -existing projects live, and append the new entry to ``dfetch.yaml``. +Dfetch fetches remote metadata (branches, tags), picks the default branch, +guesses a destination path from your existing projects, shows a preview, and +appends the entry to ``dfetch.yaml`` after a single confirmation prompt. -A confirmation prompt is shown before writing. Pass ``--force`` (or ``-f``) -to skip it:: +Skip the confirmation with ``--force``:: dfetch add -f https://github.com/some-org/some-repo.git Interactive mode ---------------- -With ``--interactive`` (or ``-i``) dfetch guides you through every manifest -field step by step:: +Use ``--interactive`` (``-i``) for a guided, step-by-step wizard:: dfetch add -i https://github.com/some-org/some-repo.git -You will be prompted for: +The wizard walks through: -* **name** – a human-readable project name (default: repository name from URL) -* **dst** – local destination directory (default: guessed from existing - projects) -* **branch / tag / revision** – version to fetch (default: default branch of - the remote) -* **src** – sub-path or glob inside the remote to copy (optional) +* **name** – defaults to the repository name extracted from the URL +* **dst** – local destination; defaults to a path guessed from existing projects +* **version** – pick from a short numbered list of branches and tags (most + relevant shown first; type any other value to use it directly) +* **src** – optional sub-path or glob to fetch only part of the repo -All prompts show a sensible default so you can just press *Enter* to accept -it. When a list of choices is available (e.g. branches or tags) the list is -displayed so you can easily pick one. - -The entry is appended at the end of the manifest and *not* fetched to disk; -run ``dfetch update`` afterwards to materialise the dependency. +All prompts have a pre-filled default so you can just press *Enter* to accept. +The entry is appended at the end of the manifest; run ``dfetch update`` +afterwards to materialise the dependency. .. scenario-include:: ../features/add-project-through-cli.feature """ import argparse import os +import re from collections.abc import Sequence from pathlib import Path -from rich.prompt import Prompt +import semver +from rich.prompt import Confirm, Prompt import dfetch.commands.command import dfetch.manifest.project @@ -65,6 +61,13 @@ logger = get_logger(__name__) +# Maximum number of branches/tags shown in the pick list. +_MAX_CHOICES = 5 + +# Characters that are not allowed in a project name or destination path. +# (YAML special chars that could break the manifest even after yaml.dump) +_UNSAFE_NAME_RE = re.compile(r"[\x00-\x1F\x7F-\x9F:#\[\]{}&*!|>'\"%@`]") + class Add(dfetch.commands.command.Command): """Add a new project to the manifest. @@ -99,8 +102,8 @@ def create_menu(subparsers: dfetch.commands.command.SubparserActionType) -> None action="store_true", help=( "Interactively guide through each manifest field. " - "Dfetch will fetch the remote branch/tag list and " - "let you confirm or override every value." + "Dfetch fetches the remote branch/tag list and lets " + "you pick or override every value." ), ) @@ -114,7 +117,7 @@ def __call__(self, args: argparse.Namespace) -> None: # Build a minimal entry so we can probe the remote. probe_entry = ProjectEntry(ProjectEntryDict(name=purl.name, url=remote_url)) - # Determines VCS type, tries to reach remote. + # Determines VCS type; tries to reach the remote. subproject = create_sub_project(probe_entry) _check_name_uniqueness(probe_entry.name, superproject.manifest.projects) @@ -163,7 +166,7 @@ def __call__(self, args: argparse.Namespace) -> None: project_entry.as_yaml(), ) - if not args.force and not _confirm(): + if not args.force and not Confirm.ask("Add project to manifest?", default=True): logger.print_warning_line(project_entry.name, "Aborting add of project") return @@ -201,16 +204,14 @@ def _interactive_flow( # --- dst --- dst = _ask_dst(name, default_dst) - # --- version: branch / tag / revision --- + # --- version (branch or tag) --- branches = subproject.list_of_branches() tags = subproject.list_of_tags() - version_type, version_value = _ask_version(default_branch, branches, tags) # --- src (optional) --- src = _ask_src() - # Build the entry dict. entry_dict: ProjectEntryDict = ProjectEntryDict( name=name, url=remote_url, @@ -234,31 +235,50 @@ def _interactive_flow( return project_entry +# --------------------------------------------------------------------------- +# Individual prompt helpers +# --------------------------------------------------------------------------- + + def _ask_name(default: str, existing_projects: Sequence[ProjectEntry]) -> str: - """Prompt for the project name, re-asking if the name already exists.""" + """Prompt for the project name, re-asking on duplicates or invalid input.""" existing_names = {p.name for p in existing_projects} while True: - name = Prompt.ask( - " [bold]Project name[/bold]", - default=default, - ) + name = Prompt.ask(" [bold]Name[/bold]", default=default).strip() + if not name: + logger.warning("Name cannot be empty.") + continue + if _UNSAFE_NAME_RE.search(name): + logger.warning( + f"Name '{name}' contains characters not allowed in a manifest name. " + "Avoid: # : [ ] {{ }} & * ! | > ' \" % @ `" + ) + continue if name in existing_names: logger.warning( - f"A project named '{name}' already exists in the manifest. " - "Please choose a different name." + f"A project named '{name}' already exists. Choose a different name." ) - else: - return name + continue + return name def _ask_dst(name: str, default: str) -> str: - """Prompt for the destination path.""" + """Prompt for the destination path, re-asking on path-traversal attempts.""" suggested = default or name - dst = Prompt.ask( - " [bold]Destination path[/bold] (relative to manifest)", - default=suggested, - ) - return dst + while True: + dst = Prompt.ask( + " [bold]Destination[/bold] (path relative to manifest)", + default=suggested, + ).strip() + if not dst: + return name # fall back to project name + # Block path traversal: reject any component that is '..' + if any(part == ".." for part in Path(dst).parts): + logger.warning( + f"Destination '{dst}' contains '..'. Paths must stay within the manifest directory." + ) + continue + return dst def _ask_version( @@ -266,68 +286,121 @@ def _ask_version( branches: list[str], tags: list[str], ) -> tuple[str, str]: - """Prompt for branch, tag, or revision. + """Show a short pick-list of branches and tags and let the user choose one. - Returns a ``(type, value)`` tuple where *type* is one of ``"branch"``, - ``"tag"``, or ``"revision"``. + The list is limited to ``_MAX_CHOICES`` entries each for branches and + tags. The user can pick by number, or type any branch/tag/SHA directly. + Returns a ``(type, value)`` tuple. """ - if branches: - logger.info( - " [blue]Available branches:[/blue] " - + ", ".join(f"[green]{b}[/green]" for b in branches[:10]) - + (" …" if len(branches) > 10 else "") - ) - if tags: - logger.info( - " [blue]Available tags:[/blue] " - + ", ".join(f"[green]{t}[/green]" for t in tags[:10]) - + (" …" if len(tags) > 10 else "") - ) + choices: list[tuple[str, str]] = [] # (type, value) - version_type = Prompt.ask( - " [bold]Version type[/bold]", - choices=["branch", "tag", "revision"], - default="branch", - ) + # Branches: put the default branch first, then a few more. + ordered_branches = _prioritise_default(branches, default_branch)[:_MAX_CHOICES] + for b in ordered_branches: + choices.append(("branch", b)) - if version_type == "branch": - value = Prompt.ask( - " [bold]Branch[/bold]", + # Tags: most-recent semver tags first, then the rest. + ordered_tags = _sort_tags_newest_first(tags)[:_MAX_CHOICES] + for t in ordered_tags: + choices.append(("tag", t)) + + _print_version_menu(choices, default_branch) + + while True: + raw = Prompt.ask( + " [bold]Version[/bold] (number, branch, tag, or SHA)", default=default_branch, - ) - elif version_type == "tag": - default_tag = tags[0] if tags else "" - value = Prompt.ask( - " [bold]Tag[/bold]", - default=default_tag, - ) - else: - value = Prompt.ask( - " [bold]Revision (full SHA)[/bold]", + ).strip() + + # Numeric pick. + if raw.isdigit(): + idx = int(raw) - 1 + if 0 <= idx < len(choices): + return choices[idx] + logger.warning(f" Pick a number between 1 and {len(choices)}.") + continue + + # Check if it matches a known branch or tag. + if raw in branches: + return ("branch", raw) + if raw in tags: + return ("tag", raw) + + # Assume it's a SHA revision if it looks like a hex string (≥ 7 chars). + if re.fullmatch(r"[0-9a-fA-F]{7,40}", raw): + return ("revision", raw) + + # Otherwise treat as a branch name (the most common case for typing a + # custom value the user knows but that didn't appear in the short list). + if raw: + return ("branch", raw) + + logger.warning(" Please enter a number or a version value.") + + +def _print_version_menu(choices: list[tuple[str, str]], default_branch: str) -> None: + """Render the numbered branch/tag pick list.""" + if not choices: + return + + lines: list[str] = [] + for i, (vtype, value) in enumerate(choices, start=1): + marker = " (default)" if value == default_branch and vtype == "branch" else "" + colour = "cyan" if vtype == "branch" else "magenta" + tag_label = f"[dim]{vtype}[/dim]" + lines.append( + f" [bold white]{i:>2}[/bold white] [{colour}]{value}[/{colour}]{marker} {tag_label}" ) - return version_type, value + # Indicate if there are more options not shown. + panel_content = "\n".join(lines) + logger.info(panel_content) def _ask_src() -> str: - """Prompt for an optional ``src:`` sub-path or glob.""" + """Optionally prompt for a ``src:`` sub-path or glob pattern.""" src = Prompt.ask( - " [bold]Source sub-path or glob[/bold] (leave empty to fetch entire repo)", + " [bold]Source path[/bold] (sub-path/glob, or Enter to fetch whole repo)", default="", - ) - return src.strip() + ).strip() + return src # --------------------------------------------------------------------------- -# Helpers +# Sorting / ordering helpers # --------------------------------------------------------------------------- -def _confirm() -> bool: - """Show a confirmation prompt to the user before adding the project.""" - return ( - Prompt.ask("Add project to manifest?", choices=["y", "n"], default="y") == "y" +def _prioritise_default(branches: list[str], default: str) -> list[str]: + """Return *branches* with *default* moved to position 0.""" + if default in branches: + rest = [b for b in branches if b != default] + return [default, *rest] + return branches + + +def _sort_tags_newest_first(tags: list[str]) -> list[str]: + """Sort *tags* with semver-parseable tags newest-first; others appended.""" + + def _semver_key(tag: str) -> semver.Version | None: + cleaned = tag.lstrip("vV") + try: + return semver.Version.parse(cleaned) + except ValueError: + return None + + semver_tags = sorted( + [t for t in tags if _semver_key(t) is not None], + key=lambda t: _semver_key(t), # type: ignore[arg-type, return-value] + reverse=True, ) + non_semver = [t for t in tags if _semver_key(t) is None] + return semver_tags + non_semver + + +# --------------------------------------------------------------------------- +# Non-interactive helpers +# --------------------------------------------------------------------------- def _check_name_uniqueness( diff --git a/features/add-project-through-cli.feature b/features/add-project-through-cli.feature index 285cdedd..2fdd2778 100644 --- a/features/add-project-through-cli.feature +++ b/features/add-project-through-cli.feature @@ -75,9 +75,8 @@ Feature: Add a project to the manifest via the CLI | prompt_contains | answer | | Project name | my-lib | | Destination path | libs/my | - | Version type | branch | - | Branch | master | - | Source sub-path or glob | | + | Version | master | + | Source path | | | Add project to manifest? | y | Then the manifest 'dfetch.yaml' contains entry """ @@ -100,9 +99,8 @@ Feature: Add a project to the manifest via the CLI | prompt_contains | answer | | Project name | my-lib | | Destination path | my-lib | - | Version type | tag | - | Tag | v1 | - | Source sub-path or glob | | + | Version | v1 | + | Source path | | | Add project to manifest? | y | Then the manifest 'dfetch.yaml' contains entry """ @@ -124,9 +122,8 @@ Feature: Add a project to the manifest via the CLI | prompt_contains | answer | | Project name | MyLib | | Destination path | MyLib | - | Version type | branch | - | Branch | master | - | Source sub-path or glob | | + | Version | master | + | Source path | | | Add project to manifest? | n | Then the manifest 'dfetch.yaml' is replaced with """ diff --git a/features/steps/add_steps.py b/features/steps/add_steps.py index 0b207f08..b7fb7525 100644 --- a/features/steps/add_steps.py +++ b/features/steps/add_steps.py @@ -33,19 +33,28 @@ def step_impl(context, remote_url): def step_impl(context, remote_url): url = _resolve_url(remote_url, context) - # Build a FIFO queue of answers in the order prompts will arrive. - answers: deque[str] = deque(row["answer"] for row in context.table) - - def _auto_answer(prompt: str, **kwargs) -> str: # type: ignore[return] + # Separate the confirmation row (if any) from the Prompt.ask rows. + # The table has columns: prompt_contains | answer. + # The final "Add project to manifest?" row drives Confirm.ask; all others + # drive Prompt.ask in order. + confirm_answer = True + prompt_answers: deque[str] = deque() + + for row in context.table: + if "Add project to manifest" in row["prompt_contains"]: + confirm_answer = row["answer"].lower() not in ("n", "no", "false") + else: + prompt_answers.append(row["answer"]) + + def _auto_prompt(prompt: str, **kwargs) -> str: # type: ignore[return] """Return the next pre-defined answer, ignoring the actual prompt text.""" - if answers: - return answers.popleft() - # Fallback: use default if provided. - default = kwargs.get("default", "") - return str(default) - - with patch("dfetch.commands.add.Prompt.ask", side_effect=_auto_answer): - call_command(context, ["add", "--interactive", url]) + if prompt_answers: + return prompt_answers.popleft() + return str(kwargs.get("default", "")) + + with patch("dfetch.commands.add.Prompt.ask", side_effect=_auto_prompt): + with patch("dfetch.commands.add.Confirm.ask", return_value=confirm_answer): + call_command(context, ["add", "--interactive", url]) @then("the manifest '{name}' contains entry") diff --git a/tests/test_add.py b/tests/test_add.py index 93fbea1e..42366332 100644 --- a/tests/test_add.py +++ b/tests/test_add.py @@ -154,7 +154,7 @@ def test_add_command_force_appends_entry(): def test_add_command_user_confirms(): - """Without --force the user is prompted; 'y' proceeds.""" + """Without --force the user is prompted; confirming proceeds.""" fake_superproject = Mock() fake_superproject.manifest = mock_manifest([], path="/some/dfetch.yaml") fake_superproject.manifest.remotes = [] @@ -171,7 +171,7 @@ def test_add_command_user_confirms(): with patch( "dfetch.commands.add.create_sub_project", return_value=fake_subproject ): - with patch("dfetch.commands.add.Prompt.ask", return_value="y"): + with patch("dfetch.commands.add.Confirm.ask", return_value=True): with patch( "dfetch.commands.add.append_entry_manifest_file" ) as mock_append: @@ -198,7 +198,7 @@ def test_add_command_user_aborts(): with patch( "dfetch.commands.add.create_sub_project", return_value=fake_subproject ): - with patch("dfetch.commands.add.Prompt.ask", return_value="n"): + with patch("dfetch.commands.add.Confirm.ask", return_value=False): with patch( "dfetch.commands.add.append_entry_manifest_file" ) as mock_append: @@ -237,7 +237,7 @@ def test_add_command_raises_on_duplicate_name(): def test_add_command_interactive_branch(): - """Interactive mode with branch selection appends correct entry.""" + """Interactive mode: typing a branch name appends entry with that branch.""" fake_superproject = Mock() fake_superproject.manifest = mock_manifest([], path="/some/dfetch.yaml") fake_superproject.manifest.remotes = [] @@ -248,8 +248,9 @@ def test_add_command_interactive_branch(): fake_subproject.list_of_branches.return_value = ["main", "dev"] fake_subproject.list_of_tags.return_value = ["v1.0"] - # Answers in order: name, dst, version_type, branch, src, confirm - answers = iter(["myrepo", "libs/myrepo", "branch", "dev", "", "y"]) + # Prompts: name, dst, version (single step), src + # Confirm is mocked separately. + prompt_answers = iter(["myrepo", "libs/myrepo", "dev", ""]) with patch( "dfetch.commands.add.create_super_project", return_value=fake_superproject @@ -259,17 +260,18 @@ def test_add_command_interactive_branch(): ): with patch( "dfetch.commands.add.Prompt.ask", - side_effect=lambda *a, **kw: next(answers), + side_effect=lambda *a, **kw: next(prompt_answers), ): - with patch( - "dfetch.commands.add.append_entry_manifest_file" - ) as mock_append: - Add()( - _make_args( - "https://github.com/org/myrepo.git", - interactive=True, + with patch("dfetch.commands.add.Confirm.ask", return_value=True): + with patch( + "dfetch.commands.add.append_entry_manifest_file" + ) as mock_append: + Add()( + _make_args( + "https://github.com/org/myrepo.git", + interactive=True, + ) ) - ) mock_append.assert_called_once() entry: ProjectEntry = mock_append.call_args[0][1] @@ -278,8 +280,48 @@ def test_add_command_interactive_branch(): assert entry.destination == "libs/myrepo" +def test_add_command_interactive_branch_by_number(): + """Interactive mode: picking a branch by number selects it correctly.""" + fake_superproject = Mock() + fake_superproject.manifest = mock_manifest([], path="/some/dfetch.yaml") + fake_superproject.manifest.remotes = [] + fake_superproject.root_directory = Path("/some") + + fake_subproject = Mock() + fake_subproject.get_default_branch.return_value = "main" + fake_subproject.list_of_branches.return_value = ["main", "dev"] + fake_subproject.list_of_tags.return_value = [] + + # "2" selects the second option in the pick list (dev). + prompt_answers = iter(["myrepo", "myrepo", "2", ""]) + + with patch( + "dfetch.commands.add.create_super_project", return_value=fake_superproject + ): + with patch( + "dfetch.commands.add.create_sub_project", return_value=fake_subproject + ): + with patch( + "dfetch.commands.add.Prompt.ask", + side_effect=lambda *a, **kw: next(prompt_answers), + ): + with patch("dfetch.commands.add.Confirm.ask", return_value=True): + with patch( + "dfetch.commands.add.append_entry_manifest_file" + ) as mock_append: + Add()( + _make_args( + "https://github.com/org/myrepo.git", + interactive=True, + ) + ) + + entry: ProjectEntry = mock_append.call_args[0][1] + assert entry.branch == "dev" + + def test_add_command_interactive_tag(): - """Interactive mode with tag selection appends entry with tag set.""" + """Interactive mode: typing a tag name appends entry with tag set.""" fake_superproject = Mock() fake_superproject.manifest = mock_manifest([], path="/some/dfetch.yaml") fake_superproject.manifest.remotes = [] @@ -290,7 +332,8 @@ def test_add_command_interactive_tag(): fake_subproject.list_of_branches.return_value = ["main"] fake_subproject.list_of_tags.return_value = ["v1.0", "v2.0"] - answers = iter(["myrepo", "myrepo", "tag", "v2.0", "", "y"]) + # version prompt: type the tag name directly. + prompt_answers = iter(["myrepo", "myrepo", "v2.0", ""]) with patch( "dfetch.commands.add.create_super_project", return_value=fake_superproject @@ -300,17 +343,18 @@ def test_add_command_interactive_tag(): ): with patch( "dfetch.commands.add.Prompt.ask", - side_effect=lambda *a, **kw: next(answers), + side_effect=lambda *a, **kw: next(prompt_answers), ): - with patch( - "dfetch.commands.add.append_entry_manifest_file" - ) as mock_append: - Add()( - _make_args( - "https://github.com/org/myrepo.git", - interactive=True, + with patch("dfetch.commands.add.Confirm.ask", return_value=True): + with patch( + "dfetch.commands.add.append_entry_manifest_file" + ) as mock_append: + Add()( + _make_args( + "https://github.com/org/myrepo.git", + interactive=True, + ) ) - ) mock_append.assert_called_once() entry: ProjectEntry = mock_append.call_args[0][1] @@ -319,7 +363,7 @@ def test_add_command_interactive_tag(): def test_add_command_interactive_abort(): - """Interactive mode: answering 'n' at confirmation does not append.""" + """Interactive mode: declining confirmation does not append.""" fake_superproject = Mock() fake_superproject.manifest = mock_manifest([], path="/some/dfetch.yaml") fake_superproject.manifest.remotes = [] @@ -330,7 +374,7 @@ def test_add_command_interactive_abort(): fake_subproject.list_of_branches.return_value = ["main"] fake_subproject.list_of_tags.return_value = [] - answers = iter(["myrepo", "myrepo", "branch", "main", "", "n"]) + prompt_answers = iter(["myrepo", "myrepo", "main", ""]) with patch( "dfetch.commands.add.create_super_project", return_value=fake_superproject @@ -340,17 +384,18 @@ def test_add_command_interactive_abort(): ): with patch( "dfetch.commands.add.Prompt.ask", - side_effect=lambda *a, **kw: next(answers), + side_effect=lambda *a, **kw: next(prompt_answers), ): - with patch( - "dfetch.commands.add.append_entry_manifest_file" - ) as mock_append: - Add()( - _make_args( - "https://github.com/org/myrepo.git", - interactive=True, + with patch("dfetch.commands.add.Confirm.ask", return_value=False): + with patch( + "dfetch.commands.add.append_entry_manifest_file" + ) as mock_append: + Add()( + _make_args( + "https://github.com/org/myrepo.git", + interactive=True, + ) ) - ) mock_append.assert_not_called() @@ -367,7 +412,8 @@ def test_add_command_interactive_with_src(): fake_subproject.list_of_branches.return_value = ["main"] fake_subproject.list_of_tags.return_value = [] - answers = iter(["myrepo", "myrepo", "branch", "main", "include/", "y"]) + # Prompts: name, dst, version, src + answers = iter(["myrepo", "myrepo", "main", "include/"]) with patch( "dfetch.commands.add.create_super_project", return_value=fake_superproject @@ -379,15 +425,16 @@ def test_add_command_interactive_with_src(): "dfetch.commands.add.Prompt.ask", side_effect=lambda *a, **kw: next(answers), ): - with patch( - "dfetch.commands.add.append_entry_manifest_file" - ) as mock_append: - Add()( - _make_args( - "https://github.com/org/myrepo.git", - interactive=True, + with patch("dfetch.commands.add.Confirm.ask", return_value=True): + with patch( + "dfetch.commands.add.append_entry_manifest_file" + ) as mock_append: + Add()( + _make_args( + "https://github.com/org/myrepo.git", + interactive=True, + ) ) - ) mock_append.assert_called_once() entry: ProjectEntry = mock_append.call_args[0][1] From ecc807a100017447d88b4e3e2b2ab7da96998e2b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 16:24:45 +0000 Subject: [PATCH 04/29] feat(add): scrollable version picker, tree browser, ignore prompt, post-add update Interactive mode now uses a real scrollable list for version selection (arrow keys, PgUp/PgDn, viewport shifts at edges) and a keyboard-driven tree browser for src/ignore selection (expand/collapse dirs, single- or multi-select, Space to toggle). After confirming the add the user is offered to run dfetch update immediately. Changes: - add.py: _scrollable_pick() with 10-item viewport and ANSI in-place redraw via _Screen; _tree_browser() with _TreeNode dataclass, lazy child loading, expand/collapse, multi-select; _ask_src(ls_fn) and _ask_ignore(ls_fn) wire through the tree; post-add Update() call - git.py: GitRemote.clone_minimal() and ls_tree() for remote tree listing - gitsubproject.py: browse_tree() context manager (shallow blobless clone) - subproject.py: browse_tree() base implementation (empty ls_fn) - feature tests: two-Confirm handling (add + update); ignore row added - unit tests: side_effect=[True, False] for Confirm; new ignore test and run-update test; _make_subproject() helper with browse_tree mock https://claude.ai/code/session_01XnrSn9ar6cLpL6Y2qDDGXd --- dfetch/commands/add.py | 506 +++++++++++++++++++++-- dfetch/project/gitsubproject.py | 25 ++ dfetch/project/subproject.py | 16 +- dfetch/vcs/git.py | 42 ++ features/add-project-through-cli.feature | 8 +- features/steps/add_steps.py | 33 +- tests/test_add.py | 180 +++++--- 7 files changed, 701 insertions(+), 109 deletions(-) diff --git a/dfetch/commands/add.py b/dfetch/commands/add.py index 3ab26606..48edd556 100644 --- a/dfetch/commands/add.py +++ b/dfetch/commands/add.py @@ -28,13 +28,15 @@ * **name** – defaults to the repository name extracted from the URL * **dst** – local destination; defaults to a path guessed from existing projects -* **version** – pick from a short numbered list of branches and tags (most - relevant shown first; type any other value to use it directly) -* **src** – optional sub-path or glob to fetch only part of the repo +* **version** – scrollable list of all branches and tags (arrow keys to + navigate, Enter to select, Esc to fall back to free-text input) +* **src** – optional sub-path; browse the remote tree with arrow keys, + expand/collapse folders with Enter/Right/Left +* **ignore** – optional list of paths to exclude; same tree browser with + Space to toggle multiple selections and Enter to confirm -All prompts have a pre-filled default so you can just press *Enter* to accept. -The entry is appended at the end of the manifest; run ``dfetch update`` -afterwards to materialise the dependency. +After confirming the add you are offered to run ``dfetch update`` immediately +so the dependency is materialised without a separate command. .. scenario-include:: ../features/add-project-through-cli.feature """ @@ -42,7 +44,9 @@ import argparse import os import re -from collections.abc import Sequence +import sys +from collections.abc import Callable, Sequence +from dataclasses import dataclass from pathlib import Path import semver @@ -61,14 +65,30 @@ logger = get_logger(__name__) -# Maximum number of branches/tags shown in the pick list. -_MAX_CHOICES = 5 - -# Characters that are not allowed in a project name or destination path. -# (YAML special chars that could break the manifest even after yaml.dump) +# --------------------------------------------------------------------------- +# ANSI codes used in the scrollable / tree UI (plain stdout; no Rich markup) +# --------------------------------------------------------------------------- +_R = "\x1b[0m" # reset +_B = "\x1b[1m" # bold +_D = "\x1b[2m" # dim +_CYN = "\x1b[36m" # cyan – branches +_MAG = "\x1b[35m" # magenta – tags +_GRN = "\x1b[32m" # green – selected ✓ +_YLW = "\x1b[33m" # yellow – cursor ▶ +_HL = "\x1b[7m" # reverse-video – highlighted row + +# Viewport height for scrollable lists. +_VIEWPORT = 10 + +# Characters that are not allowed in a project name (YAML special chars). _UNSAFE_NAME_RE = re.compile(r"[\x00-\x1F\x7F-\x9F:#\[\]{}&*!|>'\"%@`]") +# --------------------------------------------------------------------------- +# Command class +# --------------------------------------------------------------------------- + + class Add(dfetch.commands.command.Command): """Add a new project to the manifest. @@ -177,6 +197,20 @@ def __call__(self, args: argparse.Namespace) -> None: logger.print_info_line(project_entry.name, "Added project to manifest") + # Offer to run update immediately (only when we prompted the user, i.e. + # not in --force mode where we want zero interaction). + if not args.force and Confirm.ask( + f"Run 'dfetch update {project_entry.name}' now?", default=True + ): + from dfetch.commands.update import Update # local import avoids circular + + update_args = argparse.Namespace( + projects=[project_entry.name], + force=False, + no_recommendations=False, + ) + Update()(update_args) + # --------------------------------------------------------------------------- # Interactive flow @@ -209,8 +243,10 @@ def _interactive_flow( tags = subproject.list_of_tags() version_type, version_value = _ask_version(default_branch, branches, tags) - # --- src (optional) --- - src = _ask_src() + # --- src and ignore (browsed from a single minimal clone) --- + with subproject.browse_tree() as ls_fn: + src = _ask_src(ls_fn) + ignore = _ask_ignore(ls_fn) entry_dict: ProjectEntryDict = ProjectEntryDict( name=name, @@ -228,6 +264,9 @@ def _interactive_flow( if src: entry_dict["src"] = src + if ignore: + entry_dict["ignore"] = ignore + project_entry = ProjectEntry(entry_dict) if remote_to_use: project_entry.set_remote(remote_to_use) @@ -235,6 +274,325 @@ def _interactive_flow( return project_entry +# --------------------------------------------------------------------------- +# Terminal UI helpers +# --------------------------------------------------------------------------- + + +def _is_tty() -> bool: + """Return True when running attached to an interactive terminal.""" + return sys.stdin.isatty() and not os.environ.get("CI") + + +def _read_key() -> str: # pragma: no cover – raw terminal input + """Read one keypress from stdin in raw mode; return a normalised key name.""" + if sys.platform == "win32": + import msvcrt # type: ignore[import] + + ch = msvcrt.getwch() + if ch in ("\x00", "\xe0"): + ch2 = msvcrt.getwch() + return { + "H": "UP", + "P": "DOWN", + "K": "LEFT", + "M": "RIGHT", + "I": "PGUP", + "Q": "PGDN", + }.get(ch2, "UNKNOWN") + if ch in ("\r", "\n"): + return "ENTER" + if ch == "\x1b": + return "ESC" + if ch == " ": + return "SPACE" + if ch == "\x03": + raise KeyboardInterrupt + return ch + else: + import select as _select + import termios + import tty + + fd = sys.stdin.fileno() + old = termios.tcgetattr(fd) + try: + tty.setraw(fd) + ch = os.read(fd, 1) + if ch in (b"\r", b"\n"): + return "ENTER" + if ch == b"\x1b": + r, _, _ = _select.select([fd], [], [], 0.05) + if r: + rest = b"" + while True: + r2, _, _ = _select.select([fd], [], [], 0.01) + if not r2: + break + rest += os.read(fd, 1) + return { + b"\x1b[A": "UP", + b"\x1b[B": "DOWN", + b"\x1b[C": "RIGHT", + b"\x1b[D": "LEFT", + b"\x1b[5~": "PGUP", + b"\x1b[6~": "PGDN", + }.get(ch + rest, "ESC") + return "ESC" + if ch == b" ": + return "SPACE" + if ch in (b"\x03", b"\x04"): + raise KeyboardInterrupt + return ch.decode("utf-8", errors="replace") + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + + +class _Screen: + """Minimal ANSI helper for in-place redraw (writes directly to stdout).""" + + def __init__(self) -> None: + self._lines = 0 + + def draw(self, lines: list[str]) -> None: + if self._lines: + sys.stdout.write(f"\x1b[{self._lines}A\x1b[0J") + sys.stdout.write("\n".join(lines) + "\n") + sys.stdout.flush() + self._lines = len(lines) + + def clear(self) -> None: + if self._lines: + sys.stdout.write(f"\x1b[{self._lines}A\x1b[0J") + sys.stdout.flush() + self._lines = 0 + + +def _scrollable_pick( + title: str, + display_items: list[str], + *, + default_idx: int = 0, +) -> int | None: # pragma: no cover – interactive TTY only + """Scrollable single-pick list. + + *display_items* are pre-formatted strings (may include raw ANSI codes). + Returns the selected index, or ``None`` when the user pressed Esc. + """ + screen = _Screen() + idx = default_idx + top = 0 + n = len(display_items) + + while True: + idx = max(0, min(idx, n - 1)) + if idx < top: + top = idx + elif idx >= top + _VIEWPORT: + top = idx - _VIEWPORT + 1 + + lines: list[str] = [f" {_B}{title}{_R}"] + for i in range(top, min(top + _VIEWPORT, n)): + cursor = f"{_YLW}▶{_R}" if i == idx else " " + hl_s = _HL if i == idx else "" + hl_e = _R if i == idx else "" + lines.append(f" {cursor} {hl_s}{display_items[i]}{hl_e}") + + if top > 0: + lines.append(f" {_D}↑ {top} more above{_R}") + remaining = n - (top + _VIEWPORT) + if remaining > 0: + lines.append(f" {_D}↓ {remaining} more below{_R}") + lines.append( + f" {_D}↑/↓ navigate PgUp/PgDn jump Enter select Esc free-type{_R}" + ) + + screen.draw(lines) + key = _read_key() + + if key == "UP": + idx -= 1 + elif key == "DOWN": + idx += 1 + elif key == "PGUP": + idx = max(0, idx - _VIEWPORT) + elif key == "PGDN": + idx = min(n - 1, idx + _VIEWPORT) + elif key == "ENTER": + screen.clear() + return idx + elif key == "ESC": + screen.clear() + return None + + +# --------------------------------------------------------------------------- +# Tree browser +# --------------------------------------------------------------------------- + + +@dataclass +class _TreeNode: + name: str + path: str # relative from repo root + is_dir: bool + depth: int = 0 + expanded: bool = False + selected: bool = False + children_loaded: bool = False + + +def _expand_node( + nodes: list[_TreeNode], + idx: int, + ls_fn: Callable[[str], list[tuple[str, bool]]], +) -> None: + """Expand the directory node at *idx*, loading children if needed.""" + node = nodes[idx] + if not node.children_loaded: + entries = ls_fn(node.path) + children = [ + _TreeNode( + name=name, + path=f"{node.path}/{name}", + is_dir=is_dir, + depth=node.depth + 1, + ) + for name, is_dir in entries + ] + nodes[idx + 1 : idx + 1] = children + node.children_loaded = True + node.expanded = True + + +def _collapse_node(nodes: list[_TreeNode], idx: int) -> None: + """Collapse the directory node at *idx*, removing all descendant nodes.""" + parent_depth = nodes[idx].depth + i = idx + 1 + while i < len(nodes) and nodes[i].depth > parent_depth: + i += 1 + del nodes[idx + 1 : i] + nodes[idx].expanded = False + + +def _tree_browser( + ls_fn: Callable[[str], list[tuple[str, bool]]], + title: str, + *, + multi: bool = False, +) -> list[str] | str | None: # pragma: no cover – interactive TTY only + """Interactive tree browser. + + ``multi=False`` (default) — single-select; returns the selected path + string, ``""`` if the user skips with Esc, or ``None`` on keyboard + interrupt. + + ``multi=True`` — multi-select; Space toggles selection, Enter confirms; + returns a (possibly empty) ``list[str]``, or ``None`` on keyboard + interrupt. + """ + root_entries = ls_fn("") + if not root_entries: + return [] if multi else "" + + nodes: list[_TreeNode] = [ + _TreeNode(name=name, path=name, is_dir=is_dir, depth=0) + for name, is_dir in root_entries + ] + + screen = _Screen() + idx = 0 + top = 0 + + while True: + n = len(nodes) + if n == 0: + screen.clear() + return [] if multi else "" + + idx = max(0, min(idx, n - 1)) + if idx < top: + top = idx + elif idx >= top + _VIEWPORT: + top = idx - _VIEWPORT + 1 + + lines: list[str] = [f" {_B}{title}{_R}"] + for i in range(top, min(top + _VIEWPORT, n)): + node = nodes[i] + indent = " " * node.depth + if node.is_dir: + arrow = "▼" if node.expanded else "▶" + icon = f"{arrow} " + else: + icon = " " + cursor = _HL if i == idx else "" + sel = f"{_GRN}✓{_R} " if node.selected else " " + lines.append(f" {cursor}{indent}{sel}{icon}{node.name}{_R}") + + if top > 0: + lines.append(f" {_D}↑ {top} more above{_R}") + remaining = n - (top + _VIEWPORT) + if remaining > 0: + lines.append(f" {_D}↓ {remaining} more below{_R}") + + if multi: + lines.append( + f" {_D}↑/↓ navigate Space select Enter confirm →/← expand/collapse Esc skip{_R}" + ) + else: + lines.append( + f" {_D}↑/↓ navigate Enter/Space select →/← expand/collapse Esc skip{_R}" + ) + + screen.draw(lines) + key = _read_key() + node = nodes[idx] + + if key == "UP": + idx -= 1 + elif key == "DOWN": + idx += 1 + elif key == "PGUP": + idx = max(0, idx - _VIEWPORT) + elif key == "PGDN": + idx = min(n - 1, idx + _VIEWPORT) + elif key == "RIGHT": + if node.is_dir and not node.expanded: + _expand_node(nodes, idx, ls_fn) + elif key == "LEFT": + if node.is_dir and node.expanded: + _collapse_node(nodes, idx) + elif node.depth > 0: + # Jump to parent node + for i in range(idx - 1, -1, -1): + if nodes[i].depth < node.depth: + idx = i + break + elif key == "SPACE": + if multi: + node.selected = not node.selected + elif not node.is_dir: + screen.clear() + return node.path + elif key == "ENTER": + if multi: + # Confirm all selected items + selected = [nd.path for nd in nodes if nd.selected] + screen.clear() + return selected + elif node.is_dir: + if node.expanded: + _collapse_node(nodes, idx) + else: + _expand_node(nodes, idx, ls_fn) + else: + screen.clear() + return node.path + elif key == "ESC": + screen.clear() + return [] if multi else "" + + # --------------------------------------------------------------------------- # Individual prompt helpers # --------------------------------------------------------------------------- @@ -272,7 +630,6 @@ def _ask_dst(name: str, default: str) -> str: ).strip() if not dst: return name # fall back to project name - # Block path traversal: reject any component that is '..' if any(part == ".." for part in Path(dst).parts): logger.warning( f"Destination '{dst}' contains '..'. Paths must stay within the manifest directory." @@ -286,24 +643,59 @@ def _ask_version( branches: list[str], tags: list[str], ) -> tuple[str, str]: - """Show a short pick-list of branches and tags and let the user choose one. + """Choose a version (branch / tag / SHA). - The list is limited to ``_MAX_CHOICES`` entries each for branches and - tags. The user can pick by number, or type any branch/tag/SHA directly. - Returns a ``(type, value)`` tuple. + In a TTY shows a scrollable pick list (all branches then all tags). + Outside a TTY (CI, pipe, tests) falls back to a numbered text menu. """ - choices: list[tuple[str, str]] = [] # (type, value) + ordered_branches = _prioritise_default(branches, default_branch) + ordered_tags = _sort_tags_newest_first(tags) + + # (vtype, value) — all branches first, then all tags + choices: list[tuple[str, str]] = [ + *[("branch", b) for b in ordered_branches], + *[("tag", t) for t in ordered_tags], + ] + + if _is_tty() and choices: + return _scrollable_version_pick(choices, default_branch) + + return _text_version_pick(choices, default_branch, branches, tags) + + +def _scrollable_version_pick( + choices: list[tuple[str, str]], + default_branch: str, +) -> tuple[str, str]: # pragma: no cover – interactive TTY only + """Scrollable version picker; falls back to free-text on Esc.""" + display: list[str] = [] + default_idx = 0 + for i, (vtype, val) in enumerate(choices): + if vtype == "branch": + suffix = f" {_D}(default){_R}" if val == default_branch else "" + display.append(f"{_CYN}{val}{_R} {_D}branch{_R}{suffix}") + if val == default_branch and default_idx == 0: + default_idx = i + else: + display.append(f"{_MAG}{val}{_R} {_D}tag{_R}") + + result = _scrollable_pick("Version", display, default_idx=default_idx) + if result is not None: + return choices[result] - # Branches: put the default branch first, then a few more. - ordered_branches = _prioritise_default(branches, default_branch)[:_MAX_CHOICES] - for b in ordered_branches: - choices.append(("branch", b)) + # Esc pressed — fall back to free-text + branches = [v for t, v in choices if t == "branch"] + tags = [v for t, v in choices if t == "tag"] + return _text_version_pick(choices, default_branch, branches, tags) - # Tags: most-recent semver tags first, then the rest. - ordered_tags = _sort_tags_newest_first(tags)[:_MAX_CHOICES] - for t in ordered_tags: - choices.append(("tag", t)) +def _text_version_pick( + choices: list[tuple[str, str]], + default_branch: str, + branches: list[str], + tags: list[str], +) -> tuple[str, str]: + """Numbered text-based version picker (non-TTY fallback).""" _print_version_menu(choices, default_branch) while True: @@ -312,7 +704,6 @@ def _ask_version( default=default_branch, ).strip() - # Numeric pick. if raw.isdigit(): idx = int(raw) - 1 if 0 <= idx < len(choices): @@ -320,18 +711,14 @@ def _ask_version( logger.warning(f" Pick a number between 1 and {len(choices)}.") continue - # Check if it matches a known branch or tag. if raw in branches: return ("branch", raw) if raw in tags: return ("tag", raw) - # Assume it's a SHA revision if it looks like a hex string (≥ 7 chars). if re.fullmatch(r"[0-9a-fA-F]{7,40}", raw): return ("revision", raw) - # Otherwise treat as a branch name (the most common case for typing a - # custom value the user knows but that didn't appear in the short list). if raw: return ("branch", raw) @@ -339,7 +726,7 @@ def _ask_version( def _print_version_menu(choices: list[tuple[str, str]], default_branch: str) -> None: - """Render the numbered branch/tag pick list.""" + """Render the numbered branch/tag pick list (text fallback).""" if not choices: return @@ -352,18 +739,53 @@ def _print_version_menu(choices: list[tuple[str, str]], default_branch: str) -> f" [bold white]{i:>2}[/bold white] [{colour}]{value}[/{colour}]{marker} {tag_label}" ) - # Indicate if there are more options not shown. - panel_content = "\n".join(lines) - logger.info(panel_content) + logger.info("\n".join(lines)) + +def _ask_src(ls_fn: Callable[[str], list[tuple[str, bool]]]) -> str: + """Optionally prompt for a ``src:`` sub-path or glob pattern. + + In a TTY opens a tree browser for single-path selection. + Outside a TTY falls back to a free-text prompt. + """ + if _is_tty(): + result = _tree_browser( + ls_fn, "Source path (Enter to select, Esc to skip)", multi=False + ) + if result and isinstance(result, str): + return result + return "" -def _ask_src() -> str: - """Optionally prompt for a ``src:`` sub-path or glob pattern.""" - src = Prompt.ask( + return Prompt.ask( " [bold]Source path[/bold] (sub-path/glob, or Enter to fetch whole repo)", default="", ).strip() - return src + + +def _ask_ignore(ls_fn: Callable[[str], list[tuple[str, bool]]]) -> list[str]: + """Optionally prompt for ``ignore:`` paths. + + In a TTY opens a tree browser with multi-select (Space to toggle, + Enter to confirm). Outside a TTY falls back to a comma-separated + free-text prompt. + """ + if _is_tty(): + result = _tree_browser( + ls_fn, + "Ignore paths (Space to select, Enter to confirm, Esc to skip)", + multi=True, + ) + if isinstance(result, list): + return result + return [] + + raw = Prompt.ask( + " [bold]Ignore paths[/bold] (comma-separated, or Enter to skip)", + default="", + ).strip() + if not raw: + return [] + return [p.strip() for p in raw.split(",") if p.strip()] # --------------------------------------------------------------------------- @@ -428,8 +850,6 @@ def _guess_destination( common_path = os.path.commonpath(destinations) if common_path and common_path != os.path.sep: - # For a single project whose name *is* the destination the common_path - # equals that destination, so we take its parent directory instead. if len(destinations) == 1: parent = str(Path(common_path).parent) if parent and parent != ".": diff --git a/dfetch/project/gitsubproject.py b/dfetch/project/gitsubproject.py index 3b4428c4..7ffe5312 100644 --- a/dfetch/project/gitsubproject.py +++ b/dfetch/project/gitsubproject.py @@ -1,6 +1,10 @@ """Git specific implementation.""" +import contextlib import pathlib +import shutil +import tempfile +from collections.abc import Callable, Generator from functools import lru_cache from dfetch.log import get_logger @@ -44,6 +48,27 @@ def list_of_branches(self) -> list[str]: """Get list of all available branches.""" return [str(branch) for branch in self._remote_repo.list_of_branches()] + @contextlib.contextmanager + def browse_tree( + self, + ) -> Generator[Callable[[str], list[tuple[str, bool]]], None, None]: + """Shallow-clone the remote and yield a tree-listing callable. + + The yielded ``ls_fn(path="")`` calls ``git ls-tree HEAD`` on the + temporary clone. The clone is removed on context exit. + """ + tmpdir = tempfile.mkdtemp(prefix="dfetch_browse_") + ls_fn: Callable[[str], list[tuple[str, bool]]] + try: + GitRemote.clone_minimal(self._remote_repo._remote, tmpdir) + ls_fn = lambda path="": GitRemote.ls_tree(tmpdir, path=path) # noqa: E731 + except Exception: + ls_fn = lambda path="": [] # noqa: E731 + try: + yield ls_fn + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + @staticmethod def revision_is_enough() -> bool: """See if this VCS can uniquely distinguish branch with revision only.""" diff --git a/dfetch/project/subproject.py b/dfetch/project/subproject.py index 0812d264..cf2b67f1 100644 --- a/dfetch/project/subproject.py +++ b/dfetch/project/subproject.py @@ -1,9 +1,10 @@ """SubProject.""" +import contextlib import os import pathlib from abc import ABC, abstractmethod -from collections.abc import Callable, Sequence +from collections.abc import Callable, Generator, Sequence from dfetch.log import get_logger from dfetch.manifest.project import ProjectEntry @@ -408,6 +409,19 @@ def list_of_tags(self) -> list[str]: """Get list of all available tags (public wrapper around ``_list_of_tags``).""" return self._list_of_tags() + @contextlib.contextmanager + def browse_tree( + self, + ) -> Generator[Callable[[str], list[tuple[str, bool]]], None, None]: + """Context manager yielding a function to list remote tree contents. + + The yielded callable accepts an optional path (relative to repo root) + and returns a list of ``(name, is_dir)`` pairs. The default + implementation returns an empty list; VCS-specific subclasses override + this to perform a real remote tree walk. + """ + yield lambda path="": [] + def freeze_project(self, project: ProjectEntry) -> str | None: """Freeze *project* to its current on-disk version. diff --git a/dfetch/vcs/git.py b/dfetch/vcs/git.py index 47d0236a..cb1d50b8 100644 --- a/dfetch/vcs/git.py +++ b/dfetch/vcs/git.py @@ -199,6 +199,48 @@ def _ls_remote(remote: str) -> dict[str, str]: info[ref] = sha return info + @staticmethod + def clone_minimal(remote: str, target: str) -> None: + """Shallow blobless clone for browsing the tree without checking out files.""" + run_on_cmdline( + logger, + cmd=[ + "git", + "clone", + "--depth=1", + "--no-checkout", + "--quiet", + remote, + target, + ], + env=_extend_env_for_non_interactive_mode(), + ) + + @staticmethod + def ls_tree(local_path: str, path: str = "") -> list[tuple[str, bool]]: + """List the contents of the HEAD tree at *path* in a local clone. + + Returns a list of ``(name, is_dir)`` pairs sorted with directories + first (alphabetically), then files (alphabetically). + """ + cmd = ["git", "-C", local_path, "ls-tree", "HEAD"] + if path: + cmd.append(path.rstrip("/") + "/") + try: + result = run_on_cmdline(logger, cmd=cmd) + entries: list[tuple[str, bool]] = [] + for line in result.stdout.decode().splitlines(): + if not line.strip(): + continue + meta, name = line.split("\t", 1) + obj_type = meta.split()[1] + entries.append((name, obj_type == "tree")) + dirs = sorted((n, d) for n, d in entries if d) + files = sorted((n, d) for n, d in entries if not d) + return dirs + files + except SubprocessCommandError: + return [] + @staticmethod def _find_sha_of_branch_or_tag(info: dict[str, str], branch_or_tag: str) -> str: """Find SHA of a branch tip or tag.""" diff --git a/features/add-project-through-cli.feature b/features/add-project-through-cli.feature index 2fdd2778..4c024219 100644 --- a/features/add-project-through-cli.feature +++ b/features/add-project-through-cli.feature @@ -7,7 +7,8 @@ Feature: Add a project to the manifest via the CLI Pass ``--force`` / ``-f`` to skip the confirmation prompt. Pass ``--interactive`` / ``-i`` to be guided step-by-step through every - manifest field (name, destination, branch/tag/revision, optional src). + manifest field (name, destination, branch/tag/revision, optional src, + optional ignore list). Background: Given a git repository "MyLib.git" @@ -77,7 +78,9 @@ Feature: Add a project to the manifest via the CLI | Destination path | libs/my | | Version | master | | Source path | | + | Ignore paths | | | Add project to manifest? | y | + | Run update | n | Then the manifest 'dfetch.yaml' contains entry """ - name: my-lib @@ -101,7 +104,9 @@ Feature: Add a project to the manifest via the CLI | Destination path | my-lib | | Version | v1 | | Source path | | + | Ignore paths | | | Add project to manifest? | y | + | Run update | n | Then the manifest 'dfetch.yaml' contains entry """ - name: my-lib @@ -124,6 +129,7 @@ Feature: Add a project to the manifest via the CLI | Destination path | MyLib | | Version | master | | Source path | | + | Ignore paths | | | Add project to manifest? | n | Then the manifest 'dfetch.yaml' is replaced with """ diff --git a/features/steps/add_steps.py b/features/steps/add_steps.py index b7fb7525..3e969951 100644 --- a/features/steps/add_steps.py +++ b/features/steps/add_steps.py @@ -33,18 +33,33 @@ def step_impl(context, remote_url): def step_impl(context, remote_url): url = _resolve_url(remote_url, context) - # Separate the confirmation row (if any) from the Prompt.ask rows. - # The table has columns: prompt_contains | answer. - # The final "Add project to manifest?" row drives Confirm.ask; all others - # drive Prompt.ask in order. - confirm_answer = True + # Parse the answer table into three buckets: + # • "Add project to manifest?" → add_confirm (bool) + # • "Run" + "update" in prompt → update_confirm (bool, default False) + # • anything else → Prompt.ask answers (in order) + add_confirm = True + update_confirm = False prompt_answers: deque[str] = deque() for row in context.table: - if "Add project to manifest" in row["prompt_contains"]: - confirm_answer = row["answer"].lower() not in ("n", "no", "false") + prompt = row["prompt_contains"] + answer = row["answer"] + if "Add project to manifest" in prompt: + add_confirm = answer.lower() not in ("n", "no", "false") + elif "update" in prompt.lower() and "run" in prompt.lower(): + update_confirm = answer.lower() not in ("n", "no", "false") else: - prompt_answers.append(row["answer"]) + prompt_answers.append(answer) + + # Two sequential Confirm calls: first "add?", then (if added) "update?". + # We use an iterator so each call consumes the next value. + _confirm_values = iter([add_confirm, update_confirm]) + + def _auto_confirm(prompt: str, **kwargs) -> bool: + try: + return next(_confirm_values) + except StopIteration: + return bool(kwargs.get("default", False)) def _auto_prompt(prompt: str, **kwargs) -> str: # type: ignore[return] """Return the next pre-defined answer, ignoring the actual prompt text.""" @@ -53,7 +68,7 @@ def _auto_prompt(prompt: str, **kwargs) -> str: # type: ignore[return] return str(kwargs.get("default", "")) with patch("dfetch.commands.add.Prompt.ask", side_effect=_auto_prompt): - with patch("dfetch.commands.add.Confirm.ask", return_value=confirm_answer): + with patch("dfetch.commands.add.Confirm.ask", side_effect=_auto_confirm): call_command(context, ["add", "--interactive", url]) diff --git a/tests/test_add.py b/tests/test_add.py index 42366332..5863f71d 100644 --- a/tests/test_add.py +++ b/tests/test_add.py @@ -5,7 +5,7 @@ import argparse from pathlib import Path -from unittest.mock import MagicMock, Mock, patch, call +from unittest.mock import MagicMock, Mock, call, patch import pytest @@ -50,6 +50,22 @@ def _make_args( ) +def _make_subproject( + default_branch: str = "main", + branches: list[str] | None = None, + tags: list[str] | None = None, +) -> Mock: + """Return a Mock SubProject with sensible defaults.""" + sp = Mock() + sp.get_default_branch.return_value = default_branch + sp.list_of_branches.return_value = branches if branches is not None else [default_branch] + sp.list_of_tags.return_value = tags if tags is not None else [] + # browse_tree returns an empty ls_fn by default (no remote tree available) + sp.browse_tree.return_value.__enter__ = Mock(return_value=lambda path="": []) + sp.browse_tree.return_value.__exit__ = Mock(return_value=False) + return sp + + # --------------------------------------------------------------------------- # _check_name_uniqueness # --------------------------------------------------------------------------- @@ -133,10 +149,7 @@ def test_add_command_force_appends_entry(): fake_superproject.manifest.remotes = [] fake_superproject.root_directory = Path("/some") - fake_subproject = Mock() - fake_subproject.get_default_branch.return_value = "main" - fake_subproject.list_of_branches.return_value = ["main"] - fake_subproject.list_of_tags.return_value = ["v1.0"] + fake_subproject = _make_subproject("main", ["main"], ["v1.0"]) with patch( "dfetch.commands.add.create_super_project", return_value=fake_superproject @@ -154,24 +167,22 @@ def test_add_command_force_appends_entry(): def test_add_command_user_confirms(): - """Without --force the user is prompted; confirming proceeds.""" + """Without --force the user is prompted; confirming proceeds and update is declined.""" fake_superproject = Mock() fake_superproject.manifest = mock_manifest([], path="/some/dfetch.yaml") fake_superproject.manifest.remotes = [] fake_superproject.root_directory = Path("/some") - fake_subproject = Mock() - fake_subproject.get_default_branch.return_value = "main" - fake_subproject.list_of_branches.return_value = ["main"] - fake_subproject.list_of_tags.return_value = [] + fake_subproject = _make_subproject() + # First Confirm.ask → True (add), second → False (don't run update) with patch( "dfetch.commands.add.create_super_project", return_value=fake_superproject ): with patch( "dfetch.commands.add.create_sub_project", return_value=fake_subproject ): - with patch("dfetch.commands.add.Confirm.ask", return_value=True): + with patch("dfetch.commands.add.Confirm.ask", side_effect=[True, False]): with patch( "dfetch.commands.add.append_entry_manifest_file" ) as mock_append: @@ -187,10 +198,7 @@ def test_add_command_user_aborts(): fake_superproject.manifest.remotes = [] fake_superproject.root_directory = Path("/some") - fake_subproject = Mock() - fake_subproject.get_default_branch.return_value = "main" - fake_subproject.list_of_branches.return_value = ["main"] - fake_subproject.list_of_tags.return_value = [] + fake_subproject = _make_subproject() with patch( "dfetch.commands.add.create_super_project", return_value=fake_superproject @@ -216,10 +224,7 @@ def test_add_command_raises_on_duplicate_name(): fake_superproject.manifest.remotes = [] fake_superproject.root_directory = Path("/some") - fake_subproject = Mock() - fake_subproject.get_default_branch.return_value = "main" - fake_subproject.list_of_branches.return_value = [] - fake_subproject.list_of_tags.return_value = [] + fake_subproject = _make_subproject() with patch( "dfetch.commands.add.create_super_project", return_value=fake_superproject @@ -243,14 +248,10 @@ def test_add_command_interactive_branch(): fake_superproject.manifest.remotes = [] fake_superproject.root_directory = Path("/some") - fake_subproject = Mock() - fake_subproject.get_default_branch.return_value = "main" - fake_subproject.list_of_branches.return_value = ["main", "dev"] - fake_subproject.list_of_tags.return_value = ["v1.0"] + fake_subproject = _make_subproject("main", ["main", "dev"], ["v1.0"]) - # Prompts: name, dst, version (single step), src - # Confirm is mocked separately. - prompt_answers = iter(["myrepo", "libs/myrepo", "dev", ""]) + # Prompts: name, dst, version, src, ignore (text fallback, non-TTY) + prompt_answers = iter(["myrepo", "libs/myrepo", "dev", "", ""]) with patch( "dfetch.commands.add.create_super_project", return_value=fake_superproject @@ -262,7 +263,9 @@ def test_add_command_interactive_branch(): "dfetch.commands.add.Prompt.ask", side_effect=lambda *a, **kw: next(prompt_answers), ): - with patch("dfetch.commands.add.Confirm.ask", return_value=True): + with patch( + "dfetch.commands.add.Confirm.ask", side_effect=[True, False] + ): with patch( "dfetch.commands.add.append_entry_manifest_file" ) as mock_append: @@ -287,13 +290,10 @@ def test_add_command_interactive_branch_by_number(): fake_superproject.manifest.remotes = [] fake_superproject.root_directory = Path("/some") - fake_subproject = Mock() - fake_subproject.get_default_branch.return_value = "main" - fake_subproject.list_of_branches.return_value = ["main", "dev"] - fake_subproject.list_of_tags.return_value = [] + fake_subproject = _make_subproject("main", ["main", "dev"], []) # "2" selects the second option in the pick list (dev). - prompt_answers = iter(["myrepo", "myrepo", "2", ""]) + prompt_answers = iter(["myrepo", "myrepo", "2", "", ""]) with patch( "dfetch.commands.add.create_super_project", return_value=fake_superproject @@ -305,7 +305,9 @@ def test_add_command_interactive_branch_by_number(): "dfetch.commands.add.Prompt.ask", side_effect=lambda *a, **kw: next(prompt_answers), ): - with patch("dfetch.commands.add.Confirm.ask", return_value=True): + with patch( + "dfetch.commands.add.Confirm.ask", side_effect=[True, False] + ): with patch( "dfetch.commands.add.append_entry_manifest_file" ) as mock_append: @@ -327,13 +329,10 @@ def test_add_command_interactive_tag(): fake_superproject.manifest.remotes = [] fake_superproject.root_directory = Path("/some") - fake_subproject = Mock() - fake_subproject.get_default_branch.return_value = "main" - fake_subproject.list_of_branches.return_value = ["main"] - fake_subproject.list_of_tags.return_value = ["v1.0", "v2.0"] + fake_subproject = _make_subproject("main", ["main"], ["v1.0", "v2.0"]) # version prompt: type the tag name directly. - prompt_answers = iter(["myrepo", "myrepo", "v2.0", ""]) + prompt_answers = iter(["myrepo", "myrepo", "v2.0", "", ""]) with patch( "dfetch.commands.add.create_super_project", return_value=fake_superproject @@ -345,7 +344,9 @@ def test_add_command_interactive_tag(): "dfetch.commands.add.Prompt.ask", side_effect=lambda *a, **kw: next(prompt_answers), ): - with patch("dfetch.commands.add.Confirm.ask", return_value=True): + with patch( + "dfetch.commands.add.Confirm.ask", side_effect=[True, False] + ): with patch( "dfetch.commands.add.append_entry_manifest_file" ) as mock_append: @@ -369,12 +370,9 @@ def test_add_command_interactive_abort(): fake_superproject.manifest.remotes = [] fake_superproject.root_directory = Path("/some") - fake_subproject = Mock() - fake_subproject.get_default_branch.return_value = "main" - fake_subproject.list_of_branches.return_value = ["main"] - fake_subproject.list_of_tags.return_value = [] + fake_subproject = _make_subproject() - prompt_answers = iter(["myrepo", "myrepo", "main", ""]) + prompt_answers = iter(["myrepo", "myrepo", "main", "", ""]) with patch( "dfetch.commands.add.create_super_project", return_value=fake_superproject @@ -407,13 +405,10 @@ def test_add_command_interactive_with_src(): fake_superproject.manifest.remotes = [] fake_superproject.root_directory = Path("/some") - fake_subproject = Mock() - fake_subproject.get_default_branch.return_value = "main" - fake_subproject.list_of_branches.return_value = ["main"] - fake_subproject.list_of_tags.return_value = [] + fake_subproject = _make_subproject() - # Prompts: name, dst, version, src - answers = iter(["myrepo", "myrepo", "main", "include/"]) + # Prompts: name, dst, version, src, ignore + answers = iter(["myrepo", "myrepo", "main", "include/", ""]) with patch( "dfetch.commands.add.create_super_project", return_value=fake_superproject @@ -425,7 +420,9 @@ def test_add_command_interactive_with_src(): "dfetch.commands.add.Prompt.ask", side_effect=lambda *a, **kw: next(answers), ): - with patch("dfetch.commands.add.Confirm.ask", return_value=True): + with patch( + "dfetch.commands.add.Confirm.ask", side_effect=[True, False] + ): with patch( "dfetch.commands.add.append_entry_manifest_file" ) as mock_append: @@ -441,6 +438,82 @@ def test_add_command_interactive_with_src(): assert entry.source == "include/" +def test_add_command_interactive_with_ignore(): + """Interactive mode: providing ignore paths includes them in the entry.""" + fake_superproject = Mock() + fake_superproject.manifest = mock_manifest([], path="/some/dfetch.yaml") + fake_superproject.manifest.remotes = [] + fake_superproject.root_directory = Path("/some") + + fake_subproject = _make_subproject() + + # Prompts: name, dst, version, src (empty), ignore (comma-separated) + answers = iter(["myrepo", "myrepo", "main", "", "tests, docs"]) + + with patch( + "dfetch.commands.add.create_super_project", return_value=fake_superproject + ): + with patch( + "dfetch.commands.add.create_sub_project", return_value=fake_subproject + ): + with patch( + "dfetch.commands.add.Prompt.ask", + side_effect=lambda *a, **kw: next(answers), + ): + with patch( + "dfetch.commands.add.Confirm.ask", side_effect=[True, False] + ): + with patch( + "dfetch.commands.add.append_entry_manifest_file" + ) as mock_append: + Add()( + _make_args( + "https://github.com/org/myrepo.git", + interactive=True, + ) + ) + + mock_append.assert_called_once() + entry: ProjectEntry = mock_append.call_args[0][1] + assert list(entry.ignore) == ["tests", "docs"] + + +def test_add_command_interactive_run_update(): + """Interactive mode: confirming update calls the Update command.""" + fake_superproject = Mock() + fake_superproject.manifest = mock_manifest([], path="/some/dfetch.yaml") + fake_superproject.manifest.remotes = [] + fake_superproject.root_directory = Path("/some") + + fake_subproject = _make_subproject() + + prompt_answers = iter(["myrepo", "myrepo", "main", "", ""]) + + with patch( + "dfetch.commands.add.create_super_project", return_value=fake_superproject + ): + with patch( + "dfetch.commands.add.create_sub_project", return_value=fake_subproject + ): + with patch( + "dfetch.commands.add.Prompt.ask", + side_effect=lambda *a, **kw: next(prompt_answers), + ): + with patch( + "dfetch.commands.add.Confirm.ask", side_effect=[True, True] + ): + with patch("dfetch.commands.add.append_entry_manifest_file"): + with patch("dfetch.commands.update.Update.__call__") as mock_update: + Add()( + _make_args( + "https://github.com/org/myrepo.git", + interactive=True, + ) + ) + + mock_update.assert_called_once() + + # --------------------------------------------------------------------------- # Add command – remote matching # --------------------------------------------------------------------------- @@ -457,10 +530,7 @@ def test_add_command_matches_existing_remote(): fake_superproject.manifest.remotes = [fake_remote] fake_superproject.root_directory = Path("/some") - fake_subproject = Mock() - fake_subproject.get_default_branch.return_value = "main" - fake_subproject.list_of_branches.return_value = ["main"] - fake_subproject.list_of_tags.return_value = [] + fake_subproject = _make_subproject() with patch( "dfetch.commands.add.create_super_project", return_value=fake_superproject From b86e1d5d6aff0d07ed9c783bed1d91187c599fa2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Mar 2026 16:47:32 +0000 Subject: [PATCH 05/29] refactor(add): extract terminal utils, add VersionRef, split tree browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses separation of concerns, primitive obsession, and abstraction level consistency: - dfetch/util/terminal.py (new): Screen, read_key, is_tty, scrollable_pick, and named ANSI constants (RESET/BOLD/DIM/CYAN/MAGENTA/GREEN/YELLOW/REVERSE). Pure I/O with no dfetch domain knowledge. - VersionRef dataclass (frozen): replaces tuple[str, str] for version results; carries kind (Literal branch/tag/revision) + value and owns apply_to(entry_dict) so callers never switch on the string kind. - _run_tree_browser() + _tree_single_pick()/multi_pick(): replaces _tree_browser(…, multi: bool) -> list|str|None union return. Public API has unambiguous types; shared navigation loop is one private function. - _build_entry() extracted from _interactive_flow() so that flow stays at a uniform "ask questions" abstraction level. - _non_interactive_entry() extracts the else-branch of Add.__call__ for the same reason. - LsFn = Callable[[str], list[tuple[str, bool]]] defined in subproject.py (the base class that declares browse_tree) and imported by gitsubproject.py and add.py, replacing the repeated verbose annotation. https://claude.ai/code/session_01XnrSn9ar6cLpL6Y2qDDGXd --- dfetch/commands/add.py | 820 ++++++++++++++------------------ dfetch/project/gitsubproject.py | 14 +- dfetch/project/subproject.py | 17 +- dfetch/util/terminal.py | 221 +++++++++ 4 files changed, 595 insertions(+), 477 deletions(-) create mode 100644 dfetch/util/terminal.py diff --git a/dfetch/commands/add.py b/dfetch/commands/add.py index 48edd556..1d5c5957 100644 --- a/dfetch/commands/add.py +++ b/dfetch/commands/add.py @@ -44,10 +44,10 @@ import argparse import os import re -import sys -from collections.abc import Callable, Sequence +from collections.abc import Sequence from dataclasses import dataclass from pathlib import Path +from typing import Literal import semver from rich.prompt import Confirm, Prompt @@ -60,28 +60,36 @@ from dfetch.manifest.project import ProjectEntry, ProjectEntryDict from dfetch.manifest.remote import Remote from dfetch.project import create_sub_project, create_super_project -from dfetch.project.subproject import SubProject +from dfetch.project.subproject import LsFn, SubProject from dfetch.util.purl import vcs_url_to_purl +from dfetch.util import terminal logger = get_logger(__name__) +# Characters that are not allowed in a project name (YAML special chars). +_UNSAFE_NAME_RE = re.compile(r"[\x00-\x1F\x7F-\x9F:#\[\]{}&*!|>'\"%@`]") + + # --------------------------------------------------------------------------- -# ANSI codes used in the scrollable / tree UI (plain stdout; no Rich markup) +# Value objects # --------------------------------------------------------------------------- -_R = "\x1b[0m" # reset -_B = "\x1b[1m" # bold -_D = "\x1b[2m" # dim -_CYN = "\x1b[36m" # cyan – branches -_MAG = "\x1b[35m" # magenta – tags -_GRN = "\x1b[32m" # green – selected ✓ -_YLW = "\x1b[33m" # yellow – cursor ▶ -_HL = "\x1b[7m" # reverse-video – highlighted row - -# Viewport height for scrollable lists. -_VIEWPORT = 10 -# Characters that are not allowed in a project name (YAML special chars). -_UNSAFE_NAME_RE = re.compile(r"[\x00-\x1F\x7F-\x9F:#\[\]{}&*!|>'\"%@`]") + +@dataclass(frozen=True) +class VersionRef: + """A resolved version reference: a branch name, tag, or commit SHA.""" + + kind: Literal["branch", "tag", "revision"] + value: str + + def apply_to(self, entry: ProjectEntryDict) -> None: + """Write this version reference into *entry*.""" + if self.kind == "branch": + entry["branch"] = self.value + elif self.kind == "tag": + entry["tag"] = self.value + elif self.kind == "revision": + entry["revision"] = self.value # --------------------------------------------------------------------------- @@ -166,16 +174,13 @@ def __call__(self, args: argparse.Namespace) -> None: existing_projects=superproject.manifest.projects, ) else: - project_entry = ProjectEntry( - ProjectEntryDict( - name=probe_entry.name, - url=remote_url, - branch=default_branch, - dst=guessed_dst, - ), + project_entry = _non_interactive_entry( + name=probe_entry.name, + remote_url=remote_url, + branch=default_branch, + dst=guessed_dst, + remote_to_use=remote_to_use, ) - if remote_to_use: - project_entry.set_remote(remote_to_use) if project_entry is None: return @@ -197,8 +202,8 @@ def __call__(self, args: argparse.Namespace) -> None: logger.print_info_line(project_entry.name, "Added project to manifest") - # Offer to run update immediately (only when we prompted the user, i.e. - # not in --force mode where we want zero interaction). + # Offer to run update immediately (only when we already prompted the user, + # i.e. not in --force mode where we want zero interaction). if not args.force and Confirm.ask( f"Run 'dfetch update {project_entry.name}' now?", default=True ): @@ -212,6 +217,55 @@ def __call__(self, args: argparse.Namespace) -> None: Update()(update_args) +# --------------------------------------------------------------------------- +# Entry construction +# --------------------------------------------------------------------------- + + +def _non_interactive_entry( + *, + name: str, + remote_url: str, + branch: str, + dst: str, + remote_to_use: Remote | None, +) -> ProjectEntry: + """Build a ``ProjectEntry`` using inferred defaults (no user interaction).""" + entry = ProjectEntry( + ProjectEntryDict(name=name, url=remote_url, branch=branch, dst=dst) + ) + if remote_to_use: + entry.set_remote(remote_to_use) + return entry + + +def _build_entry( + *, + name: str, + remote_url: str, + dst: str, + version: VersionRef, + src: str, + ignore: list[str], + remote_to_use: Remote | None, +) -> ProjectEntry: + """Assemble a ``ProjectEntry`` from the fields collected by the wizard.""" + entry_dict: ProjectEntryDict = ProjectEntryDict( + name=name, + url=remote_url, + dst=dst, + ) + version.apply_to(entry_dict) + if src: + entry_dict["src"] = src + if ignore: + entry_dict["ignore"] = ignore + entry = ProjectEntry(entry_dict) + if remote_to_use: + entry.set_remote(remote_to_use) + return entry + + # --------------------------------------------------------------------------- # Interactive flow # --------------------------------------------------------------------------- @@ -225,205 +279,230 @@ def _interactive_flow( subproject: SubProject, remote_to_use: Remote | None, existing_projects: Sequence[ProjectEntry], -) -> ProjectEntry | None: - """Guide the user through every manifest field and return a ``ProjectEntry``. - - Returns ``None`` when the user aborts the wizard. - """ +) -> ProjectEntry: + """Guide the user through every manifest field and return a ``ProjectEntry``.""" logger.info("[bold blue]--- Interactive add wizard ---[/bold blue]") - # --- name --- name = _ask_name(default_name, existing_projects) - - # --- dst --- dst = _ask_dst(name, default_dst) - - # --- version (branch or tag) --- - branches = subproject.list_of_branches() - tags = subproject.list_of_tags() - version_type, version_value = _ask_version(default_branch, branches, tags) - - # --- src and ignore (browsed from a single minimal clone) --- + version = _ask_version( + default_branch, + subproject.list_of_branches(), + subproject.list_of_tags(), + ) with subproject.browse_tree() as ls_fn: src = _ask_src(ls_fn) ignore = _ask_ignore(ls_fn) - entry_dict: ProjectEntryDict = ProjectEntryDict( + return _build_entry( name=name, - url=remote_url, + remote_url=remote_url, dst=dst, + version=version, + src=src, + ignore=ignore, + remote_to_use=remote_to_use, ) - if version_type == "branch": - entry_dict["branch"] = version_value - elif version_type == "tag": - entry_dict["tag"] = version_value - elif version_type == "revision": - entry_dict["revision"] = version_value - if src: - entry_dict["src"] = src +# --------------------------------------------------------------------------- +# Individual prompt helpers +# --------------------------------------------------------------------------- - if ignore: - entry_dict["ignore"] = ignore - project_entry = ProjectEntry(entry_dict) - if remote_to_use: - project_entry.set_remote(remote_to_use) +def _ask_name(default: str, existing_projects: Sequence[ProjectEntry]) -> str: + """Prompt for the project name, re-asking on duplicates or invalid input.""" + existing_names = {p.name for p in existing_projects} + while True: + name = Prompt.ask(" [bold]Name[/bold]", default=default).strip() + if not name: + logger.warning("Name cannot be empty.") + continue + if _UNSAFE_NAME_RE.search(name): + logger.warning( + f"Name '{name}' contains characters not allowed in a manifest name. " + "Avoid: # : [ ] {{ }} & * ! | > ' \" % @ `" + ) + continue + if name in existing_names: + logger.warning( + f"A project named '{name}' already exists. Choose a different name." + ) + continue + return name + + +def _ask_dst(name: str, default: str) -> str: + """Prompt for the destination path, re-asking on path-traversal attempts.""" + suggested = default or name + while True: + dst = Prompt.ask( + " [bold]Destination[/bold] (path relative to manifest)", + default=suggested, + ).strip() + if not dst: + return name # fall back to project name + if any(part == ".." for part in Path(dst).parts): + logger.warning( + f"Destination '{dst}' contains '..'. " + "Paths must stay within the manifest directory." + ) + continue + return dst + + +def _ask_version( + default_branch: str, + branches: list[str], + tags: list[str], +) -> VersionRef: + """Choose a version (branch / tag / SHA) and return it as a ``VersionRef``. - return project_entry + In a TTY shows a scrollable pick list (all branches then all tags). + Outside a TTY (CI, pipe, tests) falls back to a numbered text menu. + """ + ordered_branches = _prioritise_default(branches, default_branch) + ordered_tags = _sort_tags_newest_first(tags) + + choices: list[VersionRef] = [ + *[VersionRef("branch", b) for b in ordered_branches], + *[VersionRef("tag", t) for t in ordered_tags], + ] + + if terminal.is_tty() and choices: + return _scrollable_version_pick(choices, default_branch) + + return _text_version_pick(choices, default_branch, branches, tags) + + +def _ask_src(ls_fn: LsFn) -> str: + """Optionally prompt for a ``src:`` sub-path or glob pattern. + + In a TTY opens a tree browser for single-path selection. + Outside a TTY falls back to a free-text prompt. + """ + if terminal.is_tty(): + return _tree_single_pick(ls_fn, "Source path (Enter to select, Esc to skip)") + + return Prompt.ask( + " [bold]Source path[/bold] (sub-path/glob, or Enter to fetch whole repo)", + default="", + ).strip() + + +def _ask_ignore(ls_fn: LsFn) -> list[str]: + """Optionally prompt for ``ignore:`` paths. + + In a TTY opens a tree browser with multi-select (Space to toggle, + Enter to confirm). Outside a TTY falls back to a comma-separated + free-text prompt. + """ + if terminal.is_tty(): + return _tree_multi_pick( + ls_fn, + "Ignore paths (Space to select, Enter to confirm, Esc to skip)", + ) + + raw = Prompt.ask( + " [bold]Ignore paths[/bold] (comma-separated, or Enter to skip)", + default="", + ).strip() + return [p.strip() for p in raw.split(",") if p.strip()] if raw else [] # --------------------------------------------------------------------------- -# Terminal UI helpers +# Version pickers # --------------------------------------------------------------------------- -def _is_tty() -> bool: - """Return True when running attached to an interactive terminal.""" - return sys.stdin.isatty() and not os.environ.get("CI") - - -def _read_key() -> str: # pragma: no cover – raw terminal input - """Read one keypress from stdin in raw mode; return a normalised key name.""" - if sys.platform == "win32": - import msvcrt # type: ignore[import] - - ch = msvcrt.getwch() - if ch in ("\x00", "\xe0"): - ch2 = msvcrt.getwch() - return { - "H": "UP", - "P": "DOWN", - "K": "LEFT", - "M": "RIGHT", - "I": "PGUP", - "Q": "PGDN", - }.get(ch2, "UNKNOWN") - if ch in ("\r", "\n"): - return "ENTER" - if ch == "\x1b": - return "ESC" - if ch == " ": - return "SPACE" - if ch == "\x03": - raise KeyboardInterrupt - return ch - else: - import select as _select - import termios - import tty - - fd = sys.stdin.fileno() - old = termios.tcgetattr(fd) - try: - tty.setraw(fd) - ch = os.read(fd, 1) - if ch in (b"\r", b"\n"): - return "ENTER" - if ch == b"\x1b": - r, _, _ = _select.select([fd], [], [], 0.05) - if r: - rest = b"" - while True: - r2, _, _ = _select.select([fd], [], [], 0.01) - if not r2: - break - rest += os.read(fd, 1) - return { - b"\x1b[A": "UP", - b"\x1b[B": "DOWN", - b"\x1b[C": "RIGHT", - b"\x1b[D": "LEFT", - b"\x1b[5~": "PGUP", - b"\x1b[6~": "PGDN", - }.get(ch + rest, "ESC") - return "ESC" - if ch == b" ": - return "SPACE" - if ch in (b"\x03", b"\x04"): - raise KeyboardInterrupt - return ch.decode("utf-8", errors="replace") - finally: - termios.tcsetattr(fd, termios.TCSADRAIN, old) - - -class _Screen: - """Minimal ANSI helper for in-place redraw (writes directly to stdout).""" - - def __init__(self) -> None: - self._lines = 0 - - def draw(self, lines: list[str]) -> None: - if self._lines: - sys.stdout.write(f"\x1b[{self._lines}A\x1b[0J") - sys.stdout.write("\n".join(lines) + "\n") - sys.stdout.flush() - self._lines = len(lines) - - def clear(self) -> None: - if self._lines: - sys.stdout.write(f"\x1b[{self._lines}A\x1b[0J") - sys.stdout.flush() - self._lines = 0 - - -def _scrollable_pick( - title: str, - display_items: list[str], - *, - default_idx: int = 0, -) -> int | None: # pragma: no cover – interactive TTY only - """Scrollable single-pick list. +def _scrollable_version_pick( + choices: list[VersionRef], + default_branch: str, +) -> VersionRef: # pragma: no cover – interactive TTY only + """Scrollable version picker; falls back to free-text prompt on Esc.""" + default_idx = 0 + display: list[str] = [] + for i, ref in enumerate(choices): + if ref.kind == "branch": + suffix = ( + f" {terminal.DIM}(default){terminal.RESET}" + if ref.value == default_branch + else "" + ) + display.append( + f"{terminal.CYAN}{ref.value}{terminal.RESET}" + f" {terminal.DIM}branch{terminal.RESET}{suffix}" + ) + if ref.value == default_branch and default_idx == 0: + default_idx = i + else: + display.append( + f"{terminal.MAGENTA}{ref.value}{terminal.RESET}" + f" {terminal.DIM}tag{terminal.RESET}" + ) - *display_items* are pre-formatted strings (may include raw ANSI codes). - Returns the selected index, or ``None`` when the user pressed Esc. - """ - screen = _Screen() - idx = default_idx - top = 0 - n = len(display_items) + selected = terminal.scrollable_pick("Version", display, default_idx=default_idx) + if selected is not None: + return choices[selected] + + # Esc pressed — fall back to free-text + branches = [c.value for c in choices if c.kind == "branch"] + tags = [c.value for c in choices if c.kind == "tag"] + return _text_version_pick(choices, default_branch, branches, tags) + + +def _text_version_pick( + choices: list[VersionRef], + default_branch: str, + branches: list[str], + tags: list[str], +) -> VersionRef: + """Numbered text-based version picker (non-TTY fallback).""" + _print_version_menu(choices, default_branch) while True: - idx = max(0, min(idx, n - 1)) - if idx < top: - top = idx - elif idx >= top + _VIEWPORT: - top = idx - _VIEWPORT + 1 + raw = Prompt.ask( + " [bold]Version[/bold] (number, branch, tag, or SHA)", + default=default_branch, + ).strip() - lines: list[str] = [f" {_B}{title}{_R}"] - for i in range(top, min(top + _VIEWPORT, n)): - cursor = f"{_YLW}▶{_R}" if i == idx else " " - hl_s = _HL if i == idx else "" - hl_e = _R if i == idx else "" - lines.append(f" {cursor} {hl_s}{display_items[i]}{hl_e}") + if raw.isdigit(): + idx = int(raw) - 1 + if 0 <= idx < len(choices): + return choices[idx] + logger.warning(f" Pick a number between 1 and {len(choices)}.") + continue - if top > 0: - lines.append(f" {_D}↑ {top} more above{_R}") - remaining = n - (top + _VIEWPORT) - if remaining > 0: - lines.append(f" {_D}↓ {remaining} more below{_R}") + if raw in branches: + return VersionRef("branch", raw) + if raw in tags: + return VersionRef("tag", raw) + if re.fullmatch(r"[0-9a-fA-F]{7,40}", raw): + return VersionRef("revision", raw) + if raw: + return VersionRef("branch", raw) + + logger.warning(" Please enter a number or a version value.") + + +def _print_version_menu(choices: list[VersionRef], default_branch: str) -> None: + """Render the numbered branch/tag pick list (text fallback).""" + if not choices: + return + + lines: list[str] = [] + for i, ref in enumerate(choices, start=1): + marker = ( + " (default)" if ref.value == default_branch and ref.kind == "branch" else "" + ) + colour = "cyan" if ref.kind == "branch" else "magenta" lines.append( - f" {_D}↑/↓ navigate PgUp/PgDn jump Enter select Esc free-type{_R}" + f" [bold white]{i:>2}[/bold white]" + f" [{colour}]{ref.value}[/{colour}]{marker}" + f" [dim]{ref.kind}[/dim]" ) - screen.draw(lines) - key = _read_key() - - if key == "UP": - idx -= 1 - elif key == "DOWN": - idx += 1 - elif key == "PGUP": - idx = max(0, idx - _VIEWPORT) - elif key == "PGDN": - idx = min(n - 1, idx + _VIEWPORT) - elif key == "ENTER": - screen.clear() - return idx - elif key == "ESC": - screen.clear() - return None + logger.info("\n".join(lines)) # --------------------------------------------------------------------------- @@ -433,8 +512,10 @@ def _scrollable_pick( @dataclass class _TreeNode: + """One entry in the flattened view of a remote VCS tree.""" + name: str - path: str # relative from repo root + path: str # path relative to repo root is_dir: bool depth: int = 0 expanded: bool = False @@ -442,15 +523,10 @@ class _TreeNode: children_loaded: bool = False -def _expand_node( - nodes: list[_TreeNode], - idx: int, - ls_fn: Callable[[str], list[tuple[str, bool]]], -) -> None: - """Expand the directory node at *idx*, loading children if needed.""" +def _expand_node(nodes: list[_TreeNode], idx: int, ls_fn: LsFn) -> None: + """Expand the directory node at *idx*, loading children if not yet done.""" node = nodes[idx] if not node.children_loaded: - entries = ls_fn(node.path) children = [ _TreeNode( name=name, @@ -458,7 +534,7 @@ def _expand_node( is_dir=is_dir, depth=node.depth + 1, ) - for name, is_dir in entries + for name, is_dir in ls_fn(node.path) ] nodes[idx + 1 : idx + 1] = children node.children_loaded = True @@ -466,41 +542,58 @@ def _expand_node( def _collapse_node(nodes: list[_TreeNode], idx: int) -> None: - """Collapse the directory node at *idx*, removing all descendant nodes.""" + """Collapse the directory node at *idx* and remove all descendant nodes.""" parent_depth = nodes[idx].depth - i = idx + 1 - while i < len(nodes) and nodes[i].depth > parent_depth: - i += 1 - del nodes[idx + 1 : i] + end = idx + 1 + while end < len(nodes) and nodes[end].depth > parent_depth: + end += 1 + del nodes[idx + 1 : end] nodes[idx].expanded = False -def _tree_browser( - ls_fn: Callable[[str], list[tuple[str, bool]]], +def _render_tree_lines( + nodes: list[_TreeNode], idx: int, top: int, *, hint: str +) -> list[str]: + """Build the list of display strings for one frame of the tree browser.""" + n = len(nodes) + lines: list[str] = [] + for i in range(top, min(top + terminal.VIEWPORT, n)): + node = nodes[i] + indent = " " * node.depth + icon = ("▼ " if node.expanded else "▶ ") if node.is_dir else " " + highlight = terminal.REVERSE if i == idx else "" + check = f"{terminal.GREEN}✓{terminal.RESET} " if node.selected else " " + lines.append(f" {highlight}{indent}{check}{icon}{node.name}{terminal.RESET}") + return lines + + +def _run_tree_browser( + ls_fn: LsFn, title: str, *, - multi: bool = False, -) -> list[str] | str | None: # pragma: no cover – interactive TTY only - """Interactive tree browser. - - ``multi=False`` (default) — single-select; returns the selected path - string, ``""`` if the user skips with Esc, or ``None`` on keyboard - interrupt. + multi: bool, +) -> list[str]: # pragma: no cover – interactive TTY only + """Core tree browser loop. - ``multi=True`` — multi-select; Space toggles selection, Enter confirms; - returns a (possibly empty) ``list[str]``, or ``None`` on keyboard - interrupt. + Returns a list of selected paths. In single-select mode the list has + at most one item; in multi-select mode it may have any number. """ root_entries = ls_fn("") if not root_entries: - return [] if multi else "" + return [] nodes: list[_TreeNode] = [ _TreeNode(name=name, path=name, is_dir=is_dir, depth=0) for name, is_dir in root_entries ] - screen = _Screen() + hint = ( + "↑/↓ navigate Space select Enter confirm →/← expand/collapse Esc skip" + if multi + else "↑/↓ navigate Enter/Space select →/← expand/collapse Esc skip" + ) + + screen = terminal.Screen() idx = 0 top = 0 @@ -508,44 +601,28 @@ def _tree_browser( n = len(nodes) if n == 0: screen.clear() - return [] if multi else "" + return [] idx = max(0, min(idx, n - 1)) if idx < top: top = idx - elif idx >= top + _VIEWPORT: - top = idx - _VIEWPORT + 1 - - lines: list[str] = [f" {_B}{title}{_R}"] - for i in range(top, min(top + _VIEWPORT, n)): - node = nodes[i] - indent = " " * node.depth - if node.is_dir: - arrow = "▼" if node.expanded else "▶" - icon = f"{arrow} " - else: - icon = " " - cursor = _HL if i == idx else "" - sel = f"{_GRN}✓{_R} " if node.selected else " " - lines.append(f" {cursor}{indent}{sel}{icon}{node.name}{_R}") + elif idx >= top + terminal.VIEWPORT: + top = idx - terminal.VIEWPORT + 1 + header = [f" {terminal.BOLD}{title}{terminal.RESET}"] + body = _render_tree_lines(nodes, idx, top, hint=hint) + scroll_hints = [] if top > 0: - lines.append(f" {_D}↑ {top} more above{_R}") - remaining = n - (top + _VIEWPORT) + scroll_hints.append(f" {terminal.DIM}↑ {top} more above{terminal.RESET}") + remaining = n - (top + terminal.VIEWPORT) if remaining > 0: - lines.append(f" {_D}↓ {remaining} more below{_R}") - - if multi: - lines.append( - f" {_D}↑/↓ navigate Space select Enter confirm →/← expand/collapse Esc skip{_R}" - ) - else: - lines.append( - f" {_D}↑/↓ navigate Enter/Space select →/← expand/collapse Esc skip{_R}" + scroll_hints.append( + f" {terminal.DIM}↓ {remaining} more below{terminal.RESET}" ) + footer = [f" {terminal.DIM}{hint}{terminal.RESET}"] - screen.draw(lines) - key = _read_key() + screen.draw(header + body + scroll_hints + footer) + key = terminal.read_key() node = nodes[idx] if key == "UP": @@ -553,9 +630,9 @@ def _tree_browser( elif key == "DOWN": idx += 1 elif key == "PGUP": - idx = max(0, idx - _VIEWPORT) + idx = max(0, idx - terminal.VIEWPORT) elif key == "PGDN": - idx = min(n - 1, idx + _VIEWPORT) + idx = min(n - 1, idx + terminal.VIEWPORT) elif key == "RIGHT": if node.is_dir and not node.expanded: _expand_node(nodes, idx, ls_fn) @@ -563,7 +640,6 @@ def _tree_browser( if node.is_dir and node.expanded: _collapse_node(nodes, idx) elif node.depth > 0: - # Jump to parent node for i in range(idx - 1, -1, -1): if nodes[i].depth < node.depth: idx = i @@ -573,13 +649,11 @@ def _tree_browser( node.selected = not node.selected elif not node.is_dir: screen.clear() - return node.path + return [node.path] elif key == "ENTER": if multi: - # Confirm all selected items - selected = [nd.path for nd in nodes if nd.selected] screen.clear() - return selected + return [nd.path for nd in nodes if nd.selected] elif node.is_dir: if node.expanded: _collapse_node(nodes, idx) @@ -587,205 +661,27 @@ def _tree_browser( _expand_node(nodes, idx, ls_fn) else: screen.clear() - return node.path + return [node.path] elif key == "ESC": screen.clear() - return [] if multi else "" - + return [] -# --------------------------------------------------------------------------- -# Individual prompt helpers -# --------------------------------------------------------------------------- +def _tree_single_pick(ls_fn: LsFn, title: str) -> str: + """Browse the remote tree and return a single selected path. -def _ask_name(default: str, existing_projects: Sequence[ProjectEntry]) -> str: - """Prompt for the project name, re-asking on duplicates or invalid input.""" - existing_names = {p.name for p in existing_projects} - while True: - name = Prompt.ask(" [bold]Name[/bold]", default=default).strip() - if not name: - logger.warning("Name cannot be empty.") - continue - if _UNSAFE_NAME_RE.search(name): - logger.warning( - f"Name '{name}' contains characters not allowed in a manifest name. " - "Avoid: # : [ ] {{ }} & * ! | > ' \" % @ `" - ) - continue - if name in existing_names: - logger.warning( - f"A project named '{name}' already exists. Choose a different name." - ) - continue - return name - - -def _ask_dst(name: str, default: str) -> str: - """Prompt for the destination path, re-asking on path-traversal attempts.""" - suggested = default or name - while True: - dst = Prompt.ask( - " [bold]Destination[/bold] (path relative to manifest)", - default=suggested, - ).strip() - if not dst: - return name # fall back to project name - if any(part == ".." for part in Path(dst).parts): - logger.warning( - f"Destination '{dst}' contains '..'. Paths must stay within the manifest directory." - ) - continue - return dst - - -def _ask_version( - default_branch: str, - branches: list[str], - tags: list[str], -) -> tuple[str, str]: - """Choose a version (branch / tag / SHA). - - In a TTY shows a scrollable pick list (all branches then all tags). - Outside a TTY (CI, pipe, tests) falls back to a numbered text menu. + Returns ``""`` if the user skips (Esc) or no tree is available. """ - ordered_branches = _prioritise_default(branches, default_branch) - ordered_tags = _sort_tags_newest_first(tags) + result = _run_tree_browser(ls_fn, title, multi=False) + return result[0] if result else "" - # (vtype, value) — all branches first, then all tags - choices: list[tuple[str, str]] = [ - *[("branch", b) for b in ordered_branches], - *[("tag", t) for t in ordered_tags], - ] - if _is_tty() and choices: - return _scrollable_version_pick(choices, default_branch) +def _tree_multi_pick(ls_fn: LsFn, title: str) -> list[str]: + """Browse the remote tree and return all selected paths. - return _text_version_pick(choices, default_branch, branches, tags) - - -def _scrollable_version_pick( - choices: list[tuple[str, str]], - default_branch: str, -) -> tuple[str, str]: # pragma: no cover – interactive TTY only - """Scrollable version picker; falls back to free-text on Esc.""" - display: list[str] = [] - default_idx = 0 - for i, (vtype, val) in enumerate(choices): - if vtype == "branch": - suffix = f" {_D}(default){_R}" if val == default_branch else "" - display.append(f"{_CYN}{val}{_R} {_D}branch{_R}{suffix}") - if val == default_branch and default_idx == 0: - default_idx = i - else: - display.append(f"{_MAG}{val}{_R} {_D}tag{_R}") - - result = _scrollable_pick("Version", display, default_idx=default_idx) - if result is not None: - return choices[result] - - # Esc pressed — fall back to free-text - branches = [v for t, v in choices if t == "branch"] - tags = [v for t, v in choices if t == "tag"] - return _text_version_pick(choices, default_branch, branches, tags) - - -def _text_version_pick( - choices: list[tuple[str, str]], - default_branch: str, - branches: list[str], - tags: list[str], -) -> tuple[str, str]: - """Numbered text-based version picker (non-TTY fallback).""" - _print_version_menu(choices, default_branch) - - while True: - raw = Prompt.ask( - " [bold]Version[/bold] (number, branch, tag, or SHA)", - default=default_branch, - ).strip() - - if raw.isdigit(): - idx = int(raw) - 1 - if 0 <= idx < len(choices): - return choices[idx] - logger.warning(f" Pick a number between 1 and {len(choices)}.") - continue - - if raw in branches: - return ("branch", raw) - if raw in tags: - return ("tag", raw) - - if re.fullmatch(r"[0-9a-fA-F]{7,40}", raw): - return ("revision", raw) - - if raw: - return ("branch", raw) - - logger.warning(" Please enter a number or a version value.") - - -def _print_version_menu(choices: list[tuple[str, str]], default_branch: str) -> None: - """Render the numbered branch/tag pick list (text fallback).""" - if not choices: - return - - lines: list[str] = [] - for i, (vtype, value) in enumerate(choices, start=1): - marker = " (default)" if value == default_branch and vtype == "branch" else "" - colour = "cyan" if vtype == "branch" else "magenta" - tag_label = f"[dim]{vtype}[/dim]" - lines.append( - f" [bold white]{i:>2}[/bold white] [{colour}]{value}[/{colour}]{marker} {tag_label}" - ) - - logger.info("\n".join(lines)) - - -def _ask_src(ls_fn: Callable[[str], list[tuple[str, bool]]]) -> str: - """Optionally prompt for a ``src:`` sub-path or glob pattern. - - In a TTY opens a tree browser for single-path selection. - Outside a TTY falls back to a free-text prompt. - """ - if _is_tty(): - result = _tree_browser( - ls_fn, "Source path (Enter to select, Esc to skip)", multi=False - ) - if result and isinstance(result, str): - return result - return "" - - return Prompt.ask( - " [bold]Source path[/bold] (sub-path/glob, or Enter to fetch whole repo)", - default="", - ).strip() - - -def _ask_ignore(ls_fn: Callable[[str], list[tuple[str, bool]]]) -> list[str]: - """Optionally prompt for ``ignore:`` paths. - - In a TTY opens a tree browser with multi-select (Space to toggle, - Enter to confirm). Outside a TTY falls back to a comma-separated - free-text prompt. + Returns ``[]`` if the user skips (Esc) or nothing is selected. """ - if _is_tty(): - result = _tree_browser( - ls_fn, - "Ignore paths (Space to select, Enter to confirm, Esc to skip)", - multi=True, - ) - if isinstance(result, list): - return result - return [] - - raw = Prompt.ask( - " [bold]Ignore paths[/bold] (comma-separated, or Enter to skip)", - default="", - ).strip() - if not raw: - return [] - return [p.strip() for p in raw.split(",") if p.strip()] + return _run_tree_browser(ls_fn, title, multi=True) # --------------------------------------------------------------------------- @@ -802,17 +698,16 @@ def _prioritise_default(branches: list[str], default: str) -> list[str]: def _sort_tags_newest_first(tags: list[str]) -> list[str]: - """Sort *tags* with semver-parseable tags newest-first; others appended.""" + """Sort *tags* newest-semver-first; non-semver tags appended as-is.""" def _semver_key(tag: str) -> semver.Version | None: - cleaned = tag.lstrip("vV") try: - return semver.Version.parse(cleaned) + return semver.Version.parse(tag.lstrip("vV")) except ValueError: return None semver_tags = sorted( - [t for t in tags if _semver_key(t) is not None], + (t for t in tags if _semver_key(t) is not None), key=lambda t: _semver_key(t), # type: ignore[arg-type, return-value] reverse=True, ) @@ -848,15 +743,16 @@ def _guess_destination( return "" common_path = os.path.commonpath(destinations) + if not common_path or common_path == os.path.sep: + return "" + + if len(destinations) == 1: + parent = str(Path(common_path).parent) + if parent and parent != ".": + return (Path(parent) / project_name).as_posix() + return "" - if common_path and common_path != os.path.sep: - if len(destinations) == 1: - parent = str(Path(common_path).parent) - if parent and parent != ".": - return (Path(parent) / project_name).as_posix() - return "" - return (Path(common_path) / project_name).as_posix() - return "" + return (Path(common_path) / project_name).as_posix() def _determine_remote(remotes: Sequence[Remote], remote_url: str) -> Remote | None: diff --git a/dfetch/project/gitsubproject.py b/dfetch/project/gitsubproject.py index 7ffe5312..28830933 100644 --- a/dfetch/project/gitsubproject.py +++ b/dfetch/project/gitsubproject.py @@ -4,14 +4,14 @@ import pathlib import shutil import tempfile -from collections.abc import Callable, Generator +from collections.abc import Generator from functools import lru_cache from dfetch.log import get_logger from dfetch.manifest.project import ProjectEntry from dfetch.manifest.version import Version from dfetch.project.metadata import Dependency -from dfetch.project.subproject import SubProject +from dfetch.project.subproject import LsFn, SubProject from dfetch.util.util import LICENSE_GLOBS, safe_rm from dfetch.vcs.git import CheckoutOptions, GitLocalRepo, GitRemote, get_git_version @@ -49,16 +49,14 @@ def list_of_branches(self) -> list[str]: return [str(branch) for branch in self._remote_repo.list_of_branches()] @contextlib.contextmanager - def browse_tree( - self, - ) -> Generator[Callable[[str], list[tuple[str, bool]]], None, None]: + def browse_tree(self) -> Generator[LsFn, None, None]: """Shallow-clone the remote and yield a tree-listing callable. - The yielded ``ls_fn(path="")`` calls ``git ls-tree HEAD`` on the - temporary clone. The clone is removed on context exit. + The yielded ``LsFn`` calls ``git ls-tree HEAD`` on a temporary + blobless clone. The clone is removed on context exit. """ tmpdir = tempfile.mkdtemp(prefix="dfetch_browse_") - ls_fn: Callable[[str], list[tuple[str, bool]]] + ls_fn: LsFn try: GitRemote.clone_minimal(self._remote_repo._remote, tmpdir) ls_fn = lambda path="": GitRemote.ls_tree(tmpdir, path=path) # noqa: E731 diff --git a/dfetch/project/subproject.py b/dfetch/project/subproject.py index cf2b67f1..a26cc86e 100644 --- a/dfetch/project/subproject.py +++ b/dfetch/project/subproject.py @@ -15,6 +15,11 @@ from dfetch.util.versions import latest_tag_from_list from dfetch.vcs.patch import Patch +# A callable that lists the contents of a VCS tree path. +# Accepts a path relative to the repo root (empty string for the root) +# and returns ``(name, is_dir)`` pairs for each entry at that path. +LsFn = Callable[[str], list[tuple[str, bool]]] + logger = get_logger(__name__) @@ -410,15 +415,13 @@ def list_of_tags(self) -> list[str]: return self._list_of_tags() @contextlib.contextmanager - def browse_tree( - self, - ) -> Generator[Callable[[str], list[tuple[str, bool]]], None, None]: + def browse_tree(self) -> Generator[LsFn, None, None]: """Context manager yielding a function to list remote tree contents. - The yielded callable accepts an optional path (relative to repo root) - and returns a list of ``(name, is_dir)`` pairs. The default - implementation returns an empty list; VCS-specific subclasses override - this to perform a real remote tree walk. + The yielded ``LsFn`` accepts an optional path (relative to the repo + root) and returns ``(name, is_dir)`` pairs for each entry. The + default implementation returns an empty list; VCS-specific subclasses + override this to perform a real remote tree walk. """ yield lambda path="": [] diff --git a/dfetch/util/terminal.py b/dfetch/util/terminal.py new file mode 100644 index 00000000..bd64b831 --- /dev/null +++ b/dfetch/util/terminal.py @@ -0,0 +1,221 @@ +"""Low-level interactive terminal utilities. + +Provides cross-platform raw-key reading, ANSI helpers, and a generic +scrollable single-pick list widget. All symbols here are pure I/O +primitives with no dfetch domain knowledge. +""" + +import os +import sys +from collections.abc import Sequence + +# --------------------------------------------------------------------------- +# ANSI escape sequences +# --------------------------------------------------------------------------- + +RESET = "\x1b[0m" +BOLD = "\x1b[1m" +DIM = "\x1b[2m" +REVERSE = "\x1b[7m" # swap fore/background – used for cursor highlight +CYAN = "\x1b[36m" +MAGENTA = "\x1b[35m" +GREEN = "\x1b[32m" +YELLOW = "\x1b[33m" + +# Viewport height for scrollable list widgets (number of items shown at once). +VIEWPORT = 10 + + +# --------------------------------------------------------------------------- +# TTY detection +# --------------------------------------------------------------------------- + + +def is_tty() -> bool: + """Return True when stdin is an interactive terminal (not CI, not piped).""" + return sys.stdin.isatty() and not os.environ.get("CI") + + +# --------------------------------------------------------------------------- +# Raw key reading +# --------------------------------------------------------------------------- + + +def read_key() -> str: # pragma: no cover – requires live terminal + """Read one keypress from stdin in raw mode; return a normalised key name. + + Possible return values: ``"UP"``, ``"DOWN"``, ``"LEFT"``, ``"RIGHT"``, + ``"PGUP"``, ``"PGDN"``, ``"ENTER"``, ``"SPACE"``, ``"ESC"``, or a + single printable character string. + + Raises ``KeyboardInterrupt`` on Ctrl-C / Ctrl-D. + """ + if sys.platform == "win32": + return _read_key_windows() + return _read_key_unix() + + +def _read_key_windows() -> str: # pragma: no cover + import msvcrt # type: ignore[import] + + ch = msvcrt.getwch() # type: ignore[attr-defined] + if ch in ("\x00", "\xe0"): + arrow = { + "H": "UP", + "P": "DOWN", + "K": "LEFT", + "M": "RIGHT", + "I": "PGUP", + "Q": "PGDN", + } + return arrow.get(msvcrt.getwch(), "UNKNOWN") # type: ignore[attr-defined] + if ch in ("\r", "\n"): + return "ENTER" + if ch == "\x1b": + return "ESC" + if ch == " ": + return "SPACE" + if ch == "\x03": + raise KeyboardInterrupt + return ch + + +def _read_key_unix() -> str: # pragma: no cover + import select as _select + import termios + import tty + + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + try: + tty.setraw(fd) + ch = os.read(fd, 1) + + if ch in (b"\r", b"\n"): + return "ENTER" + + if ch == b"\x1b": + readable, _, _ = _select.select([fd], [], [], 0.05) + if readable: + rest = b"" + while True: + more, _, _ = _select.select([fd], [], [], 0.01) + if not more: + break + rest += os.read(fd, 1) + escape_sequences = { + b"\x1b[A": "UP", + b"\x1b[B": "DOWN", + b"\x1b[C": "RIGHT", + b"\x1b[D": "LEFT", + b"\x1b[5~": "PGUP", + b"\x1b[6~": "PGDN", + } + return escape_sequences.get(ch + rest, "ESC") + return "ESC" + + if ch == b" ": + return "SPACE" + if ch in (b"\x03", b"\x04"): + raise KeyboardInterrupt + + return ch.decode("utf-8", errors="replace") + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + + +# --------------------------------------------------------------------------- +# In-place screen redraw +# --------------------------------------------------------------------------- + + +class Screen: + """Minimal ANSI helper for in-place redraw. + + Tracks the number of lines last written so that each ``draw()`` call + moves the cursor back up and overwrites them without flicker. + """ + + def __init__(self) -> None: + self._line_count = 0 + + def draw(self, lines: Sequence[str]) -> None: + """Overwrite the previously drawn content with *lines*.""" + if self._line_count: + sys.stdout.write(f"\x1b[{self._line_count}A\x1b[0J") + sys.stdout.write("\n".join(lines) + "\n") + sys.stdout.flush() + self._line_count = len(lines) + + def clear(self) -> None: + """Erase the previously drawn content.""" + if self._line_count: + sys.stdout.write(f"\x1b[{self._line_count}A\x1b[0J") + sys.stdout.flush() + self._line_count = 0 + + +# --------------------------------------------------------------------------- +# Scrollable pick widget +# --------------------------------------------------------------------------- + + +def scrollable_pick( + title: str, + display_items: list[str], + *, + default_idx: int = 0, +) -> int | None: # pragma: no cover – interactive TTY only + """Display a scrollable single-pick list; return the selected index or None on Esc. + + *display_items* are pre-formatted display strings (may contain raw ANSI + codes). Navigate with ↑/↓ or PgUp/PgDn; confirm with Enter; cancel + with Esc (returns ``None``). + """ + screen = Screen() + idx = default_idx + top = 0 + n = len(display_items) + + while True: + idx = max(0, min(idx, n - 1)) + if idx < top: + top = idx + elif idx >= top + VIEWPORT: + top = idx - VIEWPORT + 1 + + lines: list[str] = [f" {BOLD}{title}{RESET}"] + for i in range(top, min(top + VIEWPORT, n)): + cursor = f"{YELLOW}▶{RESET}" if i == idx else " " + highlight_start = REVERSE if i == idx else "" + highlight_end = RESET if i == idx else "" + lines.append( + f" {cursor} {highlight_start}{display_items[i]}{highlight_end}" + ) + + if top > 0: + lines.append(f" {DIM}↑ {top} more above{RESET}") + remaining = n - (top + VIEWPORT) + if remaining > 0: + lines.append(f" {DIM}↓ {remaining} more below{RESET}") + lines.append( + f" {DIM}↑/↓ navigate PgUp/PgDn jump Enter select Esc free-type{RESET}" + ) + + screen.draw(lines) + key = read_key() + + if key == "UP": + idx -= 1 + elif key == "DOWN": + idx += 1 + elif key == "PGUP": + idx = max(0, idx - VIEWPORT) + elif key == "PGDN": + idx = min(n - 1, idx + VIEWPORT) + elif key == "ENTER": + screen.clear() + return idx + elif key == "ESC": + screen.clear() + return None From 325440bb7d3805b8f8fba6ec81c3d51373a38724 Mon Sep 17 00:00:00 2001 From: Ben Spoor Date: Mon, 23 Mar 2026 21:35:28 +0100 Subject: [PATCH 06/29] Improve --- dfetch/commands/add.py | 588 ++++++++++++++++++++++++-------- dfetch/log.py | 8 +- dfetch/manifest/project.py | 1 + dfetch/project/gitsubproject.py | 20 +- dfetch/project/subproject.py | 16 +- dfetch/util/terminal.py | 239 ++++++++++--- dfetch/vcs/archive.py | 6 +- dfetch/vcs/git.py | 49 +-- features/steps/add_steps.py | 6 +- script/package.py | 2 +- tests/test_add.py | 12 +- 11 files changed, 704 insertions(+), 243 deletions(-) diff --git a/dfetch/commands/add.py b/dfetch/commands/add.py index 1d5c5957..0828ea91 100644 --- a/dfetch/commands/add.py +++ b/dfetch/commands/add.py @@ -1,3 +1,4 @@ +# pylint: disable=too-many-lines """*Dfetch* can add projects to the manifest through the CLI. Sometimes you want to add a project to your manifest, but you don't want to @@ -26,13 +27,13 @@ The wizard walks through: -* **name** – defaults to the repository name extracted from the URL -* **dst** – local destination; defaults to a path guessed from existing projects -* **version** – scrollable list of all branches and tags (arrow keys to +* **name** - defaults to the repository name extracted from the URL +* **dst** - local destination; defaults to a path guessed from existing projects +* **version** - scrollable list of all branches and tags (arrow keys to navigate, Enter to select, Esc to fall back to free-text input) -* **src** – optional sub-path; browse the remote tree with arrow keys, +* **src** - optional sub-path; browse the remote tree with arrow keys, expand/collapse folders with Enter/Right/Left -* **ignore** – optional list of paths to exclude; same tree browser with +* **ignore** - optional list of paths to exclude; same tree browser with Space to toggle multiple selections and Enter to confirm After confirming the add you are offered to run ``dfetch update`` immediately @@ -41,9 +42,12 @@ .. scenario-include:: ../features/add-project-through-cli.feature """ +from __future__ import annotations + import argparse import os import re +import sys from collections.abc import Sequence from dataclasses import dataclass from pathlib import Path @@ -61,8 +65,8 @@ from dfetch.manifest.remote import Remote from dfetch.project import create_sub_project, create_super_project from dfetch.project.subproject import LsFn, SubProject -from dfetch.util.purl import vcs_url_to_purl from dfetch.util import terminal +from dfetch.util.purl import vcs_url_to_purl logger = get_logger(__name__) @@ -148,7 +152,8 @@ def __call__(self, args: argparse.Namespace) -> None: # Determines VCS type; tries to reach the remote. subproject = create_sub_project(probe_entry) - _check_name_uniqueness(probe_entry.name, superproject.manifest.projects) + if not args.interactive: + _check_name_uniqueness(probe_entry.name, superproject.manifest.projects) remote_to_use = _determine_remote( superproject.manifest.remotes, probe_entry.remote_url @@ -185,14 +190,10 @@ def __call__(self, args: argparse.Namespace) -> None: if project_entry is None: return - logger.print_overview( - project_entry.name, - "Will add following entry to manifest:", - project_entry.as_yaml(), - ) - if not args.force and not Confirm.ask("Add project to manifest?", default=True): - logger.print_warning_line(project_entry.name, "Aborting add of project") + logger.info( + " [bold bright_yellow]> Aborting add of project[/bold bright_yellow]" + ) return append_entry_manifest_file( @@ -200,13 +201,17 @@ def __call__(self, args: argparse.Namespace) -> None: project_entry, ) - logger.print_info_line(project_entry.name, "Added project to manifest") + logger.print_info_line( + project_entry.name, + f"Added '{project_entry.name}' to manifest '{superproject.manifest.path}'", + ) # Offer to run update immediately (only when we already prompted the user, # i.e. not in --force mode where we want zero interaction). if not args.force and Confirm.ask( f"Run 'dfetch update {project_entry.name}' now?", default=True ): + # pylint: disable=import-outside-toplevel from dfetch.commands.update import Update # local import avoids circular update_args = argparse.Namespace( @@ -239,7 +244,7 @@ def _non_interactive_entry( return entry -def _build_entry( +def _build_entry( # pylint: disable=too-many-arguments *, name: str, remote_url: str, @@ -271,7 +276,17 @@ def _build_entry( # --------------------------------------------------------------------------- -def _interactive_flow( +def _print_yaml_field(key: str, value: str | list[str]) -> None: + """Print one manifest field in YAML style.""" + if isinstance(value, list): + logger.info(f" [blue]{key}:[/blue]") + for item in value: + logger.info(f" - {item}") + else: + logger.info(f" [blue]{key}:[/blue] {value}") + + +def _interactive_flow( # pylint: disable=too-many-arguments,too-many-positional-arguments remote_url: str, default_name: str, default_dst: str, @@ -281,18 +296,42 @@ def _interactive_flow( existing_projects: Sequence[ProjectEntry], ) -> ProjectEntry: """Guide the user through every manifest field and return a ``ProjectEntry``.""" - logger.info("[bold blue]--- Interactive add wizard ---[/bold blue]") + logger.print_info_line(default_name, f"Adding {remote_url}") name = _ask_name(default_name, existing_projects) + + # Show the fields that are fixed by the URL right after the name is confirmed. + seed = _build_entry( + name=name, + remote_url=remote_url, + dst=name, + version=VersionRef("branch", default_branch), + src="", + ignore=[], + remote_to_use=remote_to_use, + ).as_yaml() + for key in ("name", "remote", "url", "repo-path"): + if key in seed and isinstance(seed[key], (str, list)): + _print_yaml_field(key, seed[key]) # type: ignore[arg-type] + dst = _ask_dst(name, default_dst) + if dst != name: + _print_yaml_field("dst", dst) + version = _ask_version( default_branch, subproject.list_of_branches(), subproject.list_of_tags(), ) - with subproject.browse_tree() as ls_fn: + _print_yaml_field(version.kind, version.value) + + with subproject.browse_tree(version.value) as ls_fn: src = _ask_src(ls_fn) - ignore = _ask_ignore(ls_fn) + if src: + _print_yaml_field("src", src) + ignore = _ask_ignore(ls_fn, src=src) + if ignore: + _print_yaml_field("ignore", ignore) return _build_entry( name=name, @@ -310,11 +349,32 @@ def _interactive_flow( # --------------------------------------------------------------------------- +def _erase_prompt_line() -> None: + """Erase the last printed line (the rich prompt) when running in a TTY.""" + if terminal.is_tty(): + sys.stdout.write("\x1b[1A\x1b[2K") + sys.stdout.flush() + + +def _unique_name(base: str, existing: set[str]) -> str: + """Return *base* if unused, otherwise *base*-1, *base*-2, … until unique.""" + if base not in existing: + return base + i = 1 + while f"{base}-{i}" in existing: + i += 1 + return f"{base}-{i}" + + def _ask_name(default: str, existing_projects: Sequence[ProjectEntry]) -> str: - """Prompt for the project name, re-asking on duplicates or invalid input.""" + """Prompt for the project name, re-asking on invalid input.""" existing_names = {p.name for p in existing_projects} + suggested = _unique_name(default, existing_names) while True: - name = Prompt.ask(" [bold]Name[/bold]", default=default).strip() + if terminal.is_tty(): + name = terminal.ghost_prompt(" ? Name", suggested).strip() + else: + name = Prompt.ask(" ? [bold]Name[/bold]", default=suggested).strip() if not name: logger.warning("Name cannot be empty.") continue @@ -325,10 +385,9 @@ def _ask_name(default: str, existing_projects: Sequence[ProjectEntry]) -> str: ) continue if name in existing_names: - logger.warning( - f"A project named '{name}' already exists. Choose a different name." - ) + suggested = _unique_name(name, existing_names) continue + _erase_prompt_line() return name @@ -336,18 +395,22 @@ def _ask_dst(name: str, default: str) -> str: """Prompt for the destination path, re-asking on path-traversal attempts.""" suggested = default or name while True: - dst = Prompt.ask( - " [bold]Destination[/bold] (path relative to manifest)", - default=suggested, - ).strip() + if terminal.is_tty(): + dst = terminal.ghost_prompt(" ? Destination", suggested).strip() + else: + dst = Prompt.ask( + " ? [bold]Destination[/bold] (path relative to manifest)", + default=suggested, + ).strip() if not dst: - return name # fall back to project name + dst = name # fall back to project name if any(part == ".." for part in Path(dst).parts): logger.warning( f"Destination '{dst}' contains '..'. " "Paths must stay within the manifest directory." ) continue + _erase_prompt_line() return dst @@ -358,7 +421,7 @@ def _ask_version( ) -> VersionRef: """Choose a version (branch / tag / SHA) and return it as a ``VersionRef``. - In a TTY shows a scrollable pick list (all branches then all tags). + In a TTY shows a hierarchical tree browser (names split on '/'). Outside a TTY (CI, pipe, tests) falls back to a numbered text menu. """ ordered_branches = _prioritise_default(branches, default_branch) @@ -370,7 +433,7 @@ def _ask_version( ] if terminal.is_tty() and choices: - return _scrollable_version_pick(choices, default_branch) + return _ask_version_tree(default_branch, branches, tags, choices) return _text_version_pick(choices, default_branch, branches, tags) @@ -378,33 +441,73 @@ def _ask_version( def _ask_src(ls_fn: LsFn) -> str: """Optionally prompt for a ``src:`` sub-path or glob pattern. - In a TTY opens a tree browser for single-path selection. + In a TTY opens a tree browser for single-path selection with + directory navigation (→/← to expand/collapse). Outside a TTY falls back to a free-text prompt. """ if terminal.is_tty(): return _tree_single_pick(ls_fn, "Source path (Enter to select, Esc to skip)") return Prompt.ask( - " [bold]Source path[/bold] (sub-path/glob, or Enter to fetch whole repo)", + " ? [bold]Source path[/bold] (sub-path/glob, or Enter to fetch whole repo)", default="", ).strip() -def _ask_ignore(ls_fn: LsFn) -> list[str]: +def _normalize_ignore_paths(ignore: list[str], src: str) -> list[str]: + """Strip the *src* prefix from each path so paths are relative to *src*.""" + if not src: + return ignore + prefix = src.rstrip("/") + "/" + return [p[len(prefix) :] if p.startswith(prefix) else p for p in ignore] + + +def _should_proceed_with_ignore(nodes: list[_TreeNode]) -> bool: + """Return True when the ignore list is acceptable to use. + + If the user deselected every visible node, warn and ask for confirmation. + """ + if any(n.selected for n in nodes): + return True + logger.warning( + "You have deselected everything. This will ignore all files in the project." + ) + return bool(Confirm.ask("Continue with empty selection?", default=False)) + + +def _ask_ignore(ls_fn: LsFn, src: str = "") -> list[str]: """Optionally prompt for ``ignore:`` paths. - In a TTY opens a tree browser with multi-select (Space to toggle, - Enter to confirm). Outside a TTY falls back to a comma-separated - free-text prompt. + Opens a tree browser (TTY) or falls back to free-text. All items start + selected (= keep). Deselect items to mark them for ignoring. + + Paths are returned relative to *src* when *src* is set, otherwise relative + to the repo root. """ + + def _scoped_ls(path: str = "") -> list[tuple[str, bool]]: + return ls_fn(f"{src}/{path}" if path else src) + + browse_fn: LsFn = _scoped_ls if src else ls_fn + if terminal.is_tty(): - return _tree_multi_pick( - ls_fn, - "Ignore paths (Space to select, Enter to confirm, Esc to skip)", - ) + while True: + all_nodes: list[_TreeNode] = [] + _run_tree_browser( + browse_fn, + "Ignore (Space deselects → ignored, Enter confirms, Esc skips)", + multi=True, + all_selected=True, + _out_nodes=all_nodes, + ) + ignore = _compute_ignore_from_nodes(all_nodes) + if not ignore: + return [] + if _should_proceed_with_ignore(all_nodes): + return ignore raw = Prompt.ask( - " [bold]Ignore paths[/bold] (comma-separated, or Enter to skip)", + " ? [bold]Ignore paths[/bold] (comma-separated paths to ignore, or Enter to skip)", default="", ).strip() return [p.strip() for p in raw.split(",") if p.strip()] if raw else [] @@ -415,39 +518,92 @@ def _ask_ignore(ls_fn: LsFn) -> list[str]: # --------------------------------------------------------------------------- -def _scrollable_version_pick( - choices: list[VersionRef], +def _version_ls_fn( + branches: list[str], + tags: list[str], default_branch: str, -) -> VersionRef: # pragma: no cover – interactive TTY only - """Scrollable version picker; falls back to free-text prompt on Esc.""" - default_idx = 0 - display: list[str] = [] - for i, ref in enumerate(choices): - if ref.kind == "branch": - suffix = ( - f" {terminal.DIM}(default){terminal.RESET}" - if ref.value == default_branch - else "" - ) - display.append( - f"{terminal.CYAN}{ref.value}{terminal.RESET}" - f" {terminal.DIM}branch{terminal.RESET}{suffix}" - ) - if ref.value == default_branch and default_idx == 0: - default_idx = i - else: - display.append( - f"{terminal.MAGENTA}{ref.value}{terminal.RESET}" - f" {terminal.DIM}tag{terminal.RESET}" +) -> LsFn: + """Build a ls_fn that exposes branches and tags as a /-split tree. + + Leaf nodes (actual branch/tag names) carry ANSI colour in their display + name (cyan = branch, magenta = tag). ``_expand_node`` and + ``_run_tree_browser`` strip ANSI when building ``node.path``, so the + stored path is always the clean version name. + """ + leaf_kind: dict[str, str] = {b: "branch" for b in branches} + leaf_kind.update({t: "tag" for t in tags}) + all_names: list[str] = sorted(leaf_kind) + + def ls(path: str) -> list[tuple[str, bool]]: + prefix = (path + "/") if path else "" + seen: dict[str, bool] = {} # first segment → is_dir + + for name in all_names: + if not name.startswith(prefix): + continue + rest = name[len(prefix) :] + if not rest: + continue + seg = rest.split("/")[0] + if seg in seen: + continue + full = prefix + seg + seen[seg] = any(n.startswith(full + "/") for n in all_names) + + def _sort_key(seg: str) -> tuple[int, str]: + full = prefix + seg + on_default_path = full == default_branch or default_branch.startswith( + full + "/" ) + return (0 if on_default_path else 1, seg) + + result: list[tuple[str, bool]] = [] + for seg in sorted(seen, key=_sort_key): + full = prefix + seg + if seen[seg]: # is_dir + result.append((seg, True)) + else: + kind = leaf_kind[full] + default_marker = ( + f" {terminal.DIM}(default){terminal.RESET}" + if full == default_branch + else "" + ) + result.append( + ( + f"{seg} {terminal.DIM}{kind}{terminal.RESET}{default_marker}", + False, + ) + ) + + return result + + return ls + - selected = terminal.scrollable_pick("Version", display, default_idx=default_idx) - if selected is not None: - return choices[selected] +def _ask_version_tree( + default_branch: str, + branches: list[str], + tags: list[str], + choices: list[VersionRef], +) -> VersionRef: # pragma: no cover - interactive TTY only + """Branch/tag picker using the hierarchical tree browser. + + Splits names by '/' to build a navigable tree (e.g. ``feature/login`` + appears as ``feature ▸ login``). Leaves are cyan for branches and + magenta for tags. Falls back to the numbered text picker on Esc or + when the selected path cannot be resolved to a known version. + """ + ls = _version_ls_fn(branches, tags, default_branch) + selected = _tree_single_pick(ls, "Version (Enter to select · Esc to type freely)") + + branch_set = set(branches) + tag_set = set(tags) + if selected in branch_set: + return VersionRef("branch", selected) + if selected in tag_set: + return VersionRef("tag", selected) - # Esc pressed — fall back to free-text - branches = [c.value for c in choices if c.kind == "branch"] - tags = [c.value for c in choices if c.kind == "tag"] return _text_version_pick(choices, default_branch, branches, tags) @@ -462,7 +618,7 @@ def _text_version_pick( while True: raw = Prompt.ask( - " [bold]Version[/bold] (number, branch, tag, or SHA)", + " ? [bold]Version[/bold] (number, branch, tag, or SHA)", default=default_branch, ).strip() @@ -530,9 +686,10 @@ def _expand_node(nodes: list[_TreeNode], idx: int, ls_fn: LsFn) -> None: children = [ _TreeNode( name=name, - path=f"{node.path}/{name}", + path=f"{node.path}/{terminal.strip_ansi(name)}", is_dir=is_dir, depth=node.depth + 1, + selected=node.selected, ) for name, is_dir in ls_fn(node.path) ] @@ -549,10 +706,11 @@ def _collapse_node(nodes: list[_TreeNode], idx: int) -> None: end += 1 del nodes[idx + 1 : end] nodes[idx].expanded = False + nodes[idx].children_loaded = False def _render_tree_lines( - nodes: list[_TreeNode], idx: int, top: int, *, hint: str + nodes: list[_TreeNode], idx: int, top: int, *, ignore_mode: bool = False ) -> list[str]: """Build the list of display strings for one frame of the tree browser.""" n = len(nodes) @@ -560,111 +718,211 @@ def _render_tree_lines( for i in range(top, min(top + terminal.VIEWPORT, n)): node = nodes[i] indent = " " * node.depth - icon = ("▼ " if node.expanded else "▶ ") if node.is_dir else " " - highlight = terminal.REVERSE if i == idx else "" - check = f"{terminal.GREEN}✓{terminal.RESET} " if node.selected else " " - lines.append(f" {highlight}{indent}{check}{icon}{node.name}{terminal.RESET}") + icon = ("▾ " if node.expanded else "▸ ") if node.is_dir else " " + cursor = f"{terminal.YELLOW}▶{terminal.RESET}" if i == idx else " " + if ignore_mode: + name = ( + f"{terminal.DIM}{node.name}{terminal.RESET}" + if not node.selected + else node.name + ) + lines.append(f" {cursor} {indent}{icon}{name}") + else: + check = f"{terminal.GREEN}✓ {terminal.RESET}" if node.selected else " " + name = ( + f"{terminal.BOLD}{node.name}{terminal.RESET}" if i == idx else node.name + ) + lines.append(f" {cursor} {check}{indent}{icon}{name}") return lines +def _cascade_selection(nodes: list[_TreeNode], parent_idx: int, selected: bool) -> None: + """Set *selected* on all loaded descendants of the node at *parent_idx*.""" + parent_depth = nodes[parent_idx].depth + for i in range(parent_idx + 1, len(nodes)): + if nodes[i].depth <= parent_depth: + break + nodes[i].selected = selected + + +def _adjust_scroll(idx: int, top: int) -> int: + """Return a new *top* so that *idx* is within the visible viewport.""" + if idx < top: + return idx + if idx >= top + terminal.VIEWPORT: + return idx - terminal.VIEWPORT + 1 + return top + + +def _build_tree_frame( # pylint: disable=too-many-arguments,too-many-positional-arguments + title: str, + nodes: list[_TreeNode], + idx: int, + top: int, + hint: str, + *, + ignore_mode: bool = False, +) -> list[str]: + """Build all display lines for one render frame of the tree browser.""" + n = len(nodes) + header = [f" {terminal.BOLD}{title}{terminal.RESET}"] + if top > 0: + header.append(f" {terminal.DIM}↑ {top} more above{terminal.RESET}") + body = _render_tree_lines(nodes, idx, top, ignore_mode=ignore_mode) + footer: list[str] = [] + remaining = n - (top + terminal.VIEWPORT) + if remaining > 0: + footer.append(f" {terminal.DIM}↓ {remaining} more below{terminal.RESET}") + footer.append(f" {terminal.DIM}{hint}{terminal.RESET}") + return header + body + footer + + +def _handle_tree_nav(key: str, idx: int, n: int) -> int | None: + """Handle arrow/page navigation keys; return new index or None if not a nav key.""" + if key == "UP": + return max(0, idx - 1) + if key == "DOWN": + return min(n - 1, idx + 1) + if key == "PGUP": + return max(0, idx - terminal.VIEWPORT) + if key == "PGDN": + return min(n - 1, idx + terminal.VIEWPORT) + return None + + +def _handle_tree_left(nodes: list[_TreeNode], idx: int) -> int: + """Handle the LEFT key: collapse the current dir or jump to its parent.""" + node = nodes[idx] + if node.is_dir and node.expanded: + _collapse_node(nodes, idx) + return idx + if node.depth > 0: + for i in range(idx - 1, -1, -1): + if nodes[i].depth < node.depth: + return i + return idx + + +def _handle_tree_space( + nodes: list[_TreeNode], idx: int, multi: bool +) -> list[str] | None: + """Handle SPACE: toggle selection (multi) or select immediately (single). + + Returns a path list when the browser should exit, or ``None`` to continue. + """ + node = nodes[idx] + if multi: + node.selected = not node.selected + if node.is_dir: + _cascade_selection(nodes, idx, node.selected) + return None + return [node.path] + + +def _handle_tree_enter( + nodes: list[_TreeNode], idx: int, ls_fn: LsFn, multi: bool +) -> tuple[int, list[str] | None]: + """Handle ENTER: confirm selection (multi), expand/collapse dir, or pick file.""" + node = nodes[idx] + if multi: + return idx, [n.path for n in nodes if n.selected] + if node.is_dir: + if node.expanded: + _collapse_node(nodes, idx) + else: + _expand_node(nodes, idx, ls_fn) + return idx, None + return idx, [node.path] + + +def _handle_tree_action( + key: str, + nodes: list[_TreeNode], + idx: int, + ls_fn: LsFn, + multi: bool, +) -> tuple[int, list[str] | None]: + """Dispatch non-navigation keypresses. + + Returns ``(new_idx, result)``. When *result* is not ``None`` the browser + should exit and return it (``[]`` for ESC/skip, path list for a selection). + """ + node = nodes[idx] + if key == "RIGHT": + if node.is_dir and not node.expanded: + _expand_node(nodes, idx, ls_fn) + return idx, None + if key == "LEFT": + return _handle_tree_left(nodes, idx), None + if key == "SPACE": + return idx, _handle_tree_space(nodes, idx, multi) + if key == "ENTER": + return _handle_tree_enter(nodes, idx, ls_fn, multi) + if key == "ESC": + return idx, [] + return idx, None + + def _run_tree_browser( ls_fn: LsFn, title: str, *, multi: bool, -) -> list[str]: # pragma: no cover – interactive TTY only + all_selected: bool = False, + _out_nodes: list[_TreeNode] | None = None, +) -> list[str]: # pragma: no cover - interactive TTY only """Core tree browser loop. Returns a list of selected paths. In single-select mode the list has at most one item; in multi-select mode it may have any number. + If ``all_selected=True``, all nodes start selected. + If ``_out_nodes`` is provided, it is extended with the final node state on exit. """ root_entries = ls_fn("") if not root_entries: return [] nodes: list[_TreeNode] = [ - _TreeNode(name=name, path=name, is_dir=is_dir, depth=0) + _TreeNode( + name=name, + path=terminal.strip_ansi(name), + is_dir=is_dir, + depth=0, + selected=all_selected, + ) for name, is_dir in root_entries ] - hint = ( "↑/↓ navigate Space select Enter confirm →/← expand/collapse Esc skip" if multi else "↑/↓ navigate Enter/Space select →/← expand/collapse Esc skip" ) - screen = terminal.Screen() - idx = 0 - top = 0 + idx, top = 0, 0 while True: n = len(nodes) if n == 0: screen.clear() return [] - idx = max(0, min(idx, n - 1)) - if idx < top: - top = idx - elif idx >= top + terminal.VIEWPORT: - top = idx - terminal.VIEWPORT + 1 - - header = [f" {terminal.BOLD}{title}{terminal.RESET}"] - body = _render_tree_lines(nodes, idx, top, hint=hint) - scroll_hints = [] - if top > 0: - scroll_hints.append(f" {terminal.DIM}↑ {top} more above{terminal.RESET}") - remaining = n - (top + terminal.VIEWPORT) - if remaining > 0: - scroll_hints.append( - f" {terminal.DIM}↓ {remaining} more below{terminal.RESET}" - ) - footer = [f" {terminal.DIM}{hint}{terminal.RESET}"] - - screen.draw(header + body + scroll_hints + footer) + top = _adjust_scroll(idx, top) + screen.draw( + _build_tree_frame(title, nodes, idx, top, hint, ignore_mode=all_selected) + ) key = terminal.read_key() - node = nodes[idx] - - if key == "UP": - idx -= 1 - elif key == "DOWN": - idx += 1 - elif key == "PGUP": - idx = max(0, idx - terminal.VIEWPORT) - elif key == "PGDN": - idx = min(n - 1, idx + terminal.VIEWPORT) - elif key == "RIGHT": - if node.is_dir and not node.expanded: - _expand_node(nodes, idx, ls_fn) - elif key == "LEFT": - if node.is_dir and node.expanded: - _collapse_node(nodes, idx) - elif node.depth > 0: - for i in range(idx - 1, -1, -1): - if nodes[i].depth < node.depth: - idx = i - break - elif key == "SPACE": - if multi: - node.selected = not node.selected - elif not node.is_dir: - screen.clear() - return [node.path] - elif key == "ENTER": - if multi: - screen.clear() - return [nd.path for nd in nodes if nd.selected] - elif node.is_dir: - if node.expanded: - _collapse_node(nodes, idx) - else: - _expand_node(nodes, idx, ls_fn) - else: - screen.clear() - return [node.path] - elif key == "ESC": + + new_idx = _handle_tree_nav(key, idx, n) + if new_idx is not None: + idx = new_idx + continue + + idx, result = _handle_tree_action(key, nodes, idx, ls_fn, multi) + if result is not None: + if _out_nodes is not None: + _out_nodes.extend(nodes) screen.clear() - return [] + return result def _tree_single_pick(ls_fn: LsFn, title: str) -> str: @@ -676,12 +934,42 @@ def _tree_single_pick(ls_fn: LsFn, title: str) -> str: return result[0] if result else "" -def _tree_multi_pick(ls_fn: LsFn, title: str) -> list[str]: - """Browse the remote tree and return all selected paths. +def _all_descendants_deselected(nodes: list[_TreeNode], parent_idx: int) -> bool: + """Return True if every loaded descendant of the node at *parent_idx* is deselected.""" + parent_depth = nodes[parent_idx].depth + for i in range(parent_idx + 1, len(nodes)): + if nodes[i].depth <= parent_depth: + break + if nodes[i].selected: + return False + return True + - Returns ``[]`` if the user skips (Esc) or nothing is selected. +def _compute_ignore_from_nodes(nodes: list[_TreeNode]) -> list[str]: + """Compute the minimal ignore list from the final browser node state. + + A deselected directory is emitted as a single entry when all its loaded + descendants are also deselected (or it was never expanded). This keeps + the ignore list short. Individual deselected files are listed when their + parent directory is only partially deselected. """ - return _run_tree_browser(ls_fn, title, multi=True) + ignore: list[str] = [] + ignored_dirs: set[str] = set() + + for i, node in enumerate(nodes): + if node.selected: + continue + if any(node.path.startswith(d + "/") for d in ignored_dirs): + continue + if node.is_dir and ( + not node.children_loaded or _all_descendants_deselected(nodes, i) + ): + ignore.append(node.path) + ignored_dirs.add(node.path) + elif not node.is_dir: + ignore.append(node.path) + + return ignore # --------------------------------------------------------------------------- @@ -708,7 +996,7 @@ def _semver_key(tag: str) -> semver.Version | None: semver_tags = sorted( (t for t in tags if _semver_key(t) is not None), - key=lambda t: _semver_key(t), # type: ignore[arg-type, return-value] + key=_semver_key, # type: ignore[arg-type, return-value] reverse=True, ) non_semver = [t for t in tags if _semver_key(t) is None] diff --git a/dfetch/log.py b/dfetch/log.py index 6d9e05d8..762dbef5 100644 --- a/dfetch/log.py +++ b/dfetch/log.py @@ -107,8 +107,12 @@ def print_overview(self, name: str, title: str, info: dict[str, Any]) -> None: """Print an overview of fields.""" self.print_info_line(name, title) for key, value in info.items(): - key += ":" - self.info(f" [blue]{key:20s}[/blue][white] {value}[/white]") + if isinstance(value, list): + self.info(f" [blue]{key + ':':20s}[/blue]") + for item in value: + self.info(f" {'':20s}[white]- {item}[/white]") + else: + self.info(f" [blue]{key + ':':20s}[/blue][white] {value}[/white]") def print_title(self) -> None: """Print the DFetch tool title and version.""" diff --git a/dfetch/manifest/project.py b/dfetch/manifest/project.py index ed5e8202..e4be2510 100644 --- a/dfetch/manifest/project.py +++ b/dfetch/manifest/project.py @@ -566,6 +566,7 @@ def as_yaml(self) -> dict[str, str | list[str] | dict[str, str]]: "repo-path": self._repo_path, "vcs": self._vcs, "integrity": self._integrity.as_yaml() or None, + "ignore": list(self._ignore) if self._ignore else None, } return {k: v for k, v in yamldata.items() if v} diff --git a/dfetch/project/gitsubproject.py b/dfetch/project/gitsubproject.py index 28830933..6d12c6d5 100644 --- a/dfetch/project/gitsubproject.py +++ b/dfetch/project/gitsubproject.py @@ -49,19 +49,27 @@ def list_of_branches(self) -> list[str]: return [str(branch) for branch in self._remote_repo.list_of_branches()] @contextlib.contextmanager - def browse_tree(self) -> Generator[LsFn, None, None]: + def browse_tree(self, version: str = "") -> Generator[LsFn, None, None]: """Shallow-clone the remote and yield a tree-listing callable. The yielded ``LsFn`` calls ``git ls-tree HEAD`` on a temporary blobless clone. The clone is removed on context exit. """ tmpdir = tempfile.mkdtemp(prefix="dfetch_browse_") - ls_fn: LsFn + cloned = False try: - GitRemote.clone_minimal(self._remote_repo._remote, tmpdir) - ls_fn = lambda path="": GitRemote.ls_tree(tmpdir, path=path) # noqa: E731 - except Exception: - ls_fn = lambda path="": [] # noqa: E731 + self._remote_repo.fetch_for_tree_browse( + tmpdir, version or self._remote_repo.get_default_branch() + ) + cloned = True + except Exception: # pylint: disable=broad-exception-caught # nosec B110 + pass + + def ls_fn(path: str = "") -> list[tuple[str, bool]]: + if cloned: + return GitRemote.ls_tree(tmpdir, path=path) + return [] + try: yield ls_fn finally: diff --git a/dfetch/project/subproject.py b/dfetch/project/subproject.py index a26cc86e..5482e349 100644 --- a/dfetch/project/subproject.py +++ b/dfetch/project/subproject.py @@ -23,7 +23,7 @@ logger = get_logger(__name__) -class SubProject(ABC): +class SubProject(ABC): # pylint: disable=too-many-public-methods """Abstract SubProject object. This object represents one Project entry in the Manifest. @@ -254,6 +254,11 @@ def _log_project(self, msg: str) -> None: def _log_tool(name: str, msg: str) -> None: logger.print_report_line(name, msg.strip()) + @property + def name(self) -> str: + """Get the name of this project.""" + return self.__project.name + @property def local_path(self) -> str: """Get the local destination of this project.""" @@ -415,7 +420,7 @@ def list_of_tags(self) -> list[str]: return self._list_of_tags() @contextlib.contextmanager - def browse_tree(self) -> Generator[LsFn, None, None]: + def browse_tree(self, version: str = "") -> Generator[LsFn, None, None]: """Context manager yielding a function to list remote tree contents. The yielded ``LsFn`` accepts an optional path (relative to the repo @@ -423,7 +428,12 @@ def browse_tree(self) -> Generator[LsFn, None, None]: default implementation returns an empty list; VCS-specific subclasses override this to perform a real remote tree walk. """ - yield lambda path="": [] + _ = version + + def _empty_ls(_path: str = "") -> list[tuple[str, bool]]: + return [] + + yield _empty_ls def freeze_project(self, project: ProjectEntry) -> str | None: """Freeze *project* to its current on-disk version. diff --git a/dfetch/util/terminal.py b/dfetch/util/terminal.py index bd64b831..5757ecb5 100644 --- a/dfetch/util/terminal.py +++ b/dfetch/util/terminal.py @@ -6,6 +6,7 @@ """ import os +import re import sys from collections.abc import Sequence @@ -17,14 +18,21 @@ BOLD = "\x1b[1m" DIM = "\x1b[2m" REVERSE = "\x1b[7m" # swap fore/background – used for cursor highlight -CYAN = "\x1b[36m" -MAGENTA = "\x1b[35m" -GREEN = "\x1b[32m" -YELLOW = "\x1b[33m" +CYAN = "\x1b[96m" +MAGENTA = "\x1b[95m" +GREEN = "\x1b[92m" +YELLOW = "\x1b[93m" # Viewport height for scrollable list widgets (number of items shown at once). VIEWPORT = 10 +_ANSI_ESC_RE = re.compile(r"\x1b\[[0-9;]*m") + + +def strip_ansi(s: str) -> str: + """Strip ANSI colour/style escape sequences from *s*.""" + return _ANSI_ESC_RE.sub("", s) + # --------------------------------------------------------------------------- # TTY detection @@ -56,7 +64,7 @@ def read_key() -> str: # pragma: no cover – requires live terminal def _read_key_windows() -> str: # pragma: no cover - import msvcrt # type: ignore[import] + import msvcrt # type: ignore[import] # pylint: disable=import-outside-toplevel,import-error ch = msvcrt.getwch() # type: ignore[attr-defined] if ch in ("\x00", "\xe0"): @@ -68,7 +76,7 @@ def _read_key_windows() -> str: # pragma: no cover "I": "PGUP", "Q": "PGDN", } - return arrow.get(msvcrt.getwch(), "UNKNOWN") # type: ignore[attr-defined] + return arrow.get(msvcrt.getwch(), "UNKNOWN") # type: ignore[attr-defined,no-any-return] if ch in ("\r", "\n"): return "ENTER" if ch == "\x1b": @@ -77,13 +85,13 @@ def _read_key_windows() -> str: # pragma: no cover return "SPACE" if ch == "\x03": raise KeyboardInterrupt - return ch + return str(ch) # ch is Any from untyped msvcrt def _read_key_unix() -> str: # pragma: no cover - import select as _select - import termios - import tty + import select as _select # pylint: disable=import-outside-toplevel + import termios # pylint: disable=import-outside-toplevel + import tty # pylint: disable=import-outside-toplevel fd = sys.stdin.fileno() old_settings = termios.tcgetattr(fd) @@ -137,6 +145,7 @@ class Screen: """ def __init__(self) -> None: + """Create screen.""" self._line_count = 0 def draw(self, lines: Sequence[str]) -> None: @@ -155,67 +164,191 @@ def clear(self) -> None: self._line_count = 0 +# --------------------------------------------------------------------------- +# Ghost prompt +# --------------------------------------------------------------------------- + + +def _ghost_handle_backspace(buf: list[str], ghost_active: bool, ghost_len: int) -> bool: + """Handle backspace in a ghost prompt; returns updated *ghost_active*.""" + if buf: + buf.pop() + sys.stdout.write("\x1b[1D\x1b[K") + sys.stdout.flush() + elif ghost_active: + sys.stdout.write(f"\x1b[{ghost_len}D\x1b[K") + sys.stdout.flush() + return False + return ghost_active + + +def _ghost_handle_char( + ch: str, buf: list[str], ghost_active: bool, ghost_len: int +) -> bool: + """Append *ch* to *buf*, clearing ghost if still active; returns updated *ghost_active*.""" + if ghost_active: + sys.stdout.write(f"\x1b[{ghost_len}D\x1b[K{ch}") + ghost_active = False + else: + sys.stdout.write(ch) + sys.stdout.flush() + buf.append(ch) + return ghost_active + + +def ghost_prompt(label: str, default: str = "") -> str: # pragma: no cover + """Single-line prompt with *default* shown as dim ghost text. + + The ghost disappears the moment the user types anything. + Pressing Enter with no input accepts *default*. + """ + sys.stdout.write(f"{label}: {DIM}{default}{RESET}") + sys.stdout.flush() + + buf: list[str] = [] + ghost_active = bool(default) + + while True: + key = read_key() + if key == "ENTER": + sys.stdout.write("\n") + sys.stdout.flush() + return "".join(buf) if buf else default + if key in ("\x7f", "\x08"): + ghost_active = _ghost_handle_backspace(buf, ghost_active, len(default)) + continue + ch = " " if key == "SPACE" else key + if len(ch) == 1 and ch.isprintable(): + ghost_active = _ghost_handle_char(ch, buf, ghost_active, len(default)) + + # --------------------------------------------------------------------------- # Scrollable pick widget # --------------------------------------------------------------------------- +def _advance_pick_idx(key: str, idx: int, n: int) -> int: + """Return the new cursor position after a navigation keypress.""" + if key == "UP": + return max(0, idx - 1) + if key == "DOWN": + return min(n - 1, idx + 1) + if key == "PGUP": + return max(0, idx - VIEWPORT) + return min(n - 1, idx + VIEWPORT) # PGDN + + +def _toggle_pick_selection(idx: int, selected: set[int]) -> set[int]: + """Return a new selected set with *idx* toggled.""" + new_sel = set(selected) + if idx in new_sel: + new_sel.discard(idx) + else: + new_sel.add(idx) + return new_sel + + +def _pick_outcome( + key: str, idx: int, selected: set[int], multi: bool +) -> tuple[bool, int | list[int] | None]: + """Determine whether a keypress ends the interaction and what value to return. + + Returns ``(done, result)``. When *done* is ``False`` the loop continues. + When *done* is ``True``, *result* is the value to return to the caller. + """ + if key == "ENTER": + return True, sorted(selected) if multi else idx + if key == "ESC": + return True, None + if key not in ("UP", "DOWN", "PGUP", "PGDN", "SPACE") and not multi: + return True, None + return False, None + + +def _clamp_scroll(idx: int, top: int) -> int: + """Return an updated *top* offset so that *idx* is visible in the viewport.""" + if idx < top: + return idx + if idx >= top + VIEWPORT: + return idx - VIEWPORT + 1 + return top + + +def _render_pick_lines( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals + title: str, + items: list[str], + idx: int, + top: int, + selected: set[int], + multi: bool, + n: int, +) -> list[str]: + """Build the list of lines to draw for one frame of the pick widget.""" + lines: list[str] = [f" {BOLD}{title}{RESET}"] + if top > 0: + lines.append(f" {DIM}↑ {top} more above{RESET}") + for i in range(top, min(top + VIEWPORT, n)): + cursor = f"{YELLOW}▶{RESET}" if i == idx else " " + check = f"{GREEN}✓ {RESET}" if (multi and i in selected) else " " + item_text = items[i] + is_highlighted = (i in selected) if multi else (i == idx) + styled = f"{BOLD}{item_text}{RESET}" if is_highlighted else item_text + lines.append(f" {cursor} {check}{styled}") + remaining = n - (top + VIEWPORT) + if remaining > 0: + lines.append(f" {DIM}↓ {remaining} more below{RESET}") + hint = ( + "↑/↓ navigate Space toggle Enter confirm Esc skip" + if multi + else "↑/↓ navigate PgUp/PgDn jump Enter select Esc free-type" + ) + lines.append(f" {DIM}{hint}{RESET}") + return lines + + def scrollable_pick( title: str, display_items: list[str], *, default_idx: int = 0, -) -> int | None: # pragma: no cover – interactive TTY only - """Display a scrollable single-pick list; return the selected index or None on Esc. - - *display_items* are pre-formatted display strings (may contain raw ANSI - codes). Navigate with ↑/↓ or PgUp/PgDn; confirm with Enter; cancel - with Esc (returns ``None``). + multi: bool = False, + all_selected: bool = False, +) -> int | list[int] | None: # pragma: no cover – interactive TTY only + """Display a scrollable pick list; return selected index or indices. + + *display_items* are plain strings (no ANSI codes). Navigate with + ↑/↓ or PgUp/PgDn. In single-select mode (``multi=False``) confirm + with Enter; in multi-select mode (``multi=True``) toggle with Space + and confirm with Enter. Cancel with Esc (returns ``None``). + + Single-select: returns the selected index. + Multi-select: returns a list of selected indices (may be empty). + If ``all_selected=True``, starts with all items selected. """ screen = Screen() idx = default_idx top = 0 n = len(display_items) + selected: set[int] = ( + set(range(n)) + if (multi and all_selected) + else ({default_idx} if not multi else set()) + ) while True: idx = max(0, min(idx, n - 1)) - if idx < top: - top = idx - elif idx >= top + VIEWPORT: - top = idx - VIEWPORT + 1 - - lines: list[str] = [f" {BOLD}{title}{RESET}"] - for i in range(top, min(top + VIEWPORT, n)): - cursor = f"{YELLOW}▶{RESET}" if i == idx else " " - highlight_start = REVERSE if i == idx else "" - highlight_end = RESET if i == idx else "" - lines.append( - f" {cursor} {highlight_start}{display_items[i]}{highlight_end}" - ) - - if top > 0: - lines.append(f" {DIM}↑ {top} more above{RESET}") - remaining = n - (top + VIEWPORT) - if remaining > 0: - lines.append(f" {DIM}↓ {remaining} more below{RESET}") - lines.append( - f" {DIM}↑/↓ navigate PgUp/PgDn jump Enter select Esc free-type{RESET}" + top = _clamp_scroll(idx, top) + screen.draw( + _render_pick_lines(title, display_items, idx, top, selected, multi, n) ) - - screen.draw(lines) key = read_key() - if key == "UP": - idx -= 1 - elif key == "DOWN": - idx += 1 - elif key == "PGUP": - idx = max(0, idx - VIEWPORT) - elif key == "PGDN": - idx = min(n - 1, idx + VIEWPORT) - elif key == "ENTER": - screen.clear() - return idx - elif key == "ESC": - screen.clear() - return None + if key in ("UP", "DOWN", "PGUP", "PGDN"): + idx = _advance_pick_idx(key, idx, n) + elif key == "SPACE" and multi: + selected = _toggle_pick_selection(idx, selected) + else: + done, result = _pick_outcome(key, idx, selected, multi) + if done: + screen.clear() + return result diff --git a/dfetch/vcs/archive.py b/dfetch/vcs/archive.py index 9cbe513b..b8ce7216 100644 --- a/dfetch/vcs/archive.py +++ b/dfetch/vcs/archive.py @@ -31,6 +31,7 @@ import sys import tarfile import tempfile +import urllib.error import urllib.parse import urllib.request import zipfile @@ -130,7 +131,10 @@ def is_accessible(self) -> bool: """ parsed = urllib.parse.urlparse(self.url) if parsed.scheme == "file": - return os.path.exists(urllib.request.url2pathname(parsed.path)) + try: + return os.path.exists(urllib.request.url2pathname(parsed.path)) + except urllib.error.URLError: + return False if parsed.scheme not in ("http", "https"): return False return self._is_http_reachable(parsed) diff --git a/dfetch/vcs/git.py b/dfetch/vcs/git.py index cb1d50b8..f351607d 100644 --- a/dfetch/vcs/git.py +++ b/dfetch/vcs/git.py @@ -199,23 +199,35 @@ def _ls_remote(remote: str) -> dict[str, str]: info[ref] = sha return info - @staticmethod - def clone_minimal(remote: str, target: str) -> None: - """Shallow blobless clone for browsing the tree without checking out files.""" + def fetch_for_tree_browse(self, target: str, version: str) -> None: + """Fetch just enough objects to support ``ls_tree`` on *version*. + + Uses ``--no-checkout`` and ``--filter=blob:none`` so only tree objects + are transferred — no file contents are downloaded. + """ + run_on_cmdline(logger, ["git", "-C", target, "init"]) run_on_cmdline( logger, - cmd=[ + [ "git", - "clone", - "--depth=1", - "--no-checkout", - "--quiet", - remote, + "-C", target, + "fetch", + "--depth=1", + "--filter=blob:none", + self._remote, + version, ], env=_extend_env_for_non_interactive_mode(), ) + @staticmethod + def _parse_ls_tree_entry(line: str, prefix: str) -> tuple[str, bool]: + """Parse one ``git ls-tree`` output line into a ``(name, is_dir)`` pair.""" + meta, name = line.split("\t", 1) + base = name[len(prefix) :] if prefix and name.startswith(prefix) else name + return base, meta.split()[1] == "tree" + @staticmethod def ls_tree(local_path: str, path: str = "") -> list[tuple[str, bool]]: """List the contents of the HEAD tree at *path* in a local clone. @@ -223,20 +235,19 @@ def ls_tree(local_path: str, path: str = "") -> list[tuple[str, bool]]: Returns a list of ``(name, is_dir)`` pairs sorted with directories first (alphabetically), then files (alphabetically). """ - cmd = ["git", "-C", local_path, "ls-tree", "HEAD"] + cmd = ["git", "-C", local_path, "ls-tree", "FETCH_HEAD"] if path: cmd.append(path.rstrip("/") + "/") try: result = run_on_cmdline(logger, cmd=cmd) - entries: list[tuple[str, bool]] = [] - for line in result.stdout.decode().splitlines(): - if not line.strip(): - continue - meta, name = line.split("\t", 1) - obj_type = meta.split()[1] - entries.append((name, obj_type == "tree")) - dirs = sorted((n, d) for n, d in entries if d) - files = sorted((n, d) for n, d in entries if not d) + prefix = (path.rstrip("/") + "/") if path else "" + entries = [ + GitRemote._parse_ls_tree_entry(line, prefix) + for line in result.stdout.decode().splitlines() + if line.strip() + ] + dirs: list[tuple[str, bool]] = sorted((n, d) for n, d in entries if d) + files: list[tuple[str, bool]] = sorted((n, d) for n, d in entries if not d) return dirs + files except SubprocessCommandError: return [] diff --git a/features/steps/add_steps.py b/features/steps/add_steps.py index 3e969951..f647ad96 100644 --- a/features/steps/add_steps.py +++ b/features/steps/add_steps.py @@ -1,6 +1,6 @@ """Steps for the 'dfetch add' feature tests.""" -# pylint: disable=function-redefined, missing-function-docstring, not-callable +# pylint: disable=function-redefined, missing-function-docstring, import-error, not-callable # pyright: reportRedeclaration=false, reportAttributeAccessIssue=false, reportCallIssue=false from collections import deque @@ -55,13 +55,13 @@ def step_impl(context, remote_url): # We use an iterator so each call consumes the next value. _confirm_values = iter([add_confirm, update_confirm]) - def _auto_confirm(prompt: str, **kwargs) -> bool: + def _auto_confirm(_prompt: str, **kwargs) -> bool: try: return next(_confirm_values) except StopIteration: return bool(kwargs.get("default", False)) - def _auto_prompt(prompt: str, **kwargs) -> str: # type: ignore[return] + def _auto_prompt(_prompt: str, **kwargs) -> str: # type: ignore[return] """Return the next pre-defined answer, ignoring the actual prompt text.""" if prompt_answers: return prompt_answers.popleft() diff --git a/script/package.py b/script/package.py index 8cec26ac..126a2db1 100644 --- a/script/package.py +++ b/script/package.py @@ -8,7 +8,7 @@ import xml.etree.ElementTree as ET # nosec (only used for XML generation, not parsing untrusted input) from pathlib import Path -from setuptools_scm import get_version +from setuptools_scm import get_version # pylint: disable=import-error from dfetch import __version__ as __digit_only_version__ # Used inside the installers diff --git a/tests/test_add.py b/tests/test_add.py index 5863f71d..70bfba8e 100644 --- a/tests/test_add.py +++ b/tests/test_add.py @@ -58,7 +58,9 @@ def _make_subproject( """Return a Mock SubProject with sensible defaults.""" sp = Mock() sp.get_default_branch.return_value = default_branch - sp.list_of_branches.return_value = branches if branches is not None else [default_branch] + sp.list_of_branches.return_value = ( + branches if branches is not None else [default_branch] + ) sp.list_of_tags.return_value = tags if tags is not None else [] # browse_tree returns an empty ls_fn by default (no remote tree available) sp.browse_tree.return_value.__enter__ = Mock(return_value=lambda path="": []) @@ -499,11 +501,11 @@ def test_add_command_interactive_run_update(): "dfetch.commands.add.Prompt.ask", side_effect=lambda *a, **kw: next(prompt_answers), ): - with patch( - "dfetch.commands.add.Confirm.ask", side_effect=[True, True] - ): + with patch("dfetch.commands.add.Confirm.ask", side_effect=[True, True]): with patch("dfetch.commands.add.append_entry_manifest_file"): - with patch("dfetch.commands.update.Update.__call__") as mock_update: + with patch( + "dfetch.commands.update.Update.__call__" + ) as mock_update: Add()( _make_args( "https://github.com/org/myrepo.git", From 4aa50e5d9312d9de6a002273fbcd75e2fb635fe3 Mon Sep 17 00:00:00 2001 From: Ben Spoor Date: Tue, 24 Mar 2026 21:07:47 +0100 Subject: [PATCH 07/29] Svn support for interactive add --- dfetch/project/svnsubproject.py | 33 +++++- dfetch/vcs/svn.py | 32 ++++++ tests/test_add.py | 182 ++++++++++++++++++++++++++++++++ 3 files changed, 246 insertions(+), 1 deletion(-) diff --git a/dfetch/project/svnsubproject.py b/dfetch/project/svnsubproject.py index 1ec167f8..6fe67c1f 100644 --- a/dfetch/project/svnsubproject.py +++ b/dfetch/project/svnsubproject.py @@ -1,14 +1,16 @@ """SVN specific implementation.""" +import contextlib import os import pathlib import urllib.parse +from collections.abc import Generator from dfetch.log import get_logger from dfetch.manifest.project import ProjectEntry from dfetch.manifest.version import Version from dfetch.project.metadata import Dependency -from dfetch.project.subproject import SubProject +from dfetch.project.subproject import LsFn, SubProject from dfetch.util.util import ( find_matching_files, find_non_matching_files, @@ -178,3 +180,32 @@ def _get_revision(self, branch: str) -> str: def get_default_branch(self) -> str: """Get the default branch of this repository.""" return SvnRepo.DEFAULT_BRANCH + + def list_of_branches(self) -> list[str]: + """Return trunk plus any branches found under ``branches/``.""" + return [SvnRepo.DEFAULT_BRANCH, *self._remote_repo.list_of_branches()] + + @contextlib.contextmanager + def browse_tree(self, version: str = "") -> Generator[LsFn, None, None]: + """Yield an ls_fn that lists SVN tree contents for *version*. + + Resolves *version* to the correct remote path (trunk, + ``branches/``, or ``tags/``), then delegates + directory listing to ``svn ls``. + """ + version = version or SvnRepo.DEFAULT_BRANCH + if version == SvnRepo.DEFAULT_BRANCH: + base_url = f"{self.remote}/{SvnRepo.DEFAULT_BRANCH}" + else: + branches_url = f"{self.remote}/branches/{version}" + try: + SvnRepo.get_info_from_target(branches_url) + base_url = branches_url + except RuntimeError: + base_url = f"{self.remote}/tags/{version}" + + def ls_fn(path: str = "") -> list[tuple[str, bool]]: + url = f"{base_url}/{path}" if path else base_url + return self._remote_repo.ls_tree(url) + + yield ls_fn diff --git a/dfetch/vcs/svn.py b/dfetch/vcs/svn.py index e8ca6f84..1e931b34 100644 --- a/dfetch/vcs/svn.py +++ b/dfetch/vcs/svn.py @@ -60,6 +60,21 @@ def is_svn(self) -> bool: except RuntimeError: return False + def list_of_branches(self) -> list[str]: + """List branch names from the ``branches/`` directory.""" + try: + result = run_on_cmdline( + logger, + ["svn", "ls", "--non-interactive", f"{self._remote}/branches"], + ) + return [ + line.strip("/\r") + for line in result.stdout.decode().splitlines() + if line.strip("/\r") + ] + except (SubprocessCommandError, RuntimeError): + return [] + def list_of_tags(self) -> list[str]: """Get list of all available tags.""" result = run_on_cmdline( @@ -69,6 +84,23 @@ def list_of_tags(self) -> list[str]: str(tag).strip("/\r") for tag in result.stdout.decode().split("\n") if tag ] + def ls_tree(self, url_path: str) -> list[tuple[str, bool]]: + """List immediate children of *url_path* as ``(name, is_dir)`` pairs.""" + try: + result = run_on_cmdline( + logger, ["svn", "ls", "--non-interactive", url_path] + ) + entries: list[tuple[str, bool]] = [] + for line in result.stdout.decode().splitlines(): + line = line.strip("\r") + if not line: + continue + is_dir = line.endswith("/") + entries.append((line.rstrip("/"), is_dir)) + return entries + except (SubprocessCommandError, RuntimeError): + return [] + class SvnRepo: """An svn repository.""" diff --git a/tests/test_add.py b/tests/test_add.py index 70bfba8e..f713e712 100644 --- a/tests/test_add.py +++ b/tests/test_add.py @@ -516,6 +516,188 @@ def test_add_command_interactive_run_update(): mock_update.assert_called_once() +# --------------------------------------------------------------------------- +# Add command – interactive mode (SVN) +# --------------------------------------------------------------------------- + +_SVN_URL = "svn://example.com/myrepo" + + +def _make_svn_subproject( + branches: list[str] | None = None, + tags: list[str] | None = None, +) -> Mock: + """Return a Mock SVN SubProject with ``trunk`` as default branch.""" + all_branches = branches if branches is not None else ["trunk"] + return _make_subproject( + default_branch="trunk", + branches=all_branches, + tags=tags if tags is not None else [], + ) + + +def test_add_command_interactive_svn_trunk(): + """SVN interactive add: accepting the default trunk branch.""" + fake_superproject = Mock() + fake_superproject.manifest = mock_manifest([], path="/some/dfetch.yaml") + fake_superproject.manifest.remotes = [] + fake_superproject.root_directory = Path("/some") + + fake_subproject = _make_svn_subproject() + + # Prompts: name, dst, version (trunk), src, ignore + answers = iter(["myrepo", "myrepo", "trunk", "", ""]) + + with patch( + "dfetch.commands.add.create_super_project", return_value=fake_superproject + ): + with patch( + "dfetch.commands.add.create_sub_project", return_value=fake_subproject + ): + with patch( + "dfetch.commands.add.Prompt.ask", + side_effect=lambda *a, **kw: next(answers), + ): + with patch( + "dfetch.commands.add.Confirm.ask", side_effect=[True, False] + ): + with patch( + "dfetch.commands.add.append_entry_manifest_file" + ) as mock_append: + Add()(_make_args(_SVN_URL, interactive=True)) + + mock_append.assert_called_once() + entry: ProjectEntry = mock_append.call_args[0][1] + assert entry.name == "myrepo" + assert entry.branch == "trunk" + + +def test_add_command_interactive_svn_custom_branch(): + """SVN interactive add: selecting a branch from ``branches/``.""" + fake_superproject = Mock() + fake_superproject.manifest = mock_manifest([], path="/some/dfetch.yaml") + fake_superproject.manifest.remotes = [] + fake_superproject.root_directory = Path("/some") + + fake_subproject = _make_svn_subproject(branches=["trunk", "feature-x"]) + + # Prompts: name, dst, version (feature-x), src, ignore + answers = iter(["myrepo", "myrepo", "feature-x", "", ""]) + + with patch( + "dfetch.commands.add.create_super_project", return_value=fake_superproject + ): + with patch( + "dfetch.commands.add.create_sub_project", return_value=fake_subproject + ): + with patch( + "dfetch.commands.add.Prompt.ask", + side_effect=lambda *a, **kw: next(answers), + ): + with patch( + "dfetch.commands.add.Confirm.ask", side_effect=[True, False] + ): + with patch( + "dfetch.commands.add.append_entry_manifest_file" + ) as mock_append: + Add()(_make_args(_SVN_URL, interactive=True)) + + entry: ProjectEntry = mock_append.call_args[0][1] + assert entry.branch == "feature-x" + + +def test_add_command_interactive_svn_tag(): + """SVN interactive add: selecting a tag.""" + fake_superproject = Mock() + fake_superproject.manifest = mock_manifest([], path="/some/dfetch.yaml") + fake_superproject.manifest.remotes = [] + fake_superproject.root_directory = Path("/some") + + fake_subproject = _make_svn_subproject(tags=["v1.0", "v2.0"]) + + # Prompts: name, dst, version (v2.0 by name), src, ignore + answers = iter(["myrepo", "myrepo", "v2.0", "", ""]) + + with patch( + "dfetch.commands.add.create_super_project", return_value=fake_superproject + ): + with patch( + "dfetch.commands.add.create_sub_project", return_value=fake_subproject + ): + with patch( + "dfetch.commands.add.Prompt.ask", + side_effect=lambda *a, **kw: next(answers), + ): + with patch( + "dfetch.commands.add.Confirm.ask", side_effect=[True, False] + ): + with patch( + "dfetch.commands.add.append_entry_manifest_file" + ) as mock_append: + Add()(_make_args(_SVN_URL, interactive=True)) + + entry: ProjectEntry = mock_append.call_args[0][1] + assert entry.tag == "v2.0" + assert entry.branch == "" + + +def test_add_command_interactive_svn_branch_by_number(): + """SVN interactive add: selecting a branch by its number in the pick list.""" + fake_superproject = Mock() + fake_superproject.manifest = mock_manifest([], path="/some/dfetch.yaml") + fake_superproject.manifest.remotes = [] + fake_superproject.root_directory = Path("/some") + + # choices order: trunk (default, index 1), feature-x (index 2) + fake_subproject = _make_svn_subproject(branches=["trunk", "feature-x"]) + + answers = iter(["myrepo", "myrepo", "2", "", ""]) + + with patch( + "dfetch.commands.add.create_super_project", return_value=fake_superproject + ): + with patch( + "dfetch.commands.add.create_sub_project", return_value=fake_subproject + ): + with patch( + "dfetch.commands.add.Prompt.ask", + side_effect=lambda *a, **kw: next(answers), + ): + with patch( + "dfetch.commands.add.Confirm.ask", side_effect=[True, False] + ): + with patch( + "dfetch.commands.add.append_entry_manifest_file" + ) as mock_append: + Add()(_make_args(_SVN_URL, interactive=True)) + + entry: ProjectEntry = mock_append.call_args[0][1] + assert entry.branch == "feature-x" + + +def test_add_command_interactive_svn_force(): + """SVN non-interactive (--force) add defaults to trunk.""" + fake_superproject = Mock() + fake_superproject.manifest = mock_manifest([], path="/some/dfetch.yaml") + fake_superproject.manifest.remotes = [] + fake_superproject.root_directory = Path("/some") + + fake_subproject = _make_svn_subproject() + + with patch( + "dfetch.commands.add.create_super_project", return_value=fake_superproject + ): + with patch( + "dfetch.commands.add.create_sub_project", return_value=fake_subproject + ): + with patch("dfetch.commands.add.append_entry_manifest_file") as mock_append: + Add()(_make_args(_SVN_URL, force=True)) + + mock_append.assert_called_once() + entry: ProjectEntry = mock_append.call_args[0][1] + assert entry.branch == "trunk" + + # --------------------------------------------------------------------------- # Add command – remote matching # --------------------------------------------------------------------------- From 6e06973c4d38c087b614b43c0b8a55bec87b4f56 Mon Sep 17 00:00:00 2001 From: Ben Spoor Date: Tue, 24 Mar 2026 22:35:25 +0100 Subject: [PATCH 08/29] Split out generic concerns --- dfetch/commands/add.py | 630 ++++++-------------------------- dfetch/log.py | 9 + dfetch/manifest/manifest.py | 60 +++ dfetch/project/gitsubproject.py | 33 +- dfetch/project/subproject.py | 24 +- dfetch/project/svnsubproject.py | 29 +- dfetch/terminal/__init__.py | 45 +++ dfetch/terminal/ansi.py | 22 ++ dfetch/terminal/keys.py | 92 +++++ dfetch/terminal/pick.py | 138 +++++++ dfetch/terminal/prompt.py | 59 +++ dfetch/terminal/screen.py | 40 ++ dfetch/terminal/tree_browser.py | 307 ++++++++++++++++ dfetch/terminal/types.py | 5 + dfetch/util/terminal.py | 354 ------------------ dfetch/util/versions.py | 45 +++ dfetch/vcs/git.py | 27 ++ dfetch/vcs/svn.py | 29 +- pyproject.toml | 4 +- tests/manifest_mock.py | 11 + tests/test_add.py | 47 +-- 21 files changed, 1021 insertions(+), 989 deletions(-) create mode 100644 dfetch/terminal/__init__.py create mode 100644 dfetch/terminal/ansi.py create mode 100644 dfetch/terminal/keys.py create mode 100644 dfetch/terminal/pick.py create mode 100644 dfetch/terminal/prompt.py create mode 100644 dfetch/terminal/screen.py create mode 100644 dfetch/terminal/tree_browser.py create mode 100644 dfetch/terminal/types.py delete mode 100644 dfetch/util/terminal.py diff --git a/dfetch/commands/add.py b/dfetch/commands/add.py index 0828ea91..31b396db 100644 --- a/dfetch/commands/add.py +++ b/dfetch/commands/add.py @@ -1,4 +1,3 @@ -# pylint: disable=too-many-lines """*Dfetch* can add projects to the manifest through the CLI. Sometimes you want to add a project to your manifest, but you don't want to @@ -45,55 +44,54 @@ from __future__ import annotations import argparse -import os -import re -import sys -from collections.abc import Sequence -from dataclasses import dataclass -from pathlib import Path -from typing import Literal - -import semver +import contextlib +from collections.abc import Generator + from rich.prompt import Confirm, Prompt import dfetch.commands.command import dfetch.manifest.project import dfetch.project +from dfetch import terminal from dfetch.log import get_logger -from dfetch.manifest.manifest import append_entry_manifest_file +from dfetch.manifest.manifest import Manifest, append_entry_manifest_file from dfetch.manifest.project import ProjectEntry, ProjectEntryDict from dfetch.manifest.remote import Remote from dfetch.project import create_sub_project, create_super_project -from dfetch.project.subproject import LsFn, SubProject -from dfetch.util import terminal +from dfetch.project.gitsubproject import GitSubProject +from dfetch.project.subproject import SubProject +from dfetch.project.svnsubproject import SvnSubProject +from dfetch.terminal import LsFunction +from dfetch.terminal.tree_browser import ( + TreeNode, + deselected_paths, + run_tree_browser, + tree_single_pick, +) from dfetch.util.purl import vcs_url_to_purl +from dfetch.util.versions import ( + VersionRef, + is_commit_sha, + prioritise_default, + sort_tags_newest_first, +) logger = get_logger(__name__) -# Characters that are not allowed in a project name (YAML special chars). -_UNSAFE_NAME_RE = re.compile(r"[\x00-\x1F\x7F-\x9F:#\[\]{}&*!|>'\"%@`]") - -# --------------------------------------------------------------------------- -# Value objects -# --------------------------------------------------------------------------- - - -@dataclass(frozen=True) -class VersionRef: - """A resolved version reference: a branch name, tag, or commit SHA.""" +@contextlib.contextmanager +def browse_tree(subproject: SubProject, version: str = "") -> Generator[LsFunction]: + """Yield an ``LsFunction`` for interactively browsing *subproject*'s remote tree.""" + if isinstance(subproject, (GitSubProject, SvnSubProject)): + remote = subproject._remote_repo # pylint: disable=protected-access + with remote.browse_tree(version) as ls_function: + yield ls_function + else: - kind: Literal["branch", "tag", "revision"] - value: str + def _empty(_path: str = "") -> list[tuple[str, bool]]: + return [] - def apply_to(self, entry: ProjectEntryDict) -> None: - """Write this version reference into *entry*.""" - if self.kind == "branch": - entry["branch"] = self.value - elif self.kind == "tag": - entry["tag"] = self.value - elif self.kind == "revision": - entry["revision"] = self.value + yield _empty # --------------------------------------------------------------------------- @@ -153,19 +151,17 @@ def __call__(self, args: argparse.Namespace) -> None: subproject = create_sub_project(probe_entry) if not args.interactive: - _check_name_uniqueness(probe_entry.name, superproject.manifest.projects) + superproject.manifest.check_name_uniqueness(probe_entry.name) - remote_to_use = _determine_remote( - superproject.manifest.remotes, probe_entry.remote_url + remote_to_use = superproject.manifest.find_remote_for_url( + probe_entry.remote_url ) if remote_to_use: logger.debug( f"Remote URL {probe_entry.remote_url} matches remote {remote_to_use.name}" ) - guessed_dst = _guess_destination( - probe_entry.name, superproject.manifest.projects - ) + guessed_dst = superproject.manifest.guess_destination(probe_entry.name) default_branch = subproject.get_default_branch() if args.interactive: @@ -176,7 +172,7 @@ def __call__(self, args: argparse.Namespace) -> None: default_branch=default_branch, subproject=subproject, remote_to_use=remote_to_use, - existing_projects=superproject.manifest.projects, + manifest=superproject.manifest, ) else: project_entry = _non_interactive_entry( @@ -260,7 +256,7 @@ def _build_entry( # pylint: disable=too-many-arguments url=remote_url, dst=dst, ) - version.apply_to(entry_dict) + entry_dict[version.kind] = version.value # type: ignore[literal-required] if src: entry_dict["src"] = src if ignore: @@ -276,16 +272,6 @@ def _build_entry( # pylint: disable=too-many-arguments # --------------------------------------------------------------------------- -def _print_yaml_field(key: str, value: str | list[str]) -> None: - """Print one manifest field in YAML style.""" - if isinstance(value, list): - logger.info(f" [blue]{key}:[/blue]") - for item in value: - logger.info(f" - {item}") - else: - logger.info(f" [blue]{key}:[/blue] {value}") - - def _interactive_flow( # pylint: disable=too-many-arguments,too-many-positional-arguments remote_url: str, default_name: str, @@ -293,12 +279,12 @@ def _interactive_flow( # pylint: disable=too-many-arguments,too-many-positional default_branch: str, subproject: SubProject, remote_to_use: Remote | None, - existing_projects: Sequence[ProjectEntry], + manifest: Manifest, ) -> ProjectEntry: """Guide the user through every manifest field and return a ``ProjectEntry``.""" logger.print_info_line(default_name, f"Adding {remote_url}") - name = _ask_name(default_name, existing_projects) + name = _ask_name(default_name, manifest) # Show the fields that are fixed by the URL right after the name is confirmed. seed = _build_entry( @@ -312,26 +298,26 @@ def _interactive_flow( # pylint: disable=too-many-arguments,too-many-positional ).as_yaml() for key in ("name", "remote", "url", "repo-path"): if key in seed and isinstance(seed[key], (str, list)): - _print_yaml_field(key, seed[key]) # type: ignore[arg-type] + logger.print_yaml_field(key, seed[key]) # type: ignore[arg-type] dst = _ask_dst(name, default_dst) if dst != name: - _print_yaml_field("dst", dst) + logger.print_yaml_field("dst", dst) version = _ask_version( default_branch, subproject.list_of_branches(), subproject.list_of_tags(), ) - _print_yaml_field(version.kind, version.value) + logger.print_yaml_field(version.kind, version.value) - with subproject.browse_tree(version.value) as ls_fn: - src = _ask_src(ls_fn) + with browse_tree(subproject, version.value) as ls_function: + src = _ask_src(ls_function) if src: - _print_yaml_field("src", src) - ignore = _ask_ignore(ls_fn, src=src) + logger.print_yaml_field("src", src) + ignore = _ask_ignore(ls_function, src=src) if ignore: - _print_yaml_field("ignore", ignore) + logger.print_yaml_field("ignore", ignore) return _build_entry( name=name, @@ -349,15 +335,15 @@ def _interactive_flow( # pylint: disable=too-many-arguments,too-many-positional # --------------------------------------------------------------------------- -def _erase_prompt_line() -> None: - """Erase the last printed line (the rich prompt) when running in a TTY.""" +def _prompt(tty_label: str, rich_label: str, default: str) -> str: + """Single-line prompt with TTY ghost text or rich fallback.""" if terminal.is_tty(): - sys.stdout.write("\x1b[1A\x1b[2K") - sys.stdout.flush() + return terminal.ghost_prompt(tty_label, default).strip() + return Prompt.ask(rich_label, default=default).strip() def _unique_name(base: str, existing: set[str]) -> str: - """Return *base* if unused, otherwise *base*-1, *base*-2, … until unique.""" + """Return *base* if unused, otherwise append *-1*, *-2*, … until unique.""" if base not in existing: return base i = 1 @@ -366,28 +352,20 @@ def _unique_name(base: str, existing: set[str]) -> str: return f"{base}-{i}" -def _ask_name(default: str, existing_projects: Sequence[ProjectEntry]) -> str: +def _ask_name(default: str, manifest: Manifest) -> str: """Prompt for the project name, re-asking on invalid input.""" - existing_names = {p.name for p in existing_projects} + existing_names = {p.name for p in manifest.projects} suggested = _unique_name(default, existing_names) while True: - if terminal.is_tty(): - name = terminal.ghost_prompt(" ? Name", suggested).strip() - else: - name = Prompt.ask(" ? [bold]Name[/bold]", default=suggested).strip() - if not name: - logger.warning("Name cannot be empty.") - continue - if _UNSAFE_NAME_RE.search(name): - logger.warning( - f"Name '{name}' contains characters not allowed in a manifest name. " - "Avoid: # : [ ] {{ }} & * ! | > ' \" % @ `" - ) - continue - if name in existing_names: - suggested = _unique_name(name, existing_names) + name = _prompt(" ? Name", " ? [bold]Name[/bold]", suggested) + try: + manifest.validate_project_name(name) + except ValueError as exc: + logger.warning(str(exc)) + if name in existing_names: + suggested = _unique_name(name, existing_names) continue - _erase_prompt_line() + terminal.erase_last_line() return name @@ -395,22 +373,19 @@ def _ask_dst(name: str, default: str) -> str: """Prompt for the destination path, re-asking on path-traversal attempts.""" suggested = default or name while True: - if terminal.is_tty(): - dst = terminal.ghost_prompt(" ? Destination", suggested).strip() - else: - dst = Prompt.ask( - " ? [bold]Destination[/bold] (path relative to manifest)", - default=suggested, - ).strip() + dst = _prompt( + " ? Destination", + " ? [bold]Destination[/bold] (path relative to manifest)", + suggested, + ) if not dst: dst = name # fall back to project name - if any(part == ".." for part in Path(dst).parts): - logger.warning( - f"Destination '{dst}' contains '..'. " - "Paths must stay within the manifest directory." - ) + try: + Manifest.validate_destination(dst) + except ValueError as exc: + logger.warning(str(exc)) continue - _erase_prompt_line() + terminal.erase_last_line() return dst @@ -424,8 +399,8 @@ def _ask_version( In a TTY shows a hierarchical tree browser (names split on '/'). Outside a TTY (CI, pipe, tests) falls back to a numbered text menu. """ - ordered_branches = _prioritise_default(branches, default_branch) - ordered_tags = _sort_tags_newest_first(tags) + ordered_branches = prioritise_default(branches, default_branch) + ordered_tags = sort_tags_newest_first(tags) choices: list[VersionRef] = [ *[VersionRef("branch", b) for b in ordered_branches], @@ -438,7 +413,7 @@ def _ask_version( return _text_version_pick(choices, default_branch, branches, tags) -def _ask_src(ls_fn: LsFn) -> str: +def _ask_src(ls_function: LsFunction) -> str: """Optionally prompt for a ``src:`` sub-path or glob pattern. In a TTY opens a tree browser for single-path selection with @@ -446,7 +421,9 @@ def _ask_src(ls_fn: LsFn) -> str: Outside a TTY falls back to a free-text prompt. """ if terminal.is_tty(): - return _tree_single_pick(ls_fn, "Source path (Enter to select, Esc to skip)") + return tree_single_pick( + ls_function, "Source path (Enter to select, Esc to skip)" + ) return Prompt.ask( " ? [bold]Source path[/bold] (sub-path/glob, or Enter to fetch whole repo)", @@ -454,19 +431,8 @@ def _ask_src(ls_fn: LsFn) -> str: ).strip() -def _normalize_ignore_paths(ignore: list[str], src: str) -> list[str]: - """Strip the *src* prefix from each path so paths are relative to *src*.""" - if not src: - return ignore - prefix = src.rstrip("/") + "/" - return [p[len(prefix) :] if p.startswith(prefix) else p for p in ignore] - - -def _should_proceed_with_ignore(nodes: list[_TreeNode]) -> bool: - """Return True when the ignore list is acceptable to use. - - If the user deselected every visible node, warn and ask for confirmation. - """ +def _should_proceed_with_ignore(nodes: list[TreeNode]) -> bool: + """Warn and confirm when every visible node has been deselected.""" if any(n.selected for n in nodes): return True logger.warning( @@ -475,7 +441,7 @@ def _should_proceed_with_ignore(nodes: list[_TreeNode]) -> bool: return bool(Confirm.ask("Continue with empty selection?", default=False)) -def _ask_ignore(ls_fn: LsFn, src: str = "") -> list[str]: +def _ask_ignore(ls_function: LsFunction, src: str = "") -> list[str]: """Optionally prompt for ``ignore:`` paths. Opens a tree browser (TTY) or falls back to free-text. All items start @@ -486,21 +452,19 @@ def _ask_ignore(ls_fn: LsFn, src: str = "") -> list[str]: """ def _scoped_ls(path: str = "") -> list[tuple[str, bool]]: - return ls_fn(f"{src}/{path}" if path else src) + return ls_function(f"{src}/{path}" if path else src) - browse_fn: LsFn = _scoped_ls if src else ls_fn + browse_fn: LsFunction = _scoped_ls if src else ls_function if terminal.is_tty(): while True: - all_nodes: list[_TreeNode] = [] - _run_tree_browser( + _, all_nodes = run_tree_browser( browse_fn, "Ignore (Space deselects → ignored, Enter confirms, Esc skips)", multi=True, all_selected=True, - _out_nodes=all_nodes, ) - ignore = _compute_ignore_from_nodes(all_nodes) + ignore = deselected_paths(all_nodes) if not ignore: return [] if _should_proceed_with_ignore(all_nodes): @@ -518,17 +482,16 @@ def _scoped_ls(path: str = "") -> list[tuple[str, bool]]: # --------------------------------------------------------------------------- -def _version_ls_fn( +def _version_ls_function( branches: list[str], tags: list[str], default_branch: str, -) -> LsFn: - """Build a ls_fn that exposes branches and tags as a /-split tree. +) -> LsFunction: + """Build a ls_function that exposes branches and tags as a /-split tree. - Leaf nodes (actual branch/tag names) carry ANSI colour in their display - name (cyan = branch, magenta = tag). ``_expand_node`` and - ``_run_tree_browser`` strip ANSI when building ``node.path``, so the - stored path is always the clean version name. + Leaf nodes carry a dim kind label (``branch`` / ``tag``) so they are + visually distinct from directory segments. The tree browser strips ANSI + when building ``node.path``, so the stored path is always the clean name. """ leaf_kind: dict[str, str] = {b: "branch" for b in branches} leaf_kind.update({t: "tag" for t in tags}) @@ -589,13 +552,11 @@ def _ask_version_tree( ) -> VersionRef: # pragma: no cover - interactive TTY only """Branch/tag picker using the hierarchical tree browser. - Splits names by '/' to build a navigable tree (e.g. ``feature/login`` - appears as ``feature ▸ login``). Leaves are cyan for branches and - magenta for tags. Falls back to the numbered text picker on Esc or - when the selected path cannot be resolved to a known version. + Splits names by '/' to build a navigable tree. Falls back to the + numbered text picker on Esc or when the path can't be resolved. """ - ls = _version_ls_fn(branches, tags, default_branch) - selected = _tree_single_pick(ls, "Version (Enter to select · Esc to type freely)") + ls = _version_ls_function(branches, tags, default_branch) + selected = tree_single_pick(ls, "Version (Enter to select · Esc to type freely)") branch_set = set(branches) tag_set = set(tags) @@ -616,6 +577,9 @@ def _text_version_pick( """Numbered text-based version picker (non-TTY fallback).""" _print_version_menu(choices, default_branch) + branch_set = set(branches) + tag_set = set(tags) + while True: raw = Prompt.ask( " ? [bold]Version[/bold] (number, branch, tag, or SHA)", @@ -629,11 +593,11 @@ def _text_version_pick( logger.warning(f" Pick a number between 1 and {len(choices)}.") continue - if raw in branches: + if raw in branch_set: return VersionRef("branch", raw) - if raw in tags: + if raw in tag_set: return VersionRef("tag", raw) - if re.fullmatch(r"[0-9a-fA-F]{7,40}", raw): + if is_commit_sha(raw): return VersionRef("revision", raw) if raw: return VersionRef("branch", raw) @@ -659,393 +623,3 @@ def _print_version_menu(choices: list[VersionRef], default_branch: str) -> None: ) logger.info("\n".join(lines)) - - -# --------------------------------------------------------------------------- -# Tree browser -# --------------------------------------------------------------------------- - - -@dataclass -class _TreeNode: - """One entry in the flattened view of a remote VCS tree.""" - - name: str - path: str # path relative to repo root - is_dir: bool - depth: int = 0 - expanded: bool = False - selected: bool = False - children_loaded: bool = False - - -def _expand_node(nodes: list[_TreeNode], idx: int, ls_fn: LsFn) -> None: - """Expand the directory node at *idx*, loading children if not yet done.""" - node = nodes[idx] - if not node.children_loaded: - children = [ - _TreeNode( - name=name, - path=f"{node.path}/{terminal.strip_ansi(name)}", - is_dir=is_dir, - depth=node.depth + 1, - selected=node.selected, - ) - for name, is_dir in ls_fn(node.path) - ] - nodes[idx + 1 : idx + 1] = children - node.children_loaded = True - node.expanded = True - - -def _collapse_node(nodes: list[_TreeNode], idx: int) -> None: - """Collapse the directory node at *idx* and remove all descendant nodes.""" - parent_depth = nodes[idx].depth - end = idx + 1 - while end < len(nodes) and nodes[end].depth > parent_depth: - end += 1 - del nodes[idx + 1 : end] - nodes[idx].expanded = False - nodes[idx].children_loaded = False - - -def _render_tree_lines( - nodes: list[_TreeNode], idx: int, top: int, *, ignore_mode: bool = False -) -> list[str]: - """Build the list of display strings for one frame of the tree browser.""" - n = len(nodes) - lines: list[str] = [] - for i in range(top, min(top + terminal.VIEWPORT, n)): - node = nodes[i] - indent = " " * node.depth - icon = ("▾ " if node.expanded else "▸ ") if node.is_dir else " " - cursor = f"{terminal.YELLOW}▶{terminal.RESET}" if i == idx else " " - if ignore_mode: - name = ( - f"{terminal.DIM}{node.name}{terminal.RESET}" - if not node.selected - else node.name - ) - lines.append(f" {cursor} {indent}{icon}{name}") - else: - check = f"{terminal.GREEN}✓ {terminal.RESET}" if node.selected else " " - name = ( - f"{terminal.BOLD}{node.name}{terminal.RESET}" if i == idx else node.name - ) - lines.append(f" {cursor} {check}{indent}{icon}{name}") - return lines - - -def _cascade_selection(nodes: list[_TreeNode], parent_idx: int, selected: bool) -> None: - """Set *selected* on all loaded descendants of the node at *parent_idx*.""" - parent_depth = nodes[parent_idx].depth - for i in range(parent_idx + 1, len(nodes)): - if nodes[i].depth <= parent_depth: - break - nodes[i].selected = selected - - -def _adjust_scroll(idx: int, top: int) -> int: - """Return a new *top* so that *idx* is within the visible viewport.""" - if idx < top: - return idx - if idx >= top + terminal.VIEWPORT: - return idx - terminal.VIEWPORT + 1 - return top - - -def _build_tree_frame( # pylint: disable=too-many-arguments,too-many-positional-arguments - title: str, - nodes: list[_TreeNode], - idx: int, - top: int, - hint: str, - *, - ignore_mode: bool = False, -) -> list[str]: - """Build all display lines for one render frame of the tree browser.""" - n = len(nodes) - header = [f" {terminal.BOLD}{title}{terminal.RESET}"] - if top > 0: - header.append(f" {terminal.DIM}↑ {top} more above{terminal.RESET}") - body = _render_tree_lines(nodes, idx, top, ignore_mode=ignore_mode) - footer: list[str] = [] - remaining = n - (top + terminal.VIEWPORT) - if remaining > 0: - footer.append(f" {terminal.DIM}↓ {remaining} more below{terminal.RESET}") - footer.append(f" {terminal.DIM}{hint}{terminal.RESET}") - return header + body + footer - - -def _handle_tree_nav(key: str, idx: int, n: int) -> int | None: - """Handle arrow/page navigation keys; return new index or None if not a nav key.""" - if key == "UP": - return max(0, idx - 1) - if key == "DOWN": - return min(n - 1, idx + 1) - if key == "PGUP": - return max(0, idx - terminal.VIEWPORT) - if key == "PGDN": - return min(n - 1, idx + terminal.VIEWPORT) - return None - - -def _handle_tree_left(nodes: list[_TreeNode], idx: int) -> int: - """Handle the LEFT key: collapse the current dir or jump to its parent.""" - node = nodes[idx] - if node.is_dir and node.expanded: - _collapse_node(nodes, idx) - return idx - if node.depth > 0: - for i in range(idx - 1, -1, -1): - if nodes[i].depth < node.depth: - return i - return idx - - -def _handle_tree_space( - nodes: list[_TreeNode], idx: int, multi: bool -) -> list[str] | None: - """Handle SPACE: toggle selection (multi) or select immediately (single). - - Returns a path list when the browser should exit, or ``None`` to continue. - """ - node = nodes[idx] - if multi: - node.selected = not node.selected - if node.is_dir: - _cascade_selection(nodes, idx, node.selected) - return None - return [node.path] - - -def _handle_tree_enter( - nodes: list[_TreeNode], idx: int, ls_fn: LsFn, multi: bool -) -> tuple[int, list[str] | None]: - """Handle ENTER: confirm selection (multi), expand/collapse dir, or pick file.""" - node = nodes[idx] - if multi: - return idx, [n.path for n in nodes if n.selected] - if node.is_dir: - if node.expanded: - _collapse_node(nodes, idx) - else: - _expand_node(nodes, idx, ls_fn) - return idx, None - return idx, [node.path] - - -def _handle_tree_action( - key: str, - nodes: list[_TreeNode], - idx: int, - ls_fn: LsFn, - multi: bool, -) -> tuple[int, list[str] | None]: - """Dispatch non-navigation keypresses. - - Returns ``(new_idx, result)``. When *result* is not ``None`` the browser - should exit and return it (``[]`` for ESC/skip, path list for a selection). - """ - node = nodes[idx] - if key == "RIGHT": - if node.is_dir and not node.expanded: - _expand_node(nodes, idx, ls_fn) - return idx, None - if key == "LEFT": - return _handle_tree_left(nodes, idx), None - if key == "SPACE": - return idx, _handle_tree_space(nodes, idx, multi) - if key == "ENTER": - return _handle_tree_enter(nodes, idx, ls_fn, multi) - if key == "ESC": - return idx, [] - return idx, None - - -def _run_tree_browser( - ls_fn: LsFn, - title: str, - *, - multi: bool, - all_selected: bool = False, - _out_nodes: list[_TreeNode] | None = None, -) -> list[str]: # pragma: no cover - interactive TTY only - """Core tree browser loop. - - Returns a list of selected paths. In single-select mode the list has - at most one item; in multi-select mode it may have any number. - If ``all_selected=True``, all nodes start selected. - If ``_out_nodes`` is provided, it is extended with the final node state on exit. - """ - root_entries = ls_fn("") - if not root_entries: - return [] - - nodes: list[_TreeNode] = [ - _TreeNode( - name=name, - path=terminal.strip_ansi(name), - is_dir=is_dir, - depth=0, - selected=all_selected, - ) - for name, is_dir in root_entries - ] - hint = ( - "↑/↓ navigate Space select Enter confirm →/← expand/collapse Esc skip" - if multi - else "↑/↓ navigate Enter/Space select →/← expand/collapse Esc skip" - ) - screen = terminal.Screen() - idx, top = 0, 0 - - while True: - n = len(nodes) - if n == 0: - screen.clear() - return [] - idx = max(0, min(idx, n - 1)) - top = _adjust_scroll(idx, top) - screen.draw( - _build_tree_frame(title, nodes, idx, top, hint, ignore_mode=all_selected) - ) - key = terminal.read_key() - - new_idx = _handle_tree_nav(key, idx, n) - if new_idx is not None: - idx = new_idx - continue - - idx, result = _handle_tree_action(key, nodes, idx, ls_fn, multi) - if result is not None: - if _out_nodes is not None: - _out_nodes.extend(nodes) - screen.clear() - return result - - -def _tree_single_pick(ls_fn: LsFn, title: str) -> str: - """Browse the remote tree and return a single selected path. - - Returns ``""`` if the user skips (Esc) or no tree is available. - """ - result = _run_tree_browser(ls_fn, title, multi=False) - return result[0] if result else "" - - -def _all_descendants_deselected(nodes: list[_TreeNode], parent_idx: int) -> bool: - """Return True if every loaded descendant of the node at *parent_idx* is deselected.""" - parent_depth = nodes[parent_idx].depth - for i in range(parent_idx + 1, len(nodes)): - if nodes[i].depth <= parent_depth: - break - if nodes[i].selected: - return False - return True - - -def _compute_ignore_from_nodes(nodes: list[_TreeNode]) -> list[str]: - """Compute the minimal ignore list from the final browser node state. - - A deselected directory is emitted as a single entry when all its loaded - descendants are also deselected (or it was never expanded). This keeps - the ignore list short. Individual deselected files are listed when their - parent directory is only partially deselected. - """ - ignore: list[str] = [] - ignored_dirs: set[str] = set() - - for i, node in enumerate(nodes): - if node.selected: - continue - if any(node.path.startswith(d + "/") for d in ignored_dirs): - continue - if node.is_dir and ( - not node.children_loaded or _all_descendants_deselected(nodes, i) - ): - ignore.append(node.path) - ignored_dirs.add(node.path) - elif not node.is_dir: - ignore.append(node.path) - - return ignore - - -# --------------------------------------------------------------------------- -# Sorting / ordering helpers -# --------------------------------------------------------------------------- - - -def _prioritise_default(branches: list[str], default: str) -> list[str]: - """Return *branches* with *default* moved to position 0.""" - if default in branches: - rest = [b for b in branches if b != default] - return [default, *rest] - return branches - - -def _sort_tags_newest_first(tags: list[str]) -> list[str]: - """Sort *tags* newest-semver-first; non-semver tags appended as-is.""" - - def _semver_key(tag: str) -> semver.Version | None: - try: - return semver.Version.parse(tag.lstrip("vV")) - except ValueError: - return None - - semver_tags = sorted( - (t for t in tags if _semver_key(t) is not None), - key=_semver_key, # type: ignore[arg-type, return-value] - reverse=True, - ) - non_semver = [t for t in tags if _semver_key(t) is None] - return semver_tags + non_semver - - -# --------------------------------------------------------------------------- -# Non-interactive helpers -# --------------------------------------------------------------------------- - - -def _check_name_uniqueness( - project_name: str, manifest_projects: Sequence[ProjectEntry] -) -> None: - """Raise if *project_name* is already used in the manifest.""" - if project_name in [project.name for project in manifest_projects]: - raise RuntimeError( - f"Project with name '{project_name}' already exists in manifest!" - ) - - -def _guess_destination( - project_name: str, manifest_projects: Sequence[ProjectEntry] -) -> str: - """Guess the destination based on the common prefix of existing projects. - - With two or more existing projects the common parent directory is used. - With a single existing project its parent directory is used (if any). - """ - destinations = [p.destination for p in manifest_projects if p.destination] - if not destinations: - return "" - - common_path = os.path.commonpath(destinations) - if not common_path or common_path == os.path.sep: - return "" - - if len(destinations) == 1: - parent = str(Path(common_path).parent) - if parent and parent != ".": - return (Path(parent) / project_name).as_posix() - return "" - - return (Path(common_path) / project_name).as_posix() - - -def _determine_remote(remotes: Sequence[Remote], remote_url: str) -> Remote | None: - """Return the first remote whose base URL is a prefix of *remote_url*.""" - for remote in remotes: - if remote_url.startswith(remote.url): - return remote - return None diff --git a/dfetch/log.py b/dfetch/log.py index 762dbef5..031f26d8 100644 --- a/dfetch/log.py +++ b/dfetch/log.py @@ -122,6 +122,15 @@ def print_info_field(self, field_name: str, field: str) -> None: """Print a field with corresponding value.""" self.print_report_line(field_name, field if field else "") + def print_yaml_field(self, key: str, value: str | list[str]) -> None: + """Print one manifest field in YAML style.""" + if isinstance(value, list): + self.info(f" [blue]{key}:[/blue]") + for item in value: + self.info(f" - {item}") + else: + self.info(f" [blue]{key}:[/blue] {value}") + def warning(self, msg: object, *args: Any, **kwargs: Any) -> None: """Log warning.""" super().warning( diff --git a/dfetch/manifest/manifest.py b/dfetch/manifest/manifest.py index dd06ebad..42e0059c 100644 --- a/dfetch/manifest/manifest.py +++ b/dfetch/manifest/manifest.py @@ -371,6 +371,66 @@ def find_name_in_manifest(self, name: str) -> ManifestEntryLocation: ) raise RuntimeError(f"{name} was not found in the manifest!") + # Characters not allowed in a project name (YAML special chars). + _UNSAFE_NAME_RE = re.compile(r"[\x00-\x1F\x7F-\x9F:#\[\]{}&*!|>'\"%@`]") + + def check_name_uniqueness(self, project_name: str) -> None: + """Raise if *project_name* is already used in the manifest.""" + if project_name in {project.name for project in self.projects}: + raise RuntimeError( + f"Project with name '{project_name}' already exists in manifest!" + ) + + def validate_project_name(self, name: str) -> None: + """Raise ValueError if *name* is not valid for use in this manifest.""" + if not name: + raise ValueError("Name cannot be empty.") + if self._UNSAFE_NAME_RE.search(name): + raise ValueError( + f"Name '{name}' contains characters not allowed in a manifest name. " + "Avoid: # : [ ] { } & * ! | > ' \" % @ `" + ) + if name in {p.name for p in self.projects}: + raise ValueError(f"Project with name '{name}' already exists in manifest!") + + @staticmethod + def validate_destination(dst: str) -> None: + """Raise ValueError if *dst* is not a safe manifest destination path.""" + if any(part == ".." for part in Path(dst).parts): + raise ValueError( + f"Destination '{dst}' contains '..'. " + "Paths must stay within the manifest directory." + ) + + def guess_destination(self, project_name: str) -> str: + """Guess the destination based on the common prefix of existing projects. + + With two or more existing projects the common parent directory is used. + With a single existing project its parent directory is used (if any). + """ + destinations = [p.destination for p in self.projects if p.destination] + if not destinations: + return "" + + common_path = os.path.commonpath(destinations) + if not common_path or common_path == os.path.sep: + return "" + + if len(destinations) == 1: + parent = str(Path(common_path).parent) + if parent and parent != ".": + return (Path(parent) / project_name).as_posix() + return "" + + return (Path(common_path) / project_name).as_posix() + + def find_remote_for_url(self, remote_url: str) -> Remote | None: + """Return the first remote whose base URL is a prefix of *remote_url*.""" + for remote in self.remotes: + if remote_url.startswith(remote.url): + return remote + return None + class ManifestDumper(yaml.SafeDumper): # pylint: disable=too-many-ancestors """Dump a manifest YAML. diff --git a/dfetch/project/gitsubproject.py b/dfetch/project/gitsubproject.py index 6d12c6d5..3b4428c4 100644 --- a/dfetch/project/gitsubproject.py +++ b/dfetch/project/gitsubproject.py @@ -1,17 +1,13 @@ """Git specific implementation.""" -import contextlib import pathlib -import shutil -import tempfile -from collections.abc import Generator from functools import lru_cache from dfetch.log import get_logger from dfetch.manifest.project import ProjectEntry from dfetch.manifest.version import Version from dfetch.project.metadata import Dependency -from dfetch.project.subproject import LsFn, SubProject +from dfetch.project.subproject import SubProject from dfetch.util.util import LICENSE_GLOBS, safe_rm from dfetch.vcs.git import CheckoutOptions, GitLocalRepo, GitRemote, get_git_version @@ -48,33 +44,6 @@ def list_of_branches(self) -> list[str]: """Get list of all available branches.""" return [str(branch) for branch in self._remote_repo.list_of_branches()] - @contextlib.contextmanager - def browse_tree(self, version: str = "") -> Generator[LsFn, None, None]: - """Shallow-clone the remote and yield a tree-listing callable. - - The yielded ``LsFn`` calls ``git ls-tree HEAD`` on a temporary - blobless clone. The clone is removed on context exit. - """ - tmpdir = tempfile.mkdtemp(prefix="dfetch_browse_") - cloned = False - try: - self._remote_repo.fetch_for_tree_browse( - tmpdir, version or self._remote_repo.get_default_branch() - ) - cloned = True - except Exception: # pylint: disable=broad-exception-caught # nosec B110 - pass - - def ls_fn(path: str = "") -> list[tuple[str, bool]]: - if cloned: - return GitRemote.ls_tree(tmpdir, path=path) - return [] - - try: - yield ls_fn - finally: - shutil.rmtree(tmpdir, ignore_errors=True) - @staticmethod def revision_is_enough() -> bool: """See if this VCS can uniquely distinguish branch with revision only.""" diff --git a/dfetch/project/subproject.py b/dfetch/project/subproject.py index 5482e349..78f3facd 100644 --- a/dfetch/project/subproject.py +++ b/dfetch/project/subproject.py @@ -1,10 +1,9 @@ """SubProject.""" -import contextlib import os import pathlib from abc import ABC, abstractmethod -from collections.abc import Callable, Generator, Sequence +from collections.abc import Callable, Sequence from dfetch.log import get_logger from dfetch.manifest.project import ProjectEntry @@ -15,11 +14,6 @@ from dfetch.util.versions import latest_tag_from_list from dfetch.vcs.patch import Patch -# A callable that lists the contents of a VCS tree path. -# Accepts a path relative to the repo root (empty string for the root) -# and returns ``(name, is_dir)`` pairs for each entry at that path. -LsFn = Callable[[str], list[tuple[str, bool]]] - logger = get_logger(__name__) @@ -419,22 +413,6 @@ def list_of_tags(self) -> list[str]: """Get list of all available tags (public wrapper around ``_list_of_tags``).""" return self._list_of_tags() - @contextlib.contextmanager - def browse_tree(self, version: str = "") -> Generator[LsFn, None, None]: - """Context manager yielding a function to list remote tree contents. - - The yielded ``LsFn`` accepts an optional path (relative to the repo - root) and returns ``(name, is_dir)`` pairs for each entry. The - default implementation returns an empty list; VCS-specific subclasses - override this to perform a real remote tree walk. - """ - _ = version - - def _empty_ls(_path: str = "") -> list[tuple[str, bool]]: - return [] - - yield _empty_ls - def freeze_project(self, project: ProjectEntry) -> str | None: """Freeze *project* to its current on-disk version. diff --git a/dfetch/project/svnsubproject.py b/dfetch/project/svnsubproject.py index 6fe67c1f..4d88cafc 100644 --- a/dfetch/project/svnsubproject.py +++ b/dfetch/project/svnsubproject.py @@ -1,16 +1,14 @@ """SVN specific implementation.""" -import contextlib import os import pathlib import urllib.parse -from collections.abc import Generator from dfetch.log import get_logger from dfetch.manifest.project import ProjectEntry from dfetch.manifest.version import Version from dfetch.project.metadata import Dependency -from dfetch.project.subproject import LsFn, SubProject +from dfetch.project.subproject import SubProject from dfetch.util.util import ( find_matching_files, find_non_matching_files, @@ -184,28 +182,3 @@ def get_default_branch(self) -> str: def list_of_branches(self) -> list[str]: """Return trunk plus any branches found under ``branches/``.""" return [SvnRepo.DEFAULT_BRANCH, *self._remote_repo.list_of_branches()] - - @contextlib.contextmanager - def browse_tree(self, version: str = "") -> Generator[LsFn, None, None]: - """Yield an ls_fn that lists SVN tree contents for *version*. - - Resolves *version* to the correct remote path (trunk, - ``branches/``, or ``tags/``), then delegates - directory listing to ``svn ls``. - """ - version = version or SvnRepo.DEFAULT_BRANCH - if version == SvnRepo.DEFAULT_BRANCH: - base_url = f"{self.remote}/{SvnRepo.DEFAULT_BRANCH}" - else: - branches_url = f"{self.remote}/branches/{version}" - try: - SvnRepo.get_info_from_target(branches_url) - base_url = branches_url - except RuntimeError: - base_url = f"{self.remote}/tags/{version}" - - def ls_fn(path: str = "") -> list[tuple[str, bool]]: - url = f"{base_url}/{path}" if path else base_url - return self._remote_repo.ls_tree(url) - - yield ls_fn diff --git a/dfetch/terminal/__init__.py b/dfetch/terminal/__init__.py new file mode 100644 index 00000000..a4f5ef20 --- /dev/null +++ b/dfetch/terminal/__init__.py @@ -0,0 +1,45 @@ +"""Interactive terminal utilities. + +All public symbols are re-exported here for convenient access via +``from dfetch.terminal import X``. Implementation lives in the +sub-modules: :mod:`ansi`, :mod:`keys`, :mod:`screen`, :mod:`prompt`, +:mod:`pick`, :mod:`tree_browser`. +""" + +from .ansi import ( + BOLD, + CYAN, + DIM, + GREEN, + MAGENTA, + RESET, + REVERSE, + VIEWPORT, + YELLOW, + strip_ansi, +) +from .keys import is_tty, read_key +from .pick import scrollable_pick +from .prompt import ghost_prompt +from .screen import Screen, erase_last_line +from .types import LsFunction + +__all__ = [ + "BOLD", + "CYAN", + "DIM", + "GREEN", + "LsFunction", + "MAGENTA", + "RESET", + "REVERSE", + "VIEWPORT", + "YELLOW", + "Screen", + "erase_last_line", + "ghost_prompt", + "is_tty", + "read_key", + "scrollable_pick", + "strip_ansi", +] diff --git a/dfetch/terminal/ansi.py b/dfetch/terminal/ansi.py new file mode 100644 index 00000000..a3db710e --- /dev/null +++ b/dfetch/terminal/ansi.py @@ -0,0 +1,22 @@ +"""ANSI escape sequences and text-stripping utilities.""" + +import re + +RESET = "\x1b[0m" +BOLD = "\x1b[1m" +DIM = "\x1b[2m" +REVERSE = "\x1b[7m" # swap fore/background – used for cursor highlight +CYAN = "\x1b[96m" +MAGENTA = "\x1b[95m" +GREEN = "\x1b[92m" +YELLOW = "\x1b[93m" + +# Viewport height for scrollable list widgets (number of items shown at once). +VIEWPORT = 10 + +_ANSI_ESC_RE = re.compile(r"\x1b\[[0-9;]*m") + + +def strip_ansi(s: str) -> str: + """Strip ANSI colour/style escape sequences from *s*.""" + return _ANSI_ESC_RE.sub("", s) diff --git a/dfetch/terminal/keys.py b/dfetch/terminal/keys.py new file mode 100644 index 00000000..1b530a42 --- /dev/null +++ b/dfetch/terminal/keys.py @@ -0,0 +1,92 @@ +"""Cross-platform raw keypress reading and TTY detection.""" + +import os +import sys + + +def is_tty() -> bool: + """Return True when stdin is an interactive terminal (not CI, not piped).""" + return sys.stdin.isatty() and not os.environ.get("CI") + + +def read_key() -> str: # pragma: no cover – requires live terminal + """Read one keypress from stdin in raw mode; return a normalised key name. + + Possible return values: ``"UP"``, ``"DOWN"``, ``"LEFT"``, ``"RIGHT"``, + ``"PGUP"``, ``"PGDN"``, ``"ENTER"``, ``"SPACE"``, ``"ESC"``, or a + single printable character string. + + Raises ``KeyboardInterrupt`` on Ctrl-C / Ctrl-D. + """ + if sys.platform == "win32": + return _read_key_windows() + return _read_key_unix() + + +def _read_key_windows() -> str: # pragma: no cover + import msvcrt # type: ignore[import] # pylint: disable=import-outside-toplevel,import-error + + ch = msvcrt.getwch() # type: ignore[attr-defined] + if ch in ("\x00", "\xe0"): + arrow = { + "H": "UP", + "P": "DOWN", + "K": "LEFT", + "M": "RIGHT", + "I": "PGUP", + "Q": "PGDN", + } + return arrow.get(msvcrt.getwch(), "UNKNOWN") # type: ignore[attr-defined,no-any-return] + if ch in ("\r", "\n"): + return "ENTER" + if ch == "\x1b": + return "ESC" + if ch == " ": + return "SPACE" + if ch == "\x03": + raise KeyboardInterrupt + return str(ch) # ch is Any from untyped msvcrt + + +def _read_key_unix() -> str: # pragma: no cover + import select as _select # pylint: disable=import-outside-toplevel + import termios # pylint: disable=import-outside-toplevel + import tty # pylint: disable=import-outside-toplevel + + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + try: + tty.setraw(fd) + ch = os.read(fd, 1) + + if ch in (b"\r", b"\n"): + return "ENTER" + + if ch == b"\x1b": + readable, _, _ = _select.select([fd], [], [], 0.05) + if readable: + rest = b"" + while True: + more, _, _ = _select.select([fd], [], [], 0.01) + if not more: + break + rest += os.read(fd, 1) + escape_sequences = { + b"\x1b[A": "UP", + b"\x1b[B": "DOWN", + b"\x1b[C": "RIGHT", + b"\x1b[D": "LEFT", + b"\x1b[5~": "PGUP", + b"\x1b[6~": "PGDN", + } + return escape_sequences.get(ch + rest, "ESC") + return "ESC" + + if ch == b" ": + return "SPACE" + if ch in (b"\x03", b"\x04"): + raise KeyboardInterrupt + + return ch.decode("utf-8", errors="replace") + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) diff --git a/dfetch/terminal/pick.py b/dfetch/terminal/pick.py new file mode 100644 index 00000000..d687e790 --- /dev/null +++ b/dfetch/terminal/pick.py @@ -0,0 +1,138 @@ +"""Scrollable single/multi-select pick list widget.""" + +from .ansi import BOLD, DIM, GREEN, RESET, VIEWPORT, YELLOW +from .keys import read_key +from .screen import Screen + + +def _advance_pick_idx(key: str, idx: int, n: int) -> int: + """Return the new cursor position after a navigation keypress.""" + if key == "UP": + return max(0, idx - 1) + if key == "DOWN": + return min(n - 1, idx + 1) + if key == "PGUP": + return max(0, idx - VIEWPORT) + return min(n - 1, idx + VIEWPORT) # PGDN + + +def _toggle_pick_selection(idx: int, selected: set[int]) -> set[int]: + """Return a new selected set with *idx* toggled.""" + new_sel = set(selected) + if idx in new_sel: + new_sel.discard(idx) + else: + new_sel.add(idx) + return new_sel + + +def _pick_outcome( + key: str, idx: int, selected: set[int], multi: bool +) -> tuple[bool, int | list[int] | None]: + """Determine whether a keypress ends the interaction and what value to return. + + Returns ``(done, result)``. When *done* is ``False`` the loop continues. + When *done* is ``True``, *result* is the value to return to the caller. + """ + if key == "ENTER": + return True, sorted(selected) if multi else idx + if key == "ESC": + return True, None + if key not in ("UP", "DOWN", "PGUP", "PGDN", "SPACE") and not multi: + return True, None + return False, None + + +def _clamp_scroll(idx: int, top: int) -> int: + """Return an updated *top* offset so that *idx* is visible in the viewport.""" + if idx < top: + return idx + if idx >= top + VIEWPORT: + return idx - VIEWPORT + 1 + return top + + +def _render_pick_item( + i: int, idx: int, item: str, selected: set[int], multi: bool +) -> str: + """Format a single row for the pick widget.""" + cursor = f"{YELLOW}▶{RESET}" if i == idx else " " + check = f"{GREEN}✓ {RESET}" if (multi and i in selected) else " " + is_highlighted = (i in selected) if multi else (i == idx) + styled = f"{BOLD}{item}{RESET}" if is_highlighted else item + return f" {cursor} {check}{styled}" + + +def _render_pick_lines( # pylint: disable=too-many-arguments,too-many-positional-arguments + title: str, + items: list[str], + idx: int, + top: int, + selected: set[int], + multi: bool, + n: int, +) -> list[str]: + """Build the list of lines to draw for one frame of the pick widget.""" + lines: list[str] = [f" {BOLD}{title}{RESET}"] + if top > 0: + lines.append(f" {DIM}↑ {top} more above{RESET}") + for i in range(top, min(top + VIEWPORT, n)): + lines.append(_render_pick_item(i, idx, items[i], selected, multi)) + remaining = n - (top + VIEWPORT) + if remaining > 0: + lines.append(f" {DIM}↓ {remaining} more below{RESET}") + hint = ( + "↑/↓ navigate Space toggle Enter confirm Esc skip" + if multi + else "↑/↓ navigate PgUp/PgDn jump Enter select Esc free-type" + ) + lines.append(f" {DIM}{hint}{RESET}") + return lines + + +def scrollable_pick( + title: str, + display_items: list[str], + *, + default_idx: int = 0, + multi: bool = False, + all_selected: bool = False, +) -> int | list[int] | None: # pragma: no cover – interactive TTY only + """Display a scrollable pick list; return selected index or indices. + + *display_items* are plain strings (no ANSI codes). Navigate with + ↑/↓ or PgUp/PgDn. In single-select mode (``multi=False``) confirm + with Enter; in multi-select mode (``multi=True``) toggle with Space + and confirm with Enter. Cancel with Esc (returns ``None``). + + Single-select: returns the selected index. + Multi-select: returns a list of selected indices (may be empty). + If ``all_selected=True``, starts with all items selected. + """ + screen = Screen() + idx = default_idx + top = 0 + n = len(display_items) + selected: set[int] = ( + set(range(n)) + if (multi and all_selected) + else ({default_idx} if not multi else set()) + ) + + while True: + idx = max(0, min(idx, n - 1)) + top = _clamp_scroll(idx, top) + screen.draw( + _render_pick_lines(title, display_items, idx, top, selected, multi, n) + ) + key = read_key() + + if key in ("UP", "DOWN", "PGUP", "PGDN"): + idx = _advance_pick_idx(key, idx, n) + elif key == "SPACE" and multi: + selected = _toggle_pick_selection(idx, selected) + else: + done, result = _pick_outcome(key, idx, selected, multi) + if done: + screen.clear() + return result diff --git a/dfetch/terminal/prompt.py b/dfetch/terminal/prompt.py new file mode 100644 index 00000000..cd74ae87 --- /dev/null +++ b/dfetch/terminal/prompt.py @@ -0,0 +1,59 @@ +"""Single-line ghost prompt.""" + +import sys + +from .ansi import DIM, RESET +from .keys import read_key + + +def _ghost_handle_backspace(buf: list[str], ghost_active: bool, ghost_len: int) -> bool: + """Handle backspace in a ghost prompt; returns updated *ghost_active*.""" + if buf: + buf.pop() + sys.stdout.write("\x1b[1D\x1b[K") + sys.stdout.flush() + elif ghost_active: + sys.stdout.write(f"\x1b[{ghost_len}D\x1b[K") + sys.stdout.flush() + return False + return ghost_active + + +def _ghost_handle_char( + ch: str, buf: list[str], ghost_active: bool, ghost_len: int +) -> bool: + """Append *ch* to *buf*, clearing ghost if still active; returns updated *ghost_active*.""" + if ghost_active: + sys.stdout.write(f"\x1b[{ghost_len}D\x1b[K{ch}") + ghost_active = False + else: + sys.stdout.write(ch) + sys.stdout.flush() + buf.append(ch) + return ghost_active + + +def ghost_prompt(label: str, default: str = "") -> str: # pragma: no cover + """Single-line prompt with *default* shown as dim ghost text. + + The ghost disappears the moment the user types anything. + Pressing Enter with no input accepts *default*. + """ + sys.stdout.write(f"{label}: {DIM}{default}{RESET}") + sys.stdout.flush() + + buf: list[str] = [] + ghost_active = bool(default) + + while True: + key = read_key() + if key == "ENTER": + sys.stdout.write("\n") + sys.stdout.flush() + return "".join(buf) if buf else default + if key in ("\x7f", "\x08"): + ghost_active = _ghost_handle_backspace(buf, ghost_active, len(default)) + continue + ch = " " if key == "SPACE" else key + if len(ch) == 1 and ch.isprintable(): + ghost_active = _ghost_handle_char(ch, buf, ghost_active, len(default)) diff --git a/dfetch/terminal/screen.py b/dfetch/terminal/screen.py new file mode 100644 index 00000000..9051c691 --- /dev/null +++ b/dfetch/terminal/screen.py @@ -0,0 +1,40 @@ +"""In-place terminal screen redraw.""" + +import sys +from collections.abc import Sequence + +from .keys import is_tty + + +def erase_last_line() -> None: + """Erase the most recently printed terminal line (no-op when not a TTY).""" + if is_tty(): + sys.stdout.write("\x1b[1A\x1b[2K") + sys.stdout.flush() + + +class Screen: + """Minimal ANSI helper for in-place redraw. + + Tracks the number of lines last written so that each ``draw()`` call + moves the cursor back up and overwrites them without flicker. + """ + + def __init__(self) -> None: + """Create screen.""" + self._line_count = 0 + + def draw(self, lines: Sequence[str]) -> None: + """Overwrite the previously drawn content with *lines*.""" + if self._line_count: + sys.stdout.write(f"\x1b[{self._line_count}A\x1b[0J") + sys.stdout.write("\n".join(lines) + "\n") + sys.stdout.flush() + self._line_count = len(lines) + + def clear(self) -> None: + """Erase the previously drawn content.""" + if self._line_count: + sys.stdout.write(f"\x1b[{self._line_count}A\x1b[0J") + sys.stdout.flush() + self._line_count = 0 diff --git a/dfetch/terminal/tree_browser.py b/dfetch/terminal/tree_browser.py new file mode 100644 index 00000000..abec08cf --- /dev/null +++ b/dfetch/terminal/tree_browser.py @@ -0,0 +1,307 @@ +"""Generic scrollable tree browser for remote VCS trees. + +Provides :func:`run_tree_browser` and :func:`tree_single_pick` — interactive +terminal widgets that work with any :data:`~dfetch.terminal.LsFunction`. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .ansi import BOLD, DIM, GREEN, RESET, VIEWPORT, YELLOW, strip_ansi +from .keys import read_key +from .screen import Screen +from .types import LsFunction + + +@dataclass +class TreeNode: + """One entry in the flattened view of a remote VCS tree.""" + + name: str + path: str # path relative to repo root / tree prefix + is_dir: bool + depth: int = 0 + expanded: bool = False + selected: bool = False + children_loaded: bool = False + + +def _expand_node(nodes: list[TreeNode], idx: int, ls_function: LsFunction) -> None: + """Expand the directory node at *idx*, loading children if not yet done.""" + node = nodes[idx] + if not node.children_loaded: + children = [ + TreeNode( + name=name, + path=f"{node.path}/{strip_ansi(name)}", + is_dir=is_dir, + depth=node.depth + 1, + selected=node.selected, + ) + for name, is_dir in ls_function(node.path) + ] + nodes[idx + 1 : idx + 1] = children + node.children_loaded = True + node.expanded = True + + +def _collapse_node(nodes: list[TreeNode], idx: int) -> None: + """Collapse the directory node at *idx* and remove all descendant nodes.""" + parent_depth = nodes[idx].depth + end = idx + 1 + while end < len(nodes) and nodes[end].depth > parent_depth: + end += 1 + del nodes[idx + 1 : end] + nodes[idx].expanded = False + nodes[idx].children_loaded = False + + +def _render_tree_lines( + nodes: list[TreeNode], idx: int, top: int, *, multi_select: bool = False +) -> list[str]: + """Build the list of display strings for one frame of the tree browser.""" + n = len(nodes) + lines: list[str] = [] + for i in range(top, min(top + VIEWPORT, n)): + node = nodes[i] + indent = " " * node.depth + icon = ("▾ " if node.expanded else "▸ ") if node.is_dir else " " + cursor = f"{YELLOW}▶{RESET}" if i == idx else " " + if multi_select: + name = f"{DIM}{node.name}{RESET}" if not node.selected else node.name + lines.append(f" {cursor} {indent}{icon}{name}") + else: + check = f"{GREEN}✓ {RESET}" if node.selected else " " + name = f"{BOLD}{node.name}{RESET}" if i == idx else node.name + lines.append(f" {cursor} {check}{indent}{icon}{name}") + return lines + + +def _cascade_selection(nodes: list[TreeNode], parent_idx: int, selected: bool) -> None: + """Set *selected* on all loaded descendants of the node at *parent_idx*.""" + parent_depth = nodes[parent_idx].depth + for i in range(parent_idx + 1, len(nodes)): + if nodes[i].depth <= parent_depth: + break + nodes[i].selected = selected + + +def _adjust_scroll(idx: int, top: int) -> int: + """Return a new *top* so that *idx* is within the visible viewport.""" + if idx < top: + return idx + if idx >= top + VIEWPORT: + return idx - VIEWPORT + 1 + return top + + +def _build_tree_frame( # pylint: disable=too-many-arguments,too-many-positional-arguments + title: str, + nodes: list[TreeNode], + idx: int, + top: int, + hint: str, + *, + multi_select: bool = False, +) -> list[str]: + """Build all display lines for one render frame of the tree browser.""" + n = len(nodes) + header = [f" {BOLD}{title}{RESET}"] + if top > 0: + header.append(f" {DIM}↑ {top} more above{RESET}") + body = _render_tree_lines(nodes, idx, top, multi_select=multi_select) + footer: list[str] = [] + remaining = n - (top + VIEWPORT) + if remaining > 0: + footer.append(f" {DIM}↓ {remaining} more below{RESET}") + footer.append(f" {DIM}{hint}{RESET}") + return header + body + footer + + +def _handle_tree_nav(key: str, idx: int, n: int) -> int | None: + """Handle arrow/page navigation keys; return new index or None if not a nav key.""" + if key == "UP": + return max(0, idx - 1) + if key == "DOWN": + return min(n - 1, idx + 1) + if key == "PGUP": + return max(0, idx - VIEWPORT) + if key == "PGDN": + return min(n - 1, idx + VIEWPORT) + return None + + +def _handle_tree_left(nodes: list[TreeNode], idx: int) -> int: + """Handle the LEFT key: collapse the current dir or jump to its parent.""" + node = nodes[idx] + if node.is_dir and node.expanded: + _collapse_node(nodes, idx) + return idx + if node.depth > 0: + for i in range(idx - 1, -1, -1): + if nodes[i].depth < node.depth: + return i + return idx + + +def _handle_tree_space( + nodes: list[TreeNode], idx: int, multi: bool +) -> list[str] | None: + """Handle SPACE: toggle selection (multi) or select immediately (single). + + Returns a path list when the browser should exit, or ``None`` to continue. + """ + node = nodes[idx] + if multi: + node.selected = not node.selected + if node.is_dir: + _cascade_selection(nodes, idx, node.selected) + return None + return [node.path] + + +def _handle_tree_enter( + nodes: list[TreeNode], idx: int, ls_function: LsFunction, multi: bool +) -> tuple[int, list[str] | None]: + """Handle ENTER: confirm selection (multi), expand/collapse dir, or pick file.""" + node = nodes[idx] + if multi: + return idx, [n.path for n in nodes if n.selected] + if node.is_dir: + if node.expanded: + _collapse_node(nodes, idx) + else: + _expand_node(nodes, idx, ls_function) + return idx, None + return idx, [node.path] + + +def _handle_tree_action( + key: str, + nodes: list[TreeNode], + idx: int, + ls_function: LsFunction, + multi: bool, +) -> tuple[int, list[str] | None]: + """Dispatch non-navigation keypresses. + + Returns ``(new_idx, result)``. When *result* is not ``None`` the browser + should exit and return it (``[]`` for ESC/skip, path list for a selection). + """ + node = nodes[idx] + if key == "RIGHT": + if node.is_dir and not node.expanded: + _expand_node(nodes, idx, ls_function) + return idx, None + if key == "LEFT": + return _handle_tree_left(nodes, idx), None + if key == "SPACE": + return idx, _handle_tree_space(nodes, idx, multi) + if key == "ENTER": + return _handle_tree_enter(nodes, idx, ls_function, multi) + if key == "ESC": + return idx, [] + return idx, None + + +def run_tree_browser( + ls_function: LsFunction, + title: str, + *, + multi: bool, + all_selected: bool = False, +) -> tuple[list[str], list[TreeNode]]: # pragma: no cover - interactive TTY only + """Core tree browser loop. + + Returns ``(selected_paths, final_nodes)``. In single-select mode + ``selected_paths`` has at most one item; in multi-select mode any number. + ``final_nodes`` reflects the full node state at the time the user exits. + """ + root_entries = ls_function("") + if not root_entries: + return [], [] + + nodes: list[TreeNode] = [ + TreeNode( + name=name, + path=strip_ansi(name), + is_dir=is_dir, + depth=0, + selected=all_selected, + ) + for name, is_dir in root_entries + ] + hint = ( + "↑/↓ navigate Space select Enter confirm →/← expand/collapse Esc skip" + if multi + else "↑/↓ navigate Enter/Space select →/← expand/collapse Esc skip" + ) + screen = Screen() + idx, top = 0, 0 + + while True: + n = len(nodes) + if n == 0: + screen.clear() + return [], [] + idx = max(0, min(idx, n - 1)) + top = _adjust_scroll(idx, top) + screen.draw( + _build_tree_frame(title, nodes, idx, top, hint, multi_select=all_selected) + ) + key = read_key() + + new_idx = _handle_tree_nav(key, idx, n) + if new_idx is not None: + idx = new_idx + continue + + idx, result = _handle_tree_action(key, nodes, idx, ls_function, multi) + if result is not None: + screen.clear() + return result, nodes + + +def tree_single_pick(ls_function: LsFunction, title: str) -> str: + """Browse a remote tree and return a single selected path (``""`` on skip).""" + result, _ = run_tree_browser(ls_function, title, multi=False) + return result[0] if result else "" + + +def all_descendants_deselected(nodes: list[TreeNode], parent_idx: int) -> bool: + """Return True if every loaded descendant of *parent_idx* is deselected.""" + parent_depth = nodes[parent_idx].depth + for i in range(parent_idx + 1, len(nodes)): + if nodes[i].depth <= parent_depth: + break + if nodes[i].selected: + return False + return True + + +def deselected_paths(nodes: list[TreeNode]) -> list[str]: + """Compute minimal path list covering all deselected nodes. + + A deselected directory is emitted as a single entry when all its loaded + descendants are also deselected (or it was never expanded). This keeps + the list short. Individual deselected files are listed when their + parent directory is only partially deselected. + """ + paths: list[str] = [] + ignored_dirs: set[str] = set() + + for i, node in enumerate(nodes): + if node.selected: + continue + if any(node.path.startswith(d + "/") for d in ignored_dirs): + continue + if node.is_dir and ( + not node.children_loaded or all_descendants_deselected(nodes, i) + ): + paths.append(node.path) + ignored_dirs.add(node.path) + elif not node.is_dir: + paths.append(node.path) + + return paths diff --git a/dfetch/terminal/types.py b/dfetch/terminal/types.py new file mode 100644 index 00000000..fc91e06b --- /dev/null +++ b/dfetch/terminal/types.py @@ -0,0 +1,5 @@ +"""Type aliases for the terminal package.""" + +from collections.abc import Callable + +LsFunction = Callable[[str], list[tuple[str, bool]]] diff --git a/dfetch/util/terminal.py b/dfetch/util/terminal.py deleted file mode 100644 index 5757ecb5..00000000 --- a/dfetch/util/terminal.py +++ /dev/null @@ -1,354 +0,0 @@ -"""Low-level interactive terminal utilities. - -Provides cross-platform raw-key reading, ANSI helpers, and a generic -scrollable single-pick list widget. All symbols here are pure I/O -primitives with no dfetch domain knowledge. -""" - -import os -import re -import sys -from collections.abc import Sequence - -# --------------------------------------------------------------------------- -# ANSI escape sequences -# --------------------------------------------------------------------------- - -RESET = "\x1b[0m" -BOLD = "\x1b[1m" -DIM = "\x1b[2m" -REVERSE = "\x1b[7m" # swap fore/background – used for cursor highlight -CYAN = "\x1b[96m" -MAGENTA = "\x1b[95m" -GREEN = "\x1b[92m" -YELLOW = "\x1b[93m" - -# Viewport height for scrollable list widgets (number of items shown at once). -VIEWPORT = 10 - -_ANSI_ESC_RE = re.compile(r"\x1b\[[0-9;]*m") - - -def strip_ansi(s: str) -> str: - """Strip ANSI colour/style escape sequences from *s*.""" - return _ANSI_ESC_RE.sub("", s) - - -# --------------------------------------------------------------------------- -# TTY detection -# --------------------------------------------------------------------------- - - -def is_tty() -> bool: - """Return True when stdin is an interactive terminal (not CI, not piped).""" - return sys.stdin.isatty() and not os.environ.get("CI") - - -# --------------------------------------------------------------------------- -# Raw key reading -# --------------------------------------------------------------------------- - - -def read_key() -> str: # pragma: no cover – requires live terminal - """Read one keypress from stdin in raw mode; return a normalised key name. - - Possible return values: ``"UP"``, ``"DOWN"``, ``"LEFT"``, ``"RIGHT"``, - ``"PGUP"``, ``"PGDN"``, ``"ENTER"``, ``"SPACE"``, ``"ESC"``, or a - single printable character string. - - Raises ``KeyboardInterrupt`` on Ctrl-C / Ctrl-D. - """ - if sys.platform == "win32": - return _read_key_windows() - return _read_key_unix() - - -def _read_key_windows() -> str: # pragma: no cover - import msvcrt # type: ignore[import] # pylint: disable=import-outside-toplevel,import-error - - ch = msvcrt.getwch() # type: ignore[attr-defined] - if ch in ("\x00", "\xe0"): - arrow = { - "H": "UP", - "P": "DOWN", - "K": "LEFT", - "M": "RIGHT", - "I": "PGUP", - "Q": "PGDN", - } - return arrow.get(msvcrt.getwch(), "UNKNOWN") # type: ignore[attr-defined,no-any-return] - if ch in ("\r", "\n"): - return "ENTER" - if ch == "\x1b": - return "ESC" - if ch == " ": - return "SPACE" - if ch == "\x03": - raise KeyboardInterrupt - return str(ch) # ch is Any from untyped msvcrt - - -def _read_key_unix() -> str: # pragma: no cover - import select as _select # pylint: disable=import-outside-toplevel - import termios # pylint: disable=import-outside-toplevel - import tty # pylint: disable=import-outside-toplevel - - fd = sys.stdin.fileno() - old_settings = termios.tcgetattr(fd) - try: - tty.setraw(fd) - ch = os.read(fd, 1) - - if ch in (b"\r", b"\n"): - return "ENTER" - - if ch == b"\x1b": - readable, _, _ = _select.select([fd], [], [], 0.05) - if readable: - rest = b"" - while True: - more, _, _ = _select.select([fd], [], [], 0.01) - if not more: - break - rest += os.read(fd, 1) - escape_sequences = { - b"\x1b[A": "UP", - b"\x1b[B": "DOWN", - b"\x1b[C": "RIGHT", - b"\x1b[D": "LEFT", - b"\x1b[5~": "PGUP", - b"\x1b[6~": "PGDN", - } - return escape_sequences.get(ch + rest, "ESC") - return "ESC" - - if ch == b" ": - return "SPACE" - if ch in (b"\x03", b"\x04"): - raise KeyboardInterrupt - - return ch.decode("utf-8", errors="replace") - finally: - termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) - - -# --------------------------------------------------------------------------- -# In-place screen redraw -# --------------------------------------------------------------------------- - - -class Screen: - """Minimal ANSI helper for in-place redraw. - - Tracks the number of lines last written so that each ``draw()`` call - moves the cursor back up and overwrites them without flicker. - """ - - def __init__(self) -> None: - """Create screen.""" - self._line_count = 0 - - def draw(self, lines: Sequence[str]) -> None: - """Overwrite the previously drawn content with *lines*.""" - if self._line_count: - sys.stdout.write(f"\x1b[{self._line_count}A\x1b[0J") - sys.stdout.write("\n".join(lines) + "\n") - sys.stdout.flush() - self._line_count = len(lines) - - def clear(self) -> None: - """Erase the previously drawn content.""" - if self._line_count: - sys.stdout.write(f"\x1b[{self._line_count}A\x1b[0J") - sys.stdout.flush() - self._line_count = 0 - - -# --------------------------------------------------------------------------- -# Ghost prompt -# --------------------------------------------------------------------------- - - -def _ghost_handle_backspace(buf: list[str], ghost_active: bool, ghost_len: int) -> bool: - """Handle backspace in a ghost prompt; returns updated *ghost_active*.""" - if buf: - buf.pop() - sys.stdout.write("\x1b[1D\x1b[K") - sys.stdout.flush() - elif ghost_active: - sys.stdout.write(f"\x1b[{ghost_len}D\x1b[K") - sys.stdout.flush() - return False - return ghost_active - - -def _ghost_handle_char( - ch: str, buf: list[str], ghost_active: bool, ghost_len: int -) -> bool: - """Append *ch* to *buf*, clearing ghost if still active; returns updated *ghost_active*.""" - if ghost_active: - sys.stdout.write(f"\x1b[{ghost_len}D\x1b[K{ch}") - ghost_active = False - else: - sys.stdout.write(ch) - sys.stdout.flush() - buf.append(ch) - return ghost_active - - -def ghost_prompt(label: str, default: str = "") -> str: # pragma: no cover - """Single-line prompt with *default* shown as dim ghost text. - - The ghost disappears the moment the user types anything. - Pressing Enter with no input accepts *default*. - """ - sys.stdout.write(f"{label}: {DIM}{default}{RESET}") - sys.stdout.flush() - - buf: list[str] = [] - ghost_active = bool(default) - - while True: - key = read_key() - if key == "ENTER": - sys.stdout.write("\n") - sys.stdout.flush() - return "".join(buf) if buf else default - if key in ("\x7f", "\x08"): - ghost_active = _ghost_handle_backspace(buf, ghost_active, len(default)) - continue - ch = " " if key == "SPACE" else key - if len(ch) == 1 and ch.isprintable(): - ghost_active = _ghost_handle_char(ch, buf, ghost_active, len(default)) - - -# --------------------------------------------------------------------------- -# Scrollable pick widget -# --------------------------------------------------------------------------- - - -def _advance_pick_idx(key: str, idx: int, n: int) -> int: - """Return the new cursor position after a navigation keypress.""" - if key == "UP": - return max(0, idx - 1) - if key == "DOWN": - return min(n - 1, idx + 1) - if key == "PGUP": - return max(0, idx - VIEWPORT) - return min(n - 1, idx + VIEWPORT) # PGDN - - -def _toggle_pick_selection(idx: int, selected: set[int]) -> set[int]: - """Return a new selected set with *idx* toggled.""" - new_sel = set(selected) - if idx in new_sel: - new_sel.discard(idx) - else: - new_sel.add(idx) - return new_sel - - -def _pick_outcome( - key: str, idx: int, selected: set[int], multi: bool -) -> tuple[bool, int | list[int] | None]: - """Determine whether a keypress ends the interaction and what value to return. - - Returns ``(done, result)``. When *done* is ``False`` the loop continues. - When *done* is ``True``, *result* is the value to return to the caller. - """ - if key == "ENTER": - return True, sorted(selected) if multi else idx - if key == "ESC": - return True, None - if key not in ("UP", "DOWN", "PGUP", "PGDN", "SPACE") and not multi: - return True, None - return False, None - - -def _clamp_scroll(idx: int, top: int) -> int: - """Return an updated *top* offset so that *idx* is visible in the viewport.""" - if idx < top: - return idx - if idx >= top + VIEWPORT: - return idx - VIEWPORT + 1 - return top - - -def _render_pick_lines( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals - title: str, - items: list[str], - idx: int, - top: int, - selected: set[int], - multi: bool, - n: int, -) -> list[str]: - """Build the list of lines to draw for one frame of the pick widget.""" - lines: list[str] = [f" {BOLD}{title}{RESET}"] - if top > 0: - lines.append(f" {DIM}↑ {top} more above{RESET}") - for i in range(top, min(top + VIEWPORT, n)): - cursor = f"{YELLOW}▶{RESET}" if i == idx else " " - check = f"{GREEN}✓ {RESET}" if (multi and i in selected) else " " - item_text = items[i] - is_highlighted = (i in selected) if multi else (i == idx) - styled = f"{BOLD}{item_text}{RESET}" if is_highlighted else item_text - lines.append(f" {cursor} {check}{styled}") - remaining = n - (top + VIEWPORT) - if remaining > 0: - lines.append(f" {DIM}↓ {remaining} more below{RESET}") - hint = ( - "↑/↓ navigate Space toggle Enter confirm Esc skip" - if multi - else "↑/↓ navigate PgUp/PgDn jump Enter select Esc free-type" - ) - lines.append(f" {DIM}{hint}{RESET}") - return lines - - -def scrollable_pick( - title: str, - display_items: list[str], - *, - default_idx: int = 0, - multi: bool = False, - all_selected: bool = False, -) -> int | list[int] | None: # pragma: no cover – interactive TTY only - """Display a scrollable pick list; return selected index or indices. - - *display_items* are plain strings (no ANSI codes). Navigate with - ↑/↓ or PgUp/PgDn. In single-select mode (``multi=False``) confirm - with Enter; in multi-select mode (``multi=True``) toggle with Space - and confirm with Enter. Cancel with Esc (returns ``None``). - - Single-select: returns the selected index. - Multi-select: returns a list of selected indices (may be empty). - If ``all_selected=True``, starts with all items selected. - """ - screen = Screen() - idx = default_idx - top = 0 - n = len(display_items) - selected: set[int] = ( - set(range(n)) - if (multi and all_selected) - else ({default_idx} if not multi else set()) - ) - - while True: - idx = max(0, min(idx, n - 1)) - top = _clamp_scroll(idx, top) - screen.draw( - _render_pick_lines(title, display_items, idx, top, selected, multi, n) - ) - key = read_key() - - if key in ("UP", "DOWN", "PGUP", "PGDN"): - idx = _advance_pick_idx(key, idx, n) - elif key == "SPACE" and multi: - selected = _toggle_pick_selection(idx, selected) - else: - done, result = _pick_outcome(key, idx, selected, multi) - if done: - screen.clear() - return result diff --git a/dfetch/util/versions.py b/dfetch/util/versions.py index cca2302e..6d7ca713 100644 --- a/dfetch/util/versions.py +++ b/dfetch/util/versions.py @@ -1,8 +1,13 @@ """Module for handling version information from strings.""" +from __future__ import annotations + import re from collections import defaultdict +from dataclasses import dataclass +from typing import Literal +import semver from semver.version import Version BASEVERSION = re.compile( @@ -93,6 +98,46 @@ def _create_available_version_dict( return parsed_tags +@dataclass(frozen=True) +class VersionRef: + """A resolved version reference: a branch name, tag, or commit SHA.""" + + kind: Literal["branch", "tag", "revision"] + value: str + + +def prioritise_default(branches: list[str], default: str) -> list[str]: + """Return *branches* with *default* moved to position 0.""" + if default in branches: + rest = [b for b in branches if b != default] + return [default, *rest] + return branches + + +def sort_tags_newest_first(tags: list[str]) -> list[str]: + """Sort *tags* newest-semver-first; non-semver tags appended as-is.""" + + def _parse_semver(tag: str) -> semver.Version | None: + try: + return semver.Version.parse(tag.lstrip("vV")) + except ValueError: + return None + + parsed = {t: _parse_semver(t) for t in tags} + semver_tags = sorted( + (t for t, v in parsed.items() if v is not None), + key=lambda t: parsed[t], # type: ignore[arg-type, return-value] + reverse=True, + ) + non_semver = [t for t, v in parsed.items() if v is None] + return semver_tags + non_semver + + +def is_commit_sha(value: str) -> bool: + """Return True when *value* looks like a Git commit SHA (7–40 hex chars).""" + return bool(re.fullmatch(r"[0-9a-fA-F]{7,40}", value)) + + if __name__ == "__main__": import doctest diff --git a/dfetch/vcs/git.py b/dfetch/vcs/git.py index f351607d..7fd8d8a0 100644 --- a/dfetch/vcs/git.py +++ b/dfetch/vcs/git.py @@ -1,5 +1,6 @@ """Git specific implementation.""" +import contextlib import functools import glob import os @@ -10,6 +11,7 @@ from pathlib import Path from dfetch.log import get_logger +from dfetch.terminal import LsFunction from dfetch.util.cmdline import SubprocessCommandError, run_on_cmdline from dfetch.util.util import ( in_directory, @@ -221,6 +223,31 @@ def fetch_for_tree_browse(self, target: str, version: str) -> None: env=_extend_env_for_non_interactive_mode(), ) + @contextlib.contextmanager + def browse_tree(self, version: str = "") -> Generator[LsFunction, None, None]: + """Shallow-clone the remote and yield a tree-listing callable. + + The yielded ``LsFunction`` calls ``git ls-tree`` on a blobless temporary + clone. The clone is removed on context exit. + """ + tmpdir = tempfile.mkdtemp(prefix="dfetch_browse_") + cloned = False + try: + self.fetch_for_tree_browse(tmpdir, version or self.get_default_branch()) + cloned = True + except Exception: # pylint: disable=broad-exception-caught # nosec B110 + pass + + def ls_function(path: str = "") -> list[tuple[str, bool]]: + if cloned: + return GitRemote.ls_tree(tmpdir, path=path) + return [] + + try: + yield ls_function + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + @staticmethod def _parse_ls_tree_entry(line: str, prefix: str) -> tuple[str, bool]: """Parse one ``git ls-tree`` output line into a ``(name, is_dir)`` pair.""" diff --git a/dfetch/vcs/svn.py b/dfetch/vcs/svn.py index 1e931b34..0953c5b6 100644 --- a/dfetch/vcs/svn.py +++ b/dfetch/vcs/svn.py @@ -1,13 +1,15 @@ """Svn repository.""" +import contextlib import os import pathlib import re -from collections.abc import Sequence +from collections.abc import Generator, Sequence from pathlib import Path from typing import NamedTuple from dfetch.log import get_logger +from dfetch.terminal import LsFunction from dfetch.util.cmdline import SubprocessCommandError, run_on_cmdline from dfetch.util.util import in_directory from dfetch.vcs.patch import Patch, PatchType @@ -84,6 +86,31 @@ def list_of_tags(self) -> list[str]: str(tag).strip("/\r") for tag in result.stdout.decode().split("\n") if tag ] + @contextlib.contextmanager + def browse_tree(self, version: str = "") -> Generator[LsFunction, None, None]: + """Yield an ls_function that lists SVN tree contents for *version*. + + Resolves *version* to the correct remote path (trunk, + ``branches/``, or ``tags/``), then delegates + directory listing to ``svn ls``. + """ + version = version or SvnRepo.DEFAULT_BRANCH + if version == SvnRepo.DEFAULT_BRANCH: + base_url = f"{self._remote}/{SvnRepo.DEFAULT_BRANCH}" + else: + branches_url = f"{self._remote}/branches/{version}" + try: + SvnRepo.get_info_from_target(branches_url) + base_url = branches_url + except RuntimeError: + base_url = f"{self._remote}/tags/{version}" + + def ls_function(path: str = "") -> list[tuple[str, bool]]: + url = f"{base_url}/{path}" if path else base_url + return self.ls_tree(url) + + yield ls_function + def ls_tree(self, url_path: str) -> list[tuple[str, bool]]: """List immediate children of *url_path* as ``(name, is_dir)`` pairs.""" try: diff --git a/pyproject.toml b/pyproject.toml index 0f8cca36..2b43ffe7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -239,7 +239,7 @@ root_packages = ["dfetch"] # ↓ # dfetch.manifest | dfetch.vcs (independent lower-level modules) # ↓ -# dfetch.util | dfetch.log (foundational utilities) +# dfetch.util | dfetch.log | dfetch.terminal (foundational utilities) [[tool.importlinter.contracts]] name = "C4 architecture layers" @@ -249,5 +249,5 @@ layers = [ "dfetch.reporting", "dfetch.project", "dfetch.manifest | dfetch.vcs", - "dfetch.util | dfetch.log", + "dfetch.util | dfetch.log | dfetch.terminal", ] diff --git a/tests/manifest_mock.py b/tests/manifest_mock.py index 7df1afea..a87e729a 100644 --- a/tests/manifest_mock.py +++ b/tests/manifest_mock.py @@ -20,4 +20,15 @@ def mock_manifest(projects, path: str = "/some/path") -> MagicMock: mocked_manifest = MagicMock(spec=Manifest, projects=project_mocks, path=path) mocked_manifest.selected_projects.return_value = project_mocks + + mocked_manifest.check_name_uniqueness.side_effect = lambda name: ( + Manifest.check_name_uniqueness(mocked_manifest, name) + ) + mocked_manifest.guess_destination.side_effect = lambda name: ( + Manifest.guess_destination(mocked_manifest, name) + ) + mocked_manifest.find_remote_for_url.side_effect = lambda url: ( + Manifest.find_remote_for_url(mocked_manifest, url) + ) + return mocked_manifest diff --git a/tests/test_add.py b/tests/test_add.py index f713e712..e97d586e 100644 --- a/tests/test_add.py +++ b/tests/test_add.py @@ -9,12 +9,8 @@ import pytest -from dfetch.commands.add import ( - Add, - _check_name_uniqueness, - _determine_remote, - _guess_destination, -) +from dfetch.commands.add import Add +from dfetch.manifest.manifest import Manifest from dfetch.manifest.project import ProjectEntry, ProjectEntryDict from dfetch.manifest.remote import Remote from tests.manifest_mock import mock_manifest @@ -69,27 +65,31 @@ def _make_subproject( # --------------------------------------------------------------------------- -# _check_name_uniqueness +# Manifest.check_name_uniqueness # --------------------------------------------------------------------------- def test_check_name_uniqueness_raises_when_duplicate(): - projects = [_make_project("foo"), _make_project("bar")] + m = Mock() + m.projects = [_make_project("foo"), _make_project("bar")] with pytest.raises(RuntimeError, match="already exists"): - _check_name_uniqueness("foo", projects) + Manifest.check_name_uniqueness(m, "foo") def test_check_name_uniqueness_passes_for_new_name(): - projects = [_make_project("foo")] - _check_name_uniqueness("bar", projects) # should not raise + m = Mock() + m.projects = [_make_project("foo")] + Manifest.check_name_uniqueness(m, "bar") # should not raise def test_check_name_uniqueness_passes_for_empty_manifest(): - _check_name_uniqueness("anything", []) + m = Mock() + m.projects = [] + Manifest.check_name_uniqueness(m, "anything") # --------------------------------------------------------------------------- -# _guess_destination +# Manifest.guess_destination # --------------------------------------------------------------------------- @@ -107,33 +107,38 @@ def test_check_name_uniqueness_passes_for_empty_manifest(): ], ) def test_guess_destination(project_name, existing, expected): - projects = [_make_project(name, dst) for name, dst in existing] - assert _guess_destination(project_name, projects) == expected + m = Mock() + m.projects = [_make_project(name, dst) for name, dst in existing] + assert Manifest.guess_destination(m, project_name) == expected # --------------------------------------------------------------------------- -# _determine_remote +# Manifest.find_remote_for_url # --------------------------------------------------------------------------- def test_determine_remote_returns_matching_remote(): - remotes = [ + m = Mock() + m.remotes = [ _make_remote("github", "https://github.com/"), _make_remote("gitlab", "https://gitlab.com/"), ] - result = _determine_remote(remotes, "https://github.com/myorg/myrepo.git") + result = Manifest.find_remote_for_url(m, "https://github.com/myorg/myrepo.git") assert result is not None assert result.name == "github" def test_determine_remote_returns_none_when_no_match(): - remotes = [_make_remote("github", "https://github.com/")] - result = _determine_remote(remotes, "https://bitbucket.org/myorg/myrepo.git") + m = Mock() + m.remotes = [_make_remote("github", "https://github.com/")] + result = Manifest.find_remote_for_url(m, "https://bitbucket.org/myorg/myrepo.git") assert result is None def test_determine_remote_returns_none_for_empty_remotes(): - result = _determine_remote([], "https://github.com/myorg/myrepo.git") + m = Mock() + m.remotes = [] + result = Manifest.find_remote_for_url(m, "https://github.com/myorg/myrepo.git") assert result is None From 252919f87c2270ca8f4a9240a1fa9f96e8ed6797 Mon Sep 17 00:00:00 2001 From: Ben Spoor Date: Tue, 24 Mar 2026 23:05:41 +0100 Subject: [PATCH 09/29] Cleanup --- dfetch/commands/add.py | 16 ++++++---------- dfetch/terminal/pick.py | 8 ++++---- dfetch/terminal/prompt.py | 4 ++-- dfetch/terminal/screen.py | 2 +- dfetch/terminal/tree_browser.py | 18 +++++++++--------- 5 files changed, 22 insertions(+), 26 deletions(-) diff --git a/dfetch/commands/add.py b/dfetch/commands/add.py index 31b396db..5a873bb1 100644 --- a/dfetch/commands/add.py +++ b/dfetch/commands/add.py @@ -282,7 +282,7 @@ def _interactive_flow( # pylint: disable=too-many-arguments,too-many-positional manifest: Manifest, ) -> ProjectEntry: """Guide the user through every manifest field and return a ``ProjectEntry``.""" - logger.print_info_line(default_name, f"Adding {remote_url}") + logger.print_info_line(remote_url, "Adding project through interactive wizard") name = _ask_name(default_name, manifest) @@ -335,11 +335,11 @@ def _interactive_flow( # pylint: disable=too-many-arguments,too-many-positional # --------------------------------------------------------------------------- -def _prompt(tty_label: str, rich_label: str, default: str) -> str: +def _prompt(label: str, default: str) -> str: """Single-line prompt with TTY ghost text or rich fallback.""" if terminal.is_tty(): - return terminal.ghost_prompt(tty_label, default).strip() - return Prompt.ask(rich_label, default=default).strip() + return terminal.ghost_prompt(f" ? {label}", default).strip() + return Prompt.ask(f" ? [bold]{label}[/bold]", default=default).strip() def _unique_name(base: str, existing: set[str]) -> str: @@ -357,7 +357,7 @@ def _ask_name(default: str, manifest: Manifest) -> str: existing_names = {p.name for p in manifest.projects} suggested = _unique_name(default, existing_names) while True: - name = _prompt(" ? Name", " ? [bold]Name[/bold]", suggested) + name = _prompt("Name", suggested) try: manifest.validate_project_name(name) except ValueError as exc: @@ -373,11 +373,7 @@ def _ask_dst(name: str, default: str) -> str: """Prompt for the destination path, re-asking on path-traversal attempts.""" suggested = default or name while True: - dst = _prompt( - " ? Destination", - " ? [bold]Destination[/bold] (path relative to manifest)", - suggested, - ) + dst = _prompt("Destination", suggested) if not dst: dst = name # fall back to project name try: diff --git a/dfetch/terminal/pick.py b/dfetch/terminal/pick.py index d687e790..1b71cb7a 100644 --- a/dfetch/terminal/pick.py +++ b/dfetch/terminal/pick.py @@ -1,8 +1,8 @@ """Scrollable single/multi-select pick list widget.""" -from .ansi import BOLD, DIM, GREEN, RESET, VIEWPORT, YELLOW -from .keys import read_key -from .screen import Screen +from dfetch.terminal.ansi import BOLD, DIM, GREEN, RESET, VIEWPORT +from dfetch.terminal.keys import read_key +from dfetch.terminal.screen import Screen def _advance_pick_idx(key: str, idx: int, n: int) -> int: @@ -56,7 +56,7 @@ def _render_pick_item( i: int, idx: int, item: str, selected: set[int], multi: bool ) -> str: """Format a single row for the pick widget.""" - cursor = f"{YELLOW}▶{RESET}" if i == idx else " " + cursor = f"{GREEN}▶{RESET}" if i == idx else " " check = f"{GREEN}✓ {RESET}" if (multi and i in selected) else " " is_highlighted = (i in selected) if multi else (i == idx) styled = f"{BOLD}{item}{RESET}" if is_highlighted else item diff --git a/dfetch/terminal/prompt.py b/dfetch/terminal/prompt.py index cd74ae87..ff7d9b0a 100644 --- a/dfetch/terminal/prompt.py +++ b/dfetch/terminal/prompt.py @@ -2,8 +2,8 @@ import sys -from .ansi import DIM, RESET -from .keys import read_key +from dfetch.terminal.ansi import DIM, RESET +from dfetch.terminal.keys import read_key def _ghost_handle_backspace(buf: list[str], ghost_active: bool, ghost_len: int) -> bool: diff --git a/dfetch/terminal/screen.py b/dfetch/terminal/screen.py index 9051c691..9f2b4b23 100644 --- a/dfetch/terminal/screen.py +++ b/dfetch/terminal/screen.py @@ -3,7 +3,7 @@ import sys from collections.abc import Sequence -from .keys import is_tty +from dfetch.terminal.keys import is_tty def erase_last_line() -> None: diff --git a/dfetch/terminal/tree_browser.py b/dfetch/terminal/tree_browser.py index abec08cf..b3c3be22 100644 --- a/dfetch/terminal/tree_browser.py +++ b/dfetch/terminal/tree_browser.py @@ -8,10 +8,10 @@ from dataclasses import dataclass -from .ansi import BOLD, DIM, GREEN, RESET, VIEWPORT, YELLOW, strip_ansi -from .keys import read_key -from .screen import Screen -from .types import LsFunction +from dfetch.terminal.ansi import BOLD, DIM, GREEN, RESET, VIEWPORT, strip_ansi +from dfetch.terminal.keys import read_key +from dfetch.terminal.screen import Screen +from dfetch.terminal.types import LsFunction @dataclass @@ -67,7 +67,7 @@ def _render_tree_lines( node = nodes[i] indent = " " * node.depth icon = ("▾ " if node.expanded else "▸ ") if node.is_dir else " " - cursor = f"{YELLOW}▶{RESET}" if i == idx else " " + cursor = f"{GREEN}▶{RESET}" if i == idx else " " if multi_select: name = f"{DIM}{node.name}{RESET}" if not node.selected else node.name lines.append(f" {cursor} {indent}{icon}{name}") @@ -285,22 +285,22 @@ def deselected_paths(nodes: list[TreeNode]) -> list[str]: A deselected directory is emitted as a single entry when all its loaded descendants are also deselected (or it was never expanded). This keeps - the list short. Individual deselected files are listed when their + the list short. Individual deselected files are listed when their parent directory is only partially deselected. """ paths: list[str] = [] - ignored_dirs: set[str] = set() + dirs: set[str] = set() for i, node in enumerate(nodes): if node.selected: continue - if any(node.path.startswith(d + "/") for d in ignored_dirs): + if any(node.path.startswith(d + "/") for d in dirs): continue if node.is_dir and ( not node.children_loaded or all_descendants_deselected(nodes, i) ): paths.append(node.path) - ignored_dirs.add(node.path) + dirs.add(node.path) elif not node.is_dir: paths.append(node.path) From 073b96130a3f1f396a2a0e1fcacc827aaf329c98 Mon Sep 17 00:00:00 2001 From: Ben Spoor Date: Tue, 24 Mar 2026 23:27:07 +0100 Subject: [PATCH 10/29] Improve --- dfetch/commands/add.py | 12 ++++++++---- dfetch/commands/report.py | 3 +-- dfetch/project/gitsubproject.py | 3 ++- dfetch/project/svnsubproject.py | 2 +- dfetch/util/license.py | 10 ++++++++++ dfetch/util/util.py | 8 +------- dfetch/vcs/git.py | 2 +- 7 files changed, 24 insertions(+), 16 deletions(-) diff --git a/dfetch/commands/add.py b/dfetch/commands/add.py index 5a873bb1..fd2d8aa8 100644 --- a/dfetch/commands/add.py +++ b/dfetch/commands/add.py @@ -334,12 +334,14 @@ def _interactive_flow( # pylint: disable=too-many-arguments,too-many-positional # Individual prompt helpers # --------------------------------------------------------------------------- +_PROMPT_FORMAT = " [green]?[/green] [bold]{label}[/bold]" + def _prompt(label: str, default: str) -> str: """Single-line prompt with TTY ghost text or rich fallback.""" if terminal.is_tty(): return terminal.ghost_prompt(f" ? {label}", default).strip() - return Prompt.ask(f" ? [bold]{label}[/bold]", default=default).strip() + return Prompt.ask(_PROMPT_FORMAT.format(label), default=default).strip() def _unique_name(base: str, existing: set[str]) -> str: @@ -422,7 +424,8 @@ def _ask_src(ls_function: LsFunction) -> str: ) return Prompt.ask( - " ? [bold]Source path[/bold] (sub-path/glob, or Enter to fetch whole repo)", + _PROMPT_FORMAT.format(label="Source path") + + " (sub-path/glob, or Enter to fetch whole repo)", default="", ).strip() @@ -467,7 +470,8 @@ def _scoped_ls(path: str = "") -> list[tuple[str, bool]]: return ignore raw = Prompt.ask( - " ? [bold]Ignore paths[/bold] (comma-separated paths to ignore, or Enter to skip)", + _PROMPT_FORMAT.format(label="Ignore paths") + + " (comma-separated paths to ignore, or Enter to skip)", default="", ).strip() return [p.strip() for p in raw.split(",") if p.strip()] if raw else [] @@ -578,7 +582,7 @@ def _text_version_pick( while True: raw = Prompt.ask( - " ? [bold]Version[/bold] (number, branch, tag, or SHA)", + _PROMPT_FORMAT.format(label="Version") + " (number, branch, tag, or SHA)", default=default_branch, ).strip() diff --git a/dfetch/commands/report.py b/dfetch/commands/report.py index aa3fcd67..18d63763 100644 --- a/dfetch/commands/report.py +++ b/dfetch/commands/report.py @@ -15,8 +15,7 @@ from dfetch.project import create_super_project from dfetch.project.metadata import Metadata from dfetch.reporting import REPORTERS, ReportTypes -from dfetch.util.license import License, guess_license_in_file -from dfetch.util.util import is_license_file +from dfetch.util.license import License, guess_license_in_file, is_license_file logger = get_logger(__name__) diff --git a/dfetch/project/gitsubproject.py b/dfetch/project/gitsubproject.py index 3b4428c4..c6430a24 100644 --- a/dfetch/project/gitsubproject.py +++ b/dfetch/project/gitsubproject.py @@ -8,7 +8,8 @@ from dfetch.manifest.version import Version from dfetch.project.metadata import Dependency from dfetch.project.subproject import SubProject -from dfetch.util.util import LICENSE_GLOBS, safe_rm +from dfetch.util.license import LICENSE_GLOBS +from dfetch.util.util import safe_rm from dfetch.vcs.git import CheckoutOptions, GitLocalRepo, GitRemote, get_git_version logger = get_logger(__name__) diff --git a/dfetch/project/svnsubproject.py b/dfetch/project/svnsubproject.py index 4d88cafc..f9703407 100644 --- a/dfetch/project/svnsubproject.py +++ b/dfetch/project/svnsubproject.py @@ -9,10 +9,10 @@ from dfetch.manifest.version import Version from dfetch.project.metadata import Dependency from dfetch.project.subproject import SubProject +from dfetch.util.license import is_license_file from dfetch.util.util import ( find_matching_files, find_non_matching_files, - is_license_file, safe_rm, ) from dfetch.vcs.svn import SvnRemote, SvnRepo, get_svn_version diff --git a/dfetch/util/license.py b/dfetch/util/license.py index bf6a693b..0b3c137e 100644 --- a/dfetch/util/license.py +++ b/dfetch/util/license.py @@ -1,5 +1,6 @@ """*Dfetch* uses *Infer-License* to guess licenses from files.""" +import fnmatch from dataclasses import dataclass from os import PathLike @@ -10,6 +11,15 @@ MAX_LICENSE_FILE_SIZE = 1024 * 1024 # 1 MB +#: Glob patterns used to identify license files by filename. +LICENSE_GLOBS = ["licen[cs]e*", "copying*", "copyright*"] + + +def is_license_file(filename: str) -> bool: + """Return *True* when *filename* matches a known license file pattern.""" + return any(fnmatch.fnmatch(filename.lower(), pattern) for pattern in LICENSE_GLOBS) + + @dataclass class License: """Represents a software license with its SPDX identifiers and detection confidence. diff --git a/dfetch/util/util.py b/dfetch/util/util.py index b3a6dfdd..890a7aca 100644 --- a/dfetch/util/util.py +++ b/dfetch/util/util.py @@ -13,13 +13,7 @@ from _hashlib import HASH -#: Glob patterns used to identify license files by filename. -LICENSE_GLOBS = ["licen[cs]e*", "copying*", "copyright*"] - - -def is_license_file(filename: str) -> bool: - """Return *True* when *filename* matches a known license file pattern.""" - return any(fnmatch.fnmatch(filename.lower(), pattern) for pattern in LICENSE_GLOBS) +from dfetch.util.license import is_license_file def _copy_entry(src_entry: str, dest_entry: str, root: str) -> None: diff --git a/dfetch/vcs/git.py b/dfetch/vcs/git.py index 7fd8d8a0..1dcb7b47 100644 --- a/dfetch/vcs/git.py +++ b/dfetch/vcs/git.py @@ -13,9 +13,9 @@ from dfetch.log import get_logger from dfetch.terminal import LsFunction from dfetch.util.cmdline import SubprocessCommandError, run_on_cmdline +from dfetch.util.license import is_license_file from dfetch.util.util import ( in_directory, - is_license_file, move_directory_contents, safe_rm, strip_glob_prefix, From 63852ed6ac20061f8775cd1d4be9bb5565c44260 Mon Sep 17 00:00:00 2001 From: Ben Spoor Date: Wed, 25 Mar 2026 23:39:13 +0100 Subject: [PATCH 11/29] Improve UX --- dfetch/commands/add.py | 28 ++++++---- dfetch/log.py | 18 +++++-- dfetch/terminal/tree_browser.py | 90 +++++++++++++++++++++++++++------ 3 files changed, 105 insertions(+), 31 deletions(-) diff --git a/dfetch/commands/add.py b/dfetch/commands/add.py index fd2d8aa8..de4ec9b4 100644 --- a/dfetch/commands/add.py +++ b/dfetch/commands/add.py @@ -45,6 +45,7 @@ import argparse import contextlib +import re from collections.abc import Generator from rich.prompt import Confirm, Prompt @@ -298,7 +299,7 @@ def _interactive_flow( # pylint: disable=too-many-arguments,too-many-positional ).as_yaml() for key in ("name", "remote", "url", "repo-path"): if key in seed and isinstance(seed[key], (str, list)): - logger.print_yaml_field(key, seed[key]) # type: ignore[arg-type] + logger.print_yaml_field(key, seed[key], first=key == "name") # type: ignore[arg-type] dst = _ask_dst(name, default_dst) if dst != name: @@ -340,7 +341,9 @@ def _interactive_flow( # pylint: disable=too-many-arguments,too-many-positional def _prompt(label: str, default: str) -> str: """Single-line prompt with TTY ghost text or rich fallback.""" if terminal.is_tty(): - return terminal.ghost_prompt(f" ? {label}", default).strip() + return terminal.ghost_prompt( + f" {terminal.GREEN}?{terminal.RESET} {label}", default + ).strip() return Prompt.ask(_PROMPT_FORMAT.format(label), default=default).strip() @@ -419,9 +422,7 @@ def _ask_src(ls_function: LsFunction) -> str: Outside a TTY falls back to a free-text prompt. """ if terminal.is_tty(): - return tree_single_pick( - ls_function, "Source path (Enter to select, Esc to skip)" - ) + return tree_single_pick(ls_function, "Source path", dirs_selectable=True) return Prompt.ask( _PROMPT_FORMAT.format(label="Source path") @@ -459,7 +460,7 @@ def _scoped_ls(path: str = "") -> list[tuple[str, bool]]: while True: _, all_nodes = run_tree_browser( browse_fn, - "Ignore (Space deselects → ignored, Enter confirms, Esc skips)", + "Ignore", multi=True, all_selected=True, ) @@ -556,14 +557,19 @@ def _ask_version_tree( numbered text picker on Esc or when the path can't be resolved. """ ls = _version_ls_function(branches, tags, default_branch) - selected = tree_single_pick(ls, "Version (Enter to select · Esc to type freely)") + selected = tree_single_pick(ls, "Version", esc_label="free-type") + + # Strip the display suffixes added by _version_ls_function: " branch", " tag", + # and the optional " (default)" marker so the clean name matches the original sets. + clean = re.sub(r"\s+\(default\)\s*$", "", selected).strip() + clean = re.sub(r"\s+(branch|tag)\s*$", "", clean).strip() branch_set = set(branches) tag_set = set(tags) - if selected in branch_set: - return VersionRef("branch", selected) - if selected in tag_set: - return VersionRef("tag", selected) + if clean in branch_set: + return VersionRef("branch", clean) + if clean in tag_set: + return VersionRef("tag", clean) return _text_version_pick(choices, default_branch, branches, tags) diff --git a/dfetch/log.py b/dfetch/log.py index 031f26d8..8ac36dc3 100644 --- a/dfetch/log.py +++ b/dfetch/log.py @@ -122,14 +122,22 @@ def print_info_field(self, field_name: str, field: str) -> None: """Print a field with corresponding value.""" self.print_report_line(field_name, field if field else "") - def print_yaml_field(self, key: str, value: str | list[str]) -> None: - """Print one manifest field in YAML style.""" + def print_yaml_field( + self, key: str, value: str | list[str], *, first: bool = False + ) -> None: + """Print one manifest field in YAML style. + + When *first* is True the line is prefixed with ``- `` (YAML sequence + entry marker) and subsequent fields are indented with four spaces so + the output mirrors the manifest on disk. + """ + prefix = " - " if first else " " if isinstance(value, list): - self.info(f" [blue]{key}:[/blue]") + self.info(f"{prefix}[blue]{key}:[/blue]") for item in value: - self.info(f" - {item}") + self.info(f" - {item}") else: - self.info(f" [blue]{key}:[/blue] {value}") + self.info(f"{prefix}[blue]{key}:[/blue] {value}") def warning(self, msg: object, *args: Any, **kwargs: Any) -> None: """Log warning.""" diff --git a/dfetch/terminal/tree_browser.py b/dfetch/terminal/tree_browser.py index b3c3be22..68344b56 100644 --- a/dfetch/terminal/tree_browser.py +++ b/dfetch/terminal/tree_browser.py @@ -107,7 +107,7 @@ def _build_tree_frame( # pylint: disable=too-many-arguments,too-many-positional ) -> list[str]: """Build all display lines for one render frame of the tree browser.""" n = len(nodes) - header = [f" {BOLD}{title}{RESET}"] + header = [f" {GREEN}?{RESET} {BOLD}{title}:{RESET}"] if top > 0: header.append(f" {DIM}↑ {top} more above{RESET}") body = _render_tree_lines(nodes, idx, top, multi_select=multi_select) @@ -162,13 +162,24 @@ def _handle_tree_space( def _handle_tree_enter( - nodes: list[TreeNode], idx: int, ls_function: LsFunction, multi: bool + nodes: list[TreeNode], + idx: int, + ls_function: LsFunction, + multi: bool, + *, + dirs_selectable: bool = False, ) -> tuple[int, list[str] | None]: - """Handle ENTER: confirm selection (multi), expand/collapse dir, or pick file.""" + """Handle ENTER key. + + Confirms selection (multi), picks any node (dirs_selectable), + expands/collapses a dir, or picks a leaf file (single). + """ node = nodes[idx] if multi: return idx, [n.path for n in nodes if n.selected] if node.is_dir: + if dirs_selectable: + return idx, [node.path] if node.expanded: _collapse_node(nodes, idx) else: @@ -177,12 +188,14 @@ def _handle_tree_enter( return idx, [node.path] -def _handle_tree_action( +def _handle_tree_action( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-return-statements key: str, nodes: list[TreeNode], idx: int, ls_function: LsFunction, multi: bool, + *, + dirs_selectable: bool = False, ) -> tuple[int, list[str] | None]: """Dispatch non-navigation keypresses. @@ -197,20 +210,50 @@ def _handle_tree_action( if key == "LEFT": return _handle_tree_left(nodes, idx), None if key == "SPACE": + if not multi and node.is_dir: + return _handle_tree_enter( + nodes, idx, ls_function, multi, dirs_selectable=dirs_selectable + ) return idx, _handle_tree_space(nodes, idx, multi) if key == "ENTER": - return _handle_tree_enter(nodes, idx, ls_function, multi) + return _handle_tree_enter( + nodes, idx, ls_function, multi, dirs_selectable=dirs_selectable + ) if key == "ESC": return idx, [] return idx, None -def run_tree_browser( +def _build_nav_hint(multi: bool, esc_label: str) -> str: + """Return the bottom navigation hint line for the tree browser.""" + if multi: + return f"↑/↓ navigate Space toggle Enter confirm →/← expand/collapse Esc {esc_label}" + return f"↑/↓ navigate Enter select →/← expand/collapse Esc {esc_label}" + + +def _nudge_scroll_after_expand(nodes: list[TreeNode], idx: int, top: int) -> int: + """Nudge *top* down by one when a just-expanded dir sits at the viewport bottom. + + This makes the first child immediately visible without moving the cursor. + """ + if ( + nodes[idx].is_dir + and nodes[idx].expanded + and idx == top + VIEWPORT - 1 + and idx + 1 < len(nodes) + ): + return top + 1 + return top + + +def run_tree_browser( # pylint: disable=too-many-arguments,too-many-positional-arguments ls_function: LsFunction, title: str, *, multi: bool, all_selected: bool = False, + dirs_selectable: bool = False, + esc_label: str = "skip", ) -> tuple[list[str], list[TreeNode]]: # pragma: no cover - interactive TTY only """Core tree browser loop. @@ -232,11 +275,6 @@ def run_tree_browser( ) for name, is_dir in root_entries ] - hint = ( - "↑/↓ navigate Space select Enter confirm →/← expand/collapse Esc skip" - if multi - else "↑/↓ navigate Enter/Space select →/← expand/collapse Esc skip" - ) screen = Screen() idx, top = 0, 0 @@ -248,7 +286,14 @@ def run_tree_browser( idx = max(0, min(idx, n - 1)) top = _adjust_scroll(idx, top) screen.draw( - _build_tree_frame(title, nodes, idx, top, hint, multi_select=all_selected) + _build_tree_frame( + title, + nodes, + idx, + top, + _build_nav_hint(multi, esc_label), + multi_select=multi, + ) ) key = read_key() @@ -257,15 +302,30 @@ def run_tree_browser( idx = new_idx continue - idx, result = _handle_tree_action(key, nodes, idx, ls_function, multi) + idx, result = _handle_tree_action( + key, nodes, idx, ls_function, multi, dirs_selectable=dirs_selectable + ) if result is not None: screen.clear() return result, nodes + top = _nudge_scroll_after_expand(nodes, idx, top) -def tree_single_pick(ls_function: LsFunction, title: str) -> str: +def tree_single_pick( + ls_function: LsFunction, + title: str, + *, + dirs_selectable: bool = False, + esc_label: str = "skip", +) -> str: """Browse a remote tree and return a single selected path (``""`` on skip).""" - result, _ = run_tree_browser(ls_function, title, multi=False) + result, _ = run_tree_browser( + ls_function, + title, + multi=False, + dirs_selectable=dirs_selectable, + esc_label=esc_label, + ) return result[0] if result else "" From 3206f62c12941317fbc90955f0251ce9a8b9e8dc Mon Sep 17 00:00:00 2001 From: Ben Spoor Date: Thu, 26 Mar 2026 00:02:03 +0100 Subject: [PATCH 12/29] Refactor TreeBrowser --- dfetch/commands/add.py | 4 +- dfetch/terminal/tree_browser.py | 555 ++++++++++++++++---------------- tests/test_tree_browser.py | 391 ++++++++++++++++++++++ 3 files changed, 677 insertions(+), 273 deletions(-) create mode 100644 tests/test_tree_browser.py diff --git a/dfetch/commands/add.py b/dfetch/commands/add.py index de4ec9b4..28cc74fb 100644 --- a/dfetch/commands/add.py +++ b/dfetch/commands/add.py @@ -64,6 +64,7 @@ from dfetch.project.svnsubproject import SvnSubProject from dfetch.terminal import LsFunction from dfetch.terminal.tree_browser import ( + BrowserConfig, TreeNode, deselected_paths, run_tree_browser, @@ -461,8 +462,7 @@ def _scoped_ls(path: str = "") -> list[tuple[str, bool]]: _, all_nodes = run_tree_browser( browse_fn, "Ignore", - multi=True, - all_selected=True, + BrowserConfig(multi=True, all_selected=True), ) ignore = deselected_paths(all_nodes) if not ignore: diff --git a/dfetch/terminal/tree_browser.py b/dfetch/terminal/tree_browser.py index 68344b56..f4b3b26b 100644 --- a/dfetch/terminal/tree_browser.py +++ b/dfetch/terminal/tree_browser.py @@ -27,288 +27,305 @@ class TreeNode: children_loaded: bool = False -def _expand_node(nodes: list[TreeNode], idx: int, ls_function: LsFunction) -> None: - """Expand the directory node at *idx*, loading children if not yet done.""" - node = nodes[idx] - if not node.children_loaded: - children = [ - TreeNode( - name=name, - path=f"{node.path}/{strip_ansi(name)}", - is_dir=is_dir, - depth=node.depth + 1, - selected=node.selected, +@dataclass(frozen=True) +class BrowserConfig: + """Configuration for a :class:`TreeBrowser` session.""" + + multi: bool = False + all_selected: bool = False + dirs_selectable: bool = False + esc_label: str = "skip" + + +class TreeBrowser: + """Interactive scrollable tree browser for a remote VCS tree.""" + + def __init__( + self, + ls_function: LsFunction, + title: str, + config: BrowserConfig = BrowserConfig(), + ) -> None: + """Initialise browser configuration; call :meth:`run` to start.""" + self._ls_function = ls_function + self._title = title + self._config = config + self._nodes: list[TreeNode] = [] + self._idx = 0 + self._top = 0 + + @property + def nodes(self) -> list[TreeNode]: + """Current node list (populated after :meth:`run` or :meth:`seed`).""" + return self._nodes + + @property + def idx(self) -> int: + """Current cursor index.""" + return self._idx + + @property + def top(self) -> int: + """Current scroll offset (index of the topmost visible node).""" + return self._top + + def seed(self, nodes: list[TreeNode], *, idx: int = 0, top: int = 0) -> None: + """Pre-load node state without running the TTY loop. + + Intended for headless driving and unit tests. Cursor and scroll are + clamped to valid ranges automatically. + """ + self._nodes = list(nodes) + self._idx = max(0, min(idx, len(nodes) - 1)) if nodes else 0 + self._top = top + + def feed_key(self, key: str) -> list[str] | None: + """Process one keypress without rendering. + + Returns the selected-path list when the browser would exit (an empty + list for ESC/skip), or ``None`` to indicate the loop should continue. + Intended for headless driving and unit tests. + """ + if self._handle_nav(key): + return None + return self._handle_action(key) + + def adjust_scroll(self) -> None: + """Ensure the cursor index is within the visible viewport.""" + if self._idx < self._top: + self._top = self._idx + elif self._idx >= self._top + VIEWPORT: + self._top = self._idx - VIEWPORT + 1 + + # ------------------------------------------------------------------ + # Node mutation + # ------------------------------------------------------------------ + + def _expand(self, idx: int) -> None: + """Expand the directory node at *idx*, loading children if needed.""" + node = self._nodes[idx] + if not node.children_loaded: + children = [ + TreeNode( + name=name, + path=f"{node.path}/{strip_ansi(name)}", + is_dir=is_dir, + depth=node.depth + 1, + selected=node.selected, + ) + for name, is_dir in self._ls_function(node.path) + ] + self._nodes[idx + 1 : idx + 1] = children + node.children_loaded = True + node.expanded = True + + def _collapse(self, idx: int) -> None: + """Collapse the directory node at *idx* and remove all descendants.""" + parent_depth = self._nodes[idx].depth + end = idx + 1 + while end < len(self._nodes) and self._nodes[end].depth > parent_depth: + end += 1 + del self._nodes[idx + 1 : end] + self._nodes[idx].expanded = False + self._nodes[idx].children_loaded = False + + def _cascade_selection(self, parent_idx: int, selected: bool) -> None: + """Set *selected* on all loaded descendants of *parent_idx*.""" + parent_depth = self._nodes[parent_idx].depth + for i in range(parent_idx + 1, len(self._nodes)): + if self._nodes[i].depth <= parent_depth: + break + self._nodes[i].selected = selected + + # ------------------------------------------------------------------ + # Rendering + # ------------------------------------------------------------------ + + def _nav_hint(self) -> str: + """Return the bottom navigation hint line.""" + esc = self._config.esc_label + if self._config.multi: + return ( + f"↑/↓ navigate Space toggle Enter confirm" + f" →/← expand/collapse Esc {esc}" ) - for name, is_dir in ls_function(node.path) - ] - nodes[idx + 1 : idx + 1] = children - node.children_loaded = True - node.expanded = True - - -def _collapse_node(nodes: list[TreeNode], idx: int) -> None: - """Collapse the directory node at *idx* and remove all descendant nodes.""" - parent_depth = nodes[idx].depth - end = idx + 1 - while end < len(nodes) and nodes[end].depth > parent_depth: - end += 1 - del nodes[idx + 1 : end] - nodes[idx].expanded = False - nodes[idx].children_loaded = False - - -def _render_tree_lines( - nodes: list[TreeNode], idx: int, top: int, *, multi_select: bool = False -) -> list[str]: - """Build the list of display strings for one frame of the tree browser.""" - n = len(nodes) - lines: list[str] = [] - for i in range(top, min(top + VIEWPORT, n)): - node = nodes[i] - indent = " " * node.depth - icon = ("▾ " if node.expanded else "▸ ") if node.is_dir else " " - cursor = f"{GREEN}▶{RESET}" if i == idx else " " - if multi_select: - name = f"{DIM}{node.name}{RESET}" if not node.selected else node.name - lines.append(f" {cursor} {indent}{icon}{name}") + return f"↑/↓ navigate Enter select →/← expand/collapse Esc {esc}" + + def _render_lines(self) -> list[str]: + """Build display strings for the visible viewport slice.""" + lines: list[str] = [] + for i in range(self._top, min(self._top + VIEWPORT, len(self._nodes))): + node = self._nodes[i] + indent = " " * node.depth + icon = ("▾ " if node.expanded else "▸ ") if node.is_dir else " " + cursor = f"{GREEN}▶{RESET}" if i == self._idx else " " + if self._config.multi: + name = f"{DIM}{node.name}{RESET}" if not node.selected else node.name + lines.append(f" {cursor} {indent}{icon}{name}") + else: + check = f"{GREEN}✓ {RESET}" if node.selected else " " + name = f"{BOLD}{node.name}{RESET}" if i == self._idx else node.name + lines.append(f" {cursor} {check}{indent}{icon}{name}") + return lines + + def _build_frame(self) -> list[str]: + """Build all display lines for one render frame.""" + n = len(self._nodes) + header = [f" {GREEN}?{RESET} {BOLD}{self._title}:{RESET}"] + if self._top > 0: + header.append(f" {DIM}↑ {self._top} more above{RESET}") + footer: list[str] = [] + remaining = n - (self._top + VIEWPORT) + if remaining > 0: + footer.append(f" {DIM}↓ {remaining} more below{RESET}") + footer.append(f" {DIM}{self._nav_hint()}{RESET}") + return header + self._render_lines() + footer + + # ------------------------------------------------------------------ + # Scroll helpers + # ------------------------------------------------------------------ + + def _nudge_scroll_after_expand(self) -> None: + """Scroll down 1 when a just-expanded dir sits at the viewport bottom.""" + node = self._nodes[self._idx] + if ( + node.is_dir + and node.expanded + and self._idx == self._top + VIEWPORT - 1 + and self._idx + 1 < len(self._nodes) + ): + self._top += 1 + + # ------------------------------------------------------------------ + # Key handlers + # ------------------------------------------------------------------ + + def _handle_nav(self, key: str) -> bool: + """Handle arrow/page keys; mutate *_idx* and return True if handled.""" + n = len(self._nodes) + if key == "UP": + self._idx = max(0, self._idx - 1) + elif key == "DOWN": + self._idx = min(n - 1, self._idx + 1) + elif key == "PGUP": + self._idx = max(0, self._idx - VIEWPORT) + elif key == "PGDN": + self._idx = min(n - 1, self._idx + VIEWPORT) else: - check = f"{GREEN}✓ {RESET}" if node.selected else " " - name = f"{BOLD}{node.name}{RESET}" if i == idx else node.name - lines.append(f" {cursor} {check}{indent}{icon}{name}") - return lines - - -def _cascade_selection(nodes: list[TreeNode], parent_idx: int, selected: bool) -> None: - """Set *selected* on all loaded descendants of the node at *parent_idx*.""" - parent_depth = nodes[parent_idx].depth - for i in range(parent_idx + 1, len(nodes)): - if nodes[i].depth <= parent_depth: - break - nodes[i].selected = selected - - -def _adjust_scroll(idx: int, top: int) -> int: - """Return a new *top* so that *idx* is within the visible viewport.""" - if idx < top: - return idx - if idx >= top + VIEWPORT: - return idx - VIEWPORT + 1 - return top - - -def _build_tree_frame( # pylint: disable=too-many-arguments,too-many-positional-arguments - title: str, - nodes: list[TreeNode], - idx: int, - top: int, - hint: str, - *, - multi_select: bool = False, -) -> list[str]: - """Build all display lines for one render frame of the tree browser.""" - n = len(nodes) - header = [f" {GREEN}?{RESET} {BOLD}{title}:{RESET}"] - if top > 0: - header.append(f" {DIM}↑ {top} more above{RESET}") - body = _render_tree_lines(nodes, idx, top, multi_select=multi_select) - footer: list[str] = [] - remaining = n - (top + VIEWPORT) - if remaining > 0: - footer.append(f" {DIM}↓ {remaining} more below{RESET}") - footer.append(f" {DIM}{hint}{RESET}") - return header + body + footer - - -def _handle_tree_nav(key: str, idx: int, n: int) -> int | None: - """Handle arrow/page navigation keys; return new index or None if not a nav key.""" - if key == "UP": - return max(0, idx - 1) - if key == "DOWN": - return min(n - 1, idx + 1) - if key == "PGUP": - return max(0, idx - VIEWPORT) - if key == "PGDN": - return min(n - 1, idx + VIEWPORT) - return None - - -def _handle_tree_left(nodes: list[TreeNode], idx: int) -> int: - """Handle the LEFT key: collapse the current dir or jump to its parent.""" - node = nodes[idx] - if node.is_dir and node.expanded: - _collapse_node(nodes, idx) - return idx - if node.depth > 0: - for i in range(idx - 1, -1, -1): - if nodes[i].depth < node.depth: - return i - return idx - - -def _handle_tree_space( - nodes: list[TreeNode], idx: int, multi: bool -) -> list[str] | None: - """Handle SPACE: toggle selection (multi) or select immediately (single). - - Returns a path list when the browser should exit, or ``None`` to continue. - """ - node = nodes[idx] - if multi: - node.selected = not node.selected + return False + return True + + def _handle_left(self) -> None: + """Collapse the current dir or jump to its parent.""" + node = self._nodes[self._idx] + if node.is_dir and node.expanded: + self._collapse(self._idx) + return + if node.depth > 0: + for i in range(self._idx - 1, -1, -1): + if self._nodes[i].depth < node.depth: + self._idx = i + return + + def _handle_space(self) -> list[str] | None: + """Toggle selection (multi) or select immediately (single).""" + node = self._nodes[self._idx] + if self._config.multi: + node.selected = not node.selected + if node.is_dir: + self._cascade_selection(self._idx, node.selected) + return None + return [node.path] + + def _handle_enter(self) -> list[str] | None: + """Confirm selection (multi), pick node (dirs_selectable), expand/collapse, or pick leaf.""" + node = self._nodes[self._idx] + if self._config.multi: + return [item.path for item in self._nodes if item.selected] if node.is_dir: - _cascade_selection(nodes, idx, node.selected) + if self._config.dirs_selectable: + return [node.path] + if node.expanded: + self._collapse(self._idx) + else: + self._expand(self._idx) + return None + return [node.path] + + def _handle_action(self, key: str) -> list[str] | None: + """Dispatch a non-navigation keypress; return a result list or None to continue.""" + node = self._nodes[self._idx] + if key == "RIGHT": + if node.is_dir and not node.expanded: + self._expand(self._idx) + elif key == "LEFT": + self._handle_left() + elif key == "SPACE": + return ( + self._handle_enter() + if not self._config.multi and node.is_dir + else self._handle_space() + ) + elif key == "ENTER": + return self._handle_enter() + elif key == "ESC": + return [] return None - return [node.path] - - -def _handle_tree_enter( - nodes: list[TreeNode], - idx: int, - ls_function: LsFunction, - multi: bool, - *, - dirs_selectable: bool = False, -) -> tuple[int, list[str] | None]: - """Handle ENTER key. - - Confirms selection (multi), picks any node (dirs_selectable), - expands/collapses a dir, or picks a leaf file (single). - """ - node = nodes[idx] - if multi: - return idx, [n.path for n in nodes if n.selected] - if node.is_dir: - if dirs_selectable: - return idx, [node.path] - if node.expanded: - _collapse_node(nodes, idx) - else: - _expand_node(nodes, idx, ls_function) - return idx, None - return idx, [node.path] + # ------------------------------------------------------------------ + # Main loop + # ------------------------------------------------------------------ -def _handle_tree_action( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-return-statements - key: str, - nodes: list[TreeNode], - idx: int, - ls_function: LsFunction, - multi: bool, - *, - dirs_selectable: bool = False, -) -> tuple[int, list[str] | None]: - """Dispatch non-navigation keypresses. + def run(self) -> list[str]: # pragma: no cover - interactive TTY only + """Run the interactive browser; return selected paths (empty list on skip).""" + root_entries = self._ls_function("") + if not root_entries: + return [] - Returns ``(new_idx, result)``. When *result* is not ``None`` the browser - should exit and return it (``[]`` for ESC/skip, path list for a selection). - """ - node = nodes[idx] - if key == "RIGHT": - if node.is_dir and not node.expanded: - _expand_node(nodes, idx, ls_function) - return idx, None - if key == "LEFT": - return _handle_tree_left(nodes, idx), None - if key == "SPACE": - if not multi and node.is_dir: - return _handle_tree_enter( - nodes, idx, ls_function, multi, dirs_selectable=dirs_selectable + self._nodes = [ + TreeNode( + name=name, + path=strip_ansi(name), + is_dir=is_dir, + depth=0, + selected=self._config.all_selected, ) - return idx, _handle_tree_space(nodes, idx, multi) - if key == "ENTER": - return _handle_tree_enter( - nodes, idx, ls_function, multi, dirs_selectable=dirs_selectable - ) - if key == "ESC": - return idx, [] - return idx, None - - -def _build_nav_hint(multi: bool, esc_label: str) -> str: - """Return the bottom navigation hint line for the tree browser.""" - if multi: - return f"↑/↓ navigate Space toggle Enter confirm →/← expand/collapse Esc {esc_label}" - return f"↑/↓ navigate Enter select →/← expand/collapse Esc {esc_label}" + for name, is_dir in root_entries + ] + screen = Screen() + while True: + if not self._nodes: + screen.clear() + return [] + self._idx = max(0, min(self._idx, len(self._nodes) - 1)) + self.adjust_scroll() + screen.draw(self._build_frame()) + key = read_key() -def _nudge_scroll_after_expand(nodes: list[TreeNode], idx: int, top: int) -> int: - """Nudge *top* down by one when a just-expanded dir sits at the viewport bottom. + if self._handle_nav(key): + continue - This makes the first child immediately visible without moving the cursor. - """ - if ( - nodes[idx].is_dir - and nodes[idx].expanded - and idx == top + VIEWPORT - 1 - and idx + 1 < len(nodes) - ): - return top + 1 - return top + result = self._handle_action(key) + if result is not None: + screen.clear() + return result + self._nudge_scroll_after_expand() -def run_tree_browser( # pylint: disable=too-many-arguments,too-many-positional-arguments +def run_tree_browser( ls_function: LsFunction, title: str, - *, - multi: bool, - all_selected: bool = False, - dirs_selectable: bool = False, - esc_label: str = "skip", + config: BrowserConfig, ) -> tuple[list[str], list[TreeNode]]: # pragma: no cover - interactive TTY only - """Core tree browser loop. + """Run a tree browser and return ``(selected_paths, final_nodes)``. - Returns ``(selected_paths, final_nodes)``. In single-select mode - ``selected_paths`` has at most one item; in multi-select mode any number. - ``final_nodes`` reflects the full node state at the time the user exits. + In single-select mode ``selected_paths`` has at most one item; in + multi-select mode any number. ``final_nodes`` reflects the full node + state at the time the user exits. """ - root_entries = ls_function("") - if not root_entries: - return [], [] - - nodes: list[TreeNode] = [ - TreeNode( - name=name, - path=strip_ansi(name), - is_dir=is_dir, - depth=0, - selected=all_selected, - ) - for name, is_dir in root_entries - ] - screen = Screen() - idx, top = 0, 0 - - while True: - n = len(nodes) - if n == 0: - screen.clear() - return [], [] - idx = max(0, min(idx, n - 1)) - top = _adjust_scroll(idx, top) - screen.draw( - _build_tree_frame( - title, - nodes, - idx, - top, - _build_nav_hint(multi, esc_label), - multi_select=multi, - ) - ) - key = read_key() - - new_idx = _handle_tree_nav(key, idx, n) - if new_idx is not None: - idx = new_idx - continue - - idx, result = _handle_tree_action( - key, nodes, idx, ls_function, multi, dirs_selectable=dirs_selectable - ) - if result is not None: - screen.clear() - return result, nodes - top = _nudge_scroll_after_expand(nodes, idx, top) + browser = TreeBrowser(ls_function, title, config) + return browser.run(), browser.nodes def tree_single_pick( @@ -319,13 +336,9 @@ def tree_single_pick( esc_label: str = "skip", ) -> str: """Browse a remote tree and return a single selected path (``""`` on skip).""" - result, _ = run_tree_browser( - ls_function, - title, - multi=False, - dirs_selectable=dirs_selectable, - esc_label=esc_label, - ) + config = BrowserConfig(dirs_selectable=dirs_selectable, esc_label=esc_label) + browser = TreeBrowser(ls_function, title, config) + result = browser.run() return result[0] if result else "" diff --git a/tests/test_tree_browser.py b/tests/test_tree_browser.py new file mode 100644 index 00000000..e96ed5f1 --- /dev/null +++ b/tests/test_tree_browser.py @@ -0,0 +1,391 @@ +# pyright: reportCallIssue=false +"""Hypothesis tests for the TreeBrowser state machine. + +``TreeBrowser.run()`` is TTY-only (pragma: no cover). All tests here drive +the browser through its headless public interface: :meth:`~TreeBrowser.seed`, +:meth:`~TreeBrowser.feed_key`, and the :attr:`~TreeBrowser.idx` / +:attr:`~TreeBrowser.top` / :attr:`~TreeBrowser.nodes` properties. +""" + +from __future__ import annotations + +from hypothesis import given, settings +from hypothesis import strategies as st + +from dfetch.terminal.ansi import VIEWPORT +from dfetch.terminal.tree_browser import ( + BrowserConfig, + TreeBrowser, + TreeNode, + all_descendants_deselected, + deselected_paths, +) + +settings.register_profile("ci", max_examples=30, deadline=None) +settings.register_profile("dev", max_examples=100, deadline=None) +settings.load_profile("dev") + +# --------------------------------------------------------------------------- +# Strategies +# --------------------------------------------------------------------------- + +_NAME = st.text(alphabet="abcdefghijklmnopqrstuvwxyz_", min_size=1, max_size=10) + + +@st.composite +def _node_st(draw, depth: int = 0) -> TreeNode: + """Draw a single TreeNode at *depth*.""" + parts = [draw(_NAME) for _ in range(depth + 1)] + return TreeNode( + name=draw(_NAME), + path="/".join(parts), + is_dir=draw(st.booleans()), + depth=depth, + selected=draw(st.booleans()), + ) + + +@st.composite +def _node_list_st(draw, min_size: int = 1, max_size: int = 25) -> list[TreeNode]: + """Draw a flat list of TreeNodes with arbitrary depths.""" + size = draw(st.integers(min_value=min_size, max_value=max_size)) + return [draw(_node_st(depth=draw(st.integers(0, 3)))) for _ in range(size)] + + +@st.composite +def _proper_tree_st(draw, _prefix: str = "", _depth: int = 0) -> list[TreeNode]: + """Generate a pre-ordered node list with unique paths (parents before children). + + ``deselected_paths`` and ``all_descendants_deselected`` require this + ordering; arbitrary flat lists are not valid input for those functions. + """ + nodes: list[TreeNode] = [] + n = draw(st.integers(min_value=0 if _depth > 0 else 1, max_value=4)) + for i in range(n): + name = f"{chr(97 + i)}{_depth}" # a0, b0 … at depth 0; a1, b1 … at depth 1 + path = f"{_prefix}/{name}" if _prefix else name + is_dir = draw(st.booleans()) if _depth < 2 else False + selected = draw(st.booleans()) + nodes.append( + TreeNode( + name=name, path=path, is_dir=is_dir, depth=_depth, selected=selected + ) + ) + if is_dir: + nodes.extend(draw(_proper_tree_st(_prefix=path, _depth=_depth + 1))) + return nodes + + +@st.composite +def _dir_with_children_st( + draw, +) -> tuple[list[TreeNode], list[tuple[str, bool]]]: + """Draw an unexpanded dir node at index 0, optional siblings, and a children spec.""" + dir_name = draw(_NAME) + dir_node = TreeNode(name=dir_name, path=dir_name, is_dir=True, depth=0) + siblings = draw(st.lists(_node_st(depth=0), min_size=0, max_size=5)) + + child_names = draw(st.lists(_NAME, min_size=1, max_size=8)) + children: list[tuple[str, bool]] = [ + (name, draw(st.booleans())) for name in child_names + ] + return [dir_node] + siblings, children + + +# --------------------------------------------------------------------------- +# Helper +# --------------------------------------------------------------------------- + +_NAV_KEYS = ["UP", "DOWN", "PGUP", "PGDN"] + + +def _browser( + nodes: list[TreeNode], + *, + idx: int = 0, + top: int = 0, + config: BrowserConfig = BrowserConfig(), + children: list[tuple[str, bool]] | None = None, +) -> TreeBrowser: + """Return a seeded TreeBrowser backed by *children* (or empty) for expansions.""" + dir_path = nodes[0].path if nodes else "" + ls = ( + (lambda path: children if path == dir_path else []) + if children + else (lambda _: []) + ) + browser = TreeBrowser(ls, "test", config) + browser.seed(nodes, idx=idx, top=top) + return browser + + +# --------------------------------------------------------------------------- +# Navigation — index invariants +# --------------------------------------------------------------------------- + + +@given( + nodes=_node_list_st(), + keys=st.lists(st.sampled_from(_NAV_KEYS), min_size=1, max_size=100), + start=st.integers(min_value=0, max_value=24), +) +def test_navigation_index_stays_in_bounds(nodes, keys, start) -> None: + """Cursor index must remain in [0, len-1] after any sequence of nav keys.""" + browser = _browser(nodes, idx=start) + for key in keys: + browser.feed_key(key) + assert 0 <= browser.idx < len(browser.nodes) + + +@given( + nodes=_node_list_st(), + keys=st.lists(st.sampled_from(_NAV_KEYS), min_size=1, max_size=100), + start=st.integers(min_value=0, max_value=24), +) +def test_navigation_never_mutates_node_list(nodes, keys, start) -> None: + """Navigation keys must never add, remove or replace nodes.""" + browser = _browser(nodes, idx=start) + snapshot = list(browser.nodes) + for key in keys: + browser.feed_key(key) + assert browser.nodes == snapshot + + +@given(nodes=_node_list_st(min_size=2)) +def test_down_then_up_returns_to_start(nodes) -> None: + """DOWN then UP from index 0 must return to 0.""" + browser = _browser(nodes, idx=0) + browser.feed_key("DOWN") + browser.feed_key("UP") + assert browser.idx == 0 + + +@given(nodes=_node_list_st()) +def test_down_from_last_node_stays(nodes) -> None: + """DOWN from the last node must not move the cursor.""" + last = len(nodes) - 1 + browser = _browser(nodes, idx=last) + browser.feed_key("DOWN") + assert browser.idx == last + + +@given(nodes=_node_list_st()) +def test_up_from_first_node_stays(nodes) -> None: + """UP from the first node must not move the cursor.""" + browser = _browser(nodes, idx=0) + browser.feed_key("UP") + assert browser.idx == 0 + + +# --------------------------------------------------------------------------- +# Scroll invariants +# --------------------------------------------------------------------------- + + +@given( + nodes=_node_list_st(), + idx=st.integers(min_value=0, max_value=24), + top=st.integers(min_value=0, max_value=24), +) +def test_adjust_scroll_keeps_cursor_visible(nodes, idx, top) -> None: + """After adjust_scroll the cursor must fall inside the viewport.""" + browser = _browser(nodes, idx=idx, top=top) + browser.adjust_scroll() + assert browser.top <= browser.idx < browser.top + VIEWPORT + + +@given( + nodes=_node_list_st(), + idx=st.integers(min_value=0, max_value=24), + top=st.integers(min_value=0, max_value=24), +) +def test_adjust_scroll_top_is_nonnegative(nodes, idx, top) -> None: + """adjust_scroll must never produce a negative scroll offset.""" + browser = _browser(nodes, idx=idx, top=top) + browser.adjust_scroll() + assert browser.top >= 0 + + +# --------------------------------------------------------------------------- +# Expand / collapse symmetry +# --------------------------------------------------------------------------- + + +@given(spec=_dir_with_children_st()) +def test_expand_increases_count_by_children(spec) -> None: + """Expanding a dir must add exactly len(children) nodes.""" + nodes, children = spec + original = len(nodes) + browser = _browser(nodes, children=children) + browser.feed_key("RIGHT") + assert len(browser.nodes) == original + len(children) + + +@given(spec=_dir_with_children_st()) +def test_expand_sets_expanded_flag(spec) -> None: + """After RIGHT the node's expanded flag must be True.""" + nodes, children = spec + browser = _browser(nodes, children=children) + browser.feed_key("RIGHT") + assert browser.nodes[0].expanded + + +@given(spec=_dir_with_children_st()) +def test_expand_then_collapse_restores_count(spec) -> None: + """RIGHT then LEFT on the same dir must restore the original node count.""" + nodes, children = spec + original = len(nodes) + browser = _browser(nodes, children=children) + browser.feed_key("RIGHT") # expand + browser.feed_key("LEFT") # collapse (cursor stays at 0, which is an expanded dir) + assert len(browser.nodes) == original + + +@given(spec=_dir_with_children_st()) +def test_collapse_clears_expanded_flag(spec) -> None: + """After RIGHT then LEFT the node's expanded flag must be False.""" + nodes, children = spec + browser = _browser(nodes, children=children) + browser.feed_key("RIGHT") + browser.feed_key("LEFT") + assert not browser.nodes[0].expanded + + +@given(spec=_dir_with_children_st()) +def test_second_expand_does_not_duplicate_children(spec) -> None: + """RIGHT → LEFT → RIGHT must not duplicate children.""" + nodes, children = spec + original = len(nodes) + browser = _browser(nodes, children=children) + browser.feed_key("RIGHT") + browser.feed_key("LEFT") + browser.feed_key("RIGHT") + assert len(browser.nodes) == original + len(children) + + +# --------------------------------------------------------------------------- +# Cascade selection (multi mode, SPACE on dir) +# --------------------------------------------------------------------------- + + +@given(spec=_dir_with_children_st()) +def test_space_in_multi_mode_cascades_to_all_children(spec) -> None: + """SPACE on an expanded dir in multi mode must set all children to the same value.""" + nodes, children = spec + browser = _browser(nodes, children=children, config=BrowserConfig(multi=True)) + browser.feed_key("RIGHT") # expand + browser.feed_key("SPACE") # toggle + cascade + parent = browser.nodes[0] + for node in browser.nodes[1:]: + if node.depth <= parent.depth: + break + assert node.selected == parent.selected + + +@given(spec=_dir_with_children_st()) +def test_all_descendants_deselected_after_cascade_false(spec) -> None: + """all_descendants_deselected must be True after cascading False to all children.""" + nodes, children = spec + # Force the dir to start selected so SPACE sets it (and children) to False. + nodes[0].selected = True + browser = _browser(nodes, children=children, config=BrowserConfig(multi=True)) + browser.feed_key("RIGHT") # expand + browser.feed_key("SPACE") # toggle: True → False, cascade False + assert all_descendants_deselected(browser.nodes, 0) + + +@given(spec=_dir_with_children_st()) +def test_not_all_descendants_deselected_after_cascade_true(spec) -> None: + """all_descendants_deselected must be False after cascading True (children exist).""" + nodes, children = spec + # Force the dir to start deselected so SPACE sets it (and children) to True. + nodes[0].selected = False + browser = _browser(nodes, children=children, config=BrowserConfig(multi=True)) + browser.feed_key("RIGHT") # expand + browser.feed_key("SPACE") # toggle: False → True, cascade True + assert not all_descendants_deselected(browser.nodes, 0) + + +# --------------------------------------------------------------------------- +# deselected_paths (requires properly-ordered trees) +# --------------------------------------------------------------------------- + + +@given(nodes=_proper_tree_st()) +def test_deselected_paths_empty_when_all_selected(nodes) -> None: + """deselected_paths must return [] when every node is selected.""" + for node in nodes: + node.selected = True + assert not deselected_paths(nodes) + + +@given(nodes=_proper_tree_st()) +def test_deselected_paths_only_references_deselected_nodes(nodes) -> None: + """Every path returned by deselected_paths must belong to a deselected node.""" + deselected_node_paths = {n.path for n in nodes if not n.selected} + for path in deselected_paths(nodes): + assert path in deselected_node_paths + + +@given(nodes=_proper_tree_st()) +def test_deselected_paths_are_prefix_free(nodes) -> None: + """No returned path should be a directory-prefix of another returned path.""" + paths = deselected_paths(nodes) + for i, a in enumerate(paths): + for j, b in enumerate(paths): + if i != j: + assert not b.startswith(a + "/"), f"{a!r} is a prefix of {b!r}" + + +# --------------------------------------------------------------------------- +# Key action handlers +# --------------------------------------------------------------------------- + + +@given(nodes=_node_list_st()) +def test_esc_always_returns_empty_list(nodes) -> None: + """ESC must return [] regardless of cursor position or node state.""" + assert _browser(nodes).feed_key("ESC") == [] + + +@given(nodes=_node_list_st()) +def test_unknown_key_returns_none(nodes) -> None: + """An unrecognised key must return None (keep the loop running).""" + assert _browser(nodes).feed_key("__NO_SUCH_KEY__") is None + + +@given(nodes=_node_list_st(), idx=st.integers(min_value=0, max_value=24)) +def test_enter_on_leaf_single_mode_returns_its_path(nodes, idx) -> None: + """ENTER on a leaf in single-select mode must return exactly that node's path.""" + leaf_indices = [i for i, n in enumerate(nodes) if not n.is_dir] + if not leaf_indices: + return + i = leaf_indices[idx % len(leaf_indices)] + browser = _browser(nodes, idx=i, config=BrowserConfig(multi=False)) + assert browser.feed_key("ENTER") == [nodes[i].path] + + +@given(nodes=_node_list_st()) +def test_enter_in_multi_mode_returns_all_selected_paths(nodes) -> None: + """ENTER in multi-select mode must return exactly the paths of selected nodes.""" + browser = _browser(nodes, config=BrowserConfig(multi=True)) + expected = [n.path for n in nodes if n.selected] + assert browser.feed_key("ENTER") == expected + + +@given( + nodes=_node_list_st(), + keys=st.lists( + st.sampled_from([*_NAV_KEYS, "LEFT", "RIGHT", "ESC", "ENTER", "SPACE"]), + min_size=1, + max_size=50, + ), + start=st.integers(min_value=0, max_value=24), +) +def test_any_key_sequence_never_raises(nodes, keys, start) -> None: + """No key combination on any tree should raise an exception.""" + browser = _browser(nodes, idx=start) + for key in keys: + result = browser.feed_key(key) + if result is not None: + break # ESC or selection — browser would exit here From 3661f270b92e2dd46977f0c2676613dd705a10a2 Mon Sep 17 00:00:00 2001 From: Ben Spoor Date: Sat, 28 Mar 2026 12:35:34 +0100 Subject: [PATCH 13/29] Improve architecture --- dfetch/commands/add.py | 363 +++++++++++++------------------- dfetch/log.py | 8 + dfetch/manifest/manifest.py | 6 +- dfetch/manifest/version.py | 9 + dfetch/terminal/__init__.py | 9 +- dfetch/terminal/pick.py | 17 +- dfetch/terminal/prompt.py | 62 +++++- dfetch/terminal/tree_browser.py | 246 ++++++++++++++-------- dfetch/terminal/types.py | 32 ++- dfetch/vcs/git.py | 29 ++- dfetch/vcs/svn.py | 15 +- pyproject.toml | 4 +- tests/test_add.py | 16 +- tests/test_tree_browser.py | 69 +++++- 14 files changed, 527 insertions(+), 358 deletions(-) diff --git a/dfetch/commands/add.py b/dfetch/commands/add.py index 28cc74fb..38d5d455 100644 --- a/dfetch/commands/add.py +++ b/dfetch/commands/add.py @@ -45,7 +45,6 @@ import argparse import contextlib -import re from collections.abc import Generator from rich.prompt import Confirm, Prompt @@ -58,21 +57,23 @@ from dfetch.manifest.manifest import Manifest, append_entry_manifest_file from dfetch.manifest.project import ProjectEntry, ProjectEntryDict from dfetch.manifest.remote import Remote +from dfetch.manifest.version import Version from dfetch.project import create_sub_project, create_super_project from dfetch.project.gitsubproject import GitSubProject from dfetch.project.subproject import SubProject +from dfetch.project.superproject import SuperProject from dfetch.project.svnsubproject import SvnSubProject -from dfetch.terminal import LsFunction +from dfetch.terminal import Entry, LsFunction from dfetch.terminal.tree_browser import ( BrowserConfig, TreeNode, deselected_paths, run_tree_browser, + tree_pick_from_names, tree_single_pick, ) from dfetch.util.purl import vcs_url_to_purl from dfetch.util.versions import ( - VersionRef, is_commit_sha, prioritise_default, sort_tags_newest_first, @@ -83,14 +84,25 @@ @contextlib.contextmanager def browse_tree(subproject: SubProject, version: str = "") -> Generator[LsFunction]: - """Yield an ``LsFunction`` for interactively browsing *subproject*'s remote tree.""" + """Yield an ``LsFunction`` for interactively browsing *subproject*'s remote tree. + + Adapts the VCS-level ``(name, is_dir)`` tuples into :class:`~dfetch.terminal.Entry` + objects so the terminal tree browser has no knowledge of VCS internals. + """ if isinstance(subproject, (GitSubProject, SvnSubProject)): remote = subproject._remote_repo # pylint: disable=protected-access - with remote.browse_tree(version) as ls_function: - yield ls_function + with remote.browse_tree(version) as vcs_ls: + + def ls(path: str = "") -> list[Entry]: + return [ + Entry(display=name, has_children=is_dir) + for name, is_dir in vcs_ls(path) + ] + + yield ls else: - def _empty(_path: str = "") -> list[tuple[str, bool]]: + def _empty(_path: str = "") -> list[Entry]: return [] yield _empty @@ -152,8 +164,7 @@ def __call__(self, args: argparse.Namespace) -> None: # Determines VCS type; tries to reach the remote. subproject = create_sub_project(probe_entry) - if not args.interactive: - superproject.manifest.check_name_uniqueness(probe_entry.name) + existing_names = {p.name for p in superproject.manifest.projects} remote_to_use = superproject.manifest.find_remote_for_url( probe_entry.remote_url @@ -178,46 +189,19 @@ def __call__(self, args: argparse.Namespace) -> None: ) else: project_entry = _non_interactive_entry( - name=probe_entry.name, + name=_unique_name(probe_entry.name, existing_names), remote_url=remote_url, branch=default_branch, dst=guessed_dst, remote_to_use=remote_to_use, ) + logger.print_info_line(remote_url, "Adding project to manifest") + logger.print_yaml(project_entry.as_yaml()) if project_entry is None: return - if not args.force and not Confirm.ask("Add project to manifest?", default=True): - logger.info( - " [bold bright_yellow]> Aborting add of project[/bold bright_yellow]" - ) - return - - append_entry_manifest_file( - (superproject.root_directory / superproject.manifest.path).absolute(), - project_entry, - ) - - logger.print_info_line( - project_entry.name, - f"Added '{project_entry.name}' to manifest '{superproject.manifest.path}'", - ) - - # Offer to run update immediately (only when we already prompted the user, - # i.e. not in --force mode where we want zero interaction). - if not args.force and Confirm.ask( - f"Run 'dfetch update {project_entry.name}' now?", default=True - ): - # pylint: disable=import-outside-toplevel - from dfetch.commands.update import Update # local import avoids circular - - update_args = argparse.Namespace( - projects=[project_entry.name], - force=False, - no_recommendations=False, - ) - Update()(update_args) + _finalize_add(project_entry, args, superproject) # --------------------------------------------------------------------------- @@ -225,6 +209,43 @@ def __call__(self, args: argparse.Namespace) -> None: # --------------------------------------------------------------------------- +def _finalize_add( + project_entry: ProjectEntry, + args: argparse.Namespace, + superproject: SuperProject, +) -> None: + """Write *project_entry* to the manifest and optionally run update.""" + if not args.force and not Confirm.ask("Add project to manifest?", default=True): + logger.info( + " [bold bright_yellow]> Aborting add of project[/bold bright_yellow]" + ) + return + + append_entry_manifest_file( + (superproject.root_directory / superproject.manifest.path).absolute(), + project_entry, + ) + logger.print_info_line( + project_entry.name, + f"Added '{project_entry.name}' to manifest '{superproject.manifest.path}'", + ) + + # Offer to run update immediately (only when we already prompted the user, + # i.e. not in --force mode where we want zero interaction). + if not args.force and Confirm.ask( + f"Run 'dfetch update {project_entry.name}' now?", default=True + ): + # pylint: disable=import-outside-toplevel + from dfetch.commands.update import Update # local import avoids circular + + update_args = argparse.Namespace( + projects=[project_entry.name], + force=False, + no_recommendations=False, + ) + Update()(update_args) + + def _non_interactive_entry( *, name: str, @@ -247,18 +268,19 @@ def _build_entry( # pylint: disable=too-many-arguments name: str, remote_url: str, dst: str, - version: VersionRef, + version: Version, src: str, ignore: list[str], remote_to_use: Remote | None, ) -> ProjectEntry: """Assemble a ``ProjectEntry`` from the fields collected by the wizard.""" + kind, value = version.field entry_dict: ProjectEntryDict = ProjectEntryDict( name=name, url=remote_url, dst=dst, ) - entry_dict[version.kind] = version.value # type: ignore[literal-required] + entry_dict[kind] = value # type: ignore[literal-required] if src: entry_dict["src"] = src if ignore: @@ -274,6 +296,24 @@ def _build_entry( # pylint: disable=too-many-arguments # --------------------------------------------------------------------------- +def _show_url_fields( + name: str, remote_url: str, default_branch: str, remote_to_use: Remote | None +) -> None: + """Print the fields determined solely by the URL (name, remote, url, repo-path).""" + seed = _build_entry( + name=name, + remote_url=remote_url, + dst=name, + version=Version(branch=default_branch), + src="", + ignore=[], + remote_to_use=remote_to_use, + ).as_yaml() + logger.print_yaml( + {k: seed[k] for k in ("name", "remote", "url", "repo-path") if k in seed} + ) + + def _interactive_flow( # pylint: disable=too-many-arguments,too-many-positional-arguments remote_url: str, default_name: str, @@ -287,20 +327,7 @@ def _interactive_flow( # pylint: disable=too-many-arguments,too-many-positional logger.print_info_line(remote_url, "Adding project through interactive wizard") name = _ask_name(default_name, manifest) - - # Show the fields that are fixed by the URL right after the name is confirmed. - seed = _build_entry( - name=name, - remote_url=remote_url, - dst=name, - version=VersionRef("branch", default_branch), - src="", - ignore=[], - remote_to_use=remote_to_use, - ).as_yaml() - for key in ("name", "remote", "url", "repo-path"): - if key in seed and isinstance(seed[key], (str, list)): - logger.print_yaml_field(key, seed[key], first=key == "name") # type: ignore[arg-type] + _show_url_fields(name, remote_url, default_branch, remote_to_use) dst = _ask_dst(name, default_dst) if dst != name: @@ -311,9 +338,10 @@ def _interactive_flow( # pylint: disable=too-many-arguments,too-many-positional subproject.list_of_branches(), subproject.list_of_tags(), ) - logger.print_yaml_field(version.kind, version.value) + version_kind, version_value = version.field + logger.print_yaml_field(version_kind, version_value) - with browse_tree(subproject, version.value) as ls_function: + with browse_tree(subproject, version_value) as ls_function: src = _ask_src(ls_function) if src: logger.print_yaml_field("src", src) @@ -339,15 +367,6 @@ def _interactive_flow( # pylint: disable=too-many-arguments,too-many-positional _PROMPT_FORMAT = " [green]?[/green] [bold]{label}[/bold]" -def _prompt(label: str, default: str) -> str: - """Single-line prompt with TTY ghost text or rich fallback.""" - if terminal.is_tty(): - return terminal.ghost_prompt( - f" {terminal.GREEN}?{terminal.RESET} {label}", default - ).strip() - return Prompt.ask(_PROMPT_FORMAT.format(label), default=default).strip() - - def _unique_name(base: str, existing: set[str]) -> str: """Return *base* if unused, otherwise append *-1*, *-2*, … until unique.""" if base not in existing: @@ -363,7 +382,7 @@ def _ask_name(default: str, manifest: Manifest) -> str: existing_names = {p.name for p in manifest.projects} suggested = _unique_name(default, existing_names) while True: - name = _prompt("Name", suggested) + name = terminal.prompt("Name", suggested) try: manifest.validate_project_name(name) except ValueError as exc: @@ -379,7 +398,7 @@ def _ask_dst(name: str, default: str) -> str: """Prompt for the destination path, re-asking on path-traversal attempts.""" suggested = default or name while True: - dst = _prompt("Destination", suggested) + dst = terminal.prompt("Destination", suggested) if not dst: dst = name # fall back to project name try: @@ -395,24 +414,21 @@ def _ask_version( default_branch: str, branches: list[str], tags: list[str], -) -> VersionRef: - """Choose a version (branch / tag / SHA) and return it as a ``VersionRef``. +) -> Version: + """Choose a version (branch / tag / SHA) and return it as a :class:`~dfetch.manifest.version.Version`. In a TTY shows a hierarchical tree browser (names split on '/'). Outside a TTY (CI, pipe, tests) falls back to a numbered text menu. """ - ordered_branches = prioritise_default(branches, default_branch) - ordered_tags = sort_tags_newest_first(tags) - - choices: list[VersionRef] = [ - *[VersionRef("branch", b) for b in ordered_branches], - *[VersionRef("tag", t) for t in ordered_tags], + choices: list[Version] = [ + *[Version(branch=b) for b in prioritise_default(branches, default_branch)], + *[Version(tag=t) for t in sort_tags_newest_first(tags)], ] if terminal.is_tty() and choices: - return _ask_version_tree(default_branch, branches, tags, choices) + return _ask_version_tree(default_branch, choices) - return _text_version_pick(choices, default_branch, branches, tags) + return _text_version_pick(choices, default_branch) def _ask_src(ls_function: LsFunction) -> str: @@ -452,7 +468,7 @@ def _ask_ignore(ls_function: LsFunction, src: str = "") -> list[str]: to the repo root. """ - def _scoped_ls(path: str = "") -> list[tuple[str, bool]]: + def _scoped_ls(path: str = "") -> list[Entry]: return ls_function(f"{src}/{path}" if path else src) browse_fn: LsFunction = _scoped_ls if src else ls_function @@ -475,7 +491,7 @@ def _scoped_ls(path: str = "") -> list[tuple[str, bool]]: + " (comma-separated paths to ignore, or Enter to skip)", default="", ).strip() - return [p.strip() for p in raw.split(",") if p.strip()] if raw else [] + return [p.strip() for p in raw.split(",") if p.strip()] # --------------------------------------------------------------------------- @@ -483,149 +499,66 @@ def _scoped_ls(path: str = "") -> list[tuple[str, bool]]: # --------------------------------------------------------------------------- -def _version_ls_function( - branches: list[str], - tags: list[str], - default_branch: str, -) -> LsFunction: - """Build a ls_function that exposes branches and tags as a /-split tree. - - Leaf nodes carry a dim kind label (``branch`` / ``tag``) so they are - visually distinct from directory segments. The tree browser strips ANSI - when building ``node.path``, so the stored path is always the clean name. - """ - leaf_kind: dict[str, str] = {b: "branch" for b in branches} - leaf_kind.update({t: "tag" for t in tags}) - all_names: list[str] = sorted(leaf_kind) - - def ls(path: str) -> list[tuple[str, bool]]: - prefix = (path + "/") if path else "" - seen: dict[str, bool] = {} # first segment → is_dir - - for name in all_names: - if not name.startswith(prefix): - continue - rest = name[len(prefix) :] - if not rest: - continue - seg = rest.split("/")[0] - if seg in seen: - continue - full = prefix + seg - seen[seg] = any(n.startswith(full + "/") for n in all_names) - - def _sort_key(seg: str) -> tuple[int, str]: - full = prefix + seg - on_default_path = full == default_branch or default_branch.startswith( - full + "/" - ) - return (0 if on_default_path else 1, seg) - - result: list[tuple[str, bool]] = [] - for seg in sorted(seen, key=_sort_key): - full = prefix + seg - if seen[seg]: # is_dir - result.append((seg, True)) - else: - kind = leaf_kind[full] - default_marker = ( - f" {terminal.DIM}(default){terminal.RESET}" - if full == default_branch - else "" - ) - result.append( - ( - f"{seg} {terminal.DIM}{kind}{terminal.RESET}{default_marker}", - False, - ) - ) - - return result - - return ls - - -def _ask_version_tree( - default_branch: str, - branches: list[str], - tags: list[str], - choices: list[VersionRef], -) -> VersionRef: # pragma: no cover - interactive TTY only - """Branch/tag picker using the hierarchical tree browser. +def _resolve_raw_version(raw: str, choices: list[Version]) -> Version | None: + """Return the matching :class:`Version` from *choices*, or ``None`` when *raw* is empty. - Splits names by '/' to build a navigable tree. Falls back to the - numbered text picker on Esc or when the path can't be resolved. + Checks choices first (preserving branch/tag distinction), then falls back + to SHA detection, then treats the input as an unknown branch name. """ - ls = _version_ls_function(branches, tags, default_branch) - selected = tree_single_pick(ls, "Version", esc_label="free-type") - - # Strip the display suffixes added by _version_ls_function: " branch", " tag", - # and the optional " (default)" marker so the clean name matches the original sets. - clean = re.sub(r"\s+\(default\)\s*$", "", selected).strip() - clean = re.sub(r"\s+(branch|tag)\s*$", "", clean).strip() + if not raw: + return None + for v in choices: + if v.field[1] == raw: + return v + if is_commit_sha(raw): + return Version(revision=raw) + return Version(branch=raw) - branch_set = set(branches) - tag_set = set(tags) - if clean in branch_set: - return VersionRef("branch", clean) - if clean in tag_set: - return VersionRef("tag", clean) - return _text_version_pick(choices, default_branch, branches, tags) +_MAX_LISTED = 30 -def _text_version_pick( - choices: list[VersionRef], - default_branch: str, - branches: list[str], - tags: list[str], -) -> VersionRef: - """Numbered text-based version picker (non-TTY fallback).""" - _print_version_menu(choices, default_branch) - - branch_set = set(branches) - tag_set = set(tags) - - while True: - raw = Prompt.ask( - _PROMPT_FORMAT.format(label="Version") + " (number, branch, tag, or SHA)", - default=default_branch, - ).strip() - - if raw.isdigit(): - idx = int(raw) - 1 - if 0 <= idx < len(choices): - return choices[idx] - logger.warning(f" Pick a number between 1 and {len(choices)}.") - continue +def _version_menu_entries(choices: list[Version], default_branch: str) -> list[Entry]: + """Build the numbered branch/tag pick list as :class:`~dfetch.terminal.Entry` objects.""" + entries: list[Entry] = [] + for ref in choices[:_MAX_LISTED]: + kind, value = ref.field + marker = ( + " [dim](default)[/dim]" + if value == default_branch and kind == "branch" + else "" + ) + display = f"{value}{marker} [dim]{kind}[/dim]" + entries.append(Entry(display=display, has_children=False, value=value)) + return entries - if raw in branch_set: - return VersionRef("branch", raw) - if raw in tag_set: - return VersionRef("tag", raw) - if is_commit_sha(raw): - return VersionRef("revision", raw) - if raw: - return VersionRef("branch", raw) - logger.warning(" Please enter a number or a version value.") +def _text_version_pick(choices: list[Version], default_branch: str) -> Version: + """Numbered text-based version picker (non-TTY fallback and Esc fallback in TTY).""" + entries = _version_menu_entries(choices, default_branch) + hidden = len(choices) - len(entries) + note = f" [dim] … and {hidden} more (type name directly)[/dim]" if hidden else "" + raw = terminal.numbered_prompt( + entries, "Version", "number, branch, tag, or SHA", default_branch, note=note + ) + return _resolve_raw_version(raw, choices) or Version(branch=default_branch) -def _print_version_menu(choices: list[VersionRef], default_branch: str) -> None: - """Render the numbered branch/tag pick list (text fallback).""" - if not choices: - return - lines: list[str] = [] - for i, ref in enumerate(choices, start=1): - marker = ( - " (default)" if ref.value == default_branch and ref.kind == "branch" else "" - ) - colour = "cyan" if ref.kind == "branch" else "magenta" - lines.append( - f" [bold white]{i:>2}[/bold white]" - f" [{colour}]{ref.value}[/{colour}]{marker}" - f" [dim]{ref.kind}[/dim]" - ) +def _ask_version_tree( + default_branch: str, + choices: list[Version], +) -> Version: # pragma: no cover - interactive TTY only + """Branch/tag picker using the hierarchical tree browser. - logger.info("\n".join(lines)) + Splits names by '/' to build a navigable tree. Falls back to the + numbered text picker on Esc or when the selected path isn't in *choices*. + """ + labels = {v.field[1]: v.field[0] for v in choices} + selected = tree_pick_from_names( + labels, "Version", priority_path=default_branch, esc_label="list" + ) + for v in choices: + if v.field[1] == selected: + return v + return _text_version_pick(choices, default_branch) diff --git a/dfetch/log.py b/dfetch/log.py index 8ac36dc3..a8e4184f 100644 --- a/dfetch/log.py +++ b/dfetch/log.py @@ -122,6 +122,14 @@ def print_info_field(self, field_name: str, field: str) -> None: """Print a field with corresponding value.""" self.print_report_line(field_name, field if field else "") + def print_yaml(self, fields: dict[str, Any]) -> None: + """Print all str and list values in *fields* in YAML style.""" + first = True + for key, value in fields.items(): + if isinstance(value, (str, list)): + self.print_yaml_field(key, value, first=first) + first = False + def print_yaml_field( self, key: str, value: str | list[str], *, first: bool = False ) -> None: diff --git a/dfetch/manifest/manifest.py b/dfetch/manifest/manifest.py index 42e0059c..81b85956 100644 --- a/dfetch/manifest/manifest.py +++ b/dfetch/manifest/manifest.py @@ -417,9 +417,9 @@ def guess_destination(self, project_name: str) -> str: return "" if len(destinations) == 1: - parent = str(Path(common_path).parent) - if parent and parent != ".": - return (Path(parent) / project_name).as_posix() + parent_path = Path(common_path).parent + if parent_path != Path("."): + return (parent_path / project_name).as_posix() return "" return (Path(common_path) / project_name).as_posix() diff --git a/dfetch/manifest/version.py b/dfetch/manifest/version.py index 999ff34c..6d7299fb 100644 --- a/dfetch/manifest/version.py +++ b/dfetch/manifest/version.py @@ -24,6 +24,15 @@ def __eq__(self, other: Any) -> bool: return bool(self.branch == other.branch and self.revision == other.revision) + @property + def field(self) -> tuple[str, str]: + """Return ``(kind, value)`` for the active field: tag, revision, or branch.""" + if self.tag: + return "tag", self.tag + if self.revision: + return "revision", self.revision + return "branch", self.branch + def __repr__(self) -> str: """Get the string representing this version.""" if self.tag: diff --git a/dfetch/terminal/__init__.py b/dfetch/terminal/__init__.py index a4f5ef20..8c73a4cf 100644 --- a/dfetch/terminal/__init__.py +++ b/dfetch/terminal/__init__.py @@ -20,15 +20,17 @@ ) from .keys import is_tty, read_key from .pick import scrollable_pick -from .prompt import ghost_prompt +from .prompt import ghost_prompt, numbered_prompt, prompt from .screen import Screen, erase_last_line -from .types import LsFunction +from .tree_browser import tree_pick_from_names +from .types import Entry, LsFunction __all__ = [ "BOLD", "CYAN", "DIM", "GREEN", + "Entry", "LsFunction", "MAGENTA", "RESET", @@ -38,8 +40,11 @@ "Screen", "erase_last_line", "ghost_prompt", + "numbered_prompt", + "prompt", "is_tty", "read_key", "scrollable_pick", "strip_ansi", + "tree_pick_from_names", ] diff --git a/dfetch/terminal/pick.py b/dfetch/terminal/pick.py index 1b71cb7a..ba4f961b 100644 --- a/dfetch/terminal/pick.py +++ b/dfetch/terminal/pick.py @@ -43,6 +43,17 @@ def _pick_outcome( return False, None +def _initial_selection( + multi: bool, all_selected: bool, n: int, default_idx: int +) -> set[int]: + """Return the initial selected-indices set for a pick list.""" + if multi and all_selected: + return set(range(n)) + if multi: + return set() + return {default_idx} + + def _clamp_scroll(idx: int, top: int) -> int: """Return an updated *top* offset so that *idx* is visible in the viewport.""" if idx < top: @@ -113,11 +124,7 @@ def scrollable_pick( idx = default_idx top = 0 n = len(display_items) - selected: set[int] = ( - set(range(n)) - if (multi and all_selected) - else ({default_idx} if not multi else set()) - ) + selected = _initial_selection(multi, all_selected, n, default_idx) while True: idx = max(0, min(idx, n - 1)) diff --git a/dfetch/terminal/prompt.py b/dfetch/terminal/prompt.py index ff7d9b0a..8a154b86 100644 --- a/dfetch/terminal/prompt.py +++ b/dfetch/terminal/prompt.py @@ -2,8 +2,16 @@ import sys -from dfetch.terminal.ansi import DIM, RESET -from dfetch.terminal.keys import read_key +from rich.console import Console +from rich.prompt import Prompt + +from dfetch.terminal.ansi import DIM, GREEN, RESET +from dfetch.terminal.keys import is_tty, read_key +from dfetch.terminal.types import Entry + +_console = Console() + +_PROMPT_FORMAT = " [green]?[/green] [bold]{label}[/bold]" def _ghost_handle_backspace(buf: list[str], ghost_active: bool, ghost_len: int) -> bool: @@ -57,3 +65,53 @@ def ghost_prompt(label: str, default: str = "") -> str: # pragma: no cover ch = " " if key == "SPACE" else key if len(ch) == 1 and ch.isprintable(): ghost_active = _ghost_handle_char(ch, buf, ghost_active, len(default)) + + +def numbered_prompt( + entries: list[Entry], + label: str, + hint: str, + default: str = "", + note: str = "", +) -> str: + """Display *entries* then prompt until the user picks one or types freely. + + Each entry is printed as `` N `` where *N* is its 1-based + index. An optional *note* line (e.g. a truncation message) is printed + after the entries without a number. + + If the user enters a digit in ``[1, len(entries)]`` returns the + corresponding ``entry.value``. Any other non-empty input is returned + as-is. Out-of-range numbers loop with a warning. + """ + for i, entry in enumerate(entries, start=1): + _console.print(f" [bold white]{i:>2}[/bold white] {entry.display}") + if note: + _console.print(note) + + n = len(entries) + while True: + raw = Prompt.ask( + _PROMPT_FORMAT.format(label=label) + f" ({hint})", + default=default, + ).strip() + + if raw.isdigit(): + idx = int(raw) - 1 + if 0 <= idx < n: + return entries[idx].value + _console.print(f" [dim]Pick a number between 1 and {n}.[/dim]") + continue + + return raw + + +def prompt(label: str, default: str = "") -> str: + """Single-line prompt that adapts to the terminal environment. + + In a TTY shows ghost text via :func:`ghost_prompt`. + Outside a TTY (CI, pipe, tests) uses a Rich fallback. + """ + if is_tty(): + return ghost_prompt(f" {GREEN}?{RESET} {label}", default).strip() + return Prompt.ask(_PROMPT_FORMAT.format(label=label), default=default).strip() diff --git a/dfetch/terminal/tree_browser.py b/dfetch/terminal/tree_browser.py index f4b3b26b..29acee23 100644 --- a/dfetch/terminal/tree_browser.py +++ b/dfetch/terminal/tree_browser.py @@ -1,17 +1,18 @@ """Generic scrollable tree browser for remote VCS trees. -Provides :func:`run_tree_browser` and :func:`tree_single_pick` — interactive -terminal widgets that work with any :data:`~dfetch.terminal.LsFunction`. +Provides :func:`run_tree_browser`, :func:`tree_single_pick`, and +:func:`tree_pick_from_names` — interactive terminal widgets that work with +any :data:`~dfetch.terminal.LsFunction`. """ from __future__ import annotations from dataclasses import dataclass -from dfetch.terminal.ansi import BOLD, DIM, GREEN, RESET, VIEWPORT, strip_ansi +from dfetch.terminal.ansi import BOLD, DIM, GREEN, RESET, VIEWPORT from dfetch.terminal.keys import read_key from dfetch.terminal.screen import Screen -from dfetch.terminal.types import LsFunction +from dfetch.terminal.types import Entry, LsFunction @dataclass @@ -56,47 +57,31 @@ def __init__( @property def nodes(self) -> list[TreeNode]: - """Current node list (populated after :meth:`run` or :meth:`seed`).""" + """Current node list (populated after :meth:`run`).""" return self._nodes - @property - def idx(self) -> int: - """Current cursor index.""" - return self._idx - - @property - def top(self) -> int: - """Current scroll offset (index of the topmost visible node).""" - return self._top - - def seed(self, nodes: list[TreeNode], *, idx: int = 0, top: int = 0) -> None: - """Pre-load node state without running the TTY loop. - - Intended for headless driving and unit tests. Cursor and scroll are - clamped to valid ranges automatically. - """ - self._nodes = list(nodes) - self._idx = max(0, min(idx, len(nodes) - 1)) if nodes else 0 - self._top = top - - def feed_key(self, key: str) -> list[str] | None: - """Process one keypress without rendering. - - Returns the selected-path list when the browser would exit (an empty - list for ESC/skip), or ``None`` to indicate the loop should continue. - Intended for headless driving and unit tests. - """ - if self._handle_nav(key): - return None - return self._handle_action(key) + # ------------------------------------------------------------------ + # Scroll + # ------------------------------------------------------------------ - def adjust_scroll(self) -> None: + def _adjust_scroll(self) -> None: """Ensure the cursor index is within the visible viewport.""" if self._idx < self._top: self._top = self._idx elif self._idx >= self._top + VIEWPORT: self._top = self._idx - VIEWPORT + 1 + def _scroll_to_reveal_first_child(self) -> None: + """Scroll down one row when an expanded dir sits at the viewport bottom.""" + node = self._nodes[self._idx] + if ( + node.is_dir + and node.expanded + and self._idx == self._top + VIEWPORT - 1 + and self._idx + 1 < len(self._nodes) + ): + self._top += 1 + # ------------------------------------------------------------------ # Node mutation # ------------------------------------------------------------------ @@ -107,13 +92,13 @@ def _expand(self, idx: int) -> None: if not node.children_loaded: children = [ TreeNode( - name=name, - path=f"{node.path}/{strip_ansi(name)}", - is_dir=is_dir, + name=entry.display, + path=f"{node.path}/{entry.value}", + is_dir=entry.has_children, depth=node.depth + 1, selected=node.selected, ) - for name, is_dir in self._ls_function(node.path) + for entry in self._ls_function(node.path) ] self._nodes[idx + 1 : idx + 1] = children node.children_loaded = True @@ -151,51 +136,37 @@ def _nav_hint(self) -> str: ) return f"↑/↓ navigate Enter select →/← expand/collapse Esc {esc}" + def _render_node(self, i: int, node: TreeNode) -> str: + """Render a single node row for the visible viewport.""" + indent = " " * node.depth + icon = ("▾ " if node.expanded else "▸ ") if node.is_dir else " " + cursor = f"{GREEN}▶{RESET}" if i == self._idx else " " + if self._config.multi: + name = f"{DIM}{node.name}{RESET}" if not node.selected else node.name + return f" {cursor} {indent}{icon}{name}" + check = f"{GREEN}✓ {RESET}" if node.selected else " " + name = f"{BOLD}{node.name}{RESET}" if i == self._idx else node.name + return f" {cursor} {check}{indent}{icon}{name}" + def _render_lines(self) -> list[str]: """Build display strings for the visible viewport slice.""" - lines: list[str] = [] - for i in range(self._top, min(self._top + VIEWPORT, len(self._nodes))): - node = self._nodes[i] - indent = " " * node.depth - icon = ("▾ " if node.expanded else "▸ ") if node.is_dir else " " - cursor = f"{GREEN}▶{RESET}" if i == self._idx else " " - if self._config.multi: - name = f"{DIM}{node.name}{RESET}" if not node.selected else node.name - lines.append(f" {cursor} {indent}{icon}{name}") - else: - check = f"{GREEN}✓ {RESET}" if node.selected else " " - name = f"{BOLD}{node.name}{RESET}" if i == self._idx else node.name - lines.append(f" {cursor} {check}{indent}{icon}{name}") - return lines + return [ + self._render_node(i, self._nodes[i]) + for i in range(self._top, min(self._top + VIEWPORT, len(self._nodes))) + ] def _build_frame(self) -> list[str]: """Build all display lines for one render frame.""" - n = len(self._nodes) header = [f" {GREEN}?{RESET} {BOLD}{self._title}:{RESET}"] if self._top > 0: header.append(f" {DIM}↑ {self._top} more above{RESET}") footer: list[str] = [] - remaining = n - (self._top + VIEWPORT) - if remaining > 0: - footer.append(f" {DIM}↓ {remaining} more below{RESET}") + hidden_below = len(self._nodes) - (self._top + VIEWPORT) + if hidden_below > 0: + footer.append(f" {DIM}↓ {hidden_below} more below{RESET}") footer.append(f" {DIM}{self._nav_hint()}{RESET}") return header + self._render_lines() + footer - # ------------------------------------------------------------------ - # Scroll helpers - # ------------------------------------------------------------------ - - def _nudge_scroll_after_expand(self) -> None: - """Scroll down 1 when a just-expanded dir sits at the viewport bottom.""" - node = self._nodes[self._idx] - if ( - node.is_dir - and node.expanded - and self._idx == self._top + VIEWPORT - 1 - and self._idx + 1 < len(self._nodes) - ): - self._top += 1 - # ------------------------------------------------------------------ # Key handlers # ------------------------------------------------------------------ @@ -252,19 +223,21 @@ def _handle_enter(self) -> list[str] | None: return None return [node.path] + def _handle_right(self, node: TreeNode) -> None: + """Expand the current directory on RIGHT, if not already open.""" + if node.is_dir and not node.expanded: + self._expand(self._idx) + def _handle_action(self, key: str) -> list[str] | None: """Dispatch a non-navigation keypress; return a result list or None to continue.""" node = self._nodes[self._idx] if key == "RIGHT": - if node.is_dir and not node.expanded: - self._expand(self._idx) + self._handle_right(node) elif key == "LEFT": self._handle_left() elif key == "SPACE": return ( - self._handle_enter() - if not self._config.multi and node.is_dir - else self._handle_space() + self._handle_enter() if not self._config.multi else self._handle_space() ) elif key == "ENTER": return self._handle_enter() @@ -284,22 +257,19 @@ def run(self) -> list[str]: # pragma: no cover - interactive TTY only self._nodes = [ TreeNode( - name=name, - path=strip_ansi(name), - is_dir=is_dir, + name=entry.display, + path=entry.value, + is_dir=entry.has_children, depth=0, selected=self._config.all_selected, ) - for name, is_dir in root_entries + for entry in root_entries ] screen = Screen() while True: - if not self._nodes: - screen.clear() - return [] self._idx = max(0, min(self._idx, len(self._nodes) - 1)) - self.adjust_scroll() + self._adjust_scroll() screen.draw(self._build_frame()) key = read_key() @@ -310,7 +280,12 @@ def run(self) -> list[str]: # pragma: no cover - interactive TTY only if result is not None: screen.clear() return result - self._nudge_scroll_after_expand() + self._scroll_to_reveal_first_child() + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- def run_tree_browser( @@ -334,7 +309,7 @@ def tree_single_pick( *, dirs_selectable: bool = False, esc_label: str = "skip", -) -> str: +) -> str: # pragma: no cover - interactive TTY only """Browse a remote tree and return a single selected path (``""`` on skip).""" config = BrowserConfig(dirs_selectable=dirs_selectable, esc_label=esc_label) browser = TreeBrowser(ls_function, title, config) @@ -342,6 +317,11 @@ def tree_single_pick( return result[0] if result else "" +# --------------------------------------------------------------------------- +# Tree analysis helpers +# --------------------------------------------------------------------------- + + def all_descendants_deselected(nodes: list[TreeNode], parent_idx: int) -> bool: """Return True if every loaded descendant of *parent_idx* is deselected.""" parent_depth = nodes[parent_idx].depth @@ -353,6 +333,11 @@ def all_descendants_deselected(nodes: list[TreeNode], parent_idx: int) -> bool: return True +def _is_covered_by_dir(path: str, dirs: set[str]) -> bool: + """Return True if *path* is nested under any directory in *dirs*.""" + return any(path.startswith(d + "/") for d in dirs) + + def deselected_paths(nodes: list[TreeNode]) -> list[str]: """Compute minimal path list covering all deselected nodes. @@ -367,7 +352,7 @@ def deselected_paths(nodes: list[TreeNode]) -> list[str]: for i, node in enumerate(nodes): if node.selected: continue - if any(node.path.startswith(d + "/") for d in dirs): + if _is_covered_by_dir(node.path, dirs): continue if node.is_dir and ( not node.children_loaded or all_descendants_deselected(nodes, i) @@ -378,3 +363,86 @@ def deselected_paths(nodes: list[TreeNode]) -> list[str]: paths.append(node.path) return paths + + +# --------------------------------------------------------------------------- +# Flat-name tree browser +# --------------------------------------------------------------------------- + + +class _FlatNamesLs: # pylint: disable=too-few-public-methods + """LsFunction that exposes a flat list of slash-separated names as a tree. + + Leaf nodes carry a dim annotation label so they are visually distinct from + directory segments. The tree browser uses ``Entry.value`` (the plain name) + rather than ``Entry.display`` (which may contain markup). + """ + + def __init__( + self, labels: dict[str, str], all_names: list[str], priority_path: str + ) -> None: + """Store pre-built lookup tables and the path to sort first.""" + self._labels = labels + self._all_names = all_names + self._priority_path = priority_path + + def _segments(self, prefix: str) -> dict[str, bool]: + """Return first-level path segments under *prefix* mapped to has_children.""" + seen: dict[str, bool] = {} + for name in self._all_names: + if not name.startswith(prefix): + continue + rest = name[len(prefix) :] + seg = rest.split("/")[0] + if seg and seg not in seen: + full = prefix + seg + seen[seg] = any(n.startswith(full + "/") for n in self._all_names) + return seen + + def _sort_key(self, seg: str, prefix: str) -> tuple[int, str]: + """Sort priority path first, then alphabetically.""" + full = prefix + seg + on_priority = full == self._priority_path or self._priority_path.startswith( + full + "/" + ) + return (0 if on_priority else 1, seg) + + def _leaf_entry(self, seg: str, full: str) -> Entry: + """Build a leaf Entry with dim annotation label and optional default marker.""" + label = self._labels[full] + marker = f" {DIM}(default){RESET}" if full == self._priority_path else "" + return Entry( + display=f"{seg} {DIM}{label}{RESET}{marker}", + has_children=False, + value=seg, + ) + + def __call__(self, path: str) -> list[Entry]: + """Return entries for *path*, building the tree one level at a time.""" + prefix = (path + "/") if path else "" + seen = self._segments(prefix) + result: list[Entry] = [] + for seg in sorted(seen, key=lambda s: self._sort_key(s, prefix)): + full = prefix + seg + if seen[seg]: + result.append(Entry(display=seg, has_children=True)) + else: + result.append(self._leaf_entry(seg, full)) + return result + + +def tree_pick_from_names( + labels: dict[str, str], + title: str, + *, + priority_path: str = "", + esc_label: str = "skip", +) -> str: # pragma: no cover - interactive TTY only + """Browse a flat dict of ``name → label`` as a tree and return the picked name. + + Names are split on ``/`` to form a navigable hierarchy. Leaf nodes show + their label as a dim annotation. *priority_path* is sorted to the top. + Returns ``""`` when the user presses Esc. + """ + ls = _FlatNamesLs(labels, list(labels), priority_path) + return tree_single_pick(ls, title, esc_label=esc_label) diff --git a/dfetch/terminal/types.py b/dfetch/terminal/types.py index fc91e06b..2520a3cb 100644 --- a/dfetch/terminal/types.py +++ b/dfetch/terminal/types.py @@ -1,5 +1,35 @@ """Type aliases for the terminal package.""" +from __future__ import annotations + from collections.abc import Callable +from dataclasses import dataclass, field + + +@dataclass +class Entry: + """One entry returned by an :data:`LsFunction`. + + *display* is the string shown in the tree browser. *value* is the + underlying path segment used when building ``node.path``; it defaults + to *display* when not supplied, which is correct for plain file trees + where the two are identical. Set *value* explicitly when the display + carries decorations (ANSI codes, kind labels, etc.) that should not + bleed into the path. + + *has_children* indicates whether the entry can be expanded further. + For file trees this corresponds to directories; for other uses (e.g. + a version picker) it marks namespace groups that contain nested items. + """ + + display: str + has_children: bool + value: str = field(default="") + + def __post_init__(self) -> None: + """Default *value* to *display* when not explicitly provided.""" + if not self.value: + self.value = self.display + -LsFunction = Callable[[str], list[tuple[str, bool]]] +LsFunction = Callable[[str], list[Entry]] diff --git a/dfetch/vcs/git.py b/dfetch/vcs/git.py index 1dcb7b47..20ee0866 100644 --- a/dfetch/vcs/git.py +++ b/dfetch/vcs/git.py @@ -5,13 +5,13 @@ import glob import os import re +import shutil import tempfile -from collections.abc import Generator, Sequence +from collections.abc import Callable, Generator, Sequence from dataclasses import dataclass from pathlib import Path from dfetch.log import get_logger -from dfetch.terminal import LsFunction from dfetch.util.cmdline import SubprocessCommandError, run_on_cmdline from dfetch.util.license import is_license_file from dfetch.util.util import ( @@ -224,11 +224,13 @@ def fetch_for_tree_browse(self, target: str, version: str) -> None: ) @contextlib.contextmanager - def browse_tree(self, version: str = "") -> Generator[LsFunction, None, None]: + def browse_tree( + self, version: str = "" + ) -> Generator[Callable[[str], list[tuple[str, bool]]], None, None]: """Shallow-clone the remote and yield a tree-listing callable. - The yielded ``LsFunction`` calls ``git ls-tree`` on a blobless temporary - clone. The clone is removed on context exit. + The yielded callable accepts a path and returns ``(name, is_dir)`` pairs. + The clone is removed on context exit. """ tmpdir = tempfile.mkdtemp(prefix="dfetch_browse_") cloned = False @@ -238,13 +240,11 @@ def browse_tree(self, version: str = "") -> Generator[LsFunction, None, None]: except Exception: # pylint: disable=broad-exception-caught # nosec B110 pass - def ls_function(path: str = "") -> list[tuple[str, bool]]: - if cloned: - return GitRemote.ls_tree(tmpdir, path=path) - return [] + def ls(path: str = "") -> list[tuple[str, bool]]: + return GitRemote.ls_tree(tmpdir, path=path) if cloned else [] try: - yield ls_function + yield ls finally: shutil.rmtree(tmpdir, ignore_errors=True) @@ -259,8 +259,8 @@ def _parse_ls_tree_entry(line: str, prefix: str) -> tuple[str, bool]: def ls_tree(local_path: str, path: str = "") -> list[tuple[str, bool]]: """List the contents of the HEAD tree at *path* in a local clone. - Returns a list of ``(name, is_dir)`` pairs sorted with directories - first (alphabetically), then files (alphabetically). + Returns entries sorted with directories first (alphabetically), + then files (alphabetically). """ cmd = ["git", "-C", local_path, "ls-tree", "FETCH_HEAD"] if path: @@ -273,9 +273,8 @@ def ls_tree(local_path: str, path: str = "") -> list[tuple[str, bool]]: for line in result.stdout.decode().splitlines() if line.strip() ] - dirs: list[tuple[str, bool]] = sorted((n, d) for n, d in entries if d) - files: list[tuple[str, bool]] = sorted((n, d) for n, d in entries if not d) - return dirs + files + entries.sort(key=lambda e: (not e[1], e[0])) # dirs first, then files + return entries except SubprocessCommandError: return [] diff --git a/dfetch/vcs/svn.py b/dfetch/vcs/svn.py index 0953c5b6..89231a9d 100644 --- a/dfetch/vcs/svn.py +++ b/dfetch/vcs/svn.py @@ -4,12 +4,11 @@ import os import pathlib import re -from collections.abc import Generator, Sequence +from collections.abc import Callable, Generator, Sequence from pathlib import Path from typing import NamedTuple from dfetch.log import get_logger -from dfetch.terminal import LsFunction from dfetch.util.cmdline import SubprocessCommandError, run_on_cmdline from dfetch.util.util import in_directory from dfetch.vcs.patch import Patch, PatchType @@ -87,12 +86,14 @@ def list_of_tags(self) -> list[str]: ] @contextlib.contextmanager - def browse_tree(self, version: str = "") -> Generator[LsFunction, None, None]: - """Yield an ls_function that lists SVN tree contents for *version*. + def browse_tree( + self, version: str = "" + ) -> Generator[Callable[[str], list[tuple[str, bool]]], None, None]: + """Yield a callable that lists SVN tree contents for *version*. Resolves *version* to the correct remote path (trunk, ``branches/``, or ``tags/``), then delegates - directory listing to ``svn ls``. + directory listing to ``svn ls``. The callable returns ``(name, is_dir)`` pairs. """ version = version or SvnRepo.DEFAULT_BRANCH if version == SvnRepo.DEFAULT_BRANCH: @@ -105,11 +106,11 @@ def browse_tree(self, version: str = "") -> Generator[LsFunction, None, None]: except RuntimeError: base_url = f"{self._remote}/tags/{version}" - def ls_function(path: str = "") -> list[tuple[str, bool]]: + def ls(path: str = "") -> list[tuple[str, bool]]: url = f"{base_url}/{path}" if path else base_url return self.ls_tree(url) - yield ls_function + yield ls def ls_tree(self, url_path: str) -> list[tuple[str, bool]]: """List immediate children of *url_path* as ``(name, is_dir)`` pairs.""" diff --git a/pyproject.toml b/pyproject.toml index 2b43ffe7..f5a2ebee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -237,9 +237,9 @@ root_packages = ["dfetch"] # ↓ # dfetch.project (core domain — knows about manifest & vcs) # ↓ -# dfetch.manifest | dfetch.vcs (independent lower-level modules) +# dfetch.manifest | dfetch.vcs (independent domain services) # ↓ -# dfetch.util | dfetch.log | dfetch.terminal (foundational utilities) +# dfetch.util | dfetch.log | dfetch.terminal (foundational — siblings cannot import each other) [[tool.importlinter.contracts]] name = "C4 architecture layers" diff --git a/tests/test_add.py b/tests/test_add.py index e97d586e..16224a54 100644 --- a/tests/test_add.py +++ b/tests/test_add.py @@ -222,14 +222,17 @@ def test_add_command_user_aborts(): mock_append.assert_not_called() -def test_add_command_raises_on_duplicate_name(): - """Trying to add a project whose name already exists must raise.""" +def test_add_command_suffixes_duplicate_name(tmp_path): + """Non-interactive add with a clashing name must append a numbered suffix.""" + manifest_file = tmp_path / "dfetch.yaml" + manifest_file.write_text("") + fake_superproject = Mock() fake_superproject.manifest = mock_manifest( - [{"name": "myrepo"}], path="/some/dfetch.yaml" + [{"name": "myrepo"}], path=str(manifest_file) ) fake_superproject.manifest.remotes = [] - fake_superproject.root_directory = Path("/some") + fake_superproject.root_directory = tmp_path fake_subproject = _make_subproject() @@ -239,8 +242,9 @@ def test_add_command_raises_on_duplicate_name(): with patch( "dfetch.commands.add.create_sub_project", return_value=fake_subproject ): - with pytest.raises(RuntimeError, match="already exists"): - Add()(_make_args("https://github.com/org/myrepo.git", force=True)) + Add()(_make_args("https://github.com/org/myrepo.git", force=True)) + + assert "myrepo-1" in manifest_file.read_text() # --------------------------------------------------------------------------- diff --git a/tests/test_tree_browser.py b/tests/test_tree_browser.py index e96ed5f1..7ea252a7 100644 --- a/tests/test_tree_browser.py +++ b/tests/test_tree_browser.py @@ -1,10 +1,10 @@ # pyright: reportCallIssue=false """Hypothesis tests for the TreeBrowser state machine. -``TreeBrowser.run()`` is TTY-only (pragma: no cover). All tests here drive -the browser through its headless public interface: :meth:`~TreeBrowser.seed`, -:meth:`~TreeBrowser.feed_key`, and the :attr:`~TreeBrowser.idx` / -:attr:`~TreeBrowser.top` / :attr:`~TreeBrowser.nodes` properties. +``TreeBrowser.run()`` is TTY-only and not exercised here. Tests drive the +browser through :class:`_HeadlessBrowser`, a thin subclass that seeds state, +feeds keys, and exposes internal state — keeping all test-only scaffolding out +of the production class. """ from __future__ import annotations @@ -20,11 +20,58 @@ all_descendants_deselected, deselected_paths, ) +from dfetch.terminal.types import Entry settings.register_profile("ci", max_examples=30, deadline=None) settings.register_profile("dev", max_examples=100, deadline=None) settings.load_profile("dev") + +# --------------------------------------------------------------------------- +# Headless test subclass — all test-only scaffolding lives here +# --------------------------------------------------------------------------- + + +class _HeadlessBrowser(TreeBrowser): + """TreeBrowser subclass for headless testing. + + Adds :meth:`seed` and :meth:`feed_key` to drive the state machine + without a TTY, and exposes :attr:`nodes`, :attr:`idx`, :attr:`top` so + tests can inspect internal state. Rendering (Screen / read_key) is + never called. + """ + + def seed(self, nodes: list[TreeNode], *, idx: int = 0, top: int = 0) -> None: + """Pre-load node state; clamp cursor and scroll to valid ranges.""" + self._nodes = list(nodes) + self._idx = max(0, min(idx, len(nodes) - 1)) if nodes else 0 + self._top = top + + def feed_key(self, key: str) -> list[str] | None: + """Process one keypress without rendering. + + Returns the selected-path list when the browser would exit, or + ``None`` to continue. + """ + if self._handle_nav(key): + return None + return self._handle_action(key) + + def adjust_scroll(self) -> None: + """Expose the private scroll-adjustment for direct testing.""" + self._adjust_scroll() + + @property + def idx(self) -> int: + """Current cursor index.""" + return self._idx + + @property + def top(self) -> int: + """Current scroll offset.""" + return self._top + + # --------------------------------------------------------------------------- # Strategies # --------------------------------------------------------------------------- @@ -79,15 +126,15 @@ def _proper_tree_st(draw, _prefix: str = "", _depth: int = 0) -> list[TreeNode]: @st.composite def _dir_with_children_st( draw, -) -> tuple[list[TreeNode], list[tuple[str, bool]]]: +) -> tuple[list[TreeNode], list[Entry]]: """Draw an unexpanded dir node at index 0, optional siblings, and a children spec.""" dir_name = draw(_NAME) dir_node = TreeNode(name=dir_name, path=dir_name, is_dir=True, depth=0) siblings = draw(st.lists(_node_st(depth=0), min_size=0, max_size=5)) child_names = draw(st.lists(_NAME, min_size=1, max_size=8)) - children: list[tuple[str, bool]] = [ - (name, draw(st.booleans())) for name in child_names + children: list[Entry] = [ + Entry(display=name, has_children=draw(st.booleans())) for name in child_names ] return [dir_node] + siblings, children @@ -105,16 +152,16 @@ def _browser( idx: int = 0, top: int = 0, config: BrowserConfig = BrowserConfig(), - children: list[tuple[str, bool]] | None = None, -) -> TreeBrowser: - """Return a seeded TreeBrowser backed by *children* (or empty) for expansions.""" + children: list[Entry] | None = None, +) -> _HeadlessBrowser: + """Return a seeded _HeadlessBrowser backed by *children* (or empty) for expansions.""" dir_path = nodes[0].path if nodes else "" ls = ( (lambda path: children if path == dir_path else []) if children else (lambda _: []) ) - browser = TreeBrowser(ls, "test", config) + browser = _HeadlessBrowser(ls, "test", config) browser.seed(nodes, idx=idx, top=top) return browser From 7637a15bb8951b734bbbed05c8b435b95383ad04 Mon Sep 17 00:00:00 2001 From: Ben Spoor Date: Sat, 28 Mar 2026 13:08:30 +0100 Subject: [PATCH 14/29] Extend feature tests --- features/add-project-through-cli.feature | 89 +++++++++++++++++++++++- 1 file changed, 87 insertions(+), 2 deletions(-) diff --git a/features/add-project-through-cli.feature b/features/add-project-through-cli.feature index 4c024219..35e511ac 100644 --- a/features/add-project-through-cli.feature +++ b/features/add-project-through-cli.feature @@ -31,7 +31,7 @@ Feature: Add a project to the manifest via the CLI dst: ext/MyLib """ - Scenario: Duplicate project name is rejected + Scenario: Duplicate project name is auto-renamed in non-interactive mode Given the manifest 'dfetch.yaml' """ manifest: @@ -41,7 +41,12 @@ Feature: Add a project to the manifest via the CLI url: some-remote-server/MyLib.git """ When I add "some-remote-server/MyLib.git" with force - Then the command fails with "already exists in manifest" + Then the manifest 'dfetch.yaml' contains entry + """ + - name: MyLib-1 + url: some-remote-server/MyLib.git + branch: master + """ Scenario: Destination is guessed from common prefix of existing projects Given the manifest 'dfetch.yaml' @@ -114,6 +119,86 @@ Feature: Add a project to the manifest via the CLI tag: v1 """ + Scenario: Interactive add with src subpath + Given the manifest 'dfetch.yaml' + """ + manifest: + version: '0.0' + projects: + - name: existing + url: some-remote-server/existing.git + """ + When I interactively add "some-remote-server/MyLib.git" with inputs + | prompt_contains | answer | + | Project name | my-lib | + | Destination path | my-lib | + | Version | master | + | Source path | docs/api | + | Ignore paths | | + | Add project to manifest? | y | + | Run update | n | + Then the manifest 'dfetch.yaml' contains entry + """ + - name: my-lib + url: some-remote-server/MyLib.git + branch: master + src: docs/api + """ + + Scenario: Interactive add with ignore list + Given the manifest 'dfetch.yaml' + """ + manifest: + version: '0.0' + projects: + - name: existing + url: some-remote-server/existing.git + """ + When I interactively add "some-remote-server/MyLib.git" with inputs + | prompt_contains | answer | + | Project name | my-lib | + | Destination path | my-lib | + | Version | master | + | Source path | | + | Ignore paths | docs, tests | + | Add project to manifest? | y | + | Run update | n | + Then the manifest 'dfetch.yaml' contains entry + """ + - name: my-lib + url: some-remote-server/MyLib.git + branch: master + ignore: + - docs + - tests + """ + + Scenario: Interactive add triggers immediate fetch when update is accepted + Given the manifest 'dfetch.yaml' + """ + manifest: + version: '0.0' + projects: + - name: existing + url: some-remote-server/existing.git + """ + When I interactively add "some-remote-server/MyLib.git" with inputs + | prompt_contains | answer | + | Project name | MyLib | + | Destination path | MyLib | + | Version | master | + | Source path | | + | Ignore paths | | + | Add project to manifest? | y | + | Run update | y | + Then the manifest 'dfetch.yaml' contains entry + """ + - name: MyLib + url: some-remote-server/MyLib.git + branch: master + """ + And 'MyLib/README.md' exists + Scenario: Interactive add with abort does not modify manifest Given the manifest 'dfetch.yaml' """ From 526dbecf7c2b63d316bbf7a6bf009a1189be2a21 Mon Sep 17 00:00:00 2001 From: Ben Spoor Date: Sat, 28 Mar 2026 13:31:12 +0100 Subject: [PATCH 15/29] Add asciicast --- doc/asciicasts/interactive-add.cast | 115 +++++++++++++++++ doc/generate-casts/generate-casts.sh | 1 + doc/generate-casts/interactive-add-demo.sh | 40 ++++++ doc/generate-casts/interactive_add_helper.py | 129 +++++++++++++++++++ 4 files changed, 285 insertions(+) create mode 100644 doc/asciicasts/interactive-add.cast create mode 100755 doc/generate-casts/interactive-add-demo.sh create mode 100644 doc/generate-casts/interactive_add_helper.py diff --git a/doc/asciicasts/interactive-add.cast b/doc/asciicasts/interactive-add.cast new file mode 100644 index 00000000..94c708fc --- /dev/null +++ b/doc/asciicasts/interactive-add.cast @@ -0,0 +1,115 @@ +{"version": 2, "width": 173, "height": 36, "timestamp": 1774700938, "env": {"SHELL": "/usr/bin/zsh", "TERM": "xterm-256color"}} +[0.006587, "o", "/home/ben/Programming/dfetch/doc/generate-casts/interactive-add /home/ben/Programming/dfetch/doc/generate-casts\r\n"] +[0.008298, "o", "\u001b[H\u001b[2J\u001b[3J"] +[0.010979, "o", "$ "] +[1.015209, "o", "\u001b["] +[1.195997, "o", "1m"] +[1.286008, "o", "ca"] +[1.37609, "o", "t "] +[1.46629, "o", "df"] +[1.55649, "o", "et"] +[1.646659, "o", "ch"] +[1.73682, "o", ".y"] +[1.826954, "o", "am"] +[1.917222, "o", "l\u001b"] +[2.097806, "o", "[0"] +[2.188207, "o", "m"] +[3.190293, "o", "\r\n"] +[3.194309, "o", "manifest:\r\n version: '0.0'\r\n projects:\r\n - name: jsmn\r\n url: https://github.com/zserge/jsmn.git\r\n branch: master\r\n"] +[3.200461, "o", "$ "] +[4.204728, "o", "\u001b"] +[4.384922, "o", "[1"] +[4.475222, "o", "md"] +[4.565241, "o", "fe"] +[4.655321, "o", "t"] +[4.745924, "o", "ch"] +[4.835907, "o", " a"] +[4.926012, "o", "dd"] +[5.016554, "o", " -"] +[5.106995, "o", "i"] +[5.287819, "o", " h"] +[5.378229, "o", "tt"] +[5.46872, "o", "ps"] +[5.559124, "o", ":/"] +[5.649617, "o", "/"] +[5.740014, "o", "gi"] +[5.830347, "o", "th"] +[5.920526, "o", "ub"] +[6.010971, "o", ".c"] +[6.191347, "o", "o"] +[6.281511, "o", "m/"] +[6.371949, "o", "cp"] +[6.462498, "o", "pu"] +[6.552779, "o", "te"] +[6.643174, "o", "s"] +[6.73357, "o", "t/"] +[6.823951, "o", "cp"] +[6.914134, "o", "pu"] +[7.094652, "o", "te"] +[7.185069, "o", "s"] +[7.275514, "o", "t."] +[7.365747, "o", "gi"] +[7.45584, "o", "t\u001b"] +[7.546246, "o", "[0"] +[7.636649, "o", "m"] +[8.639035, "o", "\r\n"] +[8.826, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[9.348012, "o", " \u001b[1;92mhttps://github.com/cpputest/cpputest.git:\u001b[0m\r\n"] +[9.349009, "o", " \u001b[1;34m> Adding project through interactive wizard\u001b[0m\r\n \u001b[92m?\u001b[0m Name: \u001b[2mcpputest\u001b[0m"] +[23.37253, "o", "\r\n\u001b[1A\u001b[2K"] +[23.374059, "o", " - \u001b[34mname:\u001b[0m cpputest\r\n"] +[23.374889, "o", " \u001b[34murl:\u001b[0m https://github.com/cpputest/cpputest.git\r\n \u001b[92m?\u001b[0m Destination: \u001b[2mcpputest\u001b[0m"] +[25.161399, "o", "\r\n\u001b[1A\u001b[2K"] +[25.973393, "o", " \u001b[92m?\u001b[0m \u001b[1mVersion:\u001b[0m\r\n \u001b[92m▶\u001b[0m \u001b[1mmaster \u001b[2mbranch\u001b[0m \u001b[2m(default)\u001b[0m\u001b[0m\r\n 3.7.2 \u001b[2mtag\u001b[0m\r\n gh-pages \u001b[2mbranch\u001b[0m\r\n latest-passing-build \u001b[2mtag\u001b[0m\r\n ▸ revert-1598-fix\r\n separate_gtest \u001b[2mbranch\u001b[0m\r\n v3.3 \u001b[2mtag\u001b[0m\r\n v3.4 \u001b[2mtag\u001b[0m\r\n v3.5 \u001b[2mtag\u001b[0m\r\n v3.6 \u001b[2mtag\u001b[0m\r\n \u001b[2m↓ 4 more below\u001b[0m\r\n \u001b[2m↑/↓ navigate Enter select →/← expand/collapse Esc list\u001b[0m\r\n"] +[28.321672, "o", "\u001b[13A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mVersion:\u001b[0m\r\n master \u001b[2mbranch\u001b[0m \u001b[2m(default)\u001b[0m\r\n \u001b[92m▶\u001b[0m \u001b[1m3.7.2 \u001b[2mtag\u001b[0m\u001b[0m\r\n gh-pages \u001b[2mbranch\u001b[0m\r\n latest-passing-build \u001b[2mtag\u001b[0m\r\n ▸ revert-1598-fix\r\n separate_gtest \u001b[2mbranch\u001b[0m\r\n v3.3 \u001b[2mtag\u001b[0m\r\n v3.4 \u001b[2mtag\u001b[0m\r\n v3.5 \u001b[2mtag\u001b[0m\r\n v3.6 \u001b[2mtag\u001b[0m\r\n \u001b[2m↓ 4 more below\u001b[0m\r\n \u001b[2m↑/↓ navigate Enter select →/← expand/collapse Esc list\u001b[0m\r\n"] +[28.712652, "o", "\u001b[13A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mVersion:\u001b[0m\r\n master \u001b[2mbranch\u001b[0m \u001b[2m(default)\u001b[0m\r\n 3.7.2 \u001b[2mtag\u001b[0m\r\n \u001b[92m▶\u001b[0m \u001b[1mgh-pages \u001b[2mbranch\u001b[0m\u001b[0m\r\n latest-passing-build \u001b[2mtag\u001b[0m\r\n ▸ revert-1598-fix\r\n separate_gtest \u001b[2mbranch\u001b[0m\r\n v3.3 \u001b[2mtag\u001b[0m\r\n v3.4 \u001b[2mtag\u001b[0m\r\n v3.5 \u001b[2mtag\u001b[0m\r\n v3.6 \u001b[2mtag\u001b[0m\r\n \u001b[2m↓ 4 more below\u001b[0m\r\n \u001b[2m↑/↓ navigate Enter select →/← expand/collapse Esc list\u001b[0m\r\n"] +[29.637058, "o", "\u001b[13A\u001b[0J"] +[29.638165, "o", " \u001b[34mbranch:\u001b[0m gh-pages\r\n"] +[30.335169, "o", " \u001b[92m?\u001b[0m \u001b[1mSource path:\u001b[0m\r\n \u001b[92m▶\u001b[0m ▸ \u001b[1mimages\u001b[0m\r\n ▸ javascripts\r\n ▸ stylesheets\r\n index.html\r\n params.json\r\n \u001b[2m↑/↓ navigate Enter select →/← expand/collapse Esc skip\u001b[0m\r\n"] +[32.17768, "o", "\u001b[7A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mSource path:\u001b[0m\r\n \u001b[92m▶\u001b[0m ▾ \u001b[1mimages\u001b[0m\r\n bkg.png\r\n blacktocat.png\r\n ▸ javascripts\r\n ▸ stylesheets\r\n index.html\r\n params.json\r\n \u001b[2m↑/↓ navigate Enter select →/← expand/collapse Esc skip\u001b[0m\r\n"] +[32.884474, "o", "\u001b[9A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mSource path:\u001b[0m\r\n ▾ images\r\n \u001b[92m▶\u001b[0m \u001b[1mbkg.png\u001b[0m\r\n blacktocat.png\r\n ▸ javascripts\r\n ▸ stylesheets\r\n index.html\r\n params.json\r\n \u001b[2m↑/↓ navigate Enter select →/← expand/collapse Esc skip\u001b[0m\r\n"] +[33.455716, "o", "\u001b[9A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mSource path:\u001b[0m\r\n ▾ images\r\n bkg.png\r\n \u001b[92m▶\u001b[0m \u001b[1mblacktocat.png\u001b[0m\r\n ▸ javascripts\r\n ▸ stylesheets\r\n index.html\r\n params.json\r\n \u001b[2m↑/↓ navigate Enter select →/← expand/collapse Esc skip\u001b[0m\r\n"] +[34.978763, "o", "\u001b[9A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mSource path:\u001b[0m\r\n ▾ images\r\n \u001b[92m▶\u001b[0m \u001b[1mbkg.png\u001b[0m\r\n blacktocat.png\r\n ▸ javascripts\r\n ▸ stylesheets\r\n index.html\r\n params.json\r\n \u001b[2m↑/↓ navigate Enter select →/← expand/collapse Esc skip\u001b[0m\r\n"] +[35.218496, "o", "\u001b[9A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mSource path:\u001b[0m\r\n \u001b[92m▶\u001b[0m ▾ \u001b[1mimages\u001b[0m\r\n bkg.png\r\n blacktocat.png\r\n ▸ javascripts\r\n ▸ stylesheets\r\n index.html\r\n params.json\r\n \u001b[2m↑/↓ navigate Enter select →/← expand/collapse Esc skip\u001b[0m\r\n"] +[35.479506, "o", "\u001b[9A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mSource path:\u001b[0m\r\n \u001b[92m▶\u001b[0m ▾ \u001b[1mimages\u001b[0m\r\n bkg.png\r\n blacktocat.png\r\n ▸ javascripts\r\n ▸ stylesheets\r\n index.html\r\n params.json\r\n \u001b[2m↑/↓ navigate Enter select →/← expand/collapse Esc skip\u001b[0m\r\n"] +[35.898403, "o", "\u001b[9A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mSource path:\u001b[0m\r\n \u001b[92m▶\u001b[0m ▸ \u001b[1mimages\u001b[0m\r\n ▸ javascripts\r\n ▸ stylesheets\r\n index.html\r\n params.json\r\n \u001b[2m↑/↓ navigate Enter select →/← expand/collapse Esc skip\u001b[0m\r\n"] +[37.730104, "o", "\u001b[7A\u001b[0J"] +[37.733523, "o", " \u001b[92m?\u001b[0m \u001b[1mIgnore:\u001b[0m\r\n \u001b[92m▶\u001b[0m ▸ images\r\n ▸ javascripts\r\n ▸ stylesheets\r\n index.html\r\n params.json\r\n \u001b[2m↑/↓ navigate Space toggle Enter confirm →/← expand/collapse Esc skip\u001b[0m\r\n"] +[40.116121, "o", "\u001b[7A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mIgnore:\u001b[0m\r\n \u001b[92m▶\u001b[0m ▸ \u001b[2mimages\u001b[0m\r\n ▸ javascripts\r\n ▸ stylesheets\r\n index.html\r\n params.json\r\n \u001b[2m↑/↓ navigate Space toggle Enter confirm →/← expand/collapse Esc skip\u001b[0m\r\n"] +[40.298184, "o", "\u001b[7A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mIgnore:\u001b[0m\r\n ▸ \u001b[2mimages\u001b[0m\r\n \u001b[92m▶\u001b[0m ▸ javascripts\r\n ▸ stylesheets\r\n index.html\r\n params.json\r\n \u001b[2m↑/↓ navigate Space toggle Enter confirm →/← expand/collapse Esc skip\u001b[0m\r\n"] +[40.460012, "o", "\u001b[7A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mIgnore:\u001b[0m\r\n ▸ \u001b[2mimages\u001b[0m\r\n ▸ javascripts\r\n \u001b[92m▶\u001b[0m ▸ stylesheets\r\n index.html\r\n params.json\r\n \u001b[2m↑/↓ navigate Space toggle Enter confirm →/← expand/collapse Esc skip\u001b[0m\r\n"] +[40.790247, "o", "\u001b[7A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mIgnore:\u001b[0m\r\n ▸ \u001b[2mimages\u001b[0m\r\n ▸ javascripts\r\n \u001b[92m▶\u001b[0m ▸ \u001b[2mstylesheets\u001b[0m\r\n index.html\r\n params.json\r\n \u001b[2m↑/↓ navigate Space toggle Enter confirm →/← expand/collapse Esc skip\u001b[0m\r\n"] +[41.777204, "o", "\u001b[7A\u001b[0J"] +[41.778195, "o", " \u001b[34mignore:\u001b[0m\r\n"] +[41.77866, "o", " - images\r\n"] +[41.779063, "o", " - stylesheets\r\n"] +[41.781633, "o", "Add project to manifest? \u001b[1m(\u001b[0my\u001b[1m)\u001b[0m: "] +[42.332364, "o", "y"] +[42.392338, "o", "\r\n"] +[42.394097, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] +[42.39471, "o", " \u001b[1;34m> Added 'cpputest' to manifest '/home/ben/Programming/dfetch/doc/generate-casts/interactive-add/dfetch.yaml'\u001b[0m\r\n"] +[42.395114, "o", "Run \u001b[32m'dfetch update cpputest'\u001b[0m now? \u001b[1m(\u001b[0my\u001b[1m)\u001b[0m: "] +[42.945802, "o", "n"] +[43.005827, "o", "\r\n"] +[43.063622, "o", "$ "] +[44.068765, "o", "\u001b["] +[44.249547, "o", "1m"] +[44.339916, "o", "ca"] +[44.429885, "o", "t "] +[44.520499, "o", "df"] +[44.610923, "o", "et"] +[44.701311, "o", "ch"] +[44.791622, "o", ".y"] +[44.882051, "o", "am"] +[44.972463, "o", "l\u001b"] +[45.153102, "o", "[0m"] +[46.155253, "o", "\r\n"] +[46.159809, "o", "manifest:\r\n version: '0.0'\r\n projects:\r\n - name: jsmn\r\n url: https://github.com/zserge/jsmn.git\r\n branch: master\r\n\r\n - name: cpputest\r\n url: https://github.com/cpputest/cpputest.git\r\n branch: gh-pages\r\n ignore:\r\n - images\r\n - stylesheets\r\n"] +[49.169724, "o", "$ "] +[49.171498, "o", "\u001b"] +[49.351954, "o", "[1"] +[49.441951, "o", "m\u001b"] +[49.532505, "o", "[0"] +[49.622908, "o", "m"] +[49.623649, "o", "\r\n"] +[49.627644, "o", "/home/ben/Programming/dfetch/doc/generate-casts\r\n"] diff --git a/doc/generate-casts/generate-casts.sh b/doc/generate-casts/generate-casts.sh index bdc1ee24..e3eb28f3 100755 --- a/doc/generate-casts/generate-casts.sh +++ b/doc/generate-casts/generate-casts.sh @@ -11,6 +11,7 @@ export TZ=UTC rm -rf ../asciicasts/* +asciinema rec --overwrite -c "./interactive-add-demo.sh" ../asciicasts/interactive-add.cast asciinema rec --overwrite -c "./basic-demo.sh" ../asciicasts/basic.cast asciinema rec --overwrite -c "./init-demo.sh" ../asciicasts/init.cast asciinema rec --overwrite -c "./add-demo.sh" ../asciicasts/add.cast diff --git a/doc/generate-casts/interactive-add-demo.sh b/doc/generate-casts/interactive-add-demo.sh new file mode 100755 index 00000000..a1640166 --- /dev/null +++ b/doc/generate-casts/interactive-add-demo.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Demo of dfetch add -i (interactive wizard mode). +# +# Uses the real cpputest repository so the viewer sees dfetch fetching live +# branch/tag metadata and the wizard populating from it. + +source ./demo-magic/demo-magic.sh + +PROMPT_TIMEOUT=1 + +mkdir interactive-add +pushd interactive-add + +# Start with a manifest that already has one dependency so the demo shows +# adding to an existing project rather than starting from scratch. +cat > dfetch.yaml << 'MANIFEST' +manifest: + version: '0.0' + projects: + - name: jsmn + url: https://github.com/zserge/jsmn.git + branch: master +MANIFEST + +clear + +pe "cat dfetch.yaml" + +p "dfetch add -i https://github.com/cpputest/cpputest.git" +python3 ../interactive_add_helper.py https://github.com/cpputest/cpputest.git + +pe "cat dfetch.yaml" + +PROMPT_TIMEOUT=3 +wait + +pei "" + +popd +rm -rf interactive-add diff --git a/doc/generate-casts/interactive_add_helper.py b/doc/generate-casts/interactive_add_helper.py new file mode 100644 index 00000000..0dc5e8e4 --- /dev/null +++ b/doc/generate-casts/interactive_add_helper.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Drive ``dfetch add -i`` with simulated typing for asciinema recordings. + +Usage:: + + python3 interactive-add-helper.py + +Every Rich ``Prompt.ask`` / ``Confirm.ask`` call is intercepted: + +1. The prompt markup is rendered to the terminal exactly as dfetch would. +2. After a short "thinking" pause each answer character is written to stdout + one at a time, mimicking natural typing speed. +3. The answer is returned to dfetch as if the user had pressed Enter. + +``is_tty`` is forced to ``False`` so that dfetch uses the text-based +fallbacks (numbered version list, plain src/ignore prompts) rather than +the raw-terminal tree browser – the text fallback looks better on a cast. + +Answers +------- +``None`` in a prompt-answer slot means "accept the default" – the default +value is typed out so the viewer can read what was chosen. An explicit +string overrides the default. +""" + +from __future__ import annotations + +import sys +import time +from collections import deque +from unittest.mock import patch + +from rich.console import Console + +# --------------------------------------------------------------------------- +# Wizard answers – customise these to change what the demo shows +# --------------------------------------------------------------------------- +_PROMPT_ANSWERS: deque[str | None] = deque( + [ + None, # Name – accept the default (derived from URL) + "ext/cpputest", # Destination – show the common ext/ convention + None, # Version – accept the default branch + None, # Source path – press Enter to fetch the whole repo + None, # Ignore paths – press Enter to skip + ] +) +_CONFIRM_ANSWERS: deque[bool] = deque( + [ + True, # "Add project to manifest?" → yes + False, # "Run 'dfetch update' now?" → no + ] +) + +# --------------------------------------------------------------------------- +# Timing (seconds) – tweak for faster/slower recording +# --------------------------------------------------------------------------- +_PRE_DELAY = 0.55 # pause before starting to type (user "thinking") +_CHAR_DELAY = 0.06 # delay between consecutive characters + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- +_console = Console(force_terminal=True) + + +def _type_out(text: str) -> None: + """Write *text* to stdout one character at a time, then a newline.""" + time.sleep(_PRE_DELAY) + for ch in text: + sys.stdout.write(ch) + sys.stdout.flush() + time.sleep(_CHAR_DELAY) + sys.stdout.write("\n") + sys.stdout.flush() + + +# --------------------------------------------------------------------------- +# Prompt replacements +# --------------------------------------------------------------------------- + + +def _fake_prompt_ask(prompt_markup: str, *, default: str = "", **_kw: object) -> str: + """Render the Rich-markup prompt, then simulate typing the next answer. + + ``None`` in the queue means "accept the default" – the default is + typed out (visible to the viewer) rather than silently accepted. + """ + suffix = f" [{default}]" if default else "" + _console.print(f"{prompt_markup}{suffix}: ", end="") + + raw = _PROMPT_ANSWERS.popleft() if _PROMPT_ANSWERS else None + answer = raw if raw is not None else default + _type_out(answer) + return answer + + +def _fake_confirm_ask( + prompt_markup: str, *, default: bool = True, **_kw: object +) -> bool: + """Render the confirm prompt, then simulate typing y or n.""" + yn_hint = "y" if default else "n" + _console.print(f"{prompt_markup} [y/n] ({yn_hint}): ", end="") + + val = _CONFIRM_ANSWERS.popleft() if _CONFIRM_ANSWERS else default + _type_out("y" if val else "n") + return val + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + if len(sys.argv) < 2: + sys.exit("Usage: interactive-add-helper.py ") + + url = sys.argv[1] + + # Force text-mode prompts so dfetch uses the numbered list + plain prompts + # instead of the raw-TTY tree browser. + import dfetch.terminal.keys as _keys + + _keys.is_tty = lambda: False # type: ignore[assignment] + + with patch("rich.prompt.Prompt.ask", side_effect=_fake_prompt_ask): + with patch("rich.prompt.Confirm.ask", side_effect=_fake_confirm_ask): + from dfetch.__main__ import run + + run(["add", "--interactive", url], _console) From 4da01cb1ad1498bbdd98fe2403fb99dfa8b48185 Mon Sep 17 00:00:00 2001 From: Ben Spoor Date: Sat, 28 Mar 2026 13:53:53 +0100 Subject: [PATCH 16/29] Perform add in gtihub actions --- .github/workflows/build.yml | 1 + .github/workflows/run.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c98f449a..1dd8b78d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -170,6 +170,7 @@ jobs: git commit -m "Initial commit" - run: dfetch init - run: dfetch environment + - run: dfetch add https://github.com/dfetch-org/test-repo - run: dfetch validate - run: dfetch check - run: dfetch update diff --git a/.github/workflows/run.yml b/.github/workflows/run.yml index 90391f44..d70c4ff3 100644 --- a/.github/workflows/run.yml +++ b/.github/workflows/run.yml @@ -39,6 +39,7 @@ jobs: - run: dfetch environment - run: dfetch validate + - run: dfetch add https://github.com/dfetch-org/test-repo - run: dfetch check - run: dfetch update - run: dfetch update From 15db96dbb77eca9982424ce48e88234dc9e2c5f2 Mon Sep 17 00:00:00 2001 From: Ben Spoor Date: Sat, 28 Mar 2026 14:30:56 +0100 Subject: [PATCH 17/29] Add default . path to add -i --- dfetch/commands/add.py | 10 ++- dfetch/terminal/tree_browser.py | 4 + features/add-project-through-cli.feature | 106 ++++++++++++++--------- features/steps/add_steps.py | 19 +++- 4 files changed, 93 insertions(+), 46 deletions(-) diff --git a/dfetch/commands/add.py b/dfetch/commands/add.py index 38d5d455..b4b84c78 100644 --- a/dfetch/commands/add.py +++ b/dfetch/commands/add.py @@ -88,16 +88,22 @@ def browse_tree(subproject: SubProject, version: str = "") -> Generator[LsFuncti Adapts the VCS-level ``(name, is_dir)`` tuples into :class:`~dfetch.terminal.Entry` objects so the terminal tree browser has no knowledge of VCS internals. + + Adds '.' as the first entry to allow selecting the repo root (which is + treated as empty src). """ if isinstance(subproject, (GitSubProject, SvnSubProject)): remote = subproject._remote_repo # pylint: disable=protected-access with remote.browse_tree(version) as vcs_ls: def ls(path: str = "") -> list[Entry]: - return [ + entries = [ Entry(display=name, has_children=is_dir) for name, is_dir in vcs_ls(path) ] + if not path: + return [Entry(display=".", has_children=True, value=".")] + entries + return entries yield ls else: @@ -281,7 +287,7 @@ def _build_entry( # pylint: disable=too-many-arguments dst=dst, ) entry_dict[kind] = value # type: ignore[literal-required] - if src: + if src and src != ".": entry_dict["src"] = src if ignore: entry_dict["ignore"] = ignore diff --git a/dfetch/terminal/tree_browser.py b/dfetch/terminal/tree_browser.py index 29acee23..9496e779 100644 --- a/dfetch/terminal/tree_browser.py +++ b/dfetch/terminal/tree_browser.py @@ -265,6 +265,10 @@ def run(self) -> list[str]: # pragma: no cover - interactive TTY only ) for entry in root_entries ] + + if len(self._nodes) == 1 and self._nodes[0].is_dir: + self._expand(0) + screen = Screen() while True: diff --git a/features/add-project-through-cli.feature b/features/add-project-through-cli.feature index 35e511ac..f8e0db68 100644 --- a/features/add-project-through-cli.feature +++ b/features/add-project-through-cli.feature @@ -78,14 +78,14 @@ Feature: Add a project to the manifest via the CLI url: some-remote-server/existing.git """ When I interactively add "some-remote-server/MyLib.git" with inputs - | prompt_contains | answer | - | Project name | my-lib | - | Destination path | libs/my | - | Version | master | - | Source path | | - | Ignore paths | | - | Add project to manifest? | y | - | Run update | n | + | Question | Answer | + | Project name | my-lib | + | Destination path | libs/my | + | Version | master | + | Source path | | + | Ignore paths | | + | Add project to manifest? | y | + | Run update | n | Then the manifest 'dfetch.yaml' contains entry """ - name: my-lib @@ -104,14 +104,14 @@ Feature: Add a project to the manifest via the CLI url: some-remote-server/existing.git """ When I interactively add "some-remote-server/MyLib.git" with inputs - | prompt_contains | answer | - | Project name | my-lib | - | Destination path | my-lib | - | Version | v1 | - | Source path | | - | Ignore paths | | - | Add project to manifest? | y | - | Run update | n | + | Question | Answer | + | Project name | my-lib | + | Destination path | my-lib | + | Version | v1 | + | Source path | | + | Ignore paths | | + | Add project to manifest? | y | + | Run update | n | Then the manifest 'dfetch.yaml' contains entry """ - name: my-lib @@ -129,14 +129,14 @@ Feature: Add a project to the manifest via the CLI url: some-remote-server/existing.git """ When I interactively add "some-remote-server/MyLib.git" with inputs - | prompt_contains | answer | - | Project name | my-lib | - | Destination path | my-lib | - | Version | master | - | Source path | docs/api | - | Ignore paths | | - | Add project to manifest? | y | - | Run update | n | + | Question | Answer | + | Project name | my-lib | + | Destination path | my-lib | + | Version | master | + | Source path | docs/api | + | Ignore paths | | + | Add project to manifest? | y | + | Run update | n | Then the manifest 'dfetch.yaml' contains entry """ - name: my-lib @@ -155,7 +155,7 @@ Feature: Add a project to the manifest via the CLI url: some-remote-server/existing.git """ When I interactively add "some-remote-server/MyLib.git" with inputs - | prompt_contains | answer | + | Question | Answer | | Project name | my-lib | | Destination path | my-lib | | Version | master | @@ -183,14 +183,14 @@ Feature: Add a project to the manifest via the CLI url: some-remote-server/existing.git """ When I interactively add "some-remote-server/MyLib.git" with inputs - | prompt_contains | answer | - | Project name | MyLib | - | Destination path | MyLib | - | Version | master | - | Source path | | - | Ignore paths | | - | Add project to manifest? | y | - | Run update | y | + | Question | Answer | + | Project name | MyLib | + | Destination path | MyLib | + | Version | master | + | Source path | | + | Ignore paths | | + | Add project to manifest? | y | + | Run update | y | Then the manifest 'dfetch.yaml' contains entry """ - name: MyLib @@ -209,13 +209,13 @@ Feature: Add a project to the manifest via the CLI url: some-remote-server/existing.git """ When I interactively add "some-remote-server/MyLib.git" with inputs - | prompt_contains | answer | - | Project name | MyLib | - | Destination path | MyLib | - | Version | master | - | Source path | | - | Ignore paths | | - | Add project to manifest? | n | + | Question | Answer | + | Project name | MyLib | + | Destination path | MyLib | + | Version | master | + | Source path | | + | Ignore paths | | + | Add project to manifest? | n | Then the manifest 'dfetch.yaml' is replaced with """ manifest: @@ -224,3 +224,29 @@ Feature: Add a project to the manifest via the CLI - name: existing url: some-remote-server/existing.git """ + + Scenario: Interactive add with empty src (repo root) does not add src field + Given the manifest 'dfetch.yaml' + """ + manifest: + version: '0.0' + projects: + - name: existing + url: some-remote-server/existing.git + """ + When I interactively add "some-remote-server/MyLib.git" with inputs + | Question | Answer | + | Project name | MyLib | + | Destination path | MyLib | + | Version | master | + | Source path | | + | Ignore paths | | + | Add project to manifest? | y | + | Run update | n | + Then the manifest 'dfetch.yaml' contains entry + """ + - name: MyLib + url: some-remote-server/MyLib.git + branch: master + """ + And the manifest 'dfetch.yaml' does not contain 'src:' diff --git a/features/steps/add_steps.py b/features/steps/add_steps.py index f647ad96..d57c55f5 100644 --- a/features/steps/add_steps.py +++ b/features/steps/add_steps.py @@ -42,11 +42,11 @@ def step_impl(context, remote_url): prompt_answers: deque[str] = deque() for row in context.table: - prompt = row["prompt_contains"] - answer = row["answer"] - if "Add project to manifest" in prompt: + question = row["Question"] + answer = row["Answer"] + if "Add project to manifest" in question: add_confirm = answer.lower() not in ("n", "no", "false") - elif "update" in prompt.lower() and "run" in prompt.lower(): + elif "update" in question.lower() and "run" in question.lower(): update_confirm = answer.lower() not in ("n", "no", "false") else: prompt_answers.append(answer) @@ -100,3 +100,14 @@ def step_impl(context, message): assert ( message in context.cmd_output ), f"Expected error message '{message}' not found in output:\n{context.cmd_output}" + + +@then("the manifest '{name}' does not contain '{text}'") +def step_impl(_, name, text): + with open(name, "r", encoding="utf-8") as fh: + actual = fh.read() + + if text in actual: + print("Actual manifest:") + print(actual) + assert False, f"Expected text '{text}' should not be in manifest" From 9a39bd353f1a6c04d7e8d1037c15d35581f8597e Mon Sep 17 00:00:00 2001 From: Ben Spoor Date: Sat, 28 Mar 2026 15:33:22 +0100 Subject: [PATCH 18/29] Use force flag in cli --- .github/workflows/build.yml | 2 +- .github/workflows/run.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1dd8b78d..510729b2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -170,7 +170,7 @@ jobs: git commit -m "Initial commit" - run: dfetch init - run: dfetch environment - - run: dfetch add https://github.com/dfetch-org/test-repo + - run: dfetch add -f https://github.com/dfetch-org/test-repo - run: dfetch validate - run: dfetch check - run: dfetch update diff --git a/.github/workflows/run.yml b/.github/workflows/run.yml index d70c4ff3..4eb825c8 100644 --- a/.github/workflows/run.yml +++ b/.github/workflows/run.yml @@ -39,7 +39,7 @@ jobs: - run: dfetch environment - run: dfetch validate - - run: dfetch add https://github.com/dfetch-org/test-repo + - run: dfetch add -f https://github.com/dfetch-org/test-repo - run: dfetch check - run: dfetch update - run: dfetch update From 715af019546d014a03c4f5abfd7336204ae161c8 Mon Sep 17 00:00:00 2001 From: Ben Spoor Date: Sun, 29 Mar 2026 01:39:43 +0100 Subject: [PATCH 19/29] Make coderabbit more assertive --- .coderabbit.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index b0b0550f..81ce66ea 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -3,7 +3,7 @@ language: "en-US" early_access: false reviews: - profile: "chill" + profile: "assertive" poem: false review_status: true collapse_walkthrough: false From 46a08e798e89b11341356d284b28fd1fd62fa4e9 Mon Sep 17 00:00:00 2001 From: Ben Spoor Date: Sun, 29 Mar 2026 01:58:04 +0100 Subject: [PATCH 20/29] Review comments --- dfetch/commands/add.py | 9 ++++++--- dfetch/log.py | 19 +++++++++++++------ dfetch/manifest/manifest.py | 5 ++--- dfetch/project/gitsubproject.py | 5 +++++ dfetch/project/svnsubproject.py | 5 +++++ dfetch/terminal/pick.py | 3 +++ dfetch/vcs/git.py | 4 ++-- doc/generate-casts/add-demo.sh | 5 +++-- doc/generate-casts/interactive-add-demo.sh | 5 +++-- doc/generate-casts/interactive_add_helper.py | 8 ++++++-- tests/test_add.py | 7 ++++++- tests/test_tree_browser.py | 4 +++- 12 files changed, 57 insertions(+), 22 deletions(-) diff --git a/dfetch/commands/add.py b/dfetch/commands/add.py index b4b84c78..30a702da 100644 --- a/dfetch/commands/add.py +++ b/dfetch/commands/add.py @@ -93,7 +93,7 @@ def browse_tree(subproject: SubProject, version: str = "") -> Generator[LsFuncti treated as empty src). """ if isinstance(subproject, (GitSubProject, SvnSubProject)): - remote = subproject._remote_repo # pylint: disable=protected-access + remote = subproject.remote_repo with remote.browse_tree(version) as vcs_ls: def ls(path: str = "") -> list[Entry]: @@ -102,7 +102,9 @@ def ls(path: str = "") -> list[Entry]: for name, is_dir in vcs_ls(path) ] if not path: - return [Entry(display=".", has_children=True, value=".")] + entries + # Prepend "." as a selectable leaf so Enter accepts the + # whole repo by default; no path normalization needed. + return [Entry(display=".", has_children=False)] + entries return entries yield ls @@ -445,7 +447,8 @@ def _ask_src(ls_function: LsFunction) -> str: Outside a TTY falls back to a free-text prompt. """ if terminal.is_tty(): - return tree_single_pick(ls_function, "Source path", dirs_selectable=True) + src = tree_single_pick(ls_function, "Source path", dirs_selectable=True) + return "" if src == "." else src return Prompt.ask( _PROMPT_FORMAT.format(label="Source path") diff --git a/dfetch/log.py b/dfetch/log.py index a8e4184f..340190bd 100644 --- a/dfetch/log.py +++ b/dfetch/log.py @@ -107,12 +107,17 @@ def print_overview(self, name: str, title: str, info: dict[str, Any]) -> None: """Print an overview of fields.""" self.print_info_line(name, title) for key, value in info.items(): + safe_key = markup_escape(str(key)) if isinstance(value, list): - self.info(f" [blue]{key + ':':20s}[/blue]") + self.info(f" [blue]{safe_key + ':':20s}[/blue]") for item in value: - self.info(f" {'':20s}[white]- {item}[/white]") + self.info( + f" {'':20s}[white]- {markup_escape(str(item))}[/white]" + ) else: - self.info(f" [blue]{key + ':':20s}[/blue][white] {value}[/white]") + self.info( + f" [blue]{safe_key + ':':20s}[/blue][white] {markup_escape(str(value))}[/white]" + ) def print_title(self) -> None: """Print the DFetch tool title and version.""" @@ -141,11 +146,13 @@ def print_yaml_field( """ prefix = " - " if first else " " if isinstance(value, list): - self.info(f"{prefix}[blue]{key}:[/blue]") + self.info(f"{prefix}[blue]{markup_escape(key)}:[/blue]") for item in value: - self.info(f" - {item}") + self.info(f" - {markup_escape(item)}") else: - self.info(f"{prefix}[blue]{key}:[/blue] {value}") + self.info( + f"{prefix}[blue]{markup_escape(key)}:[/blue] {markup_escape(value)}" + ) def warning(self, msg: object, *args: Any, **kwargs: Any) -> None: """Log warning.""" diff --git a/dfetch/manifest/manifest.py b/dfetch/manifest/manifest.py index 81b85956..57efca45 100644 --- a/dfetch/manifest/manifest.py +++ b/dfetch/manifest/manifest.py @@ -377,7 +377,7 @@ def find_name_in_manifest(self, name: str) -> ManifestEntryLocation: def check_name_uniqueness(self, project_name: str) -> None: """Raise if *project_name* is already used in the manifest.""" if project_name in {project.name for project in self.projects}: - raise RuntimeError( + raise ValueError( f"Project with name '{project_name}' already exists in manifest!" ) @@ -390,8 +390,7 @@ def validate_project_name(self, name: str) -> None: f"Name '{name}' contains characters not allowed in a manifest name. " "Avoid: # : [ ] { } & * ! | > ' \" % @ `" ) - if name in {p.name for p in self.projects}: - raise ValueError(f"Project with name '{name}' already exists in manifest!") + self.check_name_uniqueness(name) @staticmethod def validate_destination(dst: str) -> None: diff --git a/dfetch/project/gitsubproject.py b/dfetch/project/gitsubproject.py index c6430a24..5a6ace16 100644 --- a/dfetch/project/gitsubproject.py +++ b/dfetch/project/gitsubproject.py @@ -25,6 +25,11 @@ def __init__(self, project: ProjectEntry): super().__init__(project) self._remote_repo = GitRemote(self.remote) + @property + def remote_repo(self) -> GitRemote: + """Return the underlying remote repository object.""" + return self._remote_repo + def check(self) -> bool: """Check if is GIT.""" return bool(self._remote_repo.is_git()) diff --git a/dfetch/project/svnsubproject.py b/dfetch/project/svnsubproject.py index f9703407..e4942e82 100644 --- a/dfetch/project/svnsubproject.py +++ b/dfetch/project/svnsubproject.py @@ -30,6 +30,11 @@ def __init__(self, project: ProjectEntry): super().__init__(project) self._remote_repo = SvnRemote(self.remote) + @property + def remote_repo(self) -> SvnRemote: + """Return the underlying remote repository object.""" + return self._remote_repo + def check(self) -> bool: """Check if is SVN.""" return self._remote_repo.is_svn() diff --git a/dfetch/terminal/pick.py b/dfetch/terminal/pick.py index ba4f961b..5432a260 100644 --- a/dfetch/terminal/pick.py +++ b/dfetch/terminal/pick.py @@ -124,6 +124,9 @@ def scrollable_pick( idx = default_idx top = 0 n = len(display_items) + if n == 0: + screen.clear() + return [] if multi else None selected = _initial_selection(multi, all_selected, n, default_idx) while True: diff --git a/dfetch/vcs/git.py b/dfetch/vcs/git.py index 20ee0866..781241d2 100644 --- a/dfetch/vcs/git.py +++ b/dfetch/vcs/git.py @@ -237,8 +237,8 @@ def browse_tree( try: self.fetch_for_tree_browse(tmpdir, version or self.get_default_branch()) cloned = True - except Exception: # pylint: disable=broad-exception-caught # nosec B110 - pass + except Exception as e: # pylint: disable=broad-exception-caught + logger.debug("Failed to fetch remote tree for '%s': %s", self._remote, e) def ls(path: str = "") -> list[tuple[str, bool]]: return GitRemote.ls_tree(tmpdir, path=path) if cloned else [] diff --git a/doc/generate-casts/add-demo.sh b/doc/generate-casts/add-demo.sh index e67e2822..34069b23 100755 --- a/doc/generate-casts/add-demo.sh +++ b/doc/generate-casts/add-demo.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +set -euo pipefail source ./demo-magic/demo-magic.sh @@ -6,7 +7,7 @@ PROMPT_TIMEOUT=1 # Copy example manifest mkdir add -pushd add +pushd add || { echo 'pushd failed' >&2; exit 1; } dfetch init clear @@ -20,5 +21,5 @@ wait pei "" -popd +popd || { echo 'popd failed' >&2; exit 1; } rm -rf add diff --git a/doc/generate-casts/interactive-add-demo.sh b/doc/generate-casts/interactive-add-demo.sh index a1640166..36e2b3eb 100755 --- a/doc/generate-casts/interactive-add-demo.sh +++ b/doc/generate-casts/interactive-add-demo.sh @@ -1,4 +1,5 @@ #!/usr/bin/env bash +set -euo pipefail # Demo of dfetch add -i (interactive wizard mode). # # Uses the real cpputest repository so the viewer sees dfetch fetching live @@ -9,7 +10,7 @@ source ./demo-magic/demo-magic.sh PROMPT_TIMEOUT=1 mkdir interactive-add -pushd interactive-add +pushd interactive-add || { echo 'pushd failed' >&2; exit 1; } # Start with a manifest that already has one dependency so the demo shows # adding to an existing project rather than starting from scratch. @@ -36,5 +37,5 @@ wait pei "" -popd +popd || { echo 'popd failed' >&2; exit 1; } rm -rf interactive-add diff --git a/doc/generate-casts/interactive_add_helper.py b/doc/generate-casts/interactive_add_helper.py index 0dc5e8e4..fe2344e6 100644 --- a/doc/generate-casts/interactive_add_helper.py +++ b/doc/generate-casts/interactive_add_helper.py @@ -88,7 +88,9 @@ def _fake_prompt_ask(prompt_markup: str, *, default: str = "", **_kw: object) -> suffix = f" [{default}]" if default else "" _console.print(f"{prompt_markup}{suffix}: ", end="") - raw = _PROMPT_ANSWERS.popleft() if _PROMPT_ANSWERS else None + raw = ( + _PROMPT_ANSWERS.popleft() if _PROMPT_ANSWERS else None + ) # IndexError if queue is exhausted answer = raw if raw is not None else default _type_out(answer) return answer @@ -101,7 +103,9 @@ def _fake_confirm_ask( yn_hint = "y" if default else "n" _console.print(f"{prompt_markup} [y/n] ({yn_hint}): ", end="") - val = _CONFIRM_ANSWERS.popleft() if _CONFIRM_ANSWERS else default + val = ( + _CONFIRM_ANSWERS.popleft() if _CONFIRM_ANSWERS else default + ) # IndexError if queue is exhausted _type_out("y" if val else "n") return val diff --git a/tests/test_add.py b/tests/test_add.py index 16224a54..f464fa62 100644 --- a/tests/test_add.py +++ b/tests/test_add.py @@ -72,7 +72,7 @@ def _make_subproject( def test_check_name_uniqueness_raises_when_duplicate(): m = Mock() m.projects = [_make_project("foo"), _make_project("bar")] - with pytest.raises(RuntimeError, match="already exists"): + with pytest.raises(ValueError, match="already exists"): Manifest.check_name_uniqueness(m, "foo") @@ -740,6 +740,11 @@ def test_add_command_matches_existing_remote(): ) mock_append.assert_called_once() + entry: ProjectEntry = mock_append.call_args[0][1] + yaml_data = entry.as_yaml() + assert yaml_data.get("remote") == "github" + assert "org/myrepo" in yaml_data.get("repo-path", "") + assert "url" not in yaml_data # --------------------------------------------------------------------------- diff --git a/tests/test_tree_browser.py b/tests/test_tree_browser.py index 7ea252a7..8744a9f0 100644 --- a/tests/test_tree_browser.py +++ b/tests/test_tree_browser.py @@ -9,6 +9,8 @@ from __future__ import annotations +import os + from hypothesis import given, settings from hypothesis import strategies as st @@ -24,7 +26,7 @@ settings.register_profile("ci", max_examples=30, deadline=None) settings.register_profile("dev", max_examples=100, deadline=None) -settings.load_profile("dev") +settings.load_profile("ci" if os.getenv("CI") else "dev") # --------------------------------------------------------------------------- From d22f41999b9eab8c25eb01dec37020a11593c2f9 Mon Sep 17 00:00:00 2001 From: Ben Spoor Date: Sun, 29 Mar 2026 03:00:59 +0200 Subject: [PATCH 21/29] Handle keyboard interrupts gracefully --- dfetch/commands/add.py | 53 +++++++++++++++++++++++------------------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/dfetch/commands/add.py b/dfetch/commands/add.py index 30a702da..6dc0b647 100644 --- a/dfetch/commands/add.py +++ b/dfetch/commands/add.py @@ -185,31 +185,36 @@ def __call__(self, args: argparse.Namespace) -> None: guessed_dst = superproject.manifest.guess_destination(probe_entry.name) default_branch = subproject.get_default_branch() - if args.interactive: - project_entry = _interactive_flow( - remote_url=remote_url, - default_name=probe_entry.name, - default_dst=guessed_dst, - default_branch=default_branch, - subproject=subproject, - remote_to_use=remote_to_use, - manifest=superproject.manifest, - ) - else: - project_entry = _non_interactive_entry( - name=_unique_name(probe_entry.name, existing_names), - remote_url=remote_url, - branch=default_branch, - dst=guessed_dst, - remote_to_use=remote_to_use, + try: + if args.interactive: + project_entry = _interactive_flow( + remote_url=remote_url, + default_name=probe_entry.name, + default_dst=guessed_dst, + default_branch=default_branch, + subproject=subproject, + remote_to_use=remote_to_use, + manifest=superproject.manifest, + ) + else: + project_entry = _non_interactive_entry( + name=_unique_name(probe_entry.name, existing_names), + remote_url=remote_url, + branch=default_branch, + dst=guessed_dst, + remote_to_use=remote_to_use, + ) + logger.print_info_line(remote_url, "Adding project to manifest") + logger.print_yaml(project_entry.as_yaml()) + + if project_entry is None: + return + + _finalize_add(project_entry, args, superproject) + except KeyboardInterrupt: + logger.info( + " [bold bright_yellow]> Aborting add of project[/bold bright_yellow]" ) - logger.print_info_line(remote_url, "Adding project to manifest") - logger.print_yaml(project_entry.as_yaml()) - - if project_entry is None: - return - - _finalize_add(project_entry, args, superproject) # --------------------------------------------------------------------------- From 7040eeb076c535bde8ea71f52d9bdb8d059b9cd0 Mon Sep 17 00:00:00 2001 From: Ben Spoor Date: Sun, 29 Mar 2026 03:09:35 +0200 Subject: [PATCH 22/29] Remove -f flag from add --- .github/workflows/build.yml | 2 +- .github/workflows/run.yml | 2 +- dfetch/commands/add.py | 20 ++----- doc/generate-casts/add-demo.sh | 2 +- doc/manual.rst | 2 +- features/add-project-through-cli.feature | 7 +-- features/steps/add_steps.py | 6 -- tests/test_add.py | 72 ++++-------------------- 8 files changed, 21 insertions(+), 92 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 510729b2..1dd8b78d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -170,7 +170,7 @@ jobs: git commit -m "Initial commit" - run: dfetch init - run: dfetch environment - - run: dfetch add -f https://github.com/dfetch-org/test-repo + - run: dfetch add https://github.com/dfetch-org/test-repo - run: dfetch validate - run: dfetch check - run: dfetch update diff --git a/.github/workflows/run.yml b/.github/workflows/run.yml index 4eb825c8..d70c4ff3 100644 --- a/.github/workflows/run.yml +++ b/.github/workflows/run.yml @@ -39,7 +39,7 @@ jobs: - run: dfetch environment - run: dfetch validate - - run: dfetch add -f https://github.com/dfetch-org/test-repo + - run: dfetch add https://github.com/dfetch-org/test-repo - run: dfetch check - run: dfetch update - run: dfetch update diff --git a/dfetch/commands/add.py b/dfetch/commands/add.py index 6dc0b647..e1f7989b 100644 --- a/dfetch/commands/add.py +++ b/dfetch/commands/add.py @@ -12,11 +12,7 @@ Dfetch fetches remote metadata (branches, tags), picks the default branch, guesses a destination path from your existing projects, shows a preview, and -appends the entry to ``dfetch.yaml`` after a single confirmation prompt. - -Skip the confirmation with ``--force``:: - - dfetch add -f https://github.com/some-org/some-repo.git +appends the entry to ``dfetch.yaml`` immediately without prompting. Interactive mode ---------------- @@ -141,13 +137,6 @@ def create_menu(subparsers: dfetch.commands.command.SubparserActionType) -> None help="Remote URL of the repository to add.", ) - parser.add_argument( - "-f", - "--force", - action="store_true", - help="Skip the confirmation prompt.", - ) - parser.add_argument( "-i", "--interactive", @@ -228,7 +217,7 @@ def _finalize_add( superproject: SuperProject, ) -> None: """Write *project_entry* to the manifest and optionally run update.""" - if not args.force and not Confirm.ask("Add project to manifest?", default=True): + if args.interactive and not Confirm.ask("Add project to manifest?", default=True): logger.info( " [bold bright_yellow]> Aborting add of project[/bold bright_yellow]" ) @@ -243,9 +232,8 @@ def _finalize_add( f"Added '{project_entry.name}' to manifest '{superproject.manifest.path}'", ) - # Offer to run update immediately (only when we already prompted the user, - # i.e. not in --force mode where we want zero interaction). - if not args.force and Confirm.ask( + # Offer to run update immediately only in interactive mode. + if args.interactive and Confirm.ask( f"Run 'dfetch update {project_entry.name}' now?", default=True ): # pylint: disable=import-outside-toplevel diff --git a/doc/generate-casts/add-demo.sh b/doc/generate-casts/add-demo.sh index 34069b23..f00479cc 100755 --- a/doc/generate-casts/add-demo.sh +++ b/doc/generate-casts/add-demo.sh @@ -13,7 +13,7 @@ clear # Run the command pe "cat dfetch.yaml" -pe "dfetch add -f https://github.com/dfetch-org/dfetch.git" +pe "dfetch add https://github.com/dfetch-org/dfetch.git" pe "cat dfetch.yaml" PROMPT_TIMEOUT=3 diff --git a/doc/manual.rst b/doc/manual.rst index 5e447e0c..0f5b3c48 100644 --- a/doc/manual.rst +++ b/doc/manual.rst @@ -209,7 +209,7 @@ Also called vendoring. More info: ` + dfetch add - Generate a manifest from existing git submodules or svn externals: diff --git a/features/add-project-through-cli.feature b/features/add-project-through-cli.feature index f8e0db68..05cd3036 100644 --- a/features/add-project-through-cli.feature +++ b/features/add-project-through-cli.feature @@ -5,7 +5,6 @@ Feature: Add a project to the manifest via the CLI fills in sensible defaults (name, destination, default branch), shows a preview, and appends the entry to ``dfetch.yaml`` after confirmation. - Pass ``--force`` / ``-f`` to skip the confirmation prompt. Pass ``--interactive`` / ``-i`` to be guided step-by-step through every manifest field (name, destination, branch/tag/revision, optional src, optional ignore list). @@ -22,7 +21,7 @@ Feature: Add a project to the manifest via the CLI - name: ext/existing url: some-remote-server/existing.git """ - When I add "some-remote-server/MyLib.git" with force + When I add "some-remote-server/MyLib.git" Then the manifest 'dfetch.yaml' contains entry """ - name: MyLib @@ -40,7 +39,7 @@ Feature: Add a project to the manifest via the CLI - name: MyLib url: some-remote-server/MyLib.git """ - When I add "some-remote-server/MyLib.git" with force + When I add "some-remote-server/MyLib.git" Then the manifest 'dfetch.yaml' contains entry """ - name: MyLib-1 @@ -59,7 +58,7 @@ Feature: Add a project to the manifest via the CLI - name: ext/lib-b url: some-remote-server/lib-b.git """ - When I add "some-remote-server/MyLib.git" with force + When I add "some-remote-server/MyLib.git" Then the manifest 'dfetch.yaml' contains entry """ - name: MyLib diff --git a/features/steps/add_steps.py b/features/steps/add_steps.py index d57c55f5..666a7fcd 100644 --- a/features/steps/add_steps.py +++ b/features/steps/add_steps.py @@ -17,12 +17,6 @@ def _resolve_url(url: str, context) -> str: return url.replace("some-remote-server", f"file:///{remote_server_path(context)}") -@when('I add "{remote_url}" with force') -def step_impl(context, remote_url): - url = _resolve_url(remote_url, context) - call_command(context, ["add", "--force", url]) - - @when('I add "{remote_url}"') def step_impl(context, remote_url): url = _resolve_url(remote_url, context) diff --git a/tests/test_add.py b/tests/test_add.py index f464fa62..ec860e73 100644 --- a/tests/test_add.py +++ b/tests/test_add.py @@ -36,12 +36,10 @@ def _make_remote(name: str, url: str) -> Mock: def _make_args( remote_url: str, - force: bool = False, interactive: bool = False, ) -> argparse.Namespace: return argparse.Namespace( remote_url=[remote_url], - force=force, interactive=interactive, ) @@ -143,12 +141,12 @@ def test_determine_remote_returns_none_for_empty_remotes(): # --------------------------------------------------------------------------- -# Add command – non-interactive (force) +# Add command – non-interactive # --------------------------------------------------------------------------- -def test_add_command_force_appends_entry(): - """With --force the entry is appended without any prompts.""" +def test_add_command_non_interactive_appends_entry(): + """Non-interactive add appends the entry to the manifest without any prompts.""" fake_superproject = Mock() fake_superproject.manifest = mock_manifest( [{"name": "ext/existing"}], path="/some/dfetch.yaml" @@ -165,7 +163,7 @@ def test_add_command_force_appends_entry(): "dfetch.commands.add.create_sub_project", return_value=fake_subproject ): with patch("dfetch.commands.add.append_entry_manifest_file") as mock_append: - Add()(_make_args("https://github.com/org/myrepo.git", force=True)) + Add()(_make_args("https://github.com/org/myrepo.git")) mock_append.assert_called_once() entry: ProjectEntry = mock_append.call_args[0][1] @@ -173,55 +171,6 @@ def test_add_command_force_appends_entry(): assert entry.branch == "main" -def test_add_command_user_confirms(): - """Without --force the user is prompted; confirming proceeds and update is declined.""" - fake_superproject = Mock() - fake_superproject.manifest = mock_manifest([], path="/some/dfetch.yaml") - fake_superproject.manifest.remotes = [] - fake_superproject.root_directory = Path("/some") - - fake_subproject = _make_subproject() - - # First Confirm.ask → True (add), second → False (don't run update) - with patch( - "dfetch.commands.add.create_super_project", return_value=fake_superproject - ): - with patch( - "dfetch.commands.add.create_sub_project", return_value=fake_subproject - ): - with patch("dfetch.commands.add.Confirm.ask", side_effect=[True, False]): - with patch( - "dfetch.commands.add.append_entry_manifest_file" - ) as mock_append: - Add()(_make_args("https://github.com/org/myrepo.git")) - - mock_append.assert_called_once() - - -def test_add_command_user_aborts(): - """Without --force the user can abort; no manifest modification.""" - fake_superproject = Mock() - fake_superproject.manifest = mock_manifest([], path="/some/dfetch.yaml") - fake_superproject.manifest.remotes = [] - fake_superproject.root_directory = Path("/some") - - fake_subproject = _make_subproject() - - with patch( - "dfetch.commands.add.create_super_project", return_value=fake_superproject - ): - with patch( - "dfetch.commands.add.create_sub_project", return_value=fake_subproject - ): - with patch("dfetch.commands.add.Confirm.ask", return_value=False): - with patch( - "dfetch.commands.add.append_entry_manifest_file" - ) as mock_append: - Add()(_make_args("https://github.com/org/myrepo.git")) - - mock_append.assert_not_called() - - def test_add_command_suffixes_duplicate_name(tmp_path): """Non-interactive add with a clashing name must append a numbered suffix.""" manifest_file = tmp_path / "dfetch.yaml" @@ -242,7 +191,7 @@ def test_add_command_suffixes_duplicate_name(tmp_path): with patch( "dfetch.commands.add.create_sub_project", return_value=fake_subproject ): - Add()(_make_args("https://github.com/org/myrepo.git", force=True)) + Add()(_make_args("https://github.com/org/myrepo.git")) assert "myrepo-1" in manifest_file.read_text() @@ -684,8 +633,8 @@ def test_add_command_interactive_svn_branch_by_number(): assert entry.branch == "feature-x" -def test_add_command_interactive_svn_force(): - """SVN non-interactive (--force) add defaults to trunk.""" +def test_add_command_non_interactive_svn(): + """SVN non-interactive add defaults to trunk.""" fake_superproject = Mock() fake_superproject.manifest = mock_manifest([], path="/some/dfetch.yaml") fake_superproject.manifest.remotes = [] @@ -700,7 +649,7 @@ def test_add_command_interactive_svn_force(): "dfetch.commands.add.create_sub_project", return_value=fake_subproject ): with patch("dfetch.commands.add.append_entry_manifest_file") as mock_append: - Add()(_make_args(_SVN_URL, force=True)) + Add()(_make_args(_SVN_URL)) mock_append.assert_called_once() entry: ProjectEntry = mock_append.call_args[0][1] @@ -735,7 +684,6 @@ def test_add_command_matches_existing_remote(): Add()( _make_args( "https://github.com/org/myrepo.git", - force=True, ) ) @@ -758,9 +706,9 @@ def test_add_create_menu(): parser = argparse.ArgumentParser() subparsers = parser.add_subparsers() Add.create_menu(subparsers) - parsed = parser.parse_args(["add", "-f", "https://example.com/repo.git"]) - assert parsed.force is True + parsed = parser.parse_args(["add", "https://example.com/repo.git"]) assert parsed.remote_url == ["https://example.com/repo.git"] + assert not hasattr(parsed, "force") def test_add_create_menu_interactive_flag(): From 8b7eee623501eec0fd5f95d402c96f14579143fc Mon Sep 17 00:00:00 2001 From: Ben Spoor Date: Sun, 29 Mar 2026 03:21:42 +0200 Subject: [PATCH 23/29] Add cli overrides --- dfetch/commands/add.py | 222 ++++++++++++++++------- features/add-project-through-cli.feature | 43 +++++ features/steps/add_steps.py | 22 ++- tests/test_add.py | 177 ++++++++++++++++++ 4 files changed, 396 insertions(+), 68 deletions(-) diff --git a/dfetch/commands/add.py b/dfetch/commands/add.py index e1f7989b..4a166a68 100644 --- a/dfetch/commands/add.py +++ b/dfetch/commands/add.py @@ -14,12 +14,20 @@ guesses a destination path from your existing projects, shows a preview, and appends the entry to ``dfetch.yaml`` immediately without prompting. +Override any field with explicit flags:: + + dfetch add --name mylib --dst ext/mylib --version v2.0 --src lib https://github.com/some-org/some-repo.git + Interactive mode ---------------- Use ``--interactive`` (``-i``) for a guided, step-by-step wizard:: dfetch add -i https://github.com/some-org/some-repo.git +Pre-fill individual fields to skip specific prompts:: + + dfetch add -i --version main --src lib/core https://github.com/some-org/some-repo.git + The wizard walks through: * **name** - defaults to the repository name extracted from the URL @@ -41,6 +49,7 @@ import argparse import contextlib +import dataclasses from collections.abc import Generator from rich.prompt import Confirm, Prompt @@ -78,6 +87,30 @@ logger = get_logger(__name__) +@dataclasses.dataclass +class _AddContext: + """Remote metadata and manifest state gathered before running any flow.""" + + url: str + default_name: str + default_dst: str + default_branch: str + subproject: SubProject + remote_to_use: Remote | None + manifest: Manifest + + +@dataclasses.dataclass +class _Overrides: + """Fields explicitly supplied on the command line (``None`` = not given).""" + + name: str | None = None + dst: str | None = None + version: str | None = None + src: str | None = None + ignore: list[str] | None = None + + @contextlib.contextmanager def browse_tree(subproject: SubProject, version: str = "") -> Generator[LsFunction]: """Yield an ``LsFunction`` for interactively browsing *subproject*'s remote tree. @@ -148,6 +181,42 @@ def create_menu(subparsers: dfetch.commands.command.SubparserActionType) -> None ), ) + parser.add_argument( + "--name", + metavar="NAME", + default=None, + help="Project name (skips the name prompt in interactive mode).", + ) + + parser.add_argument( + "--dst", + metavar="PATH", + default=None, + help="Local destination path (skips the destination prompt in interactive mode).", + ) + + parser.add_argument( + "--version", + metavar="VERSION", + default=None, + help="Branch, tag, or revision (skips the version prompt in interactive mode).", + ) + + parser.add_argument( + "--src", + metavar="PATH", + default=None, + help="Sub-path or glob inside the remote repo (skips the source prompt in interactive mode).", + ) + + parser.add_argument( + "--ignore", + metavar="PATH", + nargs="+", + default=None, + help="Paths to ignore (skips the ignore prompt in interactive mode).", + ) + def __call__(self, args: argparse.Namespace) -> None: """Perform the add.""" superproject = create_super_project() @@ -155,14 +224,9 @@ def __call__(self, args: argparse.Namespace) -> None: remote_url: str = args.remote_url[0] purl = vcs_url_to_purl(remote_url) - # Build a minimal entry so we can probe the remote. probe_entry = ProjectEntry(ProjectEntryDict(name=purl.name, url=remote_url)) - - # Determines VCS type; tries to reach the remote. subproject = create_sub_project(probe_entry) - existing_names = {p.name for p in superproject.manifest.projects} - remote_to_use = superproject.manifest.find_remote_for_url( probe_entry.remote_url ) @@ -171,29 +235,29 @@ def __call__(self, args: argparse.Namespace) -> None: f"Remote URL {probe_entry.remote_url} matches remote {remote_to_use.name}" ) - guessed_dst = superproject.manifest.guess_destination(probe_entry.name) - default_branch = subproject.get_default_branch() + ctx = _AddContext( + url=remote_url, + default_name=probe_entry.name, + default_dst=superproject.manifest.guess_destination(probe_entry.name), + default_branch=subproject.get_default_branch(), + subproject=subproject, + remote_to_use=remote_to_use, + manifest=superproject.manifest, + ) + overrides = _Overrides( + name=args.name, + dst=args.dst, + version=args.version, + src=args.src, + ignore=args.ignore, + ) try: if args.interactive: - project_entry = _interactive_flow( - remote_url=remote_url, - default_name=probe_entry.name, - default_dst=guessed_dst, - default_branch=default_branch, - subproject=subproject, - remote_to_use=remote_to_use, - manifest=superproject.manifest, - ) + project_entry = _interactive_flow(ctx, overrides) else: - project_entry = _non_interactive_entry( - name=_unique_name(probe_entry.name, existing_names), - remote_url=remote_url, - branch=default_branch, - dst=guessed_dst, - remote_to_use=remote_to_use, - ) - logger.print_info_line(remote_url, "Adding project to manifest") + project_entry = _non_interactive_entry(ctx, overrides) + logger.print_info_line(ctx.url, "Adding project to manifest") logger.print_yaml(project_entry.as_yaml()) if project_entry is None: @@ -247,21 +311,24 @@ def _finalize_add( Update()(update_args) -def _non_interactive_entry( - *, - name: str, - remote_url: str, - branch: str, - dst: str, - remote_to_use: Remote | None, -) -> ProjectEntry: +def _non_interactive_entry(ctx: _AddContext, overrides: _Overrides) -> ProjectEntry: """Build a ``ProjectEntry`` using inferred defaults (no user interaction).""" - entry = ProjectEntry( - ProjectEntryDict(name=name, url=remote_url, branch=branch, dst=dst) + version = ( + _resolve_raw_version(overrides.version, []) + or Version(branch=ctx.default_branch) + if overrides.version + else Version(branch=ctx.default_branch) + ) + existing_names = {p.name for p in ctx.manifest.projects} + return _build_entry( + name=overrides.name or _unique_name(ctx.default_name, existing_names), + remote_url=ctx.url, + dst=overrides.dst or ctx.default_dst, + version=version, + src=overrides.src or "", + ignore=overrides.ignore or [], + remote_to_use=ctx.remote_to_use, ) - if remote_to_use: - entry.set_remote(remote_to_use) - return entry def _build_entry( # pylint: disable=too-many-arguments @@ -315,49 +382,74 @@ def _show_url_fields( ) -def _interactive_flow( # pylint: disable=too-many-arguments,too-many-positional-arguments - remote_url: str, - default_name: str, - default_dst: str, - default_branch: str, - subproject: SubProject, - remote_to_use: Remote | None, - manifest: Manifest, -) -> ProjectEntry: - """Guide the user through every manifest field and return a ``ProjectEntry``.""" - logger.print_info_line(remote_url, "Adding project through interactive wizard") +def _pick_src_and_ignore( + subproject: SubProject, version_value: str, overrides: _Overrides +) -> tuple[str, list[str]]: + """Browse the remote tree (if needed) and return ``(src, ignore)``.""" + with browse_tree(subproject, version_value) as ls_function: + src = overrides.src if overrides.src is not None else _ask_src(ls_function) + if src: + logger.print_yaml_field("src", src) + ignore = ( + overrides.ignore + if overrides.ignore is not None + else _ask_ignore(ls_function, src=src) + ) + if ignore: + logger.print_yaml_field("ignore", ignore) + return src, ignore - name = _ask_name(default_name, manifest) - _show_url_fields(name, remote_url, default_branch, remote_to_use) - dst = _ask_dst(name, default_dst) +def _interactive_flow(ctx: _AddContext, overrides: _Overrides) -> ProjectEntry: + """Guide the user through every manifest field and return a ``ProjectEntry``. + + A field in *overrides* that is not ``None`` skips the corresponding prompt. + """ + logger.print_info_line(ctx.url, "Adding project through interactive wizard") + + if overrides.name is not None: + ctx.manifest.validate_project_name(overrides.name) + name = overrides.name + else: + name = _ask_name(ctx.default_name, ctx.manifest) + _show_url_fields(name, ctx.url, ctx.default_branch, ctx.remote_to_use) + + if overrides.dst is not None: + Manifest.validate_destination(overrides.dst) + dst = overrides.dst + else: + dst = _ask_dst(name, ctx.default_dst) if dst != name: logger.print_yaml_field("dst", dst) - version = _ask_version( - default_branch, - subproject.list_of_branches(), - subproject.list_of_tags(), - ) + branches = ctx.subproject.list_of_branches() + tags = ctx.subproject.list_of_tags() + if overrides.version is not None: + choices: list[Version] = [ + *[ + Version(branch=b) + for b in prioritise_default(branches, ctx.default_branch) + ], + *[Version(tag=t) for t in sort_tags_newest_first(tags)], + ] + version = _resolve_raw_version(overrides.version, choices) or Version( + branch=ctx.default_branch + ) + else: + version = _ask_version(ctx.default_branch, branches, tags) version_kind, version_value = version.field logger.print_yaml_field(version_kind, version_value) - with browse_tree(subproject, version_value) as ls_function: - src = _ask_src(ls_function) - if src: - logger.print_yaml_field("src", src) - ignore = _ask_ignore(ls_function, src=src) - if ignore: - logger.print_yaml_field("ignore", ignore) + src, ignore = _pick_src_and_ignore(ctx.subproject, version_value, overrides) return _build_entry( name=name, - remote_url=remote_url, + remote_url=ctx.url, dst=dst, version=version, src=src, ignore=ignore, - remote_to_use=remote_to_use, + remote_to_use=ctx.remote_to_use, ) diff --git a/features/add-project-through-cli.feature b/features/add-project-through-cli.feature index 05cd3036..5023febb 100644 --- a/features/add-project-through-cli.feature +++ b/features/add-project-through-cli.feature @@ -9,6 +9,9 @@ Feature: Add a project to the manifest via the CLI manifest field (name, destination, branch/tag/revision, optional src, optional ignore list). + Use ``--name``, ``--dst``, ``--version``, ``--src``, ``--ignore`` to + pre-fill individual fields (works with and without ``-i``). + Background: Given a git repository "MyLib.git" @@ -249,3 +252,43 @@ Feature: Add a project to the manifest via the CLI branch: master """ And the manifest 'dfetch.yaml' does not contain 'src:' + + Scenario: Non-interactive add with field overrides + Given the manifest 'dfetch.yaml' + """ + manifest: + version: '0.0' + projects: [] + """ + When I add "some-remote-server/MyLib.git" with options "--name my-lib --dst libs/my-lib" + Then the manifest 'dfetch.yaml' contains entry + """ + - name: my-lib + url: some-remote-server/MyLib.git + branch: master + dst: libs/my-lib + """ + + Scenario: Interactive add with pre-filled fields skips those prompts + Given the manifest 'dfetch.yaml' + """ + manifest: + version: '0.0' + projects: + - name: existing + url: some-remote-server/existing.git + """ + When I interactively add "some-remote-server/MyLib.git" with options "--name my-lib --dst libs/my" and inputs + | Question | Answer | + | Version | master | + | Source path | | + | Ignore paths | | + | Add project to manifest? | y | + | Run update | n | + Then the manifest 'dfetch.yaml' contains entry + """ + - name: my-lib + url: some-remote-server/MyLib.git + branch: master + dst: libs/my + """ diff --git a/features/steps/add_steps.py b/features/steps/add_steps.py index 666a7fcd..47d78f11 100644 --- a/features/steps/add_steps.py +++ b/features/steps/add_steps.py @@ -23,10 +23,14 @@ def step_impl(context, remote_url): call_command(context, ["add", url]) -@when('I interactively add "{remote_url}" with inputs') -def step_impl(context, remote_url): +@when('I add "{remote_url}" with options "{options}"') +def step_impl(context, remote_url, options): url = _resolve_url(remote_url, context) + call_command(context, ["add"] + options.split() + [url]) + +def _run_interactive_add(context, cmd: list[str]) -> None: + """Run an interactive add command, driving prompts from ``context.table``.""" # Parse the answer table into three buckets: # • "Add project to manifest?" → add_confirm (bool) # • "Run" + "update" in prompt → update_confirm (bool, default False) @@ -63,7 +67,19 @@ def _auto_prompt(_prompt: str, **kwargs) -> str: # type: ignore[return] with patch("dfetch.commands.add.Prompt.ask", side_effect=_auto_prompt): with patch("dfetch.commands.add.Confirm.ask", side_effect=_auto_confirm): - call_command(context, ["add", "--interactive", url]) + call_command(context, cmd) + + +@when('I interactively add "{remote_url}" with options "{options}" and inputs') +def step_impl(context, remote_url, options): + url = _resolve_url(remote_url, context) + _run_interactive_add(context, ["add", "--interactive"] + options.split() + [url]) + + +@when('I interactively add "{remote_url}" with inputs') +def step_impl(context, remote_url): + url = _resolve_url(remote_url, context) + _run_interactive_add(context, ["add", "--interactive", url]) @then("the manifest '{name}' contains entry") diff --git a/tests/test_add.py b/tests/test_add.py index ec860e73..9090e773 100644 --- a/tests/test_add.py +++ b/tests/test_add.py @@ -37,10 +37,20 @@ def _make_remote(name: str, url: str) -> Mock: def _make_args( remote_url: str, interactive: bool = False, + name: str | None = None, + dst: str | None = None, + version: str | None = None, + src: str | None = None, + ignore: list[str] | None = None, ) -> argparse.Namespace: return argparse.Namespace( remote_url=[remote_url], interactive=interactive, + name=name, + dst=dst, + version=version, + src=src, + ignore=ignore, ) @@ -171,6 +181,42 @@ def test_add_command_non_interactive_appends_entry(): assert entry.branch == "main" +def test_add_command_non_interactive_field_overrides(): + """CLI field overrides are used directly in non-interactive mode.""" + fake_superproject = Mock() + fake_superproject.manifest = mock_manifest([], path="/some/dfetch.yaml") + fake_superproject.manifest.remotes = [] + fake_superproject.root_directory = Path("/some") + + fake_subproject = _make_subproject("main", ["main", "dev"], ["v1.0"]) + + with patch( + "dfetch.commands.add.create_super_project", return_value=fake_superproject + ): + with patch( + "dfetch.commands.add.create_sub_project", return_value=fake_subproject + ): + with patch("dfetch.commands.add.append_entry_manifest_file") as mock_append: + Add()( + _make_args( + "https://github.com/org/myrepo.git", + name="custom-name", + dst="ext/custom", + version="v1.0", + src="lib", + ignore=["docs", "tests"], + ) + ) + + mock_append.assert_called_once() + entry: ProjectEntry = mock_append.call_args[0][1] + assert entry.name == "custom-name" + assert entry.destination == "ext/custom" + assert entry.tag == "v1.0" + assert entry.source == "lib" + assert entry.ignore == ["docs", "tests"] + + def test_add_command_suffixes_duplicate_name(tmp_path): """Non-interactive add with a clashing name must append a numbered suffix.""" manifest_file = tmp_path / "dfetch.yaml" @@ -474,6 +520,102 @@ def test_add_command_interactive_run_update(): mock_update.assert_called_once() +# --------------------------------------------------------------------------- +# Add command – interactive mode with CLI overrides +# --------------------------------------------------------------------------- + + +def test_add_command_interactive_with_overrides(): + """CLI overrides skip their corresponding interactive prompts.""" + fake_superproject = Mock() + fake_superproject.manifest = mock_manifest([], path="/some/dfetch.yaml") + fake_superproject.manifest.remotes = [] + fake_superproject.root_directory = Path("/some") + + fake_subproject = _make_subproject("main", ["main", "dev"], ["v2.0"]) + + # Only src and ignore prompts should be reached; name, dst, version are overridden. + prompt_answers = iter(["", ""]) # src="", ignore="" + + with patch( + "dfetch.commands.add.create_super_project", return_value=fake_superproject + ): + with patch( + "dfetch.commands.add.create_sub_project", return_value=fake_subproject + ): + with patch( + "dfetch.commands.add.Prompt.ask", + side_effect=lambda *a, **kw: next(prompt_answers), + ): + with patch( + "dfetch.commands.add.Confirm.ask", side_effect=[True, False] + ): + with patch( + "dfetch.commands.add.append_entry_manifest_file" + ) as mock_append: + Add()( + _make_args( + "https://github.com/org/myrepo.git", + interactive=True, + name="overridden-name", + dst="ext/overridden", + version="dev", + ) + ) + + mock_append.assert_called_once() + entry: ProjectEntry = mock_append.call_args[0][1] + assert entry.name == "overridden-name" + assert entry.destination == "ext/overridden" + assert entry.branch == "dev" + + +def test_add_command_interactive_with_all_overrides(): + """All CLI overrides skip all interactive prompts.""" + fake_superproject = Mock() + fake_superproject.manifest = mock_manifest([], path="/some/dfetch.yaml") + fake_superproject.manifest.remotes = [] + fake_superproject.root_directory = Path("/some") + + fake_subproject = _make_subproject("main", ["main"], ["v2.0"]) + + with patch( + "dfetch.commands.add.create_super_project", return_value=fake_superproject + ): + with patch( + "dfetch.commands.add.create_sub_project", return_value=fake_subproject + ): + with patch( + "dfetch.commands.add.Prompt.ask", + ) as mock_prompt: + with patch( + "dfetch.commands.add.Confirm.ask", side_effect=[True, False] + ): + with patch( + "dfetch.commands.add.append_entry_manifest_file" + ) as mock_append: + Add()( + _make_args( + "https://github.com/org/myrepo.git", + interactive=True, + name="mylib", + dst="ext/mylib", + version="v2.0", + src="lib/core", + ignore=["docs"], + ) + ) + + mock_prompt.assert_not_called() + mock_append.assert_called_once() + entry: ProjectEntry = mock_append.call_args[0][1] + assert entry.name == "mylib" + assert entry.destination == "ext/mylib" + assert entry.tag == "v2.0" + assert entry.source == "lib/core" + assert entry.ignore == ["docs"] + + # --------------------------------------------------------------------------- # Add command – interactive mode (SVN) # --------------------------------------------------------------------------- @@ -709,6 +851,41 @@ def test_add_create_menu(): parsed = parser.parse_args(["add", "https://example.com/repo.git"]) assert parsed.remote_url == ["https://example.com/repo.git"] assert not hasattr(parsed, "force") + assert parsed.name is None + assert parsed.dst is None + assert parsed.version is None + assert parsed.src is None + assert parsed.ignore is None + + +def test_add_create_menu_field_overrides(): + import argparse + + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers() + Add.create_menu(subparsers) + parsed = parser.parse_args( + [ + "add", + "--name", + "mylib", + "--dst", + "ext/mylib", + "--version", + "v2.0", + "--src", + "lib", + "--ignore", + "docs", + "tests", + "https://example.com/repo.git", + ] + ) + assert parsed.name == "mylib" + assert parsed.dst == "ext/mylib" + assert parsed.version == "v2.0" + assert parsed.src == "lib" + assert parsed.ignore == ["docs", "tests"] def test_add_create_menu_interactive_flag(): From 392886a05397e269b9e053fbec03d256a2dcf560 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 29 Mar 2026 03:28:58 +0200 Subject: [PATCH 24/29] Update .github/workflows/run.yml Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .github/workflows/run.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/run.yml b/.github/workflows/run.yml index d70c4ff3..0b8bf2c8 100644 --- a/.github/workflows/run.yml +++ b/.github/workflows/run.yml @@ -38,8 +38,9 @@ jobs: run: pip install . - run: dfetch environment - - run: dfetch validate + - run: dfetch environment - run: dfetch add https://github.com/dfetch-org/test-repo + - run: dfetch validate - run: dfetch check - run: dfetch update - run: dfetch update From 06c8b42d4b04f81e748cbef93339eff275abce4a Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 29 Mar 2026 03:29:58 +0200 Subject: [PATCH 25/29] Update dfetch/manifest/manifest.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- dfetch/manifest/manifest.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dfetch/manifest/manifest.py b/dfetch/manifest/manifest.py index 57efca45..043ea417 100644 --- a/dfetch/manifest/manifest.py +++ b/dfetch/manifest/manifest.py @@ -411,7 +411,10 @@ def guess_destination(self, project_name: str) -> str: if not destinations: return "" - common_path = os.path.commonpath(destinations) + try: + common_path = os.path.commonpath(destinations) + except ValueError: + return "" if not common_path or common_path == os.path.sep: return "" From 61e7106dbe277ea0f159018fc4f62ca1d4ae3475 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 29 Mar 2026 08:44:13 +0000 Subject: [PATCH 26/29] Review comments --- .github/workflows/build.yml | 10 +++++----- .github/workflows/run.yml | 1 + dfetch/commands/add.py | 12 ++++++------ dfetch/manifest/manifest.py | 4 +++- doc/generate-casts/interactive-add-demo.sh | 4 +++- 5 files changed, 18 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1dd8b78d..452cba59 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -178,14 +178,14 @@ jobs: - run: | git add -A git commit -m "Fetched dependencies" - echo "An extra line" >> jsmn/README.md - git add jsmn/README.md + echo "An extra line" >> test-repo/README.md + git add test-repo/README.md git commit -m "Update README.md" - - run: dfetch diff jsmn + - run: dfetch diff test-repo - run: | - echo " patch: jsmn.patch" >> dfetch.yaml + echo " patch: test-repo.patch" >> dfetch.yaml git add -A - git commit -m "Patch jsmn" + git commit -m "Patch test-repo" - run: dfetch update-patch - run: dfetch format-patch - run: dfetch report -t sbom diff --git a/.github/workflows/run.yml b/.github/workflows/run.yml index 0b8bf2c8..3e4e69d3 100644 --- a/.github/workflows/run.yml +++ b/.github/workflows/run.yml @@ -113,6 +113,7 @@ jobs: run: pip install . - run: dfetch environment + - run: dfetch add https://github.com/dfetch-org/test-repo - run: dfetch validate - run: dfetch check - run: dfetch update diff --git a/dfetch/commands/add.py b/dfetch/commands/add.py index 4a166a68..7b83aee9 100644 --- a/dfetch/commands/add.py +++ b/dfetch/commands/add.py @@ -313,12 +313,12 @@ def _finalize_add( def _non_interactive_entry(ctx: _AddContext, overrides: _Overrides) -> ProjectEntry: """Build a ``ProjectEntry`` using inferred defaults (no user interaction).""" - version = ( - _resolve_raw_version(overrides.version, []) - or Version(branch=ctx.default_branch) - if overrides.version - else Version(branch=ctx.default_branch) - ) + if overrides.version: + version = _resolve_raw_version(overrides.version, []) or Version( + branch=ctx.default_branch + ) + else: + version = Version(branch=ctx.default_branch) existing_names = {p.name for p in ctx.manifest.projects} return _build_entry( name=overrides.name or _unique_name(ctx.default_name, existing_names), diff --git a/dfetch/manifest/manifest.py b/dfetch/manifest/manifest.py index 043ea417..78bd0083 100644 --- a/dfetch/manifest/manifest.py +++ b/dfetch/manifest/manifest.py @@ -428,8 +428,10 @@ def guess_destination(self, project_name: str) -> str: def find_remote_for_url(self, remote_url: str) -> Remote | None: """Return the first remote whose base URL is a prefix of *remote_url*.""" + target = remote_url.rstrip("/") for remote in self.remotes: - if remote_url.startswith(remote.url): + remote_base = remote.url.rstrip("/") + if target.startswith(remote_base): return remote return None diff --git a/doc/generate-casts/interactive-add-demo.sh b/doc/generate-casts/interactive-add-demo.sh index 36e2b3eb..7088748b 100755 --- a/doc/generate-casts/interactive-add-demo.sh +++ b/doc/generate-casts/interactive-add-demo.sh @@ -5,11 +5,13 @@ set -euo pipefail # Uses the real cpputest repository so the viewer sees dfetch fetching live # branch/tag metadata and the wizard populating from it. -source ./demo-magic/demo-magic.sh +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$DIR/demo-magic/demo-magic.sh" PROMPT_TIMEOUT=1 mkdir interactive-add +trap 'popd 2>/dev/null; rm -rf interactive-add' EXIT pushd interactive-add || { echo 'pushd failed' >&2; exit 1; } # Start with a manifest that already has one dependency so the demo shows From 9dbf105fd05d5cc19cdf757d769a62eb6d9288b2 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 29 Mar 2026 08:51:25 +0000 Subject: [PATCH 27/29] Update casts --- doc/asciicasts/basic.cast | 340 +++++++++++----------- doc/asciicasts/check-ci.cast | 242 ++++++++-------- doc/asciicasts/check.cast | 122 ++++---- doc/asciicasts/diff.cast | 211 +++++++------- doc/asciicasts/environment.cast | 56 ++-- doc/asciicasts/format-patch.cast | 251 ++++++++-------- doc/asciicasts/freeze.cast | 152 +++++----- doc/asciicasts/import.cast | 135 +++++---- doc/asciicasts/init.cast | 121 ++++---- doc/asciicasts/report.cast | 89 +++--- doc/asciicasts/sbom.cast | 101 +++---- doc/asciicasts/update-patch.cast | 474 ++++++++++++++++--------------- doc/asciicasts/update.cast | 218 +++++++------- doc/asciicasts/validate.cast | 37 ++- 14 files changed, 1294 insertions(+), 1255 deletions(-) diff --git a/doc/asciicasts/basic.cast b/doc/asciicasts/basic.cast index 52323e71..c4f18bc1 100644 --- a/doc/asciicasts/basic.cast +++ b/doc/asciicasts/basic.cast @@ -1,167 +1,173 @@ -{"version": 2, "width": 165, "height": 30, "timestamp": 1774247049, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} -[0.575054, "o", "\u001b[H\u001b[2J\u001b[3J"] -[0.578772, "o", "$ "] -[1.753268, "o", "\u001b"] -[1.932344, "o", "[1"] -[2.022517, "o", "ml"] -[2.112659, "o", "s "] -[2.202799, "o", "-"] -[2.292924, "o", "l\u001b"] -[2.383263, "o", "[0"] -[2.473361, "o", "m"] -[3.475061, "o", "\r\n"] -[3.592392, "o", "total 4\r\n"] -[3.592558, "o", "-rw-rw-rw- 1 dev dev 733 Mar 23 06:24 dfetch.yaml\r\n"] -[3.597748, "o", "$ "] -[4.601017, "o", "\u001b"] -[4.78132, "o", "[1"] -[4.871477, "o", "mc"] -[4.961617, "o", "at"] -[5.05174, "o", " "] -[5.141881, "o", "df"] -[5.232023, "o", "et"] -[5.322152, "o", "ch"] -[5.41559, "o", ".y"] -[5.503826, "o", "a"] -[5.684086, "o", "ml"] -[5.77425, "o", "\u001b["] -[5.864343, "o", "0m"] -[6.865923, "o", "\r\n"] -[6.869423, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:"] -[6.869965, "o", "\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v3.4 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n"] -[6.878482, "o", "$ "] -[7.881322, "o", "\u001b"] -[8.06242, "o", "[1"] -[8.152555, "o", "md"] -[8.242702, "o", "fe"] -[8.33286, "o", "tc"] -[8.422958, "o", "h "] -[8.513105, "o", "ch"] -[8.60326, "o", "ec"] -[8.693366, "o", "k\u001b"] -[8.783543, "o", "[0"] -[8.963788, "o", "m"] -[9.965336, "o", "\r\n"] -[10.443987, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[10.461279, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n\u001b[?25l"] -[10.545844, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[10.626481, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[10.707096, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[10.768657, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Checking\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] -[10.769949, "o", " \u001b[1;34m> wanted (v3.4), available (v4.0)\u001b[0m\r\n"] -[10.770873, "o", " \u001b[1;92mjsmn:\u001b[0m\r\n"] -[10.771001, "o", "\u001b[?25l"] -[10.851827, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[10.932411, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[11.012985, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[11.093673, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[11.174356, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[11.254952, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[11.333922, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Checking\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] -[11.334767, "o", " \u001b[1;34m> available (master - 25647e692c7906b96ffd2b05ca54c097948e879c)\u001b[0m\r\n"] -[11.39488, "o", "$ "] -[12.398057, "o", "\u001b["] -[12.578008, "o", "1m"] -[12.668157, "o", "se"] -[12.758294, "o", "d "] -[12.848439, "o", "-i"] -[12.938573, "o", " '"] -[13.031414, "o", "s/"] -[13.119473, "o", "v3"] -[13.209611, "o", ".4"] -[13.299752, "o", "/v"] -[13.48002, "o", "4."] -[13.572312, "o", "0/"] -[13.662469, "o", "g'"] -[13.752595, "o", " d"] -[13.842732, "o", "fe"] -[13.932863, "o", "tc"] -[14.023001, "o", "h."] -[14.113131, "o", "ya"] -[14.203252, "o", "ml"] -[14.383655, "o", "\u001b["] -[14.473811, "o", "0m"] -[15.475425, "o", "\r\n"] -[15.483813, "o", "$ "] -[16.486997, "o", "\u001b["] -[16.667392, "o", "1m"] -[16.757532, "o", "ca"] -[16.847666, "o", "t "] -[16.937813, "o", "dfe"] -[17.02794, "o", "tc"] -[17.118085, "o", "h."] -[17.208242, "o", "ya"] -[17.298352, "o", "ml"] -[17.388489, "o", "\u001b[0"] -[17.568715, "o", "m"] -[18.570374, "o", "\r\n"] -[18.573178, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote"] -[18.573433, "o", "\r\n tag: v4.0 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n"] -[18.578342, "o", "$ "] -[19.581564, "o", "\u001b["] -[19.761822, "o", "1m"] -[19.851982, "o", "df"] -[19.942275, "o", "et"] -[20.0323, "o", "ch"] -[20.122431, "o", " u"] -[20.21255, "o", "pd"] -[20.302703, "o", "at"] -[20.393007, "o", "e\u001b"] -[20.483147, "o", "[0"] -[20.663386, "o", "m"] -[21.664696, "o", "\r\n"] -[22.170593, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[22.184259, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] -[22.184389, "o", "\u001b[?25l"] -[22.2691, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] -[22.349759, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] -[22.430361, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] -[22.510939, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] -[22.591552, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] -[22.672127, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] -[22.753055, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] -[22.834399, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] -[22.916339, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] -[22.996944, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] -[23.077558, "o", "\r\u001b[2K\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] -[23.160256, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] -[23.190028, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] -[23.190076, "o", "\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] -[23.190619, "o", " \u001b[1;34m> Fetched v4.0\u001b[0m\r\n"] -[23.215673, "o", " \u001b[1;92mjsmn:\u001b[0m\r\n"] -[23.215814, "o", "\u001b[?25l"] -[23.296738, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[23.37734, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[23.457893, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[23.538471, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[23.619047, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[23.699634, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[23.780205, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[23.861876, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[23.942462, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[24.026863, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[24.107584, "o", "\r\u001b[2K\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[24.1881, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[24.268677, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[24.349248, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[24.400093, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching \u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] -[24.400965, "o", " \u001b[1;34m> Fetched master - 25647e692c7906b96ffd2b05ca54c097948e879c\u001b[0m\r\n"] -[24.474768, "o", "$ "] -[25.477969, "o", "\u001b"] -[25.658242, "o", "[1"] -[25.748365, "o", "ml"] -[25.838504, "o", "s "] -[25.928959, "o", "-"] -[26.019122, "o", "l\u001b"] -[26.109255, "o", "[0"] -[26.199373, "o", "m"] -[27.201007, "o", "\r\n"] -[27.204466, "o", "total 12\r\n"] -[27.204578, "o", "drwxrwxrwx+ 3 dev dev 4096 Mar 23 06:24 cpputest\r\n-rw-rw-rw- 1 dev dev 733 Mar 23 06:24 dfetch.yaml\r\ndrwxrwxrwx+ 4 dev dev 4096 Mar 23 06:24 jsmn\r\n"] -[30.213341, "o", "$ "] -[30.215274, "o", "\u001b["] -[30.395592, "o", "1m"] -[30.485702, "o", "\u001b["] -[30.575831, "o", "0m"] -[30.576462, "o", "\r\n"] -[30.579282, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] +{"version": 2, "width": 173, "height": 25, "timestamp": 1774773929, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[0.7619, "o", "\u001b[H\u001b[2J\u001b[3J"] +[0.766171, "o", "$ "] +[1.865267, "o", "\u001b"] +[2.045623, "o", "[1"] +[2.135772, "o", "ml"] +[2.225923, "o", "s "] +[2.31604, "o", "-l"] +[2.406161, "o", "\u001b["] +[2.496309, "o", "0m"] +[3.497894, "o", "\r\n"] +[3.547567, "o", "total 4\r\n"] +[3.547737, "o", "-rw-rw-rw- 1 dev dev 733 Mar 29 08:45 dfetch.yaml\r\n"] +[3.552644, "o", "$ "] +[4.55551, "o", "\u001b["] +[4.736319, "o", "1m"] +[4.826469, "o", "ca"] +[4.916612, "o", "t "] +[5.006747, "o", "df"] +[5.098777, "o", "et"] +[5.186942, "o", "ch"] +[5.27708, "o", ".y"] +[5.367221, "o", "am"] +[5.457367, "o", "l\u001b"] +[5.63761, "o", "[0"] +[5.727752, "o", "m"] +[6.729478, "o", "\r\n"] +[6.732861, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest"] +[6.732892, "o", "\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote"] +[6.733035, "o", "\r\n tag: v3.4 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n"] +[6.738013, "o", "$ "] +[7.740626, "o", "\u001b["] +[7.921014, "o", "1m"] +[8.011313, "o", "df"] +[8.101481, "o", "et"] +[8.191603, "o", "ch "] +[8.281742, "o", "ch"] +[8.371854, "o", "ec"] +[8.461989, "o", "k\u001b"] +[8.552136, "o", "[0"] +[8.642882, "o", "m"] +[9.644783, "o", "\r\n"] +[10.088287, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[10.102536, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] +[10.102838, "o", "\u001b[?25l"] +[10.183615, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.26422, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.344806, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.425405, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.469005, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Checking\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] +[10.469859, "o", " \u001b[1;34m> wanted (v3.4), available (v4.0)\u001b[0m\r\n"] +[10.470768, "o", " \u001b[1;92mjsmn:\u001b[0m\r\n"] +[10.470943, "o", "\u001b[?25l"] +[10.551776, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.632327, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.712925, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.793494, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.874051, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.954624, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[11.035188, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[11.11575, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[11.192474, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Checking\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] +[11.192944, "o", " \u001b[1;34m> available (master - 25647e692c7906b96ffd2b05ca54c097948e879c)\u001b[0m\r\n"] +[11.282343, "o", "$ "] +[12.285333, "o", "\u001b["] +[12.465827, "o", "1m"] +[12.555964, "o", "se"] +[12.646089, "o", "d "] +[12.736238, "o", "-i"] +[12.826347, "o", " '"] +[12.916488, "o", "s/"] +[13.006668, "o", "v3"] +[13.096817, "o", ".4"] +[13.186944, "o", "/v"] +[13.367164, "o", "4.0"] +[13.457308, "o", "/g"] +[13.547438, "o", "' "] +[13.637576, "o", "df"] +[13.727699, "o", "et"] +[13.81784, "o", "ch"] +[13.908153, "o", ".y"] +[13.99828, "o", "am"] +[14.088409, "o", "l\u001b"] +[14.268778, "o", "[0"] +[14.35887, "o", "m"] +[15.360455, "o", "\r\n"] +[15.373158, "o", "$ "] +[16.376416, "o", "\u001b"] +[16.557165, "o", "[1"] +[16.647295, "o", "mc"] +[16.737424, "o", "at"] +[16.827549, "o", " d"] +[16.917687, "o", "fe"] +[17.007814, "o", "tc"] +[17.097953, "o", "h."] +[17.188079, "o", "ya"] +[17.278207, "o", "ml"] +[17.458596, "o", "\u001b"] +[17.548774, "o", "[0"] +[17.638905, "o", "m"] +[18.641636, "o", "\r\n"] +[18.644642, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v4.0 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n"] +[18.649943, "o", "$ "] +[19.654073, "o", "\u001b["] +[19.833926, "o", "1m"] +[19.924074, "o", "df"] +[20.0142, "o", "et"] +[20.104593, "o", "ch"] +[20.194455, "o", " u"] +[20.284592, "o", "pd"] +[20.374761, "o", "at"] +[20.464861, "o", "e\u001b"] +[20.554997, "o", "[0"] +[20.735256, "o", "m"] +[21.736781, "o", "\r\n"] +[22.183767, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[22.198039, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] +[22.198181, "o", "\u001b[?25l"] +[22.279181, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] +[22.359767, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] +[22.440348, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] +[22.52095, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] +[22.601515, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] +[22.68203, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] +[22.762622, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] +[22.843185, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] +[22.923676, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] +[23.00441, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] +[23.085093, "o", "\r\u001b[2K\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] +[23.168451, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] +[23.249382, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] +[23.329933, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] +[23.410719, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] +[23.491137, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] +[23.508978, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] +[23.509731, "o", " \u001b[1;34m> Fetched v4.0\u001b[0m\r\n"] +[23.536782, "o", " \u001b[1;92mjsmn:\u001b[0m\r\n"] +[23.536827, "o", "\u001b[?25l"] +[23.617984, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[23.698605, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[23.779174, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[23.859739, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[23.941435, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[24.021782, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[24.102383, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[24.182939, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[24.26407, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[24.344622, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[24.425384, "o", "\r\u001b[2K\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[24.507608, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[24.587964, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[24.668899, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[24.749459, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[24.777899, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching \u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] +[24.778862, "o", " \u001b[1;34m> Fetched master - 25647e692c7906b96ffd2b05ca54c097948e879c\u001b[0m\r\n"] +[24.849972, "o", "$ "] +[25.853186, "o", "\u001b["] +[26.033477, "o", "1m"] +[26.1236, "o", "ls"] +[26.213748, "o", " -"] +[26.303857, "o", "l\u001b"] +[26.393981, "o", "[0"] +[26.484104, "o", "m"] +[27.485666, "o", "\r\n"] +[27.488991, "o", "total 12\r\n"] +[27.489162, "o", "drwxrwxrwx+ 3 dev dev 4096 Mar 29 08:45 cpputest\r\n-rw-rw-rw- 1 dev dev 733 Mar 29 08:45 dfetch.yaml\r\ndrwxrwxrwx+ 4 dev dev 4096 Mar 29 08:45 jsmn\r\n"] +[30.498119, "o", "$ "] +[30.499969, "o", "\u001b["] +[30.680256, "o", "1m"] +[30.770389, "o", "\u001b["] +[30.860514, "o", "0m"] +[30.861219, "o", "\r\n"] +[30.864099, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] diff --git a/doc/asciicasts/check-ci.cast b/doc/asciicasts/check-ci.cast index 6e2b6f2a..99d77ccd 100644 --- a/doc/asciicasts/check-ci.cast +++ b/doc/asciicasts/check-ci.cast @@ -1,122 +1,120 @@ -{"version": 2, "width": 165, "height": 30, "timestamp": 1774247123, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} -[0.52885, "o", "\u001b[H\u001b[2J\u001b[3J"] -[0.532733, "o", "$ "] -[1.536017, "o", "\u001b["] -[1.716296, "o", "1m"] -[1.806423, "o", "ca"] -[1.896572, "o", "t "] -[1.986691, "o", "df"] -[2.076842, "o", "et"] -[2.166966, "o", "ch"] -[2.257112, "o", ".y"] -[2.347264, "o", "am"] -[2.43738, "o", "l\u001b"] -[2.617631, "o", "[0m"] -[3.620643, "o", "\r\n"] -[3.624392, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/"] -[3.626141, "o", "\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v3.4 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n"] -[3.643676, "o", "$ "] -[4.647179, "o", "\u001b["] -[4.827498, "o", "1m"] -[4.917648, "o", "df"] -[5.007781, "o", "et"] -[5.097891, "o", "ch"] -[5.188041, "o", " c"] -[5.278357, "o", "he"] -[5.36838, "o", "ck"] -[5.458508, "o", " -"] -[5.548643, "o", "-j"] -[5.728891, "o", "en"] -[5.819135, "o", "ki"] -[5.909254, "o", "ns"] -[5.999532, "o", "-j"] -[6.08972, "o", "so"] -[6.179792, "o", "n "] -[6.270004, "o", "je"] -[6.360141, "o", "nk"] -[6.450395, "o", "in"] -[6.630659, "o", "s."] -[6.7208, "o", "js"] -[6.810917, "o", "on"] -[6.901059, "o", " -"] -[6.991186, "o", "-s"] -[7.081346, "o", "ar"] -[7.171499, "o", "if"] -[7.261628, "o", " s"] -[7.35179, "o", "ar"] -[7.532145, "o", "if"] -[7.622447, "o", ".j"] -[7.712556, "o", "so"] -[7.802691, "o", "n\u001b"] -[7.89283, "o", "[0"] -[7.982967, "o", "m"] -[8.984729, "o", "\r\n"] -[9.445039, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[9.46285, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n\u001b[?25l"] -[9.54838, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[9.629109, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[9.70974, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[9.742882, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Checking\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] -[9.743862, "o", " \u001b[1;34m> wanted (v3.4), available (v4.0)\u001b[0m\r\n"] -[9.745104, "o", " \u001b[1;92mjsmn:\u001b[0m\r\n"] -[9.745236, "o", "\u001b[?25l"] -[9.826709, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[9.907555, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[9.988104, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[10.068658, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[10.14928, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[10.229882, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[10.284178, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Checking\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] -[10.284909, "o", " \u001b[1;34m> available (master - 25647e692c7906b96ffd2b05ca54c097948e879c)\u001b[0m\r\n"] -[10.348052, "o", "$ "] -[11.351255, "o", "\u001b["] -[11.531556, "o", "1m"] -[11.62171, "o", "ls"] -[11.711859, "o", " -"] -[11.801989, "o", "l "] -[11.892109, "o", ".\u001b"] -[11.982263, "o", "[0"] -[12.072381, "o", "m"] -[13.073949, "o", "\r\n"] -[13.077493, "o", "total 16\r\n"] -[13.077605, "o", "-rw-rw-rw- 1 dev dev 733 Mar 23 06:25 dfetch.yaml\r\n-rw-rw-rw- 1 dev dev 1027 Mar 23 06:25 jenkins.json\r\n-rw-rw-rw- 1 dev dev 6117 Mar 23 06:25 sarif.json\r\n"] -[13.082295, "o", "$ "] -[14.085361, "o", "\u001b["] -[14.265626, "o", "1m"] -[14.35578, "o", "ca"] -[14.445943, "o", "t "] -[14.536058, "o", "je"] -[14.62619, "o", "nk"] -[14.716338, "o", "in"] -[14.806499, "o", "s."] -[14.896636, "o", "js"] -[14.987072, "o", "on"] -[15.167108, "o", "\u001b["] -[15.257235, "o", "0m"] -[16.258427, "o", "\r\n"] -[16.261557, "o", "{\r\n \"_class\": \"io.jenkins.plugins.analysis.core.restapi.ReportApi\",\r\n \"issues\": [\r\n {\r\n \"fileName\": \"dfetch.yaml\",\r\n \"severity\": \"High\",\r\n \"message\": \"cpputest : cpputest was never fetched!\",\r\n \"description\": \"The manifest requires version 'v3.4' of cpputest. it was never fetched, fetch it with 'dfetch update cpputest'. The latest version available is 'v4.0'\",\r\n \"lineStart\": 9,\r\n \"lineEnd\": 9,\r\n \"columnStart\": 11,\r\n \"columnEnd\": 18\r\n },\r\n {\r\n \"fileName\": \"dfetch.yaml\",\r\n \"severity\": \"High\",\r\n \"message\": \"jsmn : jsmn was never fetched!\",\r\n \"description\": \"The manifest requires version 'latest' of jsmn. it was never fetched, fetch it with 'dfetch update jsmn'. The latest version available is 'master - 25647e692c7906b96ffd2b05ca54c097948e879c'\",\r\n \"lineStart\": 14,\r\n \"lineEnd\": 14,\r\n \"columnStart\": 11,\r\n \"columnEnd\": 14\r\n }\r\n ]\r\n}"] -[16.267501, "o", "$ "] -[17.270649, "o", "\u001b"] -[17.450852, "o", "[1"] -[17.541025, "o", "mc"] -[17.631162, "o", "at"] -[17.721302, "o", " s"] -[17.811429, "o", "ar"] -[17.901979, "o", "if"] -[17.99255, "o", ".j"] -[18.081936, "o", "so"] -[18.172071, "o", "n\u001b"] -[18.352413, "o", "["] -[18.442553, "o", "0m"] -[19.444145, "o", "\r\n"] -[19.447374, "o", "{\r\n \"runs\": [\r\n {\r\n \"tool\": {\r\n \"driver\": {\r\n \"name\": \"DFetch\",\r\n \"informationUri\": \"https://dfetch.rtfd.io\","] -[19.447414, "o", "\r\n \"rules\": [\r\n {"] -[19.447462, "o", "\r\n \"id\": \"unfetched-project\",\r\n \"help\": {\r\n \"text\": \"The project mentioned in the manifest was never fetched, fetch it with 'dfetch update '. After fetching, commit the updated project to your repository.\"\r\n },\r\n \"shortDescription\": {\r\n \"text\": \"Project was never fetched\"\r\n }\r\n },\r\n {\r\n \"id\": \"up-to-date-project\",\r\n \"help\": {\r\n \"text\": \"The project mentioned in the manifest is up-to-date, everything is ok, nothing to do.\"\r\n },\r\n \"shortDescription\": {\r\n \"text\": \"Project is up-to-date\"\r\n }\r\n },\r\n {\r\n \"id\": \"unavailable-project-version\",\r\n \"help\": {\r\n \"text\": \"The project mentioned in the manifest is pinned to a specific version, For instance a branch, tag, or revision. However the specific version is not available at the upstream of the project. Check if the remote has the given version. \"\r\n },\r\n \"shortDescription\": {\r\n \"text\": \"Requested project version is unavailable at the remote\"\r\n }\r\n },\r\n {\r\n \"id\": \"pinned-but-out-of-date-project\",\r\n \"help\": {\r\n \"text\": \"The project mentioned in the manifest is pinned to a specific version, For instance a branch, tag, or revision. This is currently the state of the project. However a newer version is available at the upstream of the project. Either ignore this warning or update the version to the latest and update using 'dfetch update ' and commit the result to your repository.\"\r\n },\r\n \"shortDescription\": {\r\n \"text\": \"Project is pinned, but out-of-date\"\r\n }\r\n },\r\n {\r\n \"id\": \"out-of-date-project\",\r\n \"help\": {\r\n \"text\": \"The project is configured to always follow the latest version, There is a newer version available at the upstream of the project. Please update the project using 'dfetch update ' and commit the result to your repository.\"\r\n },\r\n \"shortDescription\": {\r\n \"text\": \"Project is out-of-date\"\r\n }\r\n },\r\n {\r\n \"id\": \"local-changes-in-project\",\r\n \"help\": {\r\n \"text\": \"The files of this project are different then when they were added, Please create a patch using 'dfetch diff ' and add it to the manifest using the 'patch:' attribute. Or better yet, upstream the changes and update your project. When running 'dfetch check' on a platform with different line endings, then this warning is likely a false positive.\"\r\n },\r\n \"shortDescription\": {\r\n \"text\": \"Project was locally changed\"\r\n }\r\n }\r\n ]\r\n }\r\n },\r\n \"artifacts\": [\r\n {\r\n \"location\": {\r\n \"uri\": \"dfetch.yaml\"\r\n },\r\n \"sourceLanguage\": \"yaml\"\r\n }\r\n ],\r\n \"results\": [\r\n {\r\n \"mess"] -[19.447485, "o", "age\": {\r\n \"text\": \"cpputest : cpputest was never fetched!\"\r\n },\r\n \"level\": \"error\",\r\n \"locations\": [\r\n {\r\n \"physicalLocation\": {\r\n \"artifactLocation\": {\r\n \"index\": 0,\r\n \"uri\": \"dfetch.yaml\"\r\n },\r\n \"region\": {\r\n \"endColumn\": 19,\r\n \"endLine\": 9,\r\n \"startColumn\": 11,\r\n \"startLine\": 9\r\n }\r\n }\r\n }\r\n ],\r\n \"ruleId\": \"unfetched-project\"\r\n },\r\n {\r\n \"message\": {\r\n \"text\": \"jsmn : jsmn was never fetched!\"\r\n },\r\n \"level\": \"error\",\r\n \"locations\": [\r\n {\r\n \"physicalLocation\": {\r\n \"artifactLocation\": {\r\n \"index\": 0,\r\n \"uri\": \"dfetch.yaml\"\r\n },\r\n \"region\": {\r\n \"endColumn\": 15,\r\n \"endLine\": 14,\r\n \"startColumn\": 11,\r\n \"startLine\": 14\r\n }\r\n }\r\n }\r\n ],\r\n \"ruleId\": \"unfetched-project\"\r\n }\r\n ]\r\n }\r\n ],\r\n \"version\": \"2.1.0\"\r\n}"] -[22.455303, "o", "$ "] -[22.457294, "o", "\u001b["] -[22.637561, "o", "1m"] -[22.727694, "o", "\u001b["] -[22.817825, "o", "0m"] -[22.818423, "o", "\r\n"] -[22.821348, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] +{"version": 2, "width": 173, "height": 25, "timestamp": 1774774005, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[0.540611, "o", "\u001b[H\u001b[2J\u001b[3J"] +[0.544813, "o", "$ "] +[1.548103, "o", "\u001b["] +[1.728559, "o", "1m"] +[1.818674, "o", "ca"] +[1.90882, "o", "t "] +[1.998976, "o", "dfe"] +[2.089058, "o", "tc"] +[2.179171, "o", "h."] +[2.269347, "o", "ya"] +[2.359416, "o", "ml"] +[2.4497, "o", "\u001b[0"] +[2.629942, "o", "m"] +[3.631487, "o", "\r\n"] +[3.63451, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v3.4 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n"] +[3.63986, "o", "$ "] +[4.643114, "o", "\u001b["] +[4.82347, "o", "1m"] +[4.913593, "o", "df"] +[5.00373, "o", "et"] +[5.093854, "o", "ch"] +[5.184007, "o", " c"] +[5.274129, "o", "he"] +[5.364247, "o", "ck"] +[5.454395, "o", " -"] +[5.544532, "o", "-j"] +[5.724786, "o", "enk"] +[5.814923, "o", "in"] +[5.90505, "o", "s-"] +[5.995193, "o", "js"] +[6.085313, "o", "on"] +[6.175436, "o", " j"] +[6.265564, "o", "en"] +[6.355697, "o", "ki"] +[6.445889, "o", "ns"] +[6.626161, "o", ".j"] +[6.716279, "o", "son"] +[6.806406, "o", " -"] +[6.896539, "o", "-s"] +[6.986666, "o", "ar"] +[7.076806, "o", "if"] +[7.166945, "o", " s"] +[7.257067, "o", "ar"] +[7.347208, "o", "if"] +[7.527446, "o", ".j"] +[7.617579, "o", "so"] +[7.70772, "o", "n\u001b["] +[7.797835, "o", "0m"] +[8.799469, "o", "\r\n"] +[9.319943, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[9.334143, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] +[9.33429, "o", "\u001b[?25l"] +[9.415383, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[9.495907, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[9.576634, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[9.657297, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[9.703542, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Checking\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] +[9.705466, "o", " \u001b[1;34m> wanted (v3.4), available (v4.0)\u001b[0m\r\n"] +[9.706477, "o", " \u001b[1;92mjsmn:\u001b[0m\r\n\u001b[?25l"] +[9.787083, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[9.86765, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[9.948217, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.028703, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.109981, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.191188, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.271736, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.352325, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.419821, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Checking\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K \u001b[1;34m> available (master - 25647e692c7906b96ffd2b05ca54c097948e879c)\u001b[0m\r\n"] +[10.48351, "o", "$ "] +[11.486953, "o", "\u001b["] +[11.667186, "o", "1m"] +[11.757368, "o", "ls"] +[11.847482, "o", " -"] +[11.937626, "o", "l "] +[12.027744, "o", ".\u001b"] +[12.117885, "o", "[0"] +[12.208026, "o", "m"] +[13.20956, "o", "\r\n"] +[13.213211, "o", "total 16\r\n"] +[13.213256, "o", "-rw-rw-rw- 1 dev dev 733 Mar 29 08:46 dfetch.yaml\r\n-rw-rw-rw- 1 dev dev 1027 Mar 29 08:46 jenkins.json\r\n-rw-rw-rw- 1 dev dev 6117 Mar 29 08:46 sarif.json\r\n"] +[13.218321, "o", "$ "] +[14.221721, "o", "\u001b"] +[14.401976, "o", "[1"] +[14.492245, "o", "mc"] +[14.58231, "o", "at"] +[14.672427, "o", " "] +[14.762586, "o", "je"] +[14.852779, "o", "nk"] +[14.942916, "o", "in"] +[15.033035, "o", "s."] +[15.123178, "o", "j"] +[15.303431, "o", "so"] +[15.39355, "o", "n\u001b"] +[15.483679, "o", "[0"] +[15.573871, "o", "m"] +[16.57543, "o", "\r\n"] +[16.578548, "o", "{\r\n \"_class\": \"io.jenkins.plugins.analysis.core.restapi.ReportApi\",\r\n \"issues\": [\r\n {\r\n \"fileName\": \"dfetch.yaml\",\r\n \"severity\": \"High\",\r\n \"message\": \"cpputest : cpputest was never fetched!\",\r\n \"description\": \"The manifest requires version 'v3.4' of cpputest. it was never fetched, fetch it with 'dfetch update cpputest'. The latest version available is 'v4.0'\",\r\n \"lineStart\": 9,\r\n \"lineEnd\": 9,\r\n \"columnStart\": 11,\r\n \"columnEnd\": 18\r\n },\r\n {\r\n \"fileName\": \"dfetch.yaml\",\r\n \"severity\": \"High\",\r\n \"message\": \"jsmn : jsmn was never fetched!\",\r\n \"description\": \"The manifest requires version 'latest' of jsmn. it was never fetched, fetch it with 'dfetch update jsmn'. The latest version available is 'master - 25647e692c7906b96ffd2b05ca54c097948e879c'\",\r\n \"lineStart\": 14,\r\n \"lineEnd\": 14,\r\n \"columnStart\": 11,\r\n \"columnEnd\": 14\r\n }\r\n ]\r\n}"] +[16.585142, "o", "$ "] +[17.587942, "o", "\u001b["] +[17.768177, "o", "1m"] +[17.858304, "o", "ca"] +[17.948427, "o", "t "] +[18.038565, "o", "sa"] +[18.128692, "o", "ri"] +[18.218825, "o", "f."] +[18.308959, "o", "js"] +[18.399301, "o", "on"] +[18.489458, "o", "\u001b["] +[18.669738, "o", "0m"] +[19.671477, "o", "\r\n"] +[19.675315, "o", "{\r\n \"runs\": [\r\n {\r\n \"tool\": {\r\n \"driver\": {\r\n \"name\": \"DFetch\",\r\n \"informationUri\": \"https://dfetch.rtfd.io\",\r\n \"rules\": [\r\n {\r\n \"id\": \"unfetched-project\",\r\n \"help\": {\r\n \"text\": \"The project mentioned in the manifest was never fetched, fetch it with 'dfetch update '. After fetching, commit the updated project to your repository.\"\r\n },\r\n \"shortDescription\": {\r\n \"text\": \"Project was never fetched\"\r\n }\r\n },\r\n {\r\n \"id\": \"up-to-date-project\",\r\n \"help\": {\r\n \"text\": \"The project mentioned in the manifest is up-to-date, everything is ok, nothing to do.\"\r\n },\r\n \"shortDescription\": {\r\n \"text\": \"Project is up-to-date\"\r\n }\r\n },\r\n {\r\n \"id\": \"unavailable-project-version\",\r\n \"help\": {\r\n \"text\": \"The project mentioned in the manifest is pinned to a specific version, For instance a branch, tag, or revision. However the specific version is not available at the upstream of the project. Check if the remote has the given version. \"\r\n },\r\n \"shortDescription\": {\r\n \"text\": \"Requested project version is unavailable at the remote\"\r\n }\r\n },\r\n {\r\n \"id\": \"pinned-but-out-of-date-project\",\r\n \"help\": {\r\n \"text\": \"The project mentioned in the manifest is pinned to a specific version, For instance a branch, tag, or revision. This is currently the state of the project. However a newer version is available at the upstream of the project. Either ignore this warning or update the version to the latest and update using 'dfetch update ' and commit the result to your repository.\"\r\n },\r\n \"shortDescription\": {\r\n \"text\": \"Project is pinned, but out-of-date\"\r\n }\r\n },\r\n {\r\n \"id\": \"out-of-date-project\",\r\n \"help\": {\r\n \"text\": \"The project is configured to always follow the latest version, There is a newer version available at the upstream of the project. Please update the project using 'dfetch update ' and commit the result to your repository.\"\r\n },\r\n \"shortDescription\": {\r\n \"text\": \"Project is out-of-date\"\r\n }\r\n },\r\n {\r\n \"id\": \"local-changes-in-project\",\r\n \"help\": {\r\n \"text\": \"The files of this project are different then when they were added, Please create a patch using 'dfetch diff ' and add it to the manifest using the 'patch:' attribute. Or better yet, upstream the changes and update your project. When running 'dfetch check' on a platform with different line endings, then this warning is likely a false positive.\"\r\n },\r\n \"shortDescription\": {\r\n \"text\": \"Project was locally changed\"\r\n }\r\n }\r\n ]\r\n }\r\n },\r\n \"artifacts\": [\r\n {\r\n "] +[19.675361, "o", " \"location\": {\r\n \"uri\": \"dfetch.yaml\"\r\n },\r\n \"sourceLanguage\": \"yaml\"\r\n }\r\n ],\r\n \"results\": [\r\n {\r\n \"message\": {\r\n \"text\": \"cpputest : cpputest was never fetched!\"\r\n },\r\n \"level\": \"error\",\r\n \"locations\": [\r\n {\r\n \"physicalLocation\": {\r\n \"artifactLocation\": {\r\n \"index\": 0,\r\n \"uri\": \"dfetch.yaml\"\r\n },\r\n \"region\": {\r\n \"endColumn\": 19,\r\n \"endLine\": 9,\r\n \"startColumn\": 11,\r\n \"startLine\": 9\r\n }\r\n }\r\n }\r\n ],\r\n \"ruleId\": \"unfetched-project\"\r\n },\r\n {\r\n \"message\": {\r\n \"text\": \"jsmn : jsmn was never fetched!\"\r\n },\r\n \"level\": \"error\",\r\n \"locations\": [\r\n {\r\n \"physicalLocation\": {\r\n \"artifactLocation\": {\r\n \"index\": 0,\r\n \"uri\": \"dfetch.yaml\"\r\n },\r\n \"region\": {\r\n \"endColumn\": 15,\r\n \"endLine\": 14,\r\n \"startColumn\": 11,\r\n \"startLine\": 14\r\n }\r\n }\r\n }\r\n ],\r\n \"ruleId\": \"unfetched-project\"\r\n }\r\n ]\r\n }\r\n ],\r\n \"version\": \"2.1.0\"\r\n}"] +[22.685211, "o", "$ "] +[22.687078, "o", "\u001b["] +[22.867372, "o", "1m"] +[22.957498, "o", "\u001b["] +[23.047625, "o", "0m"] +[23.048251, "o", "\r\n"] +[23.05133, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] diff --git a/doc/asciicasts/check.cast b/doc/asciicasts/check.cast index 080a4168..e890bcc9 100644 --- a/doc/asciicasts/check.cast +++ b/doc/asciicasts/check.cast @@ -1,59 +1,63 @@ -{"version": 2, "width": 165, "height": 30, "timestamp": 1774247111, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} -[0.66465, "o", "\u001b[H\u001b[2J\u001b[3J"] -[0.668498, "o", "$ "] -[1.671618, "o", "\u001b"] -[1.852983, "o", "[1"] -[1.94317, "o", "mc"] -[2.034404, "o", "at"] -[2.124541, "o", " "] -[2.214675, "o", "df"] -[2.30482, "o", "et"] -[2.394943, "o", "ch"] -[2.485085, "o", ".y"] -[2.575257, "o", "a"] -[2.755682, "o", "ml"] -[2.845712, "o", "\u001b["] -[2.935834, "o", "0m"] -[3.937415, "o", "\r\n"] -[3.940467, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v3.4 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n"] -[3.945503, "o", "$ "] -[4.948613, "o", "\u001b"] -[5.129105, "o", "[1"] -[5.219231, "o", "md"] -[5.309371, "o", "fe"] -[5.399529, "o", "t"] -[5.49124, "o", "ch"] -[5.581308, "o", " c"] -[5.671443, "o", "he"] -[5.761584, "o", "ck"] -[5.851713, "o", "\u001b"] -[6.031978, "o", "[0"] -[6.122457, "o", "m"] -[7.12417, "o", "\r\n"] -[7.617044, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[7.630225, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] -[7.63045, "o", "\u001b[?25l"] -[7.715034, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[7.795646, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[7.87624, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[7.914558, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Checking\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] -[7.915386, "o", " \u001b[1;34m> wanted (v3.4), available (v4.0)\u001b[0m\r\n"] -[7.916246, "o", " \u001b[1;92mjsmn:\u001b[0m\r\n"] -[7.91638, "o", "\u001b[?25l"] -[7.99715, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[8.077644, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[8.158245, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[8.238823, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[8.319411, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[8.400061, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[8.460435, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[8.461023, "o", "\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] -[8.462981, "o", " \u001b[1;34m> available (master - 25647e692c7906b96ffd2b05ca54c097948e879c)\u001b[0m\r\n"] -[11.528152, "o", "$ "] -[11.53011, "o", "\u001b"] -[11.710398, "o", "[1"] -[11.800546, "o", "m\u001b"] -[11.890677, "o", "[0"] -[11.980814, "o", "m"] -[11.981405, "o", "\r\n"] -[11.984299, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] +{"version": 2, "width": 173, "height": 25, "timestamp": 1774773993, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[0.551372, "o", "\u001b[H\u001b[2J\u001b[3J"] +[0.555774, "o", "$ "] +[1.559107, "o", "\u001b"] +[1.739374, "o", "[1"] +[1.829516, "o", "mc"] +[1.919734, "o", "at"] +[2.009812, "o", " d"] +[2.099939, "o", "fe"] +[2.190088, "o", "tc"] +[2.280204, "o", "h."] +[2.370326, "o", "ya"] +[2.460532, "o", "ml"] +[2.640806, "o", "\u001b"] +[2.730932, "o", "[0"] +[2.821023, "o", "m"] +[3.822654, "o", "\r\n"] +[3.825876, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v3.4 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n"] +[3.831326, "o", "$ "] +[4.834557, "o", "\u001b["] +[5.014882, "o", "1m"] +[5.105038, "o", "df"] +[5.195165, "o", "et"] +[5.285305, "o", "ch"] +[5.375424, "o", " c"] +[5.465577, "o", "he"] +[5.555707, "o", "ck"] +[5.645841, "o", "\u001b["] +[5.735993, "o", "0m"] +[6.737743, "o", "\r\n"] +[7.210249, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[7.223727, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] +[7.22387, "o", "\u001b[?25l"] +[7.305116, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[7.385702, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[7.466485, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[7.547185, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[7.608235, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[7.608529, "o", "\r\n"] +[7.608613, "o", "\u001b[?25h\r\u001b[1A\u001b[2K"] +[7.610156, "o", " \u001b[1;34m> wanted (v3.4), available (v4.0)\u001b[0m"] +[7.610392, "o", "\r\n"] +[7.612145, "o", " \u001b[1;92mjsmn:\u001b[0m"] +[7.61251, "o", "\r\n"] +[7.612845, "o", "\u001b[?25l"] +[7.694073, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[7.774559, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[7.855118, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[7.935615, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[8.016287, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[8.096916, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[8.177491, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[8.258018, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[8.32008, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Checking\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] +[8.320821, "o", " \u001b[1;34m> available (master - 25647e692c7906b96ffd2b05ca54c097948e879c)\u001b[0m\r\n"] +[11.389398, "o", "$ "] +[11.391474, "o", "\u001b"] +[11.571741, "o", "[1"] +[11.662017, "o", "m\u001b"] +[11.752155, "o", "[0"] +[11.842262, "o", "m"] +[11.842804, "o", "\r\n"] +[11.845949, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] diff --git a/doc/asciicasts/diff.cast b/doc/asciicasts/diff.cast index c70b2f49..e350d47a 100644 --- a/doc/asciicasts/diff.cast +++ b/doc/asciicasts/diff.cast @@ -1,107 +1,104 @@ -{"version": 2, "width": 165, "height": 30, "timestamp": 1774247207, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} -[0.297282, "o", "\u001b[H\u001b[2J\u001b[3J"] -[0.308402, "o", "$ "] -[1.310834, "o", "\u001b["] -[1.491147, "o", "1m"] -[1.581282, "o", "ls"] -[1.671427, "o", " -"] -[1.761551, "o", "l ."] -[1.851721, "o", "\u001b["] -[1.941822, "o", "0m"] -[2.943437, "o", "\r\n"] -[2.947021, "o", "total 12\r\n"] -[2.947132, "o", "drwxr-xr-x+ 3 dev dev 4096 Mar 23 06:26 cpputest\r\n-rw-rw-rw- 1 dev dev 733 Mar 23 06:26 dfetch.yaml\r\ndrwxr-xr-x+ 4 dev dev 4096 Mar 23 06:26 jsmn\r\n"] -[2.952751, "o", "$ "] -[3.956151, "o", "\u001b["] -[4.136429, "o", "1m"] -[4.226576, "o", "ls"] -[4.316879, "o", " -"] -[4.406952, "o", "l c"] -[4.497095, "o", "pp"] -[4.587199, "o", "ut"] -[4.677345, "o", "es"] -[4.767572, "o", "t/"] -[4.8577, "o", "src"] -[5.037956, "o", "/R"] -[5.128135, "o", "EA"] -[5.21826, "o", "DM"] -[5.308386, "o", "E."] -[5.398532, "o", "md\u001b"] -[5.489477, "o", "[0"] -[5.58001, "o", "m"] -[6.581699, "o", "\r\n"] -[6.585138, "o", "-rw-rw-rw- 1 dev dev 6777 Mar 23 06:26 cpputest/src/README.md\r\n"] -[6.590292, "o", "$ "] -[7.593436, "o", "\u001b"] -[7.773712, "o", "[1"] -[7.865028, "o", "ms"] -[7.955031, "o", "ed"] -[8.045161, "o", " "] -[8.135294, "o", "-i"] -[8.225425, "o", " '"] -[8.315576, "o", "s/"] -[8.405723, "o", "gi"] -[8.495997, "o", "t"] -[8.676235, "o", "hu"] -[8.766378, "o", "b/"] -[8.856517, "o", "gi"] -[8.946662, "o", "tl"] -[9.036777, "o", "a"] -[9.126914, "o", "b/"] -[9.217045, "o", "g'"] -[9.30718, "o", " c"] -[9.397311, "o", "pp"] -[9.577594, "o", "u"] -[9.667717, "o", "te"] -[9.757857, "o", "st"] -[9.847993, "o", "/s"] -[9.938322, "o", "rc"] -[10.028282, "o", "/"] -[10.118418, "o", "RE"] -[10.208543, "o", "AD"] -[10.298974, "o", "ME"] -[10.480288, "o", ".m"] -[10.570256, "o", "d"] -[10.660377, "o", "\u001b["] -[10.750514, "o", "0m"] -[11.752172, "o", "\r\n"] -[11.760729, "o", "$ "] -[12.76387, "o", "\u001b["] -[12.944144, "o", "1m"] -[13.034268, "o", "df"] -[13.124423, "o", "et"] -[13.214548, "o", "ch "] -[13.304689, "o", "di"] -[13.394753, "o", "ff"] -[13.484885, "o", " c"] -[13.575022, "o", "pp"] -[13.665153, "o", "ute"] -[13.845418, "o", "st"] -[13.935577, "o", "\u001b["] -[14.02676, "o", "0m"] -[15.028362, "o", "\r\n"] -[15.511793, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[15.576794, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n \u001b[1;34m> Generating patch cpputest.patch since 33706eb2aaa0bf0e6c462056aaf53a95415980e9 in /workspaces/dfetch/doc/generate-casts/diff\u001b[0m\r\n"] -[15.668988, "o", "$ "] -[16.672084, "o", "\u001b["] -[16.852563, "o", "1m"] -[16.942614, "o", "ca"] -[17.03276, "o", "t "] -[17.122906, "o", "cp"] -[17.213025, "o", "pu"] -[17.303157, "o", "te"] -[17.393376, "o", "st"] -[17.483512, "o", ".p"] -[17.57364, "o", "at"] -[17.753902, "o", "ch\u001b"] -[17.844042, "o", "[0"] -[17.934163, "o", "m"] -[18.93576, "o", "\r\n"] -[18.938914, "o", "diff --git a/README.md b/README.md\r\nindex 2655a7b..fc6084e 100644\r\n--- a/README.md\r\n+++ b/README.md\r\n@@ -3,7 +3,7 @@ CppUTest\r\n \r\n CppUTest unit testing and mocking framework for C/C++\r\n \r\n-[More information on the project page](http://cpputest.github.com)\r\n+[More information on the project page](http://cpputest.gitlab.com)\r\n \r\n [![Build Status](https://travis-ci.org/cpputest/cpputest.png?branch=master)](https://travis-ci.org/cpputest/cpputest)\r\n \r\n"] -[21.94809, "o", "$ "] -[21.949812, "o", "\u001b["] -[22.13027, "o", "1m"] -[22.220398, "o", "\u001b["] -[22.310585, "o", "0m"] -[22.311207, "o", "\r\n"] -[22.313952, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] +{"version": 2, "width": 173, "height": 25, "timestamp": 1774774090, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[0.291332, "o", "\u001b[H\u001b[2J\u001b[3J"] +[0.297838, "o", "$ "] +[1.300927, "o", "\u001b["] +[1.481216, "o", "1m"] +[1.571342, "o", "ls"] +[1.661494, "o", " -"] +[1.75174, "o", "l "] +[1.841875, "o", ".\u001b"] +[1.931973, "o", "[0"] +[2.022134, "o", "m"] +[3.023735, "o", "\r\n"] +[3.027376, "o", "total 12\r\n"] +[3.027429, "o", "drwxr-xr-x+ 3 dev dev 4096 Mar 29 08:48 cpputest\r\n-rw-rw-rw- 1 dev dev 733 Mar 29 08:48 dfetch.yaml\r\ndrwxr-xr-x+ 4 dev dev 4096 Mar 29 08:48 jsmn\r\n"] +[3.033002, "o", "$ "] +[4.036262, "o", "\u001b["] +[4.216531, "o", "1m"] +[4.306702, "o", "ls"] +[4.396808, "o", " -"] +[4.486955, "o", "l "] +[4.577098, "o", "cp"] +[4.667274, "o", "pu"] +[4.757378, "o", "te"] +[4.847504, "o", "st"] +[4.937663, "o", "/s"] +[5.117893, "o", "rc"] +[5.208028, "o", "/R"] +[5.298126, "o", "EA"] +[5.388262, "o", "DM"] +[5.478394, "o", "E."] +[5.568526, "o", "md"] +[5.658672, "o", "\u001b["] +[5.748782, "o", "0m"] +[6.750333, "o", "\r\n"] +[6.753845, "o", "-rw-rw-rw- 1 dev dev 6777 Mar 29 08:48 cpputest/src/README.md\r\n"] +[6.759527, "o", "$ "] +[7.762518, "o", "\u001b["] +[7.942941, "o", "1m"] +[8.033097, "o", "se"] +[8.123218, "o", "d "] +[8.213355, "o", "-i "] +[8.303479, "o", "'s"] +[8.393616, "o", "/g"] +[8.483745, "o", "it"] +[8.573888, "o", "hu"] +[8.664018, "o", "b/g"] +[8.844264, "o", "it"] +[8.934447, "o", "la"] +[9.024586, "o", "b/"] +[9.114713, "o", "g'"] +[9.204835, "o", " cp"] +[9.294965, "o", "pu"] +[9.385097, "o", "te"] +[9.475347, "o", "st"] +[9.565711, "o", "/s"] +[9.745969, "o", "rc/"] +[9.836113, "o", "RE"] +[9.926223, "o", "AD"] +[10.01636, "o", "ME"] +[10.106503, "o", ".m"] +[10.196632, "o", "d\u001b["] +[10.286766, "o", "0m"] +[11.288496, "o", "\r\n"] +[11.303099, "o", "$ "] +[12.306749, "o", "\u001b["] +[12.487056, "o", "1m"] +[12.577154, "o", "df"] +[12.667299, "o", "et"] +[12.757432, "o", "ch "] +[12.847563, "o", "di"] +[12.937708, "o", "ff"] +[13.027806, "o", " c"] +[13.117938, "o", "pp"] +[13.208159, "o", "ute"] +[13.388383, "o", "st"] +[13.478513, "o", "\u001b["] +[13.568637, "o", "0m"] +[14.5703, "o", "\r\n"] +[15.105174, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[15.150307, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] +[15.150971, "o", " \u001b[1;34m> Generating patch cpputest.patch since 782d182fe535ff8e48cd466d5f1d1c889ade12d6 in /workspaces/dfetch/doc/generate-casts/diff\u001b[0m\r\n"] +[15.216187, "o", "$ "] +[16.219341, "o", "\u001b["] +[16.399677, "o", "1m"] +[16.489804, "o", "ca"] +[16.579934, "o", "t "] +[16.670068, "o", "cp"] +[16.760227, "o", "pu"] +[16.850354, "o", "te"] +[16.940506, "o", "st"] +[17.030625, "o", ".p"] +[17.120918, "o", "at"] +[17.301148, "o", "ch"] +[17.391316, "o", "\u001b["] +[17.481457, "o", "0m"] +[18.4835, "o", "\r\n"] +[18.486905, "o", "diff --git a/README.md b/README.md\r\nindex 2655a7b..fc6084e 100644\r\n--- a/README.md\r\n+++ b/README.md\r\n@@ -3,7 +3,7 @@ CppUTest\r\n \r\n CppUTest unit testing and mocking framework for C/C++\r\n \r\n-[More information on the project page](http://cpputest.github.com)\r\n+[More information on the project page](http://cpputest.gitlab.com)\r\n \r\n [![Build Status](https://travis-ci.org/cpputest/cpputest.png?branch=master)](https://travis-ci.org/cpputest/cpputest)\r\n \r\n"] +[21.495347, "o", "$ "] +[21.497255, "o", "\u001b["] +[21.677538, "o", "1m"] +[21.76776, "o", "\u001b["] +[21.857974, "o", "0m"] +[21.858526, "o", "\r\n"] +[21.861449, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] diff --git a/doc/asciicasts/environment.cast b/doc/asciicasts/environment.cast index 5a01fbb4..3f067f52 100644 --- a/doc/asciicasts/environment.cast +++ b/doc/asciicasts/environment.cast @@ -1,27 +1,29 @@ -{"version": 2, "width": 165, "height": 30, "timestamp": 1774247096, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} -[0.020181, "o", "$ "] -[1.02333, "o", "\u001b["] -[1.203634, "o", "1m"] -[1.293774, "o", "df"] -[1.383891, "o", "et"] -[1.474082, "o", "ch"] -[1.564168, "o", " e"] -[1.654306, "o", "nv"] -[1.744455, "o", "ir"] -[1.834587, "o", "on"] -[1.924727, "o", "me"] -[2.104972, "o", "nt"] -[2.19512, "o", "\u001b["] -[2.285232, "o", "0m"] -[3.286818, "o", "\r\n"] -[3.78698, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[3.78782, "o", " \u001b[1;92mplatform :\u001b[0m\u001b[1;34m Linux 6.8.0-1044-azure\u001b[0m\r\n"] -[3.790882, "o", " \u001b[1;92mgit :\u001b[0m\u001b[1;34m 2.52.0\u001b[0m\r\n"] -[3.80384, "o", " \u001b[1;92msvn :\u001b[0m\u001b[1;34m 1.14.5 (r1922182)\u001b[0m\r\n"] -[6.867225, "o", "$ "] -[6.869712, "o", "\u001b"] -[7.050015, "o", "[1"] -[7.140245, "o", "m\u001b"] -[7.230362, "o", "[0"] -[7.32048, "o", "m"] -[7.321059, "o", "\r\n"] +{"version": 2, "width": 173, "height": 25, "timestamp": 1774773977, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[0.02211, "o", "$ "] +[1.026177, "o", "\u001b"] +[1.206443, "o", "[1"] +[1.296579, "o", "md"] +[1.386694, "o", "fe"] +[1.476846, "o", "t"] +[1.566997, "o", "ch"] +[1.657124, "o", " e"] +[1.747281, "o", "nv"] +[1.837535, "o", "ir"] +[1.927665, "o", "o"] +[2.108227, "o", "nm"] +[2.198388, "o", "en"] +[2.288553, "o", "t\u001b"] +[2.378688, "o", "[0"] +[2.468851, "o", "m"] +[3.469413, "o", "\r\n"] +[3.956226, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[3.956994, "o", " \u001b[1;92mplatform :\u001b[0m\u001b[1;34m Linux 6.8.0-1044-azure\u001b[0m\r\n"] +[3.960809, "o", " \u001b[1;92mgit :\u001b[0m\u001b[1;34m 2.52.0\u001b[0m\r\n"] +[5.006079, "o", " \u001b[1;92msvn :\u001b[0m\u001b[1;34m 1.14.5 (r1922182)\u001b[0m\r\n"] +[8.068121, "o", "$ "] +[8.070061, "o", "\u001b"] +[8.250347, "o", "[1"] +[8.340651, "o", "m\u001b"] +[8.43079, "o", "[0"] +[8.520903, "o", "m"] +[8.521613, "o", "\r\n"] diff --git a/doc/asciicasts/format-patch.cast b/doc/asciicasts/format-patch.cast index 41658241..52e941d3 100644 --- a/doc/asciicasts/format-patch.cast +++ b/doc/asciicasts/format-patch.cast @@ -1,127 +1,124 @@ -{"version": 2, "width": 165, "height": 30, "timestamp": 1774247273, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} -[0.827732, "o", "\u001b[H\u001b[2J\u001b[3J"] -[0.83158, "o", "$ "] -[1.834752, "o", "\u001b["] -[2.015073, "o", "1m"] -[2.105205, "o", "ls"] -[2.195353, "o", " -"] -[2.285519, "o", "l ."] -[2.375663, "o", "\u001b["] -[2.465787, "o", "0m"] -[3.467311, "o", "\r\n"] -[3.470858, "o", "total 16\r\n"] -[3.470913, "o", "drwxr-xr-x+ 3 dev dev 4096 Mar 23 06:27 cpputest\r\n-rw-rw-rw- 1 dev dev 229 Mar 23 06:27 dfetch.yaml\r\ndrwxr-xr-x+ 4 dev dev 4096 Mar 23 06:27 jsmn\r\ndrwxrwxrwx+ 2 dev dev 4096 Mar 23 06:27 patches\r\n"] -[3.476076, "o", "$ "] -[4.479354, "o", "\u001b"] -[4.659756, "o", "[1"] -[4.749897, "o", "mc"] -[4.84002, "o", "at"] -[4.930171, "o", " "] -[5.020309, "o", "df"] -[5.110433, "o", "et"] -[5.200573, "o", "ch"] -[5.290709, "o", ".y"] -[5.380834, "o", "a"] -[5.561089, "o", "ml"] -[5.651373, "o", "\u001b["] -[5.741416, "o", "0m"] -[6.743112, "o", "\r\n"] -[6.746222, "o", "manifest:\r\n version: 0.0\r\n\r\n remotes:\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/\r\n repo-path: cpputest/cpputest.git\r\n tag: v3.4\r\n patch: patches/cpputest.patch\r\n\r\n"] -[6.751435, "o", "$ "] -[7.75466, "o", "\u001b["] -[7.934917, "o", "1m"] -[8.025052, "o", "ca"] -[8.115183, "o", "t "] -[8.205324, "o", "pa"] -[8.295449, "o", "tc"] -[8.385573, "o", "he"] -[8.476443, "o", "s/"] -[8.565912, "o", "cp"] -[8.65606, "o", "pu"] -[8.836316, "o", "te"] -[8.926555, "o", "st"] -[9.01669, "o", ".p"] -[9.106812, "o", "at"] -[9.196953, "o", "ch"] -[9.28711, "o", "\u001b["] -[9.377229, "o", "0m"] -[10.378877, "o", "\r\n"] -[10.382104, "o", "diff --git a/README.md b/README.md\r\nindex 2655a7b..fc6084e 100644\r\n--- a/README.md\r\n+++ b/README.md\r\n@@ -3,7 +3,7 @@ CppUTest\r\n \r\n CppUTest unit testing and mocking framework for C/C++\r\n \r\n-[More information on the project page](http://cpputest.github.com)\r\n+[More information on the project page](http://cpputest.gitlab.com)\r\n \r\n [![Build Status](https://travis-ci.org/cpputest/cpputest.png?branch=master)](https://travis-ci.org/cpputest/cpputest)\r\n \r\n"] -[10.387422, "o", "$ "] -[11.390553, "o", "\u001b"] -[11.570885, "o", "[1"] -[11.660973, "o", "md"] -[11.751107, "o", "fe"] -[11.841249, "o", "t"] -[11.931433, "o", "ch"] -[12.021564, "o", " f"] -[12.111694, "o", "or"] -[12.201934, "o", "ma"] -[12.292019, "o", "t"] -[12.472292, "o", "-p"] -[12.562416, "o", "at"] -[12.652558, "o", "ch"] -[12.74269, "o", " c"] -[12.832827, "o", "p"] -[12.922991, "o", "pu"] -[13.013121, "o", "te"] -[13.103282, "o", "st"] -[13.193412, "o", " -"] -[13.374738, "o", "-"] -[13.463884, "o", "ou"] -[13.554013, "o", "tp"] -[13.644135, "o", "ut"] -[13.734325, "o", "-d"] -[13.824434, "o", "i"] -[13.91459, "o", "re"] -[14.004738, "o", "ct"] -[14.094907, "o", "or"] -[14.275152, "o", "y "] -[14.365291, "o", "f"] -[14.455446, "o", "or"] -[14.545571, "o", "ma"] -[14.635706, "o", "tt"] -[14.725876, "o", "ed"] -[14.815991, "o", "-"] -[14.906125, "o", "pa"] -[14.996258, "o", "tc"] -[15.176523, "o", "he"] -[15.266679, "o", "s\u001b"] -[15.356801, "o", "["] -[15.446931, "o", "0m"] -[16.448596, "o", "\r\n"] -[16.90849, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[16.932549, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] -[16.933138, "o", " \u001b[1;34m> formatted patch written to formatted-patches/cpputest.patch\u001b[0m\r\n"] -[16.999031, "o", "$ "] -[18.001567, "o", "\u001b["] -[18.181864, "o", "1m"] -[18.271995, "o", "ca"] -[18.362127, "o", "t "] -[18.453764, "o", "fo"] -[18.54385, "o", "rm"] -[18.633955, "o", "at"] -[18.724109, "o", "te"] -[18.814244, "o", "d-"] -[18.904369, "o", "pa"] -[19.084689, "o", "tc"] -[19.1748, "o", "he"] -[19.26493, "o", "s/"] -[19.355119, "o", "cp"] -[19.445229, "o", "pu"] -[19.535371, "o", "te"] -[19.625491, "o", "st"] -[19.715644, "o", ".p"] -[19.8072, "o", "at"] -[19.986226, "o", "ch"] -[20.076346, "o", "\u001b["] -[20.166491, "o", "0m"] -[21.168195, "o", "\r\n"] -[21.171408, "o", "From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001\r\nFrom: John Doe \r\nDate: Mon, 23 Mar 2026 06:28:10 +0000\r\nSubject: [PATCH] Patch for cpputest\r\n\r\nPatch for cpputest\r\n\r\ndiff --git a/README.md b/README.md\r\nindex 2655a7b..fc6084e 100644\r\n--- a/README.md\r\n+++ b/README.md\r\n@@ -3,7 +3,7 @@\r\n \r\n CppUTest unit testing and mocking framework for C/C++\r\n \r\n-[More information on the project page](http://cpputest.github.com)\r\n+[More information on the project page](http://cpputest.gitlab.com)\r\n \r\n [![Build Status](https://travis-ci.org/cpputest/cpputest.png?branch=master)](https://travis-ci.org/cpputest/cpputest)\r\n \r\n"] -[24.178781, "o", "$ "] -[24.180615, "o", "\u001b["] -[24.360898, "o", "1m"] -[24.451026, "o", "\u001b["] -[24.541173, "o", "0m"] -[24.541658, "o", "\r\n"] -[24.54463, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] +{"version": 2, "width": 173, "height": 25, "timestamp": 1774774157, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[0.843591, "o", "\u001b[H\u001b[2J\u001b[3J"] +[0.847124, "o", "$ "] +[1.850318, "o", "\u001b["] +[2.030604, "o", "1m"] +[2.120789, "o", "ls"] +[2.210896, "o", " -"] +[2.301036, "o", "l "] +[2.391172, "o", ".\u001b"] +[2.482903, "o", "[0"] +[2.573062, "o", "m"] +[3.574085, "o", "\r\n"] +[3.577822, "o", "total 16\r\n"] +[3.57787, "o", "drwxr-xr-x+ 3 dev dev 4096 Mar 29 08:49 cpputest\r\n-rw-rw-rw- 1 dev dev 229 Mar 29 08:49 dfetch.yaml\r\ndrwxr-xr-x+ 4 dev dev 4096 Mar 29 08:49 jsmn\r\ndrwxrwxrwx+ 2 dev dev 4096 Mar 29 08:49 patches\r\n"] +[3.583388, "o", "$ "] +[4.586555, "o", "\u001b["] +[4.766867, "o", "1m"] +[4.856892, "o", "ca"] +[4.947032, "o", "t "] +[5.037179, "o", "df"] +[5.127323, "o", "et"] +[5.21744, "o", "ch"] +[5.307572, "o", ".y"] +[5.397698, "o", "am"] +[5.487827, "o", "l\u001b"] +[5.66808, "o", "[0"] +[5.758211, "o", "m"] +[6.759845, "o", "\r\n"] +[6.763027, "o", "manifest:\r\n version: 0.0\r\n\r\n remotes:\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/\r\n repo-path: cpputest/cpputest.git\r\n tag: v3.4\r\n patch: patches/cpputest.patch\r\n\r\n"] +[6.76773, "o", "$ "] +[7.770913, "o", "\u001b["] +[7.951184, "o", "1m"] +[8.041323, "o", "ca"] +[8.13145, "o", "t "] +[8.221581, "o", "pa"] +[8.311734, "o", "tc"] +[8.401876, "o", "he"] +[8.492002, "o", "s/"] +[8.582246, "o", "cp"] +[8.672373, "o", "pu"] +[8.852633, "o", "te"] +[8.942785, "o", "st"] +[9.032897, "o", ".p"] +[9.123034, "o", "at"] +[9.213154, "o", "ch"] +[9.303291, "o", "\u001b["] +[9.393412, "o", "0m"] +[10.395052, "o", "\r\n"] +[10.39813, "o", "diff --git a/README.md b/README.md\r\nindex 2655a7b..fc6084e 100644\r\n--- a/README.md\r\n+++ b/README.md\r\n@@ -3,7 +3,7 @@ CppUTest"] +[10.39826, "o", "\r\n \r\n CppUTest unit testing and mocking framework for C/C++\r\n \r\n-[More information on the project page](http://cpputest.github.com)\r\n+[More information on the project page](http://cpputest.gitlab.com)\r\n \r\n [![Build Status](https://travis-ci.org/cpputest/cpputest.png?branch=master)](https://travis-ci.org/cpputest/cpputest)\r\n \r\n"] +[10.40299, "o", "$ "] +[11.406193, "o", "\u001b["] +[11.586475, "o", "1m"] +[11.676607, "o", "df"] +[11.766733, "o", "et"] +[11.856884, "o", "ch"] +[11.947015, "o", " f"] +[12.037151, "o", "or"] +[12.127314, "o", "ma"] +[12.217425, "o", "t-"] +[12.307562, "o", "pa"] +[12.48786, "o", "tch"] +[12.580055, "o", " c"] +[12.670085, "o", "pp"] +[12.760214, "o", "ut"] +[12.850347, "o", "es"] +[12.940488, "o", "t "] +[13.030618, "o", "--"] +[13.120757, "o", "ou"] +[13.210872, "o", "tp"] +[13.391124, "o", "ut"] +[13.481258, "o", "-di"] +[13.571393, "o", "re"] +[13.661521, "o", "ct"] +[13.751663, "o", "or"] +[13.841805, "o", "y "] +[13.931925, "o", "fo"] +[14.022079, "o", "rm"] +[14.112279, "o", "at"] +[14.292528, "o", "te"] +[14.382658, "o", "d-"] +[14.472798, "o", "pat"] +[14.562908, "o", "ch"] +[14.653045, "o", "es"] +[14.743175, "o", "\u001b["] +[14.833302, "o", "0m"] +[15.834845, "o", "\r\n"] +[16.326055, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[16.350536, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] +[16.351113, "o", " \u001b[1;34m> formatted patch written to formatted-patches/cpputest.patch\u001b[0m\r\n"] +[16.429275, "o", "$ "] +[17.432506, "o", "\u001b["] +[17.61279, "o", "1m"] +[17.702925, "o", "ca"] +[17.793038, "o", "t "] +[17.883172, "o", "fo"] +[17.97329, "o", "rm"] +[18.063427, "o", "at"] +[18.153553, "o", "te"] +[18.243684, "o", "d-"] +[18.333889, "o", "pa"] +[18.514215, "o", "tc"] +[18.604491, "o", "he"] +[18.694617, "o", "s/"] +[18.784738, "o", "cp"] +[18.874912, "o", "pu"] +[18.96504, "o", "te"] +[19.055175, "o", "st"] +[19.145308, "o", ".p"] +[19.235433, "o", "at"] +[19.415671, "o", "ch"] +[19.505833, "o", "\u001b["] +[19.595945, "o", "0m"] +[20.597671, "o", "\r\n"] +[20.600881, "o", "From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001\r\n"] +[20.601057, "o", "From: John Doe \r\nDate: Sun, 29 Mar 2026 08:49:33 +0000\r\nSubject: [PATCH] Patch for cpputest\r\n\r\nPatch for cpputest\r\n\r\ndiff --git a/README.md b/README.md\r\nindex 2655a7b..fc6084e 100644\r\n--- a/README.md\r\n+++ b/README.md\r\n@@ -3,7 +3,7 @@\r\n \r\n CppUTest unit testing and mocking framework for C/C++\r\n \r\n-[More information on the project page](http://cpputest.github.com)\r\n+[More information on the project page](http://cpputest.gitlab.com)\r\n \r\n [![Build Status](https://travis-ci.org/cpputest/cpputest.png?branch=master)](https://travis-ci.org/cpputest/cpputest)\r\n \r\n"] +[23.609609, "o", "$ "] +[23.611536, "o", "\u001b"] +[23.791812, "o", "[1"] +[23.88193, "o", "m\u001b"] +[23.972076, "o", "[0"] +[24.062211, "o", "m"] +[24.062707, "o", "\r\n"] +[24.065585, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] diff --git a/doc/asciicasts/freeze.cast b/doc/asciicasts/freeze.cast index affbd135..c6b15273 100644 --- a/doc/asciicasts/freeze.cast +++ b/doc/asciicasts/freeze.cast @@ -1,69 +1,83 @@ -{"version": 2, "width": 165, "height": 30, "timestamp": 1774247191, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} -[0.038216, "o", "\u001b[H\u001b[2J\u001b[3J"] -[0.043613, "o", "$ "] -[1.046885, "o", "\u001b"] -[1.227163, "o", "[1"] -[1.317301, "o", "mc"] -[1.40879, "o", "at"] -[1.497739, "o", " d"] -[1.587917, "o", "fe"] -[1.679002, "o", "tc"] -[1.76822, "o", "h."] -[1.858797, "o", "ya"] -[1.948954, "o", "ml"] -[2.129216, "o", "\u001b"] -[2.220018, "o", "[0"] -[2.310154, "o", "m"] -[3.311743, "o", "\r\n"] -[3.314993, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v3.4 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n"] -[3.320107, "o", "$ "] -[4.323283, "o", "\u001b["] -[4.503544, "o", "1m"] -[4.593725, "o", "df"] -[4.683858, "o", "et"] -[4.774025, "o", "ch"] -[4.864152, "o", " f"] -[4.954268, "o", "re"] -[5.044445, "o", "ez"] -[5.134637, "o", "e\u001b"] -[5.224693, "o", "[0"] -[5.404934, "o", "m"] -[6.406123, "o", "\r\n"] -[7.0302, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[7.051233, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] -[7.051844, "o", " \u001b[1;34m> Already pinned in manifest on version v3.4\u001b[0m\r\n"] -[7.054891, "o", " \u001b[1;92mjsmn:\u001b[0m\r\n"] -[7.055456, "o", " \u001b[1;34m> Frozen on version 25647e692c7906b96ffd2b05ca54c097948e879c\u001b[0m\r\n"] -[7.058041, "o", "Updated manifest (dfetch.yaml) in /workspaces/dfetch/doc/generate-casts/freeze\r\n"] -[7.126346, "o", "$ "] -[8.129351, "o", "\u001b["] -[8.309624, "o", "1m"] -[8.399968, "o", "ca"] -[8.49038, "o", "t "] -[8.580221, "o", "df"] -[8.670353, "o", "et"] -[8.760504, "o", "ch"] -[8.850634, "o", ".y"] -[8.941702, "o", "am"] -[9.031651, "o", "l\u001b"] -[9.211915, "o", "[0m"] -[10.213679, "o", "\r\n"] -[10.216842, "o", "manifest:\r\n version: '0.0'\r\n\r\n remotes:\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/\r\n tag: v3.4\r\n repo-path: cpputest/cpputest.git\r\n\r\n - name: jsmn\r\n revision: 25647e692c7906b96ffd2b05ca54c097948e879c\r\n branch: master\r\n repo-path: zserge/jsmn.git\r\n"] -[10.22202, "o", "$ "] -[11.225568, "o", "\u001b["] -[11.405891, "o", "1m"] -[11.496048, "o", "ls"] -[11.586185, "o", " -"] -[11.676329, "o", "l ."] -[11.768024, "o", "\u001b["] -[11.857137, "o", "0m"] -[12.858752, "o", "\r\n"] -[12.863027, "o", "total 16\r\ndrwxr-xr-x+ 3 dev dev 4096 Mar 23 06:26 cpputest\r\n-rw-rw-rw- 1 dev dev 317 Mar 23 06:26 dfetch.yaml\r\n-rw-rw-rw- 1 dev dev 733 Mar 23 06:26 dfetch.yaml.backup\r\ndrwxr-xr-x+ 4 dev dev 4096 Mar 23 06:26 jsmn\r\n"] -[15.871264, "o", "$ "] -[15.873193, "o", "\u001b["] -[16.053489, "o", "1m"] -[16.143622, "o", "\u001b["] -[16.234505, "o", "0m"] -[16.235402, "o", "\r\n"] -[16.241797, "o", "/workspaces/dfetch/doc/generate-casts"] -[16.242269, "o", "\r\n"] +{"version": 2, "width": 173, "height": 25, "timestamp": 1774774074, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[0.041691, "o", "\u001b[H\u001b[2J\u001b[3J"] +[0.046702, "o", "$ "] +[1.050314, "o", "\u001b"] +[1.230496, "o", "[1"] +[1.320646, "o", "mc"] +[1.410781, "o", "at"] +[1.50093, "o", " d"] +[1.591036, "o", "fe"] +[1.681176, "o", "tc"] +[1.771289, "o", "h."] +[1.861437, "o", "ya"] +[1.951569, "o", "ml"] +[2.131819, "o", "\u001b"] +[2.221933, "o", "[0"] +[2.312074, "o", "m"] +[3.313399, "o", "\r\n"] +[3.317228, "o", "manifest:"] +[3.317265, "o", "\r\n"] +[3.317286, "o", " version: 0.0 # DFetch Module syntax version"] +[3.317304, "o", "\r\n"] +[3.317321, "o", "\r\n"] +[3.317339, "o", " remotes: # declare common sources in one place"] +[3.317356, "o", "\r\n"] +[3.317374, "o", " - name: github"] +[3.31739, "o", "\r\n"] +[3.317408, "o", " url-base: https://github.com/"] +[3.317433, "o", "\r\n"] +[3.31745, "o", "\r\n"] +[3.317467, "o", " projects:"] +[3.317553, "o", "\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v3.4 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n"] +[3.329646, "o", "$ "] +[4.332667, "o", "\u001b["] +[4.514772, "o", "1m"] +[4.604917, "o", "df"] +[4.695328, "o", "et"] +[4.785469, "o", "ch "] +[4.875601, "o", "fr"] +[4.965749, "o", "ee"] +[5.055878, "o", "ze"] +[5.146015, "o", "\u001b["] +[5.236139, "o", "0m"] +[6.237658, "o", "\r\n"] +[6.73802, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[6.754571, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] +[6.755131, "o", " \u001b[1;34m> Already pinned in manifest on version v3.4\u001b[0m\r\n"] +[6.757134, "o", " \u001b[1;92mjsmn:\u001b[0m\r\n"] +[6.757682, "o", " \u001b[1;34m> Frozen on version 25647e692c7906b96ffd2b05ca54c097948e879c\u001b[0m\r\n"] +[6.759236, "o", "Updated manifest (dfetch.yaml) in /workspaces/dfetch/doc/generate-casts/freeze\r\n"] +[6.822403, "o", "$ "] +[7.82558, "o", "\u001b["] +[8.005855, "o", "1m"] +[8.09597, "o", "ca"] +[8.186108, "o", "t "] +[8.276217, "o", "df"] +[8.367279, "o", "et"] +[8.456479, "o", "ch"] +[8.546627, "o", ".y"] +[8.636764, "o", "am"] +[8.726889, "o", "l\u001b"] +[8.907323, "o", "[0m"] +[9.909138, "o", "\r\n"] +[9.912099, "o", "manifest:\r\n version: '0.0'\r\n\r\n remotes:\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/\r\n tag: v3.4\r\n repo-path: cpputest/cpputest.git\r\n\r\n - name: jsmn\r\n revision: 25647e692c7906b96ffd2b05ca54c097948e879c\r\n branch: master\r\n repo-path: zserge/jsmn.git\r\n"] +[9.917439, "o", "$ "] +[10.920548, "o", "\u001b["] +[11.101311, "o", "1m"] +[11.191464, "o", "ls"] +[11.281735, "o", " -"] +[11.371746, "o", "l "] +[11.461878, "o", ".\u001b"] +[11.55201, "o", "[0"] +[11.642136, "o", "m"] +[12.643837, "o", "\r\n"] +[12.647357, "o", "total 16\r\n"] +[12.647473, "o", "drwxr-xr-x+ 3 dev dev 4096 Mar 29 08:47 cpputest\r\n-rw-rw-rw- 1 dev dev 317 Mar 29 08:48 dfetch.yaml\r\n-rw-rw-rw- 1 dev dev 733 Mar 29 08:47 dfetch.yaml.backup\r\ndrwxr-xr-x+ 4 dev dev 4096 Mar 29 08:47 jsmn\r\n"] +[15.659238, "o", "$ "] +[15.661125, "o", "\u001b"] +[15.841433, "o", "[1"] +[15.931575, "o", "m\u001b"] +[16.021695, "o", "[0"] +[16.111828, "o", "m"] +[16.112398, "o", "\r\n"] +[16.116992, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] diff --git a/doc/asciicasts/import.cast b/doc/asciicasts/import.cast index 7b35ab5c..4ddaf7dd 100644 --- a/doc/asciicasts/import.cast +++ b/doc/asciicasts/import.cast @@ -1,69 +1,66 @@ -{"version": 2, "width": 165, "height": 30, "timestamp": 1774247301, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} -[0.021266, "o", "$ "] -[1.024256, "o", "\u001b"] -[1.204551, "o", "[1"] -[1.294674, "o", "ml"] -[1.384794, "o", "s "] -[1.474943, "o", "-"] -[1.565052, "o", "l\u001b"] -[1.655189, "o", "[0"] -[1.745348, "o", "m"] -[2.746928, "o", "\r\n"] -[2.750491, "o", "total 580\r\n"] -[2.750604, "o", "-rw-rw-rw- 1 dev dev 1137 Mar 23 06:23 CMakeLists.txt\r\n-rw-rw-rw- 1 dev dev 35147 Mar 23 06:23 LICENSE\r\n-rw-rw-rw- 1 dev dev 1796 Mar 23 06:23 README.md\r\n-rw-rw-rw- 1 dev dev 1381 Mar 23 06:23 appveyor.yml\r\n-rwxrwxrwx 1 dev dev 229 Mar 23 06:23 create_doc.sh\r\ndrwxrwxrwx+ 2 dev dev 4096 Mar 23 06:23 data\r\ndrwxrwxrwx+ 4 dev dev 4096 Mar 23 06:23 doc\r\ndrwxrwxrwx+ 4 dev dev 4096 Mar 23 06:23 docs\r\ndrwxrwxrwx+ 2 dev dev 4096 Mar 23 06:23 installer\r\ndrwxrwxrwx+ 4 dev dev 4096 Mar 23 06:23 libraries\r\n-rw-rw-rw- 1 dev dev 505101 Mar 23 06:23 modbusscope_demo.gif\r\ndrwxrwxrwx+ 5 dev dev 4096 Mar 23 06:23 resources\r\ndrwxrwxrwx+ 9 dev dev 4096 Mar 23 06:23 src\r\ndrwxrwxrwx+ 9 dev dev 4096 Mar 23 06:23 tests\r\n"] -[2.756067, "o", "$ "] -[3.75877, "o", "\u001b"] -[3.939032, "o", "[1"] -[4.029319, "o", "mc"] -[4.119468, "o", "at"] -[4.209613, "o", " ."] -[4.29975, "o", "gi"] -[4.389894, "o", "tm"] -[4.480026, "o", "od"] -[4.570187, "o", "ul"] -[4.660326, "o", "es"] -[4.840612, "o", "\u001b"] -[4.930754, "o", "[0"] -[5.020869, "o", "m"] -[6.023293, "o", "\r\n"] -[6.033538, "o", "[submodule \"tests/googletest\"]\r\n\tpath = tests/googletest\r\n\turl = https://github.com/google/googletest.git\r\n[submodule \"libraries/muparser\"]\r\n\tpath = libraries/muparser\r\n\turl = https://github.com/beltoforion/muparser.git\r\n"] -[6.034087, "o", "$ "] -[7.03736, "o", "\u001b"] -[7.217943, "o", "[1"] -[7.308089, "o", "md"] -[7.398224, "o", "fe"] -[7.488368, "o", "t"] -[7.5785, "o", "ch"] -[7.668637, "o", " i"] -[7.758753, "o", "mp"] -[7.848901, "o", "or"] -[7.939024, "o", "t"] -[8.119432, "o", "\u001b["] -[8.209577, "o", "0m"] -[9.211214, "o", "\r\n"] -[9.659333, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[15.2872, "o", "Found libraries/muparser\r\n"] -[15.287732, "o", "Found tests/googletest\r\n"] -[15.289874, "o", "Created manifest (dfetch.yaml) in /workspaces/dfetch/doc/generate-casts/ModbusScope\r\n"] -[15.350794, "o", "$ "] -[16.35396, "o", "\u001b["] -[16.534262, "o", "1m"] -[16.624409, "o", "ca"] -[16.714548, "o", "t "] -[16.804664, "o", "df"] -[16.894796, "o", "et"] -[16.984932, "o", "ch"] -[17.075079, "o", ".y"] -[17.165284, "o", "am"] -[17.255328, "o", "l\u001b"] -[17.435795, "o", "[0"] -[17.525823, "o", "m"] -[18.527427, "o", "\r\n"] -[18.530295, "o", "manifest:\r\n version: '0.0'\r\n\r\n remotes:\r\n - name: github-com-beltoforion\r\n url-base: https://github.com/beltoforion\r\n\r\n - name: github-com-google\r\n url-base: https://github.com/google\r\n\r\n projects:\r\n - name: libraries/muparser\r\n revision: 207d5b77c05c9111ff51ab91082701221220c477"] -[18.530424, "o", "\r\n remote: github-com-beltoforion\r\n tag: v2.3.2\r\n repo-path: muparser.git\r\n\r\n - name: tests/googletest\r\n revision: dcc92d0ab6c4ce022162a23566d44f673251eee4\r\n remote: github-com-google\r\n repo-path: googletest.git\r\n"] -[21.538779, "o", "$ "] -[21.540635, "o", "\u001b["] -[21.721005, "o", "1m"] -[21.811138, "o", "\u001b["] -[21.901272, "o", "0m"] -[21.90185, "o", "\r\n"] +{"version": 2, "width": 173, "height": 25, "timestamp": 1774774186, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[0.021865, "o", "$ "] +[1.024942, "o", "\u001b["] +[1.205267, "o", "1m"] +[1.295394, "o", "ls"] +[1.385541, "o", " -"] +[1.475678, "o", "l\u001b["] +[1.565787, "o", "0m"] +[2.567345, "o", "\r\n"] +[2.571397, "o", "total 580\r\n"] +[2.571644, "o", "-rw-rw-rw- 1 dev dev 1137 Mar 29 08:49 CMakeLists.txt\r\n-rw-rw-rw- 1 dev dev 35147 Mar 29 08:49 LICENSE\r\n-rw-rw-rw- 1 dev dev 1796 Mar 29 08:49 README.md\r\n-rw-rw-rw- 1 dev dev 1381 Mar 29 08:49 appveyor.yml\r\n-rwxrwxrwx 1 dev dev 229 Mar 29 08:49 create_doc.sh\r\ndrwxrwxrwx+ 2 dev dev 4096 Mar 29 08:49 data\r\ndrwxrwxrwx+ 4 dev dev 4096 Mar 29 08:49 doc\r\ndrwxrwxrwx+ 4 dev dev 4096 Mar 29 08:49 docs\r\ndrwxrwxrwx+ 2 dev dev 4096 Mar 29 08:49 installer\r\ndrwxrwxrwx+ 4 dev dev 4096 Mar 29 08:49 libraries\r\n-rw-rw-rw- 1 dev dev 505101 Mar 29 08:49 modbusscope_demo.gif\r\ndrwxrwxrwx+ 5 dev dev 4096 Mar 29 08:49 resources\r\ndrwxrwxrwx+ 9 dev dev 4096 Mar 29 08:49 src\r\ndrwxrwxrwx+ 9 dev dev 4096 Mar 29 08:49 tests\r\n"] +[2.577846, "o", "$ "] +[3.581172, "o", "\u001b"] +[3.762701, "o", "[1"] +[3.852848, "o", "mc"] +[3.94298, "o", "at"] +[4.033175, "o", " "] +[4.123313, "o", ".g"] +[4.213428, "o", "it"] +[4.303567, "o", "mo"] +[4.393725, "o", "du"] +[4.483863, "o", "l"] +[4.665716, "o", "es"] +[4.755831, "o", "\u001b["] +[4.845954, "o", "0m"] +[5.847536, "o", "\r\n"] +[5.850598, "o", "[submodule \"tests/googletest\"]\r\n\tpath = tests/googletest\r\n\turl = https://github.com/google/googletest.git\r\n[submodule \"libraries/muparser\"]\r\n\tpath = libraries/muparser\r\n\turl = https://github.com/beltoforion/muparser.git\r\n"] +[5.855515, "o", "$ "] +[6.859809, "o", "\u001b"] +[7.040107, "o", "[1"] +[7.13035, "o", "md"] +[7.220495, "o", "fe"] +[7.310607, "o", "tc"] +[7.400749, "o", "h "] +[7.4909, "o", "im"] +[7.581093, "o", "po"] +[7.671224, "o", "rt"] +[7.761423, "o", "\u001b["] +[7.941661, "o", "0"] +[8.031772, "o", "m"] +[9.033518, "o", "\r\n"] +[9.472966, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[10.201941, "o", "Found libraries/muparser\r\n"] +[10.202426, "o", "Found tests/googletest\r\n"] +[10.205022, "o", "Created manifest (dfetch.yaml) in /workspaces/dfetch/doc/generate-casts/ModbusScope\r\n"] +[10.265742, "o", "$ "] +[11.268276, "o", "\u001b["] +[11.448527, "o", "1m"] +[11.538679, "o", "ca"] +[11.628804, "o", "t "] +[11.718934, "o", "df"] +[11.809087, "o", "et"] +[11.899213, "o", "ch"] +[11.989335, "o", ".y"] +[12.079516, "o", "am"] +[12.169664, "o", "l\u001b"] +[12.349895, "o", "[0"] +[12.439999, "o", "m"] +[13.440602, "o", "\r\n"] +[13.44321, "o", "manifest:\r\n version: '0.0'\r\n\r\n remotes:\r\n - name: github-com-beltoforion\r\n url-base: https://github.com/beltoforion\r\n\r\n - name: github-com-google\r\n url-base: https://github.com/google\r\n\r\n projects:\r\n - name: libraries/muparser\r\n revision: 207d5b77c05c9111ff51ab91082701221220c477\r\n remote: github-com-beltoforion\r\n tag: v2.3.2\r\n repo-path: muparser.git\r\n\r\n - name: tests/googletest\r\n revision: dcc92d0ab6c4ce022162a23566d44f673251eee4\r\n remote: github-com-google\r\n repo-path: googletest.git\r\n"] +[16.453208, "o", "$ "] +[16.456227, "o", "\u001b["] +[16.636532, "o", "1m"] +[16.726695, "o", "\u001b["] +[16.817784, "o", "0m"] +[16.818137, "o", "\r\n"] diff --git a/doc/asciicasts/init.cast b/doc/asciicasts/init.cast index 1a874e1b..b04b15a2 100644 --- a/doc/asciicasts/init.cast +++ b/doc/asciicasts/init.cast @@ -1,62 +1,59 @@ -{"version": 2, "width": 165, "height": 30, "timestamp": 1774247080, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} -[0.031816, "o", "\u001b[H\u001b[2J\u001b[3J"] -[0.038194, "o", "$ "] -[1.041604, "o", "\u001b"] -[1.221887, "o", "[1"] -[1.312005, "o", "ml"] -[1.402137, "o", "s "] -[1.492286, "o", "-l"] -[1.58241, "o", "\u001b["] -[1.672573, "o", "0m"] -[2.673483, "o", "\r\n"] -[2.678411, "o", "total 0\r\n"] -[2.684241, "o", "$ "] -[3.687919, "o", "\u001b["] -[3.86821, "o", "1m"] -[3.958369, "o", "df"] -[4.048479, "o", "et"] -[4.138623, "o", "ch"] -[4.228761, "o", " i"] -[4.318889, "o", "ni"] -[4.409004, "o", "t\u001b"] -[4.499166, "o", "[0"] -[4.589306, "o", "m"] -[5.590817, "o", "\r\n"] -[6.050037, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[6.051035, "o", "Created dfetch.yaml\r\n"] -[6.123621, "o", "$ "] -[7.126883, "o", "\u001b"] -[7.307436, "o", "[1"] -[7.39757, "o", "ml"] -[7.487715, "o", "s "] -[7.577871, "o", "-"] -[7.668007, "o", "l\u001b"] -[7.758176, "o", "[0"] -[7.848319, "o", "m"] -[8.849909, "o", "\r\n"] -[8.853427, "o", "total 4\r\n"] -[8.853712, "o", "-rw-rw-rw- 1 dev dev 733 Mar 23 06:24 dfetch.yaml\r\n"] -[8.858837, "o", "$ "] -[9.862119, "o", "\u001b"] -[10.042414, "o", "[1"] -[10.132532, "o", "mc"] -[10.22267, "o", "at"] -[10.312833, "o", " d"] -[10.403076, "o", "fe"] -[10.493247, "o", "tc"] -[10.58336, "o", "h."] -[10.673495, "o", "ya"] -[10.763623, "o", "ml"] -[10.943904, "o", "\u001b"] -[11.034041, "o", "[0"] -[11.124174, "o", "m"] -[12.125803, "o", "\r\n"] -[12.129146, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v3.4 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n"] -[12.129313, "o", " repo-path: zserge/jsmn.git # only repo-path is enough\r\n"] -[15.140188, "o", "$ "] -[15.141604, "o", "\u001b["] -[15.321965, "o", "1m"] -[15.412142, "o", "\u001b["] -[15.502282, "o", "0m"] -[15.502794, "o", "\r\n"] -[15.506047, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] +{"version": 2, "width": 173, "height": 25, "timestamp": 1774773960, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[0.038871, "o", "\u001b[H\u001b[2J\u001b[3J"] +[0.046365, "o", "$ "] +[1.050138, "o", "\u001b["] +[1.230388, "o", "1m"] +[1.32065, "o", "ls"] +[1.410792, "o", " -"] +[1.500919, "o", "l\u001b["] +[1.591059, "o", "0m"] +[2.592577, "o", "\r\n"] +[2.595927, "o", "total 0\r\n"] +[2.600869, "o", "$ "] +[3.603963, "o", "\u001b["] +[3.784223, "o", "1m"] +[3.87438, "o", "df"] +[3.964504, "o", "et"] +[4.054624, "o", "ch"] +[4.144756, "o", " i"] +[4.235004, "o", "ni"] +[4.325134, "o", "t\u001b"] +[4.415271, "o", "[0"] +[4.505532, "o", "m"] +[5.507698, "o", "\r\n"] +[6.051616, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[6.052516, "o", "Created dfetch.yaml\r\n"] +[6.113311, "o", "$ "] +[7.116314, "o", "\u001b["] +[7.296842, "o", "1m"] +[7.387127, "o", "ls"] +[7.477205, "o", " -"] +[7.567336, "o", "l\u001b"] +[7.657476, "o", "[0"] +[7.747591, "o", "m"] +[8.748222, "o", "\r\n"] +[8.751743, "o", "total 4\r\n"] +[8.751794, "o", "-rw-rw-rw- 1 dev dev 733 Mar 29 08:46 dfetch.yaml\r\n"] +[8.757714, "o", "$ "] +[9.760367, "o", "\u001b["] +[9.940643, "o", "1m"] +[10.03077, "o", "ca"] +[10.120912, "o", "t "] +[10.211041, "o", "df"] +[10.301194, "o", "et"] +[10.391316, "o", "ch"] +[10.481445, "o", ".y"] +[10.571547, "o", "am"] +[10.661702, "o", "l\u001b"] +[10.841933, "o", "[0"] +[10.932649, "o", "m"] +[11.933711, "o", "\r\n"] +[11.93667, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v3.4 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n"] +[14.94415, "o", "$ "] +[14.946022, "o", "\u001b"] +[15.126374, "o", "[1"] +[15.21673, "o", "m\u001b"] +[15.306792, "o", "[0"] +[15.397056, "o", "m"] +[15.397555, "o", "\r\n"] +[15.400612, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] diff --git a/doc/asciicasts/report.cast b/doc/asciicasts/report.cast index 986b8a02..dce5dfeb 100644 --- a/doc/asciicasts/report.cast +++ b/doc/asciicasts/report.cast @@ -1,45 +1,44 @@ -{"version": 2, "width": 165, "height": 30, "timestamp": 1774247168, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} -[0.045612, "o", "\u001b[H\u001b[2J\u001b[3J"] -[0.049871, "o", "$ "] -[1.053174, "o", "\u001b["] -[1.233538, "o", "1m"] -[1.323672, "o", "ls"] -[1.413824, "o", " -"] -[1.503937, "o", "l\u001b"] -[1.594081, "o", "[0"] -[1.684878, "o", "m"] -[2.686286, "o", "\r\n"] -[2.690187, "o", "total 12\r\n"] -[2.690506, "o", "drwxr-xr-x+ 3 dev dev 4096 Mar 23 06:26 cpputest\r\n-rw-rw-rw- 1 dev dev 733 Mar 23 06:26 dfetch.yaml\r\ndrwxr-xr-x+ 4 dev dev 4096 Mar 23 06:26 jsmn\r\n"] -[2.697025, "o", "$ "] -[3.699485, "o", "\u001b["] -[3.879818, "o", "1m"] -[3.970058, "o", "df"] -[4.060259, "o", "et"] -[4.150603, "o", "ch"] -[4.240608, "o", " r"] -[4.330741, "o", "ep"] -[4.420922, "o", "or"] -[4.511042, "o", "t\u001b"] -[4.60117, "o", "[0"] -[4.781892, "o", "m"] -[5.782939, "o", "\r\n"] -[6.252819, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[6.293479, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] -[6.294254, "o", " \u001b[1;92m- remote :\u001b[0m\u001b[1;34m github\u001b[0m\r\n"] -[6.295702, "o", " \u001b[1;92m remote url :\u001b[0m\u001b[1;34m https://github.com/cpputest/cpputest.git\u001b[0m\r\n"] -[6.296252, "o", " \u001b[1;92m branch :\u001b[0m\u001b[1;34m master\u001b[0m\r\n"] -[6.296794, "o", " \u001b[1;92m tag :\u001b[0m\u001b[1;34m v3.4\u001b[0m\r\n"] -[6.297328, "o", " \u001b[1;92m last fetch :\u001b[0m\u001b[1;34m 23/03/2026, 06:25:57\u001b[0m\r\n"] -[6.297859, "o", " \u001b[1;92m revision :\u001b[0m\u001b[1;34m \u001b[0m\r\n"] -[6.298374, "o", " \u001b[1;92m patch :\u001b[0m\u001b[1;34m \u001b[0m\r\n"] -[6.299045, "o", " \u001b[1;92m licenses :\u001b[0m\u001b[1;34m BSD 3-Clause \"New\" or \"Revised\" License\u001b[0m\r\n"] -[6.303295, "o", " \u001b[1;92mjsmn:\u001b[0m\r\n"] -[6.303927, "o", " \u001b[1;92m- remote :\u001b[0m\u001b[1;34m github\u001b[0m\r\n"] -[6.306005, "o", " \u001b[1;92m remote url :\u001b[0m\u001b[1;34m https://github.com/zserge/jsmn.git\u001b[0m\r\n \u001b[1;92m branch :\u001b[0m\u001b[1;34m master\u001b[0m\r\n"] -[6.306758, "o", " \u001b[1;92m tag :\u001b[0m\u001b[1;34m \u001b[0m\r\n"] -[6.307353, "o", " \u001b[1;92m last fetch :\u001b[0m\u001b[1;34m 23/03/2026, 06:25:58\u001b[0m\r\n"] -[6.308402, "o", " \u001b[1;92m revision :\u001b[0m\u001b[1;34m 25647e692c7906b96ffd2b05ca54c097948e879c\u001b[0m\r\n"] -[6.308825, "o", " \u001b[1;92m patch :\u001b[0m\u001b[1;34m \u001b[0m\r\n"] -[6.309627, "o", " \u001b[1;92m licenses :\u001b[0m\u001b[1;34m MIT License\u001b[0m\r\n"] -[9.382775, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] +{"version": 2, "width": 173, "height": 25, "timestamp": 1774774051, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[0.042994, "o", "\u001b[H\u001b[2J\u001b[3J"] +[0.048488, "o", "$ "] +[1.051645, "o", "\u001b["] +[1.231919, "o", "1m"] +[1.322237, "o", "ls"] +[1.412363, "o", " -"] +[1.50251, "o", "l\u001b"] +[1.592638, "o", "[0"] +[1.682985, "o", "m"] +[2.684576, "o", "\r\n"] +[2.688128, "o", "total 12\r\n"] +[2.688183, "o", "drwxr-xr-x+ 3 dev dev 4096 Mar 29 08:47 cpputest\r\n-rw-rw-rw- 1 dev dev 733 Mar 29 08:47 dfetch.yaml\r\ndrwxr-xr-x+ 4 dev dev 4096 Mar 29 08:47 jsmn\r\n"] +[2.693625, "o", "$ "] +[3.69675, "o", "\u001b"] +[3.879138, "o", "[1"] +[3.968006, "o", "md"] +[4.058163, "o", "fe"] +[4.148309, "o", "t"] +[4.238451, "o", "ch"] +[4.328569, "o", " r"] +[4.418696, "o", "ep"] +[4.508829, "o", "or"] +[4.598882, "o", "t"] +[4.77926, "o", "\u001b["] +[4.869945, "o", "0m"] +[5.870888, "o", "\r\n"] +[6.335615, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[6.37037, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] +[6.371012, "o", " \u001b[1;92m- remote :\u001b[0m\u001b[1;34m github\u001b[0m\r\n"] +[6.372468, "o", " \u001b[1;92m remote url :\u001b[0m\u001b[1;34m https://github.com/cpputest/cpputest.git\u001b[0m\r\n"] +[6.373653, "o", " \u001b[1;92m branch :\u001b[0m\u001b[1;34m master\u001b[0m\r\n"] +[6.374003, "o", " \u001b[1;92m tag :\u001b[0m\u001b[1;34m v3.4\u001b[0m\r\n"] +[6.374305, "o", " \u001b[1;92m last fetch :\u001b[0m\u001b[1;34m 29/03/2026, 08:47:20\u001b[0m\r\n"] +[6.376078, "o", " \u001b[1;92m revision :\u001b[0m\u001b[1;34m \u001b[0m\r\n \u001b[1;92m patch :\u001b[0m\u001b[1;34m \u001b[0m\r\n"] +[6.376194, "o", " \u001b[1;92m licenses :\u001b[0m\u001b[1;34m BSD 3-Clause \"New\" or \"Revised\" License\u001b[0m\r\n"] +[6.381695, "o", " \u001b[1;92mjsmn:\u001b[0m\r\n"] +[6.382366, "o", " \u001b[1;92m- remote :\u001b[0m\u001b[1;34m github\u001b[0m\r\n"] +[6.383722, "o", " \u001b[1;92m remote url :\u001b[0m\u001b[1;34m https://github.com/zserge/jsmn.git\u001b[0m\r\n"] +[6.385813, "o", " \u001b[1;92m branch :\u001b[0m\u001b[1;34m master\u001b[0m\r\n \u001b[1;92m tag :\u001b[0m\u001b[1;34m \u001b[0m\r\n \u001b[1;92m last fetch :\u001b[0m\u001b[1;34m 29/03/2026, 08:47:21\u001b[0m\r\n"] +[6.386109, "o", " \u001b[1;92m revision :\u001b[0m\u001b[1;34m 25647e692c7906b96ffd2b05ca54c097948e879c\u001b[0m\r\n"] +[6.386677, "o", " \u001b[1;92m patch :\u001b[0m\u001b[1;34m \u001b[0m\r\n"] +[6.390512, "o", " \u001b[1;92m licenses :\u001b[0m\u001b[1;34m MIT License\u001b[0m\r\n"] +[9.513043, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] diff --git a/doc/asciicasts/sbom.cast b/doc/asciicasts/sbom.cast index acdd57ed..50566e57 100644 --- a/doc/asciicasts/sbom.cast +++ b/doc/asciicasts/sbom.cast @@ -1,50 +1,51 @@ -{"version": 2, "width": 165, "height": 30, "timestamp": 1774247178, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} -[0.043053, "o", "\u001b[H\u001b[2J\u001b[3J"] -[0.048537, "o", "$ "] -[1.051541, "o", "\u001b["] -[1.231806, "o", "1m"] -[1.321953, "o", "ls"] -[1.412097, "o", " -"] -[1.502223, "o", "l\u001b"] -[1.592519, "o", "[0"] -[1.682591, "o", "m"] -[2.684033, "o", "\r\n"] -[2.688422, "o", "total 12\r\n"] -[2.688473, "o", "drwxr-xr-x+ 3 dev dev 4096 Mar 23 06:26 cpputest\r\n-rw-rw-rw- 1 dev dev 733 Mar 23 06:26 dfetch.yaml\r\ndrwxr-xr-x+ 4 dev dev 4096 Mar 23 06:26 jsmn\r\n"] -[2.694441, "o", "$ "] -[3.699382, "o", "\u001b["] -[3.87915, "o", "1m"] -[3.969283, "o", "df"] -[4.059408, "o", "et"] -[4.150202, "o", "ch"] -[4.239706, "o", " r"] -[4.331648, "o", "ep"] -[4.421775, "o", "or"] -[4.511916, "o", "t "] -[4.602064, "o", "-t"] -[4.78232, "o", " sb"] -[4.872459, "o", "om"] -[4.962612, "o", "\u001b["] -[5.052725, "o", "0m"] -[6.054318, "o", "\r\n"] -[6.54686, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[6.586475, "o", "Generated SBoM report: report.json\r\n"] -[6.657691, "o", "$ "] -[7.660886, "o", "\u001b["] -[7.841184, "o", "1m"] -[7.931322, "o", "ca"] -[8.021464, "o", "t "] -[8.1116, "o", "re"] -[8.201738, "o", "po"] -[8.291867, "o", "rt"] -[8.382014, "o", ".j"] -[8.472119, "o", "so"] -[8.563745, "o", "n\u001b"] -[8.742502, "o", "[0"] -[8.832648, "o", "m"] -[9.834239, "o", "\r\n"] -[9.837464, "o", "{\r\n \"components\": [\r\n {\r\n \"bom-ref\": \"cpputest-v3.4\",\r\n \"evidence\": {\r\n \"identity\": [\r\n {\r\n \"concludedValue\": \"cpputest\",\r\n \"field\": \"name\",\r\n \"methods\": [\r\n {\r\n \"confidence\": 0.4,\r\n \"technique\": \"manifest-analysis\",\r\n \"value\": \"Name as used for project in dfetch.yaml\"\r\n }\r\n ],\r\n \"tools\": [\r\n \"dfetch-0.12.1\"\r\n ]\r\n },\r\n {\r\n \"concludedValue\": \"pkg:github/cpputest/cpputest@v3.4\",\r\n \"field\": \"purl\",\r\n \"methods\": [\r\n {\r\n \"confidence\": 0.4,\r\n \"technique\": \"manifest-analysis\","] -[9.837667, "o", "\r\n \"value\": \"Determined from https://github.com/cpputest/cpputest.git as used for the project cpputest in dfetch.yaml\"\r\n }\r\n ],\r\n \"tools\": [\r\n \"dfetch-0.12.1\"\r\n ]\r\n },\r\n {\r\n \"concludedValue\": \"v3.4\",\r\n \"field\": \"version\",\r\n \"methods\": [\r\n {\r\n \"confidence\": 0.4,\r\n \"technique\": \"manifest-analysis\",\r\n \"value\": \"Version as used for project in dfetch.yaml\"\r\n }\r\n ],\r\n \"tools\": [\r\n \"dfetch-0.12.1\"\r\n ]\r\n }\r\n ],\r\n \"licenses\": [\r\n {\r\n \"license\": {\r\n \"id\": \"BSD-3-Clause\"\r\n }\r\n }\r\n ],\r\n \"occurrences\": [\r\n {\r\n \"line\": 9,\r\n \"location\": \"dfetch.yaml\",\r\n \"offset\": 11\r\n }\r\n ]\r\n },\r\n \"externalReferences\": [\r\n {\r\n \"type\": \"vcs\",\r\n \"url\": \"https://github.com/cpputest/cpputest\"\r\n }\r\n ],\r\n \"group\": \"cpputest\",\r\n \"licenses\": [\r\n {\r\n \"license\": {\r\n \"id\": \"BSD-3-Clause\"\r\n }\r\n }\r\n ],\r\n \"name\": \"cpputest\",\r\n \"purl\": \"pkg:github/cpputest/cpputest@v3.4\",\r\n \"type\": \"library\",\r\n \"version\": \"v3.4\"\r\n },\r\n {\r\n \"bom-ref\": \"jsmn-25647e692c7906b96ffd2b05ca54c097948e879c\",\r\n \"evidence\": {\r\n \"identity\": [\r\n {\r\n \"concludedValue\": \"jsmn\",\r\n \"field\": \"name\",\r\n \"methods\": [\r\n {\r\n \"confidence\": 0.4,\r\n \"technique\": \"manifest-analysis\",\r\n \"value\": \"Name as used for project in dfetch.yaml\"\r\n }\r\n ],\r\n \"tools\": [\r\n \"dfetch-0.12.1\"\r\n ]\r\n },\r\n {\r\n \"concludedValue\": \"pkg:github/zserge/jsmn@25647e692c7906b96ffd2b05ca54c097948e879c\",\r\n \"field\": \"purl\",\r\n \"methods\": [\r\n {\r\n \"confidence\": 0.4,\r\n \"technique\": \"manifest-analysis\",\r\n \"value\": \"Determined from https://github.com/zserge/jsmn.git as used for the project jsmn in dfetch.yaml\"\r\n }\r\n ],\r\n \"tools\": [\r\n \"dfetch-0.12.1\"\r\n ]\r\n },\r\n {\r\n \"concludedValue\": \"25647e692c7906b96ffd2b05ca54c097948e879c\",\r\n \"field\": \"version\",\r\n \"methods\": [\r\n {\r\n \"confidence\": 0.4,\r\n \"technique\": \"manifest-analysis\",\r\n \"value\": \"Version as used for project in dfetch.yaml\"\r\n }\r\n ],\r\n \"tools\": [\r\n \"dfetch-0.12.1\"\r\n ]\r\n }\r\n ],\r\n \"licenses\": [\r\n {\r\n "] -[9.837712, "o", " \"license\": {\r\n \"id\": \"MIT\"\r\n }\r\n }\r\n ],\r\n \"occurrences\": [\r\n {\r\n \"line\": 14,\r\n \"location\": \"dfetch.yaml\",\r\n \"offset\": 11\r\n }\r\n ]\r\n },\r\n \"externalReferences\": [\r\n {\r\n \"type\": \"vcs\",\r\n \"url\": \"https://github.com/zserge/jsmn\"\r\n }\r\n ],\r\n \"group\": \"zserge\",\r\n \"licenses\": [\r\n {\r\n \"license\": {\r\n \"id\": \"MIT\"\r\n }\r\n }\r\n ],\r\n \"name\": \"jsmn\",\r\n \"purl\": \"pkg:github/zserge/jsmn@25647e692c7906b96ffd2b05ca54c097948e879c\",\r\n \"type\": \"library\",\r\n \"version\": \"25647e692c7906b96ffd2b05ca54c097948e879c\"\r\n }\r\n ],\r\n \"dependencies\": [\r\n {\r\n \"ref\": \"cpputest-v3.4\"\r\n },\r\n {\r\n \"ref\": \"jsmn-25647e692c7906b96ffd2b05ca54c097948e879c\"\r\n }\r\n ],\r\n \"metadata\": {\r\n \"timestamp\": \"2026-03-23T06:26:24.650061+00:00\",\r\n \"tools\": {\r\n \"components\": [\r\n {\r\n \"bom-ref\": \"dfetch-0.12.1\",\r\n \"externalReferences\": [\r\n {\r\n \"type\": \"build-system\",\r\n \"url\": \"https://github.com/dfetch-org/dfetch/actions\"\r\n },\r\n {\r\n \"type\": \"distribution\",\r\n \"url\": \"https://pypi.org/project/dfetch/\"\r\n },\r\n {\r\n \"type\": \"documentation\",\r\n \"url\": \"https://dfetch.readthedocs.io/\"\r\n },\r\n {\r\n \"type\": \"issue-tracker\",\r\n \"url\": \"https://github.com/dfetch-org/dfetch/issues\"\r\n },\r\n {\r\n \"type\": \"license\",\r\n \"url\": \"https://github.com/dfetch-org/dfetch/blob/main/LICENSE\"\r\n },\r\n {\r\n \"type\": \"release-notes\",\r\n \"url\": \"https://github.com/dfetch-org/dfetch/blob/main/CHANGELOG.rst\"\r\n },\r\n {\r\n \"type\": \"vcs\",\r\n \"url\": \"https://github.com/dfetch-org/dfetch\"\r\n },\r\n {\r\n \"type\": \"website\",\r\n \"url\": \"https://dfetch-org.github.io/\"\r\n }\r\n ],\r\n \"licenses\": [\r\n {\r\n \"license\": {\r\n \"acknowledgement\": \"declared\",\r\n \"id\": \"MIT\"\r\n }\r\n }\r\n ],\r\n \"name\": \"dfetch\",\r\n \"supplier\": {\r\n \"name\": \"dfetch-org\"\r\n },\r\n \"type\": \"application\",\r\n \"version\": \"0.12.1\"\r\n },\r\n {\r\n \"description\": \"Python library for CycloneDX\",\r\n \"externalReferences\": [\r\n {\r\n \"type\": \"build-system\",\r\n \"url\": \"https://github.com/CycloneDX/cyclonedx-python-lib/actions\"\r\n },\r\n {\r\n \"type\": \"distribution\",\r\n \"url\": \"https://pypi.org/project/cyclonedx-python-lib/\"\r\n },\r\n {\r\n \"type\": \"documentation\",\r\n"] -[9.837736, "o", " \"url\": \"https://cyclonedx-python-library.readthedocs.io/\"\r\n },\r\n {\r\n \"type\": \"issue-tracker\",\r\n \"url\": \"https://github.com/CycloneDX/cyclonedx-python-lib/issues\"\r\n },\r\n {\r\n \"type\": \"license\",\r\n \"url\": \"https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/LICENSE\"\r\n },\r\n {\r\n \"type\": \"release-notes\",\r\n \"url\": \"https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/CHANGELOG.md\"\r\n },\r\n {\r\n \"type\": \"vcs\",\r\n \"url\": \"https://github.com/CycloneDX/cyclonedx-python-lib\"\r\n },\r\n {\r\n \"type\": \"website\",\r\n \"url\": \"https://github.com/CycloneDX/cyclonedx-python-lib/#readme\"\r\n }\r\n ],\r\n \"group\": \"CycloneDX\",\r\n \"licenses\": [\r\n {\r\n \"license\": {\r\n \"acknowledgement\": \"declared\",\r\n \"id\": \"Apache-2.0\"\r\n }\r\n }\r\n ],\r\n \"name\": \"cyclonedx-python-lib\",\r\n \"type\": \"library\",\r\n \"version\": \"11.7.0\"\r\n }\r\n ]\r\n }\r\n },\r\n \"serialNumber\": \"urn:uuid:732db7bd-d622-44c1-a4c4-57337557686b\",\r\n \"version\": 1,\r\n \"$schema\": \"http://cyclonedx.org/schema/bom-1.6.schema.json\",\r\n \"bomFormat\": \"CycloneDX\",\r\n \"specVersion\": \"1.6\"\r\n}"] -[12.842484, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] +{"version": 2, "width": 173, "height": 25, "timestamp": 1774774061, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[0.039778, "o", "\u001b[H\u001b[2J\u001b[3J"] +[0.044904, "o", "$ "] +[1.047823, "o", "\u001b"] +[1.228091, "o", "[1"] +[1.318232, "o", "ml"] +[1.408376, "o", "s "] +[1.498509, "o", "-l"] +[1.588651, "o", "\u001b["] +[1.678765, "o", "0m"] +[2.680389, "o", "\r\n"] +[2.683902, "o", "total 12\r\n"] +[2.68396, "o", "drwxr-xr-x+ 3 dev dev 4096 Mar 29 08:47 cpputest\r\n-rw-rw-rw- 1 dev dev 733 Mar 29 08:47 dfetch.yaml\r\ndrwxr-xr-x+ 4 dev dev 4096 Mar 29 08:47 jsmn\r\n"] +[2.688954, "o", "$ "] +[3.692097, "o", "\u001b["] +[3.872333, "o", "1m"] +[3.962473, "o", "df"] +[4.052597, "o", "et"] +[4.142768, "o", "ch"] +[4.232882, "o", " r"] +[4.323003, "o", "ep"] +[4.413137, "o", "or"] +[4.503278, "o", "t "] +[4.593406, "o", "-t"] +[4.773676, "o", " s"] +[4.863793, "o", "bo"] +[4.954002, "o", "m\u001b"] +[5.044098, "o", "[0"] +[5.134231, "o", "m"] +[6.135805, "o", "\r\n"] +[6.600737, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[6.638176, "o", "Generated SBoM report: report.json\r\n"] +[6.713095, "o", "$ "] +[7.716296, "o", "\u001b["] +[7.896585, "o", "1m"] +[7.986721, "o", "ca"] +[8.076851, "o", "t "] +[8.166951, "o", "re"] +[8.257124, "o", "po"] +[8.347262, "o", "rt"] +[8.437396, "o", ".j"] +[8.527509, "o", "so"] +[8.617675, "o", "n\u001b"] +[8.797888, "o", "[0"] +[8.888103, "o", "m"] +[9.889693, "o", "\r\n"] +[9.89288, "o", "{\r\n \"components\": [\r\n {\r\n \"bom-ref\": \"cpputest-v3.4\",\r\n \"evidence\": {\r\n \"identity\": [\r\n {\r\n \"concludedValue\": \"cpputest\",\r\n \"field\": \"name\",\r\n \"methods\": [\r\n {\r\n \"confidence\": 0.4,\r\n \"technique\": \"manifest-analysis\",\r\n \"value\": \"Name as used for project in dfetch.yaml\"\r\n }\r\n ],\r\n \"tools\": [\r\n \"dfetch-0.12.1\"\r\n ]\r\n },\r\n {\r\n \"concludedValue\": \"pkg:github/cpputest/cpputest@v3.4\",\r\n \"field\": \"purl\",\r\n \"methods\": [\r\n {\r\n \"confidence\": 0.4,\r\n \"technique\": \"manifest-analysis\",\r\n \"value\": \"Determined from https://github.com/cpputest/cpputest.git as used for the project cpputest in dfetch.yaml\"\r\n }\r\n ],\r\n \"tools\": [\r\n \"dfetch-0.12.1\"\r\n ]\r\n },\r\n {\r\n \"concludedValue\": \"v3.4\",\r\n \"field\": \"version\",\r\n \"methods\": [\r\n {\r\n \"confidence\": 0.4,\r\n \"technique\": \"manifest-analysis\",\r\n \"value\": \"Version as used for project in dfetch.yaml\"\r\n }\r\n ],\r\n \"tools\": [\r\n \"dfetch-0.12.1\"\r\n ]\r\n }\r\n ],\r\n \"licenses\": [\r\n {\r\n \"license\": {\r\n "] +[9.893194, "o", " \"id\": \"BSD-3-Clause\"\r\n }\r\n }\r\n ],\r\n \"occurrences\": [\r\n {\r\n \"line\": 9,\r\n \"location\": \"dfetch.yaml\",\r\n \"offset\": 11\r\n }\r\n ]\r\n },\r\n \"externalReferences\": [\r\n {\r\n \"type\": \"vcs\",\r\n \"url\": \"https://github.com/cpputest/cpputest\"\r\n }\r\n ],\r\n \"group\": \"cpputest\",\r\n \"licenses\": [\r\n {\r\n \"license\": {\r\n \"id\": \"BSD-3-Clause\"\r\n }\r\n }\r\n ],\r\n \"name\": \"cpputest\",\r\n \"purl\": \"pkg:github/cpputest/cpputest@v3.4\",\r\n \"type\": \"library\",\r\n \"version\": \"v3.4\"\r\n },\r\n {\r\n \"bom-ref\": \"jsmn-25647e692c7906b96ffd2b05ca54c097948e879c\",\r\n \"evidence\": {\r\n \"identity\": [\r\n {\r\n \"concludedValue\": \"jsmn\",\r\n \"field\": \"name\",\r\n \"methods\": [\r\n {\r\n \"confidence\": 0.4,\r\n \"technique\": \"manifest-analysis\",\r\n \"value\": \"Name as used for project in dfetch.yaml\"\r\n }\r\n ],\r\n \"tools\": [\r\n \"dfetch-0.12.1\"\r\n ]\r\n },\r\n {\r\n \"concludedValue\": \"pkg:github/zserge/jsmn@25647e692c7906b96ffd2b05ca54c097948e879c\",\r\n \"field\": \"purl\",\r\n \"methods\": [\r\n {\r\n \"confidence\": 0.4,\r\n \"technique\": \"manifest-analysis\",\r\n \"value\": \"Determined from https://github.com/zserge/jsmn.git as used for the project jsmn in dfetch.yaml\"\r\n }\r\n ],\r\n \"tools\": [\r\n \"dfetch-0.12.1\"\r\n ]\r\n },\r\n {\r\n \"concludedValue\": \"25647e692c7906b96ffd2b05ca54c097948e879c\",\r\n \"field\": \"version\",\r\n \"methods\": [\r\n {\r\n \"confidence\": 0.4,\r\n \"technique\": \"manifest-analysis\",\r\n \"value\": \"Version as used for project in dfetch.yaml\"\r\n }\r\n ],\r\n \"tools\": [\r\n \"dfetch-0.12.1\"\r\n ]\r\n }\r\n ],\r\n \"licenses\": [\r\n {\r\n \"license\": {\r\n \"id\": \"MIT\"\r\n }\r\n }\r\n ],\r\n \"occurrences\": [\r\n {\r\n \"line\": 14,\r\n \"location\": \"dfetch.yaml\",\r\n \"offset\": 11\r\n }\r\n ]\r\n },\r\n \"externalReferences\": [\r\n {\r\n \"type\": \"vcs\",\r\n \"url\": \"https://github.com/zserge/jsmn\"\r\n }\r\n ],\r\n \"group\": \"zserge\",\r\n \"licenses\": [\r\n {\r\n \"license\": {\r\n \"id\": \"MIT\"\r\n }\r\n }\r\n ],\r\n \"name\": \"jsmn\",\r\n \"purl\": \"pkg:github/zserge/jsmn@25647e692c7906b96ffd2b05ca54c097948e879c\",\r\n \"type\": \"library\",\r\n \"version\": \"25647e692c7906b96ffd2b05ca54c097948e879c\"\r\n }\r\n ],\r\n \"dependencies\": [\r\n {\r\n "] +[9.893301, "o", " \"ref\": \"cpputest-v3.4\"\r\n },\r\n {\r\n \"ref\": \"jsmn-25647e692c7906b96ffd2b05ca54c097948e879c\"\r\n }\r\n ],\r\n \"metadata\": {\r\n \"timestamp\": \"2026-03-29T08:47:47.661232+00:00\",\r\n \"tools\": {\r\n \"components\": [\r\n {\r\n \"bom-ref\": \"dfetch-0.12.1\",\r\n \"externalReferences\": [\r\n {\r\n \"type\": \"build-system\",\r\n \"url\": \"https://github.com/dfetch-org/dfetch/actions\"\r\n },\r\n {\r\n \"type\": \"distribution\",\r\n \"url\": \"https://pypi.org/project/dfetch/\"\r\n },\r\n {\r\n \"type\": \"documentation\",\r\n \"url\": \"https://dfetch.readthedocs.io/\"\r\n },\r\n {\r\n \"type\": \"issue-tracker\",\r\n \"url\": \"https://github.com/dfetch-org/dfetch/issues\"\r\n },\r\n {\r\n \"type\": \"license\",\r\n \"url\": \"https://github.com/dfetch-org/dfetch/blob/main/LICENSE\"\r\n },\r\n {\r\n \"type\": \"release-notes\",\r\n \"url\": \"https://github.com/dfetch-org/dfetch/blob/main/CHANGELOG.rst\"\r\n },\r\n {\r\n \"type\": \"vcs\",\r\n \"url\": \"https://github.com/dfetch-org/dfetch\"\r\n },\r\n {\r\n \"type\": \"website\",\r\n \"url\": \"https://dfetch-org.github.io/\"\r\n }\r\n ],\r\n \"licenses\": [\r\n {\r\n \"license\": {\r\n \"acknowledgement\": \"declared\",\r\n \"id\": \"MIT\"\r\n }\r\n }\r\n ],\r\n \"name\": \"dfetch\",\r\n \"supplier\": {\r\n \"name\": \"dfetch-org\"\r\n },\r\n \"type\": \"application\",\r\n \"version\": \"0.12.1\"\r\n },\r\n {\r\n \"description\": \"Python library for CycloneDX\",\r\n \"externalReferences\": [\r\n {\r\n \"type\": \"build-system\",\r\n \"url\": \"https://github.com/CycloneDX/cyclonedx-python-lib/actions\"\r\n },\r\n {\r\n \"type\": \"distribution\",\r\n \"url\": \"https://pypi.org/project/cyclonedx-python-lib/\"\r\n },\r\n {\r\n \"type\": \"documentation\",\r\n \"url\": \"https://cyclonedx-python-library.readthedocs.io/\"\r\n },\r\n {\r\n \"type\": \"issue-tracker\",\r\n \"url\": \"https://github.com/CycloneDX/cyclonedx-python-lib/issues\"\r\n },\r\n {\r\n \"type\": \"license\",\r\n \"url\": \"https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/LICENSE\"\r\n },\r\n {\r\n \"type\": \"release-notes\",\r\n \"url\": \"https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/CHANGELOG.md\"\r\n },\r\n {\r\n \"type\": \"vcs\",\r\n \"url\": \"https://github.com/CycloneDX/cyclonedx-python-lib\"\r\n },\r\n {\r\n \"type\": \"website\",\r\n \"url\": \"https:"] +[9.893323, "o", "//github.com/CycloneDX/cyclonedx-python-lib/#readme\"\r\n }\r\n ],\r\n \"group\": \"CycloneDX\",\r\n \"licenses\": [\r\n {\r\n \"license\": {\r\n \"acknowledgement\": \"declared\",\r\n \"id\": \"Apache-2.0\"\r\n }\r\n }\r\n ],\r\n \"name\": \"cyclonedx-python-lib\",\r\n \"type\": \"library\",\r\n \"version\": \"11.7.0\"\r\n }\r\n ]\r\n }\r\n },\r\n \"serialNumber\": \"urn:uuid:bd57b26d-db19-4e87-bcd4-e3eeda5af76c\",\r\n \"version\": 1,\r\n \"$schema\": \"http://cyclonedx.org/schema/bom-1.6.schema.json\",\r\n \"bomFormat\": \"CycloneDX\",\r\n \"specVersion\": \"1.6\"\r\n}"] +[12.895873, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] diff --git a/doc/asciicasts/update-patch.cast b/doc/asciicasts/update-patch.cast index 805d4f02..5e691c34 100644 --- a/doc/asciicasts/update-patch.cast +++ b/doc/asciicasts/update-patch.cast @@ -1,226 +1,248 @@ -{"version": 2, "width": 165, "height": 30, "timestamp": 1774247230, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} -[2.679649, "o", "\u001b[H\u001b[2J\u001b[3J"] -[2.684885, "o", "$ "] -[3.688252, "o", "\u001b"] -[3.869801, "o", "[1"] -[3.959776, "o", "ml"] -[4.049904, "o", "s "] -[4.140044, "o", "-l"] -[4.230172, "o", " ."] -[4.320289, "o", "\u001b["] -[4.410438, "o", "0m"] -[5.41212, "o", "\r\n"] -[5.415596, "o", "total 16\r\n"] -[5.415714, "o", "drwxr-xr-x+ 3 dev dev 4096 Mar 23 06:27 cpputest\r\n-rw-rw-rw- 1 dev dev 229 Mar 23 06:27 dfetch.yaml\r\ndrwxr-xr-x+ 4 dev dev 4096 Mar 23 06:27 jsmn\r\ndrwxrwxrwx+ 2 dev dev 4096 Mar 23 06:27 patches\r\n"] -[5.421158, "o", "$ "] -[6.424276, "o", "\u001b["] -[6.604707, "o", "1m"] -[6.694767, "o", "ca"] -[6.784905, "o", "t "] -[6.875029, "o", "df"] -[6.965194, "o", "et"] -[7.055322, "o", "ch"] -[7.145468, "o", ".y"] -[7.235605, "o", "am"] -[7.32574, "o", "l\u001b"] -[7.50602, "o", "[0"] -[7.596127, "o", "m"] -[8.597737, "o", "\r\n"] -[8.600857, "o", "manifest:\r\n version: 0.0\r\n\r\n remotes:\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/\r\n repo-path: cpputest/cpputest.git\r\n tag: v3.4\r\n patch: patches/cpputest.patch\r\n\r\n"] -[8.606351, "o", "$ "] -[9.609779, "o", "\u001b"] -[9.790508, "o", "[1"] -[9.880623, "o", "mc"] -[9.970763, "o", "at"] -[10.062535, "o", " "] -[10.151057, "o", "pa"] -[10.241191, "o", "tc"] -[10.331335, "o", "he"] -[10.42146, "o", "s/"] -[10.511585, "o", "c"] -[10.691847, "o", "pp"] -[10.781986, "o", "ut"] -[10.872113, "o", "es"] -[10.962232, "o", "t."] -[11.052353, "o", "p"] -[11.142513, "o", "at"] -[11.232645, "o", "ch"] -[11.322774, "o", "\u001b["] -[11.412896, "o", "0m"] -[12.416043, "o", "\r\n"] -[12.422528, "o", "diff --git a/README.md b/README.md\r\nindex 2655a7b..fc6084e 100644\r\n--- a/README.md\r\n+++ b/README.md\r\n@@ -3,7 +3,7 @@ CppUTest\r\n \r\n CppUTest unit testing and mocking framework for C/C++\r\n \r\n-[More information on the project page](http://cpputest.github.com)\r\n+[More information on the project page](http://cpputest.gitlab.com)\r\n \r\n [![Build Status](https://travis-ci.org/cpputest/cpputest.png?branch=master)](https://travis-ci.org/cpputest/cpputest)\r\n \r\n"] -[12.43523, "o", "$ "] -[13.438487, "o", "\u001b["] -[13.618766, "o", "1m"] -[13.708896, "o", "gi"] -[13.799059, "o", "t "] -[13.889204, "o", "sta"] -[13.979331, "o", "tu"] -[14.06947, "o", "s\u001b"] -[14.160496, "o", "[0"] -[14.250296, "o", "m"] -[15.252133, "o", "\r\n"] -[15.274579, "o", "On branch main\r\n"] -[15.274685, "o", "nothing to commit, working tree clean\r\n"] -[15.279602, "o", "$ "] -[16.283037, "o", "\u001b"] -[16.463326, "o", "[1"] -[16.553447, "o", "ms"] -[16.644844, "o", "ed"] -[16.733917, "o", " "] -[16.824086, "o", "-i"] -[16.914242, "o", " '"] -[17.004394, "o", "s/"] -[17.094521, "o", "gi"] -[17.184676, "o", "t"] -[17.364908, "o", "la"] -[17.45504, "o", "b/"] -[17.546198, "o", "gi"] -[17.635283, "o", "te"] -[17.725435, "o", "a"] -[17.815549, "o", "/g"] -[17.905698, "o", "' "] -[17.997097, "o", "cp"] -[18.085979, "o", "pu"] -[18.267398, "o", "t"] -[18.357218, "o", "es"] -[18.447359, "o", "t/"] -[18.537476, "o", "sr"] -[18.627639, "o", "c/"] -[18.717766, "o", "R"] -[18.80798, "o", "EA"] -[18.898057, "o", "DM"] -[18.988199, "o", "E."] -[19.168441, "o", "md"] -[19.258608, "o", "\u001b"] -[19.348735, "o", "[0"] -[19.439033, "o", "m"] -[20.441248, "o", "\r\n"] -[20.451314, "o", "$ "] -[21.453518, "o", "\u001b"] -[21.633762, "o", "[1"] -[21.723919, "o", "mg"] -[21.814068, "o", "it"] -[21.908029, "o", " a"] -[21.997647, "o", "dd"] -[22.087783, "o", " ."] -[22.177931, "o", "\u001b["] -[22.268069, "o", "0m"] -[23.269616, "o", "\r\n"] -[23.281799, "o", "$ "] -[24.285111, "o", "\u001b["] -[24.465368, "o", "1m"] -[24.555516, "o", "gi"] -[24.645635, "o", "t "] -[24.735783, "o", "com"] -[24.825908, "o", "mi"] -[24.916287, "o", "t "] -[25.006523, "o", "-a"] -[25.096593, "o", " -"] -[25.186727, "o", "m '"] -[25.367009, "o", "Fi"] -[25.457127, "o", "x "] -[25.547264, "o", "vc"] -[25.637396, "o", "s "] -[25.727519, "o", "hos"] -[25.817651, "o", "t'"] -[25.907789, "o", "\u001b["] -[25.99792, "o", "0m"] -[26.99958, "o", "\r\n"] -[27.009597, "o", "[main d9c949e] Fix vcs host\r\n"] -[27.009792, "o", " 1 file changed, 1 insertion(+), 1 deletion(-)\r\n"] -[27.017534, "o", "$ "] -[28.020595, "o", "\u001b"] -[28.200877, "o", "[1"] -[28.291014, "o", "md"] -[28.381168, "o", "fe"] -[28.471509, "o", "tc"] -[28.561559, "o", "h "] -[28.651705, "o", "up"] -[28.74183, "o", "da"] -[28.831992, "o", "te"] -[28.922231, "o", "-p"] -[29.102482, "o", "a"] -[29.19276, "o", "tc"] -[29.282829, "o", "h "] -[29.37297, "o", "cp"] -[29.463097, "o", "pu"] -[29.553368, "o", "te"] -[29.643368, "o", "st"] -[29.733712, "o", "\u001b["] -[29.82378, "o", "0m"] -[30.825507, "o", "\r\n"] -[31.290516, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[31.330486, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] -[31.330905, "o", "\u001b[?25l"] -[31.411842, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[31.492459, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[31.57308, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[31.653675, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[31.739402, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[31.820007, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[31.902025, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[31.983676, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[32.066589, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[32.147225, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[32.22791, "o", "\r\u001b[2K\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[32.308516, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[32.389126, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[32.422286, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] -[32.423098, "o", " \u001b[1;34m> Fetched v3.4\u001b[0m\r\n"] -[32.508714, "o", " \u001b[1;34m> Updating patch \"patches/cpputest.patch\"\u001b[0m\r\n"] -[32.52478, "o", "\u001b[?25l"] -[32.605632, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[32.686385, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[32.766994, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[32.847569, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[32.928175, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[33.008746, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[33.091624, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[33.174219, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[33.254869, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[33.288973, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] -[33.289714, "o", " \u001b[1;34m> Fetched v3.4\u001b[0m\r\n"] -[33.290448, "o", " \u001b[1;34m> Applying patch \"patches/cpputest.patch\"\u001b[0m\r\n"] -[33.293167, "o", " \u001b[34msuccessfully patched 1/1: \u001b[0m\u001b[34m \u001b[0m \r\n\u001b[34mb'README.md'\u001b[0m \r\n"] -[33.376907, "o", "$ "] -[34.380162, "o", "\u001b["] -[34.560464, "o", "1m"] -[34.650612, "o", "ca"] -[34.740726, "o", "t "] -[34.830872, "o", "pat"] -[34.921012, "o", "ch"] -[35.011072, "o", "es"] -[35.101219, "o", "/c"] -[35.191398, "o", "pp"] -[35.281553, "o", "ute"] -[35.461796, "o", "st"] -[35.551928, "o", ".p"] -[35.642065, "o", "at"] -[35.732207, "o", "ch"] -[35.822371, "o", "\u001b[0"] -[35.912491, "o", "m"] -[36.914126, "o", "\r\n"] -[36.917467, "o", "diff --git a/README.md b/README.md\r\nindex 2655a7b..da133cb 100644\r\n--- a/README.md\r\n+++ b/README.md\r\n@@ -3,7 +3,7 @@ CppUTest\r\n \r\n CppUTest unit testing and mocking framework for C/C++\r\n \r\n-[More information on the project page](http://cpputest.github.com)\r\n+[More information on the project page](http://cpputest.gitea.com)\r\n \r\n [![Build Status](https://travis-ci.org/cpputest/cpputest.png?branch=master)](https://travis-ci.org/cpputest/cpputest)\r\n \r\n"] -[36.922667, "o", "$ "] -[37.925928, "o", "\u001b"] -[38.106205, "o", "[1"] -[38.196359, "o", "mg"] -[38.28649, "o", "it"] -[38.376634, "o", " "] -[38.466758, "o", "st"] -[38.55689, "o", "at"] -[38.647193, "o", "us"] -[38.737268, "o", "\u001b["] -[38.827402, "o", "0"] -[39.007657, "o", "m"] -[40.009953, "o", "\r\n"] -[40.042467, "o", "On branch main\r\n"] -[40.042507, "o", "Changes not staged for commit:\r\n (use \"git add ...\" to update what will be committed)\r\n (use \"git restore ...\" to discard changes in working directory)\r\n\t\u001b[31mmodified: cpputest/src/.dfetch_data.yaml\u001b[m\r\n\t\u001b[31mmodified: patches/cpputest.patch\u001b[m\r\n\r\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\r\n"] -[43.050156, "o", "$ "] -[43.052102, "o", "\u001b["] -[43.232375, "o", "1m"] -[43.322521, "o", "\u001b["] -[43.412646, "o", "0m"] -[43.413234, "o", "\r\n"] -[43.416154, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] +{"version": 2, "width": 173, "height": 25, "timestamp": 1774774112, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[2.74536, "o", "\u001b[H\u001b[2J\u001b[3J"] +[2.749101, "o", "$ "] +[3.752136, "o", "\u001b"] +[3.932434, "o", "[1"] +[4.022544, "o", "ml"] +[4.112677, "o", "s "] +[4.202814, "o", "-l"] +[4.292949, "o", " ."] +[4.38337, "o", "\u001b["] +[4.473485, "o", "0m"] +[5.47523, "o", "\r\n"] +[5.478706, "o", "total 16\r\n"] +[5.478827, "o", "drwxr-xr-x+ 3 dev dev 4096 Mar 29 08:48 cpputest\r\n-rw-rw-rw- 1 dev dev 229 Mar 29 08:48 dfetch.yaml\r\ndrwxr-xr-x+ 4 dev dev 4096 Mar 29 08:48 jsmn\r\ndrwxrwxrwx+ 2 dev dev 4096 Mar 29 08:48 patches\r\n"] +[5.484052, "o", "$ "] +[6.487204, "o", "\u001b"] +[6.667527, "o", "[1"] +[6.75767, "o", "mc"] +[6.847791, "o", "at"] +[6.937925, "o", " "] +[7.02805, "o", "df"] +[7.118193, "o", "et"] +[7.20839, "o", "ch"] +[7.298506, "o", ".y"] +[7.388639, "o", "a"] +[7.568998, "o", "ml"] +[7.659218, "o", "\u001b["] +[7.749356, "o", "0m"] +[8.750911, "o", "\r\n"] +[8.753993, "o", "manifest:\r\n version: 0.0\r\n\r\n remotes:\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/\r\n repo-path: cpputest/cpputest.git\r\n tag: v3.4\r\n patch: patches/cpputest.patch\r\n\r\n"] +[8.759005, "o", "$ "] +[9.762111, "o", "\u001b["] +[9.942368, "o", "1m"] +[10.032497, "o", "ca"] +[10.122637, "o", "t "] +[10.21277, "o", "pa"] +[10.30291, "o", "tc"] +[10.393053, "o", "he"] +[10.483183, "o", "s/"] +[10.573321, "o", "cp"] +[10.663529, "o", "pu"] +[10.843802, "o", "tes"] +[10.933923, "o", "t."] +[11.024058, "o", "pa"] +[11.115495, "o", "tc"] +[11.205, "o", "h\u001b"] +[11.29513, "o", "[0"] +[11.385913, "o", "m"] +[12.386996, "o", "\r\n"] +[12.390233, "o", "diff --git a/README.md b/README.md\r\nindex 2655a7b..fc6084e 100644\r\n--- a/README.md\r\n+++ b/README.md\r\n@@ -3,7 +3,7 @@ CppUTest\r\n \r\n CppUTest unit testing and mocking framework for C/C++\r\n \r\n-[More information on the project page](http://cpputest.github.com)\r\n+[More information on the project page](http://cpputest.gitlab.com)\r\n \r\n [![Build Status](https://travis-ci.org/cpputest/cpputest.png?branch=master)](https://travis-ci.org/cpputest/cpputest)\r\n \r\n"] +[12.395767, "o", "$ "] +[13.398864, "o", "\u001b"] +[13.579131, "o", "[1"] +[13.669301, "o", "mg"] +[13.759419, "o", "it"] +[13.849544, "o", " s"] +[13.939665, "o", "ta"] +[14.029795, "o", "tu"] +[14.119914, "o", "s\u001b"] +[14.210198, "o", "[0"] +[14.300317, "o", "m"] +[15.301848, "o", "\r\n"] +[15.307985, "o", "On branch main\r\nnothing to commit, working tree clean\r\n"] +[15.314129, "o", "$ "] +[16.317213, "o", "\u001b"] +[16.497525, "o", "[1"] +[16.587669, "o", "ms"] +[16.677787, "o", "ed"] +[16.767929, "o", " "] +[16.858047, "o", "-i"] +[16.948346, "o", " '"] +[17.038494, "o", "s/"] +[17.12906, "o", "gi"] +[17.219079, "o", "t"] +[17.399344, "o", "la"] +[17.489507, "o", "b/"] +[17.579655, "o", "gi"] +[17.669798, "o", "te"] +[17.759956, "o", "a"] +[17.850075, "o", "/g"] +[17.940218, "o", "' "] +[18.030355, "o", "cp"] +[18.120513, "o", "pu"] +[18.300774, "o", "t"] +[18.39093, "o", "es"] +[18.481045, "o", "t/"] +[18.571191, "o", "sr"] +[18.661309, "o", "c/"] +[18.751465, "o", "R"] +[18.84157, "o", "EA"] +[18.931714, "o", "DM"] +[19.021851, "o", "E."] +[19.202109, "o", "md"] +[19.292425, "o", "\u001b"] +[19.382525, "o", "[0"] +[19.472652, "o", "m"] +[20.47439, "o", "\r\n"] +[20.48538, "o", "$ "] +[21.48886, "o", "\u001b["] +[21.669093, "o", "1m"] +[21.75924, "o", "gi"] +[21.849375, "o", "t "] +[21.939565, "o", "ad"] +[22.029712, "o", "d "] +[22.119831, "o", ".\u001b"] +[22.209962, "o", "[0"] +[22.300096, "o", "m"] +[23.301316, "o", "\r\n"] +[23.313667, "o", "$ "] +[24.316778, "o", "\u001b"] +[24.497069, "o", "[1"] +[24.587329, "o", "mg"] +[24.677361, "o", "it"] +[24.7675, "o", " c"] +[24.857619, "o", "om"] +[24.947747, "o", "mi"] +[25.037886, "o", "t "] +[25.128009, "o", "-a"] +[25.218502, "o", " -"] +[25.39865, "o", "m"] +[25.488881, "o", " '"] +[25.578928, "o", "Fi"] +[25.669058, "o", "x "] +[25.759179, "o", "vc"] +[25.849304, "o", "s "] +[25.939439, "o", "ho"] +[26.029568, "o", "st"] +[26.119747, "o", "'\u001b"] +[26.300152, "o", "[0"] +[26.390322, "o", "m"] +[27.391957, "o", "\r\n"] +[27.402814, "o", "[main 5303947] Fix vcs host\r\n 1 file changed, 1 insertion(+), 1 deletion(-)\r\n"] +[27.408385, "o", "$ "] +[28.41153, "o", "\u001b["] +[28.591785, "o", "1m"] +[28.68198, "o", "df"] +[28.772088, "o", "et"] +[28.862225, "o", "ch"] +[28.952344, "o", " u"] +[29.0425, "o", "pd"] +[29.132628, "o", "at"] +[29.222779, "o", "e-"] +[29.312933, "o", "pa"] +[29.494341, "o", "tch"] +[29.583868, "o", " c"] +[29.673764, "o", "pp"] +[29.763912, "o", "ut"] +[29.854044, "o", "es"] +[29.944171, "o", "t\u001b"] +[30.034302, "o", "[0"] +[30.124426, "o", "m"] +[31.125766, "o", "\r\n"] +[31.58142, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[31.621668, "o", " \u001b[1;92mcpputest:\u001b[0m"] +[31.621792, "o", "\r\n"] +[31.622041, "o", "\u001b[?25l"] +[31.703067, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[31.783688, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[31.86431, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[31.944825, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[32.02541, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[32.10601, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[32.18658, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[32.267097, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[32.348143, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[32.428744, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[32.510265, "o", "\r\u001b[2K\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[32.591039, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[32.67167, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[32.752157, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[32.832744, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[32.913334, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[32.993891, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[33.074466, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[33.088007, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] +[33.088748, "o", " \u001b[1;34m> Fetched v3.4\u001b[0m\r\n"] +[33.175633, "o", " \u001b[1;34m> Updating patch \"patches/cpputest.patch\"\u001b[0m\r\n"] +[33.1938, "o", "\u001b[?25l"] +[33.274783, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[33.355409, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[33.435965, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[33.516809, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[33.597278, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[33.679039, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[33.759881, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[33.840452, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[33.921007, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[34.001585, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[34.082144, "o", "\r\u001b[2K\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[34.162721, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[34.243291, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[34.323828, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[34.40435, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[34.485066, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[34.569919, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[34.650365, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[34.733333, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[34.818328, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[34.873497, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[34.87395, "o", "\r\n"] +[34.874188, "o", "\u001b[?25h\r\u001b[1A\u001b[2K"] +[34.875171, "o", " \u001b[1;34m> Fetched v3.4\u001b[0m"] +[34.875417, "o", "\r\n"] +[34.877279, "o", " \u001b[1;34m> Applying patch \"patches/cpputest.patch\"\u001b[0m"] +[34.877311, "o", "\r\n"] +[34.883686, "o", " \u001b[34msuccessfully patched 1/1: \u001b[0m\u001b[34m \u001b[0m "] +[34.883903, "o", "\r\n"] +[34.884078, "o", "\u001b[34mb'README.md'\u001b[0m "] +[34.884235, "o", "\r\n"] +[35.106871, "o", "$ "] +[36.111006, "o", "\u001b["] +[36.290851, "o", "1m"] +[36.381051, "o", "ca"] +[36.4712, "o", "t "] +[36.561319, "o", "pat"] +[36.651448, "o", "ch"] +[36.741569, "o", "es"] +[36.831719, "o", "/c"] +[36.921822, "o", "pp"] +[37.012071, "o", "ute"] +[37.192368, "o", "st"] +[37.282493, "o", ".p"] +[37.372656, "o", "at"] +[37.462801, "o", "ch"] +[37.552943, "o", "\u001b[0"] +[37.643081, "o", "m"] +[38.644435, "o", "\r\n"] +[38.647496, "o", "diff --git a/README.md b/README.md\r\nindex 2655a7b..da133cb 100644\r\n--- a/README.md\r\n+++ b/README.md\r\n@@ -3,7 +3,7 @@ CppUTest\r\n \r\n CppUTest unit testing and mocking framework for C/C++\r\n \r\n-[More information on the project page](http://cpputest.github.com)\r\n+[More information on the project page](http://cpputest.gitea.com)\r\n \r\n [![Build Status](https://travis-ci.org/cpputest/cpputest.png?branch=master)](https://travis-ci.org/cpputest/cpputest)\r\n \r\n"] +[38.652952, "o", "$ "] +[39.656309, "o", "\u001b["] +[39.836544, "o", "1m"] +[39.926672, "o", "gi"] +[40.016808, "o", "t "] +[40.106929, "o", "sta"] +[40.197061, "o", "tu"] +[40.287209, "o", "s\u001b"] +[40.377358, "o", "[0"] +[40.467485, "o", "m"] +[41.469018, "o", "\r\n"] +[41.491465, "o", "On branch main\r\nChanges not staged for commit:\r\n (use \"git add ...\" to update what will be committed)\r\n (use \"git restore ...\" to discard changes in working directory)\r\n\t\u001b[31mmodified: cpputest/src/.dfetch_data.yaml\u001b[m\r\n\t\u001b[31mmodified: patches/cpputest.patch\u001b[m\r\n\r\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\r\n"] +[44.50053, "o", "$ "] +[44.502823, "o", "\u001b"] +[44.683147, "o", "[1"] +[44.773291, "o", "m\u001b"] +[44.863387, "o", "[0"] +[44.95353, "o", "m"] +[44.953922, "o", "\r\n"] +[44.957223, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] diff --git a/doc/asciicasts/update.cast b/doc/asciicasts/update.cast index 7eda8e96..ac5b84ec 100644 --- a/doc/asciicasts/update.cast +++ b/doc/asciicasts/update.cast @@ -1,106 +1,112 @@ -{"version": 2, "width": 165, "height": 30, "timestamp": 1774247146, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} -[0.645115, "o", "\u001b[H\u001b[2J\u001b[3J"] -[0.649085, "o", "$ "] -[1.652356, "o", "\u001b["] -[1.832675, "o", "1m"] -[1.922737, "o", "ls"] -[2.012907, "o", " -"] -[2.103024, "o", "l\u001b"] -[2.193155, "o", "[0"] -[2.283979, "o", "m"] -[3.284743, "o", "\r\n"] -[3.289257, "o", "total 4"] -[3.289298, "o", "\r\n"] -[3.289376, "o", "-rw-rw-rw- 1 dev dev 733 Mar 23 06:25 dfetch.yaml"] -[3.289392, "o", "\r\n"] -[3.313916, "o", "$ "] -[4.318926, "o", "\u001b["] -[4.498117, "o", "1m"] -[4.58831, "o", "ca"] -[4.678426, "o", "t "] -[4.768581, "o", "dfe"] -[4.858734, "o", "tc"] -[4.948853, "o", "h."] -[5.038993, "o", "ya"] -[5.12916, "o", "ml"] -[5.219305, "o", "\u001b[0"] -[5.401373, "o", "m"] -[6.402465, "o", "\r\n"] -[6.405694, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v3.4 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n"] -[6.410677, "o", "$ "] -[7.414034, "o", "\u001b"] -[7.594297, "o", "[1"] -[7.684435, "o", "md"] -[7.774577, "o", "fe"] -[7.864706, "o", "t"] -[7.954851, "o", "ch"] -[8.045005, "o", " u"] -[8.135196, "o", "pd"] -[8.225336, "o", "at"] -[8.315478, "o", "e"] -[8.495705, "o", "\u001b["] -[8.585841, "o", "0m"] -[9.587454, "o", "\r\n"] -[10.055519, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[10.069455, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] -[10.069588, "o", "\u001b[?25l"] -[10.155491, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[10.236126, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[10.316767, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[10.397341, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[10.478158, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[10.558675, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[10.639485, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[10.72157, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[10.802469, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[10.883069, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[10.96363, "o", "\r\u001b[2K\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[11.044244, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[11.122267, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] -[11.123193, "o", " \u001b[1;34m> Fetched v3.4\u001b[0m\r\n"] -[11.148347, "o", " \u001b[1;92mjsmn:\u001b[0m\r\n"] -[11.148748, "o", "\u001b[?25l"] -[11.229774, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[11.310372, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[11.390937, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[11.471595, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[11.552195, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[11.633698, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[11.714266, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[11.794833, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[11.875955, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[11.956671, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[12.03725, "o", "\r\u001b[2K\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[12.117826, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[12.198699, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[12.208625, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching \u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] -[12.209406, "o", " \u001b[1;34m> Fetched master - 25647e692c7906b96ffd2b05ca54c097948e879c\u001b[0m\r\n"] -[12.289005, "o", "$ "] -[13.292261, "o", "\u001b["] -[13.47406, "o", "1m"] -[13.564182, "o", "ls"] -[13.654301, "o", " -"] -[13.744587, "o", "l\u001b["] -[13.834631, "o", "0m"] -[14.836654, "o", "\r\n"] -[14.840351, "o", "total 12\r\ndrwxrwxrwx+ 3 dev dev 4096 Mar 23 06:25 cpputest\r\n-rw-rw-rw- 1 dev dev 733 Mar 23 06:25 dfetch.yaml\r\n"] -[14.841518, "o", "drwxrwxrwx+ 4 dev dev 4096 Mar 23 06:25 jsmn\r\n"] -[14.850112, "o", "$ "] -[15.853294, "o", "\u001b["] -[16.035276, "o", "1m"] -[16.125329, "o", "df"] -[16.21547, "o", "et"] -[16.305751, "o", "ch"] -[16.395803, "o", " u"] -[16.485947, "o", "pd"] -[16.576075, "o", "at"] -[16.66621, "o", "e\u001b"] -[16.756337, "o", "[0"] -[16.93659, "o", "m"] -[17.938264, "o", "\r\n"] -[18.392468, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[18.411772, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] -[18.412474, "o", " \u001b[1;34m> up-to-date (v3.4)\u001b[0m\r\n"] -[18.970087, "o", " \u001b[1;92mjsmn:\u001b[0m\r\n"] -[18.970777, "o", " \u001b[1;34m> up-to-date (master - 25647e692c7906b96ffd2b05ca54c097948e879c)\u001b[0m\r\n"] -[22.034113, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] +{"version": 2, "width": 173, "height": 25, "timestamp": 1774774028, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[0.553192, "o", "\u001b[H\u001b[2J\u001b[3J"] +[0.556734, "o", "$ "] +[1.559936, "o", "\u001b"] +[1.740303, "o", "[1"] +[1.830446, "o", "ml"] +[1.920602, "o", "s "] +[2.010716, "o", "-l"] +[2.100847, "o", "\u001b["] +[2.191436, "o", "0m"] +[3.192371, "o", "\r\n"] +[3.197142, "o", "total 4\r\n"] +[3.197469, "o", "-rw-rw-rw- 1 dev dev 733 Mar 29 08:47 dfetch.yaml\r\n"] +[3.204475, "o", "$ "] +[4.207428, "o", "\u001b["] +[4.388733, "o", "1m"] +[4.478693, "o", "ca"] +[4.568838, "o", "t "] +[4.65896, "o", "df"] +[4.74909, "o", "et"] +[4.839211, "o", "ch"] +[4.929448, "o", ".y"] +[5.01959, "o", "am"] +[5.109725, "o", "l\u001b"] +[5.289975, "o", "[0"] +[5.380296, "o", "m"] +[6.381879, "o", "\r\n"] +[6.384989, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v3.4 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n"] +[6.390044, "o", "$ "] +[7.393426, "o", "\u001b"] +[7.57371, "o", "[1"] +[7.66383, "o", "md"] +[7.753954, "o", "fe"] +[7.844101, "o", "tc"] +[7.934251, "o", "h "] +[8.024381, "o", "up"] +[8.114505, "o", "da"] +[8.204616, "o", "te"] +[8.294773, "o", "\u001b["] +[8.475024, "o", "0"] +[8.565151, "o", "m"] +[9.566758, "o", "\r\n"] +[10.019955, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[10.034467, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] +[10.034769, "o", "\u001b[?25l"] +[10.115707, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[10.196245, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[10.276803, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[10.357386, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[10.441249, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[10.519289, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[10.599811, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[10.680531, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[10.761169, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[10.842035, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[10.922481, "o", "\r\u001b[2K\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[11.003092, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[11.083601, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[11.164186, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[11.244761, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[11.325648, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[11.333264, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[11.33406, "o", "\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] +[11.334712, "o", " \u001b[1;34m> Fetched v3.4\u001b[0m"] +[11.334932, "o", "\r\n"] +[11.358511, "o", " \u001b[1;92mjsmn:\u001b[0m\r\n"] +[11.358954, "o", "\u001b[?25l"] +[11.439304, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[11.519861, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[11.60038, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[11.680859, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[11.761443, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[11.841914, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[11.92251, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[12.003054, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[12.084112, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[12.166099, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[12.245571, "o", "\r\u001b[2K\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[12.326146, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[12.406725, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[12.488122, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[12.510908, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching \u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] +[12.511774, "o", " \u001b[1;34m> Fetched master - 25647e692c7906b96ffd2b05ca54c097948e879c\u001b[0m\r\n"] +[12.600058, "o", "$ "] +[13.603268, "o", "\u001b["] +[13.783357, "o", "1m"] +[13.873471, "o", "ls"] +[13.963614, "o", " -"] +[14.053735, "o", "l\u001b"] +[14.143879, "o", "[0"] +[14.233998, "o", "m"] +[15.235545, "o", "\r\n"] +[15.238965, "o", "total 12\r\n"] +[15.239016, "o", "drwxrwxrwx+ 3 dev dev 4096 Mar 29 08:47 cpputest\r\n-rw-rw-rw- 1 dev dev 733 Mar 29 08:47 dfetch.yaml\r\ndrwxrwxrwx+ 4 dev dev 4096 Mar 29 08:47 jsmn\r\n"] +[15.244373, "o", "$ "] +[16.247629, "o", "\u001b["] +[16.429452, "o", "1m"] +[16.519625, "o", "df"] +[16.609741, "o", "et"] +[16.699887, "o", "ch "] +[16.790025, "o", "up"] +[16.880177, "o", "da"] +[16.97028, "o", "te"] +[17.060415, "o", "\u001b["] +[17.150546, "o", "0m"] +[18.153077, "o", "\r\n"] +[18.623887, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[18.647875, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] +[18.651458, "o", " \u001b[1;34m> up-to-date (v3.4)\u001b[0m\r\n"] +[19.349776, "o", " \u001b[1;92mjsmn:\u001b[0m\r\n"] +[19.350523, "o", " \u001b[1;34m> up-to-date (master - 25647e692c7906b96ffd2b05ca54c097948e879c)\u001b[0m\r\n"] +[22.410675, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] diff --git a/doc/asciicasts/validate.cast b/doc/asciicasts/validate.cast index dc1eda58..d4ffa560 100644 --- a/doc/asciicasts/validate.cast +++ b/doc/asciicasts/validate.cast @@ -1,19 +1,18 @@ -{"version": 2, "width": 165, "height": 30, "timestamp": 1774247103, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} -[0.544564, "o", "\u001b[H\u001b[2J\u001b[3J"] -[0.548222, "o", "$ "] -[1.551241, "o", "\u001b["] -[1.732758, "o", "1m"] -[1.821751, "o", "df"] -[1.911919, "o", "et"] -[2.002037, "o", "ch"] -[2.094464, "o", " v"] -[2.182521, "o", "al"] -[2.272672, "o", "id"] -[2.362794, "o", "at"] -[2.452919, "o", "e\u001b"] -[2.633569, "o", "[0"] -[2.725691, "o", "m"] -[3.726097, "o", "\r\n"] -[4.179101, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[4.185554, "o", " \u001b[1;92mdfetch.yaml :\u001b[0m\u001b[1;34m valid\u001b[0m\r\n"] -[7.24934, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] +{"version": 2, "width": 173, "height": 25, "timestamp": 1774773986, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[0.532595, "o", "\u001b[H\u001b[2J\u001b[3J"] +[0.536526, "o", "$ "] +[1.539781, "o", "\u001b["] +[1.720034, "o", "1m"] +[1.810206, "o", "df"] +[1.900318, "o", "et"] +[1.990479, "o", "ch "] +[2.080585, "o", "va"] +[2.170813, "o", "li"] +[2.260961, "o", "da"] +[2.351095, "o", "te"] +[2.441365, "o", "\u001b[0"] +[2.621666, "o", "m"] +[3.630467, "o", "\r\n"] +[4.3584, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[4.365156, "o", " \u001b[1;92mdfetch.yaml :\u001b[0m\u001b[1;34m valid\u001b[0m\r\n"] +[7.424524, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] From 1a9b91b892c27f3c45a68cca0a153ddf7f209c26 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 29 Mar 2026 09:03:34 +0000 Subject: [PATCH 28/29] Update casts --- doc/asciicasts/add.cast | 167 +++---- doc/asciicasts/basic.cast | 336 +++++++------- doc/asciicasts/check-ci.cast | 250 ++++++----- doc/asciicasts/check.cast | 117 +++-- doc/asciicasts/diff.cast | 217 +++++----- doc/asciicasts/environment.cast | 57 ++- doc/asciicasts/format-patch.cast | 245 ++++++----- doc/asciicasts/freeze.cast | 152 +++---- doc/asciicasts/import.cast | 133 +++--- doc/asciicasts/init.cast | 118 ++--- doc/asciicasts/interactive-add.cast | 230 +++++----- doc/asciicasts/report.cast | 90 ++-- doc/asciicasts/sbom.cast | 103 ++--- doc/asciicasts/update-patch.cast | 482 ++++++++++----------- doc/asciicasts/update.cast | 233 +++++----- doc/asciicasts/validate.cast | 38 +- doc/generate-casts/add-demo.sh | 3 +- doc/generate-casts/interactive-add-demo.sh | 1 - 18 files changed, 1479 insertions(+), 1493 deletions(-) diff --git a/doc/asciicasts/add.cast b/doc/asciicasts/add.cast index 01f24fe1..a136c2c4 100644 --- a/doc/asciicasts/add.cast +++ b/doc/asciicasts/add.cast @@ -1,81 +1,86 @@ -{"version": 2, "width": 175, "height": 16, "timestamp": 1771797309, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} -[0.494902, "o", "\u001b[H\u001b[2J\u001b[3J"] -[0.497658, "o", "$ "] -[1.500235, "o", "\u001b["] -[1.680644, "o", "1m"] -[1.770828, "o", "ca"] -[1.860948, "o", "t "] -[1.951108, "o", "dfe"] -[2.041269, "o", "tc"] -[2.131404, "o", "h."] -[2.221555, "o", "ya"] -[2.311663, "o", "ml"] -[2.401896, "o", "\u001b[0"] -[2.582125, "o", "m"] -[3.583662, "o", "\r\n"] -[3.585601, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v3.4 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n"] -[3.589043, "o", "$ "] -[4.591589, "o", "\u001b"] -[4.771858, "o", "[1"] -[4.861983, "o", "md"] -[4.966335, "o", "fe"] -[5.043363, "o", "tc"] -[5.133493, "o", "h "] -[5.223631, "o", "ad"] -[5.31377, "o", "d "] -[5.403904, "o", "-f"] -[5.494126, "o", " h"] -[5.674549, "o", "t"] -[5.764721, "o", "tp"] -[5.854808, "o", "s:"] -[5.94494, "o", "//"] -[6.035324, "o", "gi"] -[6.125438, "o", "th"] -[6.215568, "o", "ub"] -[6.305716, "o", ".c"] -[6.39586, "o", "om"] -[6.576041, "o", "/d"] -[6.666201, "o", "f"] -[6.756456, "o", "et"] -[6.846865, "o", "ch"] -[6.936987, "o", "-o"] -[7.02721, "o", "rg"] -[7.11736, "o", "/d"] -[7.20751, "o", "fe"] -[7.297833, "o", "tc"] -[7.47816, "o", "h."] -[7.56839, "o", "gi"] -[7.658512, "o", "t"] -[7.748652, "o", "\u001b["] -[7.839, "o", "0m"] -[8.840408, "o", "\r\n"] -[9.321142, "o", "\u001b[1;34mDfetch (0.12.0)\u001b[0m \r\n"] -[9.632322, "o", " \u001b[1;92mdfetch:\u001b[0m \r\n"] -[9.632906, "o", " \u001b[1;34m> Will add following entry to manifest:\u001b[0m \r\n"] -[9.633466, "o", " \u001b[34mname: \u001b[0m\u001b[37m dfetch\u001b[0m \r\n"] -[9.633962, "o", " \u001b[34mremote: \u001b[0m\u001b[37m github\u001b[0m \r\n"] -[9.63445, "o", " \u001b[34mbranch: \u001b[0m\u001b[37m main\u001b[0m \r\n"] -[9.634929, "o", " \u001b[34mrepo-path: \u001b[0m\u001b[37m dfetch-org/dfetch.git\u001b[0m \r\n"] -[9.63603, "o", " \u001b[1;34m> Added project to manifest\u001b[0m \r\n"] -[9.698037, "o", "$ "] -[10.700827, "o", "\u001b["] -[10.881118, "o", "1m"] -[10.971276, "o", "ca"] -[11.061415, "o", "t "] -[11.151604, "o", "df"] -[11.241877, "o", "et"] -[11.332175, "o", "ch"] -[11.422206, "o", ".y"] -[11.512344, "o", "am"] -[11.60248, "o", "l\u001b"] -[11.782771, "o", "[0"] -[11.872931, "o", "m"] -[12.874505, "o", "\r\n"] -[12.87646, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v3.4 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n\r\n - name: dfetch\r\n remote: github\r\n branch: main\r\n repo-path: dfetch-org/dfetch.git\r\n"] -[15.8913, "o", "$ "] -[15.891782, "o", "\u001b["] -[16.071982, "o", "1m"] -[16.162128, "o", "\u001b["] -[16.252276, "o", "0m"] -[16.252759, "o", "\r\n"] -[16.254621, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] +{"version": 2, "width": 111, "height": 25, "timestamp": 1774774765, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[0.526136, "o", "\u001b[H\u001b[2J\u001b[3J"] +[0.529983, "o", "$ "] +[1.532978, "o", "\u001b"] +[1.713503, "o", "[1"] +[1.80361, "o", "mc"] +[1.893763, "o", "at"] +[1.983888, "o", " d"] +[2.074031, "o", "fe"] +[2.164342, "o", "tc"] +[2.254453, "o", "h."] +[2.344586, "o", "ya"] +[2.434735, "o", "ml"] +[2.614966, "o", "\u001b"] +[2.705088, "o", "[0"] +[2.795223, "o", "m"] +[3.796618, "o", "\r\n"] +[3.799921, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v3.4 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n"] +[3.806059, "o", "$ "] +[4.809269, "o", "\u001b"] +[4.989573, "o", "[1"] +[5.079884, "o", "md"] +[5.170155, "o", "fe"] +[5.260134, "o", "t"] +[5.35027, "o", "ch"] +[5.440405, "o", " a"] +[5.530529, "o", "dd"] +[5.620673, "o", " h"] +[5.710822, "o", "t"] +[5.891083, "o", "tp"] +[5.98132, "o", "s:"] +[6.071444, "o", "//"] +[6.161576, "o", "gi"] +[6.251704, "o", "t"] +[6.341821, "o", "hu"] +[6.431956, "o", "b."] +[6.52208, "o", "co"] +[6.61222, "o", "m/"] +[6.792762, "o", "d"] +[6.882949, "o", "fe"] +[6.973091, "o", "tc"] +[7.063222, "o", "h-"] +[7.153345, "o", "or"] +[7.243847, "o", "g"] +[7.333795, "o", "/d"] +[7.42393, "o", "fe"] +[7.51406, "o", "tc"] +[7.69429, "o", "h."] +[7.784584, "o", "g"] +[7.874714, "o", "it"] +[7.96483, "o", "\u001b["] +[8.054957, "o", "0m"] +[9.056615, "o", "\r\n"] +[9.52535, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[10.052605, "o", " \u001b[1;92mhttps://github.com/dfetch-org/dfetch.git:\u001b[0m\r\n"] +[10.053209, "o", " \u001b[1;34m> Adding project to manifest\u001b[0m\r\n"] +[10.053749, "o", " - \u001b[34mname:\u001b[0m dfetch\r\n"] +[10.054229, "o", " \u001b[34mremote:\u001b[0m github\r\n"] +[10.054731, "o", " \u001b[34mbranch:\u001b[0m main\r\n"] +[10.055203, "o", " \u001b[34mrepo-path:\u001b[0m dfetch-org/dfetch.git\r\n"] +[10.05624, "o", " \u001b[1;92mdfetch:\u001b[0m\r\n"] +[10.056777, "o", " \u001b[1;34m> Added 'dfetch' to manifest '/workspaces/dfetch/doc/generate-casts/add/dfetch.yaml'\u001b[0m\r\n"] +[10.114965, "o", "$ "] +[11.118103, "o", "\u001b"] +[11.298382, "o", "[1"] +[11.388507, "o", "mc"] +[11.478657, "o", "at"] +[11.568774, "o", " d"] +[11.658911, "o", "fe"] +[11.749043, "o", "tc"] +[11.839164, "o", "h."] +[11.929283, "o", "ya"] +[12.019422, "o", "ml"] +[12.199664, "o", "\u001b"] +[12.289795, "o", "[0"] +[12.379925, "o", "m"] +[13.381651, "o", "\r\n"] +[13.387827, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v3.4 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n\r\n - name: dfetch\r\n remote: github\r\n branch: main\r\n repo-path: dfetch-org/dfetch.git\r\n"] +[16.398412, "o", "$ "] +[16.40025, "o", "\u001b"] +[16.580666, "o", "[1"] +[16.670843, "o", "m\u001b"] +[16.76096, "o", "[0"] +[16.851064, "o", "m"] +[16.851564, "o", "\r\n"] +[16.854342, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] diff --git a/doc/asciicasts/basic.cast b/doc/asciicasts/basic.cast index c4f18bc1..1e1fb36b 100644 --- a/doc/asciicasts/basic.cast +++ b/doc/asciicasts/basic.cast @@ -1,173 +1,163 @@ -{"version": 2, "width": 173, "height": 25, "timestamp": 1774773929, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} -[0.7619, "o", "\u001b[H\u001b[2J\u001b[3J"] -[0.766171, "o", "$ "] -[1.865267, "o", "\u001b"] -[2.045623, "o", "[1"] -[2.135772, "o", "ml"] -[2.225923, "o", "s "] -[2.31604, "o", "-l"] -[2.406161, "o", "\u001b["] -[2.496309, "o", "0m"] -[3.497894, "o", "\r\n"] -[3.547567, "o", "total 4\r\n"] -[3.547737, "o", "-rw-rw-rw- 1 dev dev 733 Mar 29 08:45 dfetch.yaml\r\n"] -[3.552644, "o", "$ "] -[4.55551, "o", "\u001b["] -[4.736319, "o", "1m"] -[4.826469, "o", "ca"] -[4.916612, "o", "t "] -[5.006747, "o", "df"] -[5.098777, "o", "et"] -[5.186942, "o", "ch"] -[5.27708, "o", ".y"] -[5.367221, "o", "am"] -[5.457367, "o", "l\u001b"] -[5.63761, "o", "[0"] -[5.727752, "o", "m"] -[6.729478, "o", "\r\n"] -[6.732861, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest"] -[6.732892, "o", "\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote"] -[6.733035, "o", "\r\n tag: v3.4 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n"] -[6.738013, "o", "$ "] -[7.740626, "o", "\u001b["] -[7.921014, "o", "1m"] -[8.011313, "o", "df"] -[8.101481, "o", "et"] -[8.191603, "o", "ch "] -[8.281742, "o", "ch"] -[8.371854, "o", "ec"] -[8.461989, "o", "k\u001b"] -[8.552136, "o", "[0"] -[8.642882, "o", "m"] -[9.644783, "o", "\r\n"] -[10.088287, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[10.102536, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] -[10.102838, "o", "\u001b[?25l"] -[10.183615, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[10.26422, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[10.344806, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[10.425405, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[10.469005, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Checking\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] -[10.469859, "o", " \u001b[1;34m> wanted (v3.4), available (v4.0)\u001b[0m\r\n"] -[10.470768, "o", " \u001b[1;92mjsmn:\u001b[0m\r\n"] -[10.470943, "o", "\u001b[?25l"] -[10.551776, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[10.632327, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[10.712925, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[10.793494, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[10.874051, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[10.954624, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[11.035188, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[11.11575, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[11.192474, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Checking\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] -[11.192944, "o", " \u001b[1;34m> available (master - 25647e692c7906b96ffd2b05ca54c097948e879c)\u001b[0m\r\n"] -[11.282343, "o", "$ "] -[12.285333, "o", "\u001b["] -[12.465827, "o", "1m"] -[12.555964, "o", "se"] -[12.646089, "o", "d "] -[12.736238, "o", "-i"] -[12.826347, "o", " '"] -[12.916488, "o", "s/"] -[13.006668, "o", "v3"] -[13.096817, "o", ".4"] -[13.186944, "o", "/v"] -[13.367164, "o", "4.0"] -[13.457308, "o", "/g"] -[13.547438, "o", "' "] -[13.637576, "o", "df"] -[13.727699, "o", "et"] -[13.81784, "o", "ch"] -[13.908153, "o", ".y"] -[13.99828, "o", "am"] -[14.088409, "o", "l\u001b"] -[14.268778, "o", "[0"] -[14.35887, "o", "m"] -[15.360455, "o", "\r\n"] -[15.373158, "o", "$ "] -[16.376416, "o", "\u001b"] -[16.557165, "o", "[1"] -[16.647295, "o", "mc"] -[16.737424, "o", "at"] -[16.827549, "o", " d"] -[16.917687, "o", "fe"] -[17.007814, "o", "tc"] -[17.097953, "o", "h."] -[17.188079, "o", "ya"] -[17.278207, "o", "ml"] -[17.458596, "o", "\u001b"] -[17.548774, "o", "[0"] -[17.638905, "o", "m"] -[18.641636, "o", "\r\n"] -[18.644642, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v4.0 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n"] -[18.649943, "o", "$ "] -[19.654073, "o", "\u001b["] -[19.833926, "o", "1m"] -[19.924074, "o", "df"] -[20.0142, "o", "et"] -[20.104593, "o", "ch"] -[20.194455, "o", " u"] -[20.284592, "o", "pd"] -[20.374761, "o", "at"] -[20.464861, "o", "e\u001b"] -[20.554997, "o", "[0"] -[20.735256, "o", "m"] -[21.736781, "o", "\r\n"] -[22.183767, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[22.198039, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] -[22.198181, "o", "\u001b[?25l"] -[22.279181, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] -[22.359767, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] -[22.440348, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] -[22.52095, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] -[22.601515, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] -[22.68203, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] -[22.762622, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] -[22.843185, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] -[22.923676, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] -[23.00441, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] -[23.085093, "o", "\r\u001b[2K\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] -[23.168451, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] -[23.249382, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] -[23.329933, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] -[23.410719, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] -[23.491137, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] -[23.508978, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] -[23.509731, "o", " \u001b[1;34m> Fetched v4.0\u001b[0m\r\n"] -[23.536782, "o", " \u001b[1;92mjsmn:\u001b[0m\r\n"] -[23.536827, "o", "\u001b[?25l"] -[23.617984, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[23.698605, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[23.779174, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[23.859739, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[23.941435, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[24.021782, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[24.102383, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[24.182939, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[24.26407, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[24.344622, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[24.425384, "o", "\r\u001b[2K\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[24.507608, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[24.587964, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[24.668899, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[24.749459, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[24.777899, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching \u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] -[24.778862, "o", " \u001b[1;34m> Fetched master - 25647e692c7906b96ffd2b05ca54c097948e879c\u001b[0m\r\n"] -[24.849972, "o", "$ "] -[25.853186, "o", "\u001b["] -[26.033477, "o", "1m"] -[26.1236, "o", "ls"] -[26.213748, "o", " -"] -[26.303857, "o", "l\u001b"] -[26.393981, "o", "[0"] -[26.484104, "o", "m"] -[27.485666, "o", "\r\n"] -[27.488991, "o", "total 12\r\n"] -[27.489162, "o", "drwxrwxrwx+ 3 dev dev 4096 Mar 29 08:45 cpputest\r\n-rw-rw-rw- 1 dev dev 733 Mar 29 08:45 dfetch.yaml\r\ndrwxrwxrwx+ 4 dev dev 4096 Mar 29 08:45 jsmn\r\n"] -[30.498119, "o", "$ "] -[30.499969, "o", "\u001b["] -[30.680256, "o", "1m"] -[30.770389, "o", "\u001b["] -[30.860514, "o", "0m"] -[30.861219, "o", "\r\n"] -[30.864099, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] +{"version": 2, "width": 111, "height": 25, "timestamp": 1774774719, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[0.51454, "o", "\u001b[H\u001b[2J\u001b[3J"] +[0.518533, "o", "$ "] +[1.521734, "o", "\u001b"] +[1.702056, "o", "[1"] +[1.792431, "o", "ml"] +[1.882603, "o", "s "] +[1.972708, "o", "-l"] +[2.062839, "o", "\u001b["] +[2.15297, "o", "0m"] +[3.154465, "o", "\r\n"] +[3.157987, "o", "total 4\r\n"] +[3.158039, "o", "-rw-rw-rw- 1 dev dev 733 Mar 29 08:58 dfetch.yaml\r\n"] +[3.16297, "o", "$ "] +[4.166161, "o", "\u001b["] +[4.346484, "o", "1m"] +[4.436548, "o", "ca"] +[4.526693, "o", "t "] +[4.61682, "o", "dfe"] +[4.708656, "o", "tc"] +[4.797875, "o", "h."] +[4.888015, "o", "ya"] +[4.978137, "o", "ml"] +[5.068398, "o", "\u001b[0"] +[5.248651, "o", "m"] +[6.250173, "o", "\r\n"] +[6.25345, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v3.4 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n"] +[6.258653, "o", "$ "] +[7.261801, "o", "\u001b["] +[7.442068, "o", "1m"] +[7.532653, "o", "df"] +[7.62236, "o", "et"] +[7.712488, "o", "ch "] +[7.802632, "o", "ch"] +[7.892769, "o", "ec"] +[7.982873, "o", "k\u001b"] +[8.07301, "o", "[0"] +[8.163131, "o", "m"] +[9.164647, "o", "\r\n"] +[9.651652, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[9.678456, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n\u001b[?25l"] +[9.76056, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[9.841154, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[9.921787, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.002443, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.082956, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.152372, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Checking\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] +[10.154105, "o", " \u001b[1;34m> wanted (v3.4), available (v4.0)\u001b[0m\r\n \u001b[1;92mjsmn:\u001b[0m"] +[10.1547, "o", "\r\n\u001b[?25l"] +[10.23593, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.316234, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.396835, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.477363, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.557931, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.638501, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.718994, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.799692, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.833604, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.834039, "o", "\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] +[10.835057, "o", " \u001b[1;34m> available (master - 25647e692c7906b96ffd2b05ca54c097948e879c)\u001b[0m\r\n"] +[10.925167, "o", "$ "] +[11.927744, "o", "\u001b["] +[12.10803, "o", "1m"] +[12.198152, "o", "se"] +[12.288288, "o", "d "] +[12.378411, "o", "-i "] +[12.468571, "o", "'s"] +[12.558733, "o", "/v"] +[12.648829, "o", "3."] +[12.739059, "o", "4/"] +[12.829156, "o", "v4."] +[13.009525, "o", "0/"] +[13.099717, "o", "g'"] +[13.189856, "o", " d"] +[13.279982, "o", "fe"] +[13.370095, "o", "tch"] +[13.46024, "o", ".y"] +[13.550382, "o", "am"] +[13.640505, "o", "l\u001b"] +[13.730642, "o", "[0"] +[13.910817, "o", "m"] +[14.912399, "o", "\r\n"] +[14.921958, "o", "$ "] +[15.924897, "o", "\u001b["] +[16.105144, "o", "1m"] +[16.195287, "o", "ca"] +[16.285438, "o", "t "] +[16.375546, "o", "df"] +[16.465695, "o", "et"] +[16.555826, "o", "ch"] +[16.645988, "o", ".y"] +[16.736133, "o", "am"] +[16.826252, "o", "l\u001b"] +[17.006477, "o", "[0m"] +[18.008241, "o", "\r\n"] +[18.011006, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v4.0 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n"] +[18.016229, "o", "$ "] +[19.01921, "o", "\u001b["] +[19.199487, "o", "1m"] +[19.289638, "o", "df"] +[19.379751, "o", "et"] +[19.469878, "o", "ch"] +[19.560015, "o", " u"] +[19.650146, "o", "pd"] +[19.740262, "o", "at"] +[19.830399, "o", "e\u001b"] +[19.922389, "o", "[0"] +[20.102168, "o", "m"] +[21.103221, "o", "\r\n"] +[21.534191, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[21.548022, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] +[21.548169, "o", "\u001b[?25l"] +[21.62924, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] +[21.709844, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] +[21.790421, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] +[21.870992, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] +[21.951553, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] +[22.032133, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] +[22.112993, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] +[22.196255, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] +[22.276631, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] +[22.357193, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] +[22.43769, "o", "\r\u001b[2K\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] +[22.518241, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m"] +[22.551063, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v4.0\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] +[22.551816, "o", " \u001b[1;34m> Fetched v4.0\u001b[0m\r\n"] +[22.577375, "o", " \u001b[1;92mjsmn:\u001b[0m"] +[22.577929, "o", "\r\n\u001b[?25l"] +[22.659, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[22.739634, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[22.820175, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[22.90067, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[22.981272, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[23.061846, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[23.142397, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[23.222972, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[23.303796, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[23.385465, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[23.466016, "o", "\r\u001b[2K\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[23.546594, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[23.624793, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[23.62564, "o", "\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] +[23.626212, "o", " \u001b[1;34m> Fetched master - 25647e692c7906b96ffd2b05ca54c097948e879c\u001b[0m"] +[23.626478, "o", "\r\n"] +[23.715724, "o", "$ "] +[24.719051, "o", "\u001b"] +[24.899468, "o", "[1"] +[24.989611, "o", "ml"] +[25.079748, "o", "s "] +[25.169869, "o", "-"] +[25.259998, "o", "l\u001b"] +[25.350136, "o", "[0"] +[25.440269, "o", "m"] +[26.441785, "o", "\r\n"] +[26.44542, "o", "total 12\r\n"] +[26.445471, "o", "drwxrwxrwx+ 3 dev dev 4096 Mar 29 08:59 cpputest\r\n-rw-rw-rw- 1 dev dev 733 Mar 29 08:58 dfetch.yaml\r\ndrwxrwxrwx+ 4 dev dev 4096 Mar 29 08:59 jsmn\r\n"] +[29.454233, "o", "$ "] +[29.45604, "o", "\u001b["] +[29.636322, "o", "1m"] +[29.726466, "o", "\u001b["] +[29.816685, "o", "0m"] +[29.817111, "o", "\r\n"] +[29.820288, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] diff --git a/doc/asciicasts/check-ci.cast b/doc/asciicasts/check-ci.cast index 99d77ccd..588d3d72 100644 --- a/doc/asciicasts/check-ci.cast +++ b/doc/asciicasts/check-ci.cast @@ -1,120 +1,130 @@ -{"version": 2, "width": 173, "height": 25, "timestamp": 1774774005, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} -[0.540611, "o", "\u001b[H\u001b[2J\u001b[3J"] -[0.544813, "o", "$ "] -[1.548103, "o", "\u001b["] -[1.728559, "o", "1m"] -[1.818674, "o", "ca"] -[1.90882, "o", "t "] -[1.998976, "o", "dfe"] -[2.089058, "o", "tc"] -[2.179171, "o", "h."] -[2.269347, "o", "ya"] -[2.359416, "o", "ml"] -[2.4497, "o", "\u001b[0"] -[2.629942, "o", "m"] -[3.631487, "o", "\r\n"] -[3.63451, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v3.4 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n"] -[3.63986, "o", "$ "] -[4.643114, "o", "\u001b["] -[4.82347, "o", "1m"] -[4.913593, "o", "df"] -[5.00373, "o", "et"] -[5.093854, "o", "ch"] -[5.184007, "o", " c"] -[5.274129, "o", "he"] -[5.364247, "o", "ck"] -[5.454395, "o", " -"] -[5.544532, "o", "-j"] -[5.724786, "o", "enk"] -[5.814923, "o", "in"] -[5.90505, "o", "s-"] -[5.995193, "o", "js"] -[6.085313, "o", "on"] -[6.175436, "o", " j"] -[6.265564, "o", "en"] -[6.355697, "o", "ki"] -[6.445889, "o", "ns"] -[6.626161, "o", ".j"] -[6.716279, "o", "son"] -[6.806406, "o", " -"] -[6.896539, "o", "-s"] -[6.986666, "o", "ar"] -[7.076806, "o", "if"] -[7.166945, "o", " s"] -[7.257067, "o", "ar"] -[7.347208, "o", "if"] -[7.527446, "o", ".j"] -[7.617579, "o", "so"] -[7.70772, "o", "n\u001b["] -[7.797835, "o", "0m"] -[8.799469, "o", "\r\n"] -[9.319943, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[9.334143, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] -[9.33429, "o", "\u001b[?25l"] -[9.415383, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[9.495907, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[9.576634, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[9.657297, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[9.703542, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Checking\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] -[9.705466, "o", " \u001b[1;34m> wanted (v3.4), available (v4.0)\u001b[0m\r\n"] -[9.706477, "o", " \u001b[1;92mjsmn:\u001b[0m\r\n\u001b[?25l"] -[9.787083, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[9.86765, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[9.948217, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[10.028703, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[10.109981, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[10.191188, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[10.271736, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[10.352325, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[10.419821, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Checking\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K \u001b[1;34m> available (master - 25647e692c7906b96ffd2b05ca54c097948e879c)\u001b[0m\r\n"] -[10.48351, "o", "$ "] -[11.486953, "o", "\u001b["] -[11.667186, "o", "1m"] -[11.757368, "o", "ls"] -[11.847482, "o", " -"] -[11.937626, "o", "l "] -[12.027744, "o", ".\u001b"] -[12.117885, "o", "[0"] -[12.208026, "o", "m"] -[13.20956, "o", "\r\n"] -[13.213211, "o", "total 16\r\n"] -[13.213256, "o", "-rw-rw-rw- 1 dev dev 733 Mar 29 08:46 dfetch.yaml\r\n-rw-rw-rw- 1 dev dev 1027 Mar 29 08:46 jenkins.json\r\n-rw-rw-rw- 1 dev dev 6117 Mar 29 08:46 sarif.json\r\n"] -[13.218321, "o", "$ "] -[14.221721, "o", "\u001b"] -[14.401976, "o", "[1"] -[14.492245, "o", "mc"] -[14.58231, "o", "at"] -[14.672427, "o", " "] -[14.762586, "o", "je"] -[14.852779, "o", "nk"] -[14.942916, "o", "in"] -[15.033035, "o", "s."] -[15.123178, "o", "j"] -[15.303431, "o", "so"] -[15.39355, "o", "n\u001b"] -[15.483679, "o", "[0"] -[15.573871, "o", "m"] -[16.57543, "o", "\r\n"] -[16.578548, "o", "{\r\n \"_class\": \"io.jenkins.plugins.analysis.core.restapi.ReportApi\",\r\n \"issues\": [\r\n {\r\n \"fileName\": \"dfetch.yaml\",\r\n \"severity\": \"High\",\r\n \"message\": \"cpputest : cpputest was never fetched!\",\r\n \"description\": \"The manifest requires version 'v3.4' of cpputest. it was never fetched, fetch it with 'dfetch update cpputest'. The latest version available is 'v4.0'\",\r\n \"lineStart\": 9,\r\n \"lineEnd\": 9,\r\n \"columnStart\": 11,\r\n \"columnEnd\": 18\r\n },\r\n {\r\n \"fileName\": \"dfetch.yaml\",\r\n \"severity\": \"High\",\r\n \"message\": \"jsmn : jsmn was never fetched!\",\r\n \"description\": \"The manifest requires version 'latest' of jsmn. it was never fetched, fetch it with 'dfetch update jsmn'. The latest version available is 'master - 25647e692c7906b96ffd2b05ca54c097948e879c'\",\r\n \"lineStart\": 14,\r\n \"lineEnd\": 14,\r\n \"columnStart\": 11,\r\n \"columnEnd\": 14\r\n }\r\n ]\r\n}"] -[16.585142, "o", "$ "] -[17.587942, "o", "\u001b["] -[17.768177, "o", "1m"] -[17.858304, "o", "ca"] -[17.948427, "o", "t "] -[18.038565, "o", "sa"] -[18.128692, "o", "ri"] -[18.218825, "o", "f."] -[18.308959, "o", "js"] -[18.399301, "o", "on"] -[18.489458, "o", "\u001b["] -[18.669738, "o", "0m"] -[19.671477, "o", "\r\n"] -[19.675315, "o", "{\r\n \"runs\": [\r\n {\r\n \"tool\": {\r\n \"driver\": {\r\n \"name\": \"DFetch\",\r\n \"informationUri\": \"https://dfetch.rtfd.io\",\r\n \"rules\": [\r\n {\r\n \"id\": \"unfetched-project\",\r\n \"help\": {\r\n \"text\": \"The project mentioned in the manifest was never fetched, fetch it with 'dfetch update '. After fetching, commit the updated project to your repository.\"\r\n },\r\n \"shortDescription\": {\r\n \"text\": \"Project was never fetched\"\r\n }\r\n },\r\n {\r\n \"id\": \"up-to-date-project\",\r\n \"help\": {\r\n \"text\": \"The project mentioned in the manifest is up-to-date, everything is ok, nothing to do.\"\r\n },\r\n \"shortDescription\": {\r\n \"text\": \"Project is up-to-date\"\r\n }\r\n },\r\n {\r\n \"id\": \"unavailable-project-version\",\r\n \"help\": {\r\n \"text\": \"The project mentioned in the manifest is pinned to a specific version, For instance a branch, tag, or revision. However the specific version is not available at the upstream of the project. Check if the remote has the given version. \"\r\n },\r\n \"shortDescription\": {\r\n \"text\": \"Requested project version is unavailable at the remote\"\r\n }\r\n },\r\n {\r\n \"id\": \"pinned-but-out-of-date-project\",\r\n \"help\": {\r\n \"text\": \"The project mentioned in the manifest is pinned to a specific version, For instance a branch, tag, or revision. This is currently the state of the project. However a newer version is available at the upstream of the project. Either ignore this warning or update the version to the latest and update using 'dfetch update ' and commit the result to your repository.\"\r\n },\r\n \"shortDescription\": {\r\n \"text\": \"Project is pinned, but out-of-date\"\r\n }\r\n },\r\n {\r\n \"id\": \"out-of-date-project\",\r\n \"help\": {\r\n \"text\": \"The project is configured to always follow the latest version, There is a newer version available at the upstream of the project. Please update the project using 'dfetch update ' and commit the result to your repository.\"\r\n },\r\n \"shortDescription\": {\r\n \"text\": \"Project is out-of-date\"\r\n }\r\n },\r\n {\r\n \"id\": \"local-changes-in-project\",\r\n \"help\": {\r\n \"text\": \"The files of this project are different then when they were added, Please create a patch using 'dfetch diff ' and add it to the manifest using the 'patch:' attribute. Or better yet, upstream the changes and update your project. When running 'dfetch check' on a platform with different line endings, then this warning is likely a false positive.\"\r\n },\r\n \"shortDescription\": {\r\n \"text\": \"Project was locally changed\"\r\n }\r\n }\r\n ]\r\n }\r\n },\r\n \"artifacts\": [\r\n {\r\n "] -[19.675361, "o", " \"location\": {\r\n \"uri\": \"dfetch.yaml\"\r\n },\r\n \"sourceLanguage\": \"yaml\"\r\n }\r\n ],\r\n \"results\": [\r\n {\r\n \"message\": {\r\n \"text\": \"cpputest : cpputest was never fetched!\"\r\n },\r\n \"level\": \"error\",\r\n \"locations\": [\r\n {\r\n \"physicalLocation\": {\r\n \"artifactLocation\": {\r\n \"index\": 0,\r\n \"uri\": \"dfetch.yaml\"\r\n },\r\n \"region\": {\r\n \"endColumn\": 19,\r\n \"endLine\": 9,\r\n \"startColumn\": 11,\r\n \"startLine\": 9\r\n }\r\n }\r\n }\r\n ],\r\n \"ruleId\": \"unfetched-project\"\r\n },\r\n {\r\n \"message\": {\r\n \"text\": \"jsmn : jsmn was never fetched!\"\r\n },\r\n \"level\": \"error\",\r\n \"locations\": [\r\n {\r\n \"physicalLocation\": {\r\n \"artifactLocation\": {\r\n \"index\": 0,\r\n \"uri\": \"dfetch.yaml\"\r\n },\r\n \"region\": {\r\n \"endColumn\": 15,\r\n \"endLine\": 14,\r\n \"startColumn\": 11,\r\n \"startLine\": 14\r\n }\r\n }\r\n }\r\n ],\r\n \"ruleId\": \"unfetched-project\"\r\n }\r\n ]\r\n }\r\n ],\r\n \"version\": \"2.1.0\"\r\n}"] -[22.685211, "o", "$ "] -[22.687078, "o", "\u001b["] -[22.867372, "o", "1m"] -[22.957498, "o", "\u001b["] -[23.047625, "o", "0m"] -[23.048251, "o", "\r\n"] -[23.05133, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] +{"version": 2, "width": 111, "height": 25, "timestamp": 1774774809, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[0.604147, "o", "\u001b[H\u001b[2J\u001b[3J"] +[0.608004, "o", "$ "] +[1.611257, "o", "\u001b"] +[1.791504, "o", "[1"] +[1.881637, "o", "mc"] +[1.971798, "o", "at"] +[2.061926, "o", " "] +[2.152043, "o", "df"] +[2.242169, "o", "et"] +[2.332317, "o", "ch"] +[2.422458, "o", ".y"] +[2.512578, "o", "a"] +[2.692957, "o", "ml"] +[2.783078, "o", "\u001b["] +[2.873246, "o", "0m"] +[3.874904, "o", "\r\n"] +[3.878249, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v3.4 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n"] +[3.883761, "o", "$ "] +[4.887097, "o", "\u001b"] +[5.068525, "o", "[1"] +[5.158666, "o", "md"] +[5.248808, "o", "fe"] +[5.338924, "o", "tc"] +[5.429056, "o", "h "] +[5.51919, "o", "ch"] +[5.60931, "o", "ec"] +[5.699477, "o", "k "] +[5.789607, "o", "--"] +[5.970038, "o", "j"] +[6.060164, "o", "en"] +[6.15031, "o", "ki"] +[6.240431, "o", "ns"] +[6.330569, "o", "-j"] +[6.420692, "o", "so"] +[6.51082, "o", "n "] +[6.600961, "o", "je"] +[6.691224, "o", "nk"] +[6.871441, "o", "in"] +[6.961626, "o", "s"] +[7.051747, "o", ".j"] +[7.141882, "o", "so"] +[7.232011, "o", "n "] +[7.322126, "o", "--"] +[7.412261, "o", "sa"] +[7.50262, "o", "ri"] +[7.592741, "o", "f "] +[7.773026, "o", "sa"] +[7.863146, "o", "ri"] +[7.953265, "o", "f"] +[8.043408, "o", ".j"] +[8.133554, "o", "so"] +[8.233129, "o", "n\u001b"] +[8.313833, "o", "[0"] +[8.404005, "o", "m"] +[9.405803, "o", "\r\n"] +[9.849789, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[9.86444, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] +[9.864693, "o", "\u001b[?25l"] +[9.945501, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.027606, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.108102, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.18869, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.249285, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Checking\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] +[10.250143, "o", " \u001b[1;34m> wanted (v3.4), available (v4.0)\u001b[0m\r\n"] +[10.25205, "o", " \u001b[1;92mjsmn:\u001b[0m\r\n\u001b[?25l"] +[10.3327, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.41325, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.493796, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.574356, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.654941, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.735935, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.817575, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.898131, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[10.978693, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[11.059264, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[11.085828, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Checking\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] +[11.086521, "o", " \u001b[1;34m> available (master - 25647e692c7906b96ffd2b05ca54c097948e879c)\u001b[0m\r\n"] +[11.154468, "o", "$ "] +[12.157324, "o", "\u001b["] +[12.337585, "o", "1m"] +[12.427894, "o", "ls"] +[12.51809, "o", " -"] +[12.608211, "o", "l "] +[12.698342, "o", ".\u001b"] +[12.788478, "o", "[0"] +[12.878609, "o", "m"] +[13.880167, "o", "\r\n"] +[13.883089, "o", "total 16\r\n"] +[13.883338, "o", "-rw-rw-rw- 1 dev dev 733 Mar 29 09:00 dfetch.yaml\r\n-rw-rw-rw- 1 dev dev 1027 Mar 29 09:00 jenkins.json\r\n-rw-rw-rw- 1 dev dev 6117 Mar 29 09:00 sarif.json\r\n"] +[13.888523, "o", "$ "] +[14.897366, "o", "\u001b["] +[15.077243, "o", "1m"] +[15.167373, "o", "ca"] +[15.257506, "o", "t "] +[15.347623, "o", "je"] +[15.437757, "o", "nk"] +[15.527907, "o", "in"] +[15.618015, "o", "s."] +[15.708188, "o", "js"] +[15.798304, "o", "on"] +[15.978946, "o", "\u001b["] +[16.069089, "o", "0m"] +[17.070826, "o", "\r\n"] +[17.073648, "o", "{\r\n \"_class\": \"io.jenkins.plugins.analysis.core.restapi.ReportApi\",\r\n \"issues\": [\r\n {\r\n \"fileName\": \"dfetch.yaml\",\r\n \"severity\": \"High\",\r\n \"message\": \"cpputest : cpputest was never fetched!\",\r\n \"description\": \"The manifest requires version 'v3.4' of cpputest. it was never fetched, fetch it with 'dfetch update cpputest'. The latest version available is 'v4.0'\",\r\n"] +[17.0738, "o", " \"lineStart\": 9,\r\n \"lineEnd\": 9,\r\n \"columnStart\": 11,\r\n \"columnEnd\": 18\r\n },\r\n {\r\n \"fileName\": \"dfetch.yaml\",\r\n \"severity\": \"High\",\r\n \"message\": \"jsmn : jsmn was never fetched!\",\r\n \"description\": \"The manifest requires version 'latest' of jsmn. it was never fetched, fetch it with 'dfetch update jsmn'. The latest version available is 'master - 25647e692c7906b96ffd2b05ca54c097948e879c'\",\r\n \"lineStart\": 14,\r\n \"lineEnd\": 14,\r\n \"columnStart\": 11,\r\n \"columnEnd\": 14\r\n }\r\n ]\r\n}"] +[17.079861, "o", "$ "] +[18.083186, "o", "\u001b"] +[18.263547, "o", "[1"] +[18.353676, "o", "mc"] +[18.443834, "o", "at"] +[18.533953, "o", " s"] +[18.624079, "o", "ar"] +[18.714154, "o", "if"] +[18.804326, "o", ".j"] +[18.89446, "o", "so"] +[18.986321, "o", "n\u001b"] +[19.166575, "o", "["] +[19.256707, "o", "0m"] +[20.257455, "o", "\r\n"] +[20.260628, "o", "{\r\n \"runs\": [\r\n {\r\n \"tool\": {\r\n \"driver\": {\r\n \"name\": \"DFetch\",\r\n \"informationUri\": \"https://dfetch.rtfd.io\",\r\n \"rules\": [\r\n {\r\n \"id\": \"unfetched-project\",\r\n \"help\": {\r\n \"text\": \"The project mentioned in the manifest was never fetched, fetch it with 'dfetch update '. After fetching, commit the updated project to your repository.\"\r\n },\r\n \"shortDescription\": {\r\n \"text\": \"Project was never fetched\"\r\n }\r\n },\r\n {\r\n \"id\": \"up-to-date-project\",\r\n \"help\": {\r\n \"text\": \"The project mentioned in the manifest is up-to-date, everything is ok, nothing to do.\""] +[20.260771, "o", "\r\n },\r\n \"shortDescription\": {\r\n \"text\": \"Project is up-to-date\"\r\n }\r\n },\r\n {\r\n \"id\": \"unavailable-project-version\",\r\n \"help\": {\r\n \"text\": \"The project mentioned in the manifest is pinned to a specific version, For instance a branch, tag, or revision. However the specific version is not available at the upstream of the project. Check if the remote has the given version. \"\r\n },\r\n \"shortDescription\": {\r\n \"text\": \"Requested project version is unavailable at the remote\"\r\n }\r\n },\r\n {\r\n \"id\": \"pinned-but-out-of-date-project\",\r\n \"help\": {\r\n \"text\": \"The project mentioned in the manifest is pinned to a specific version, For instance a branch, tag, or revision. This is currently the state of the project. However a newer version is available at the upstream of the project. Either ignore this warning or update the version to the latest and update using 'dfetch update ' and commit the result to your repository.\"\r\n },\r\n \"shortDescription\": {\r\n \"text\": \"Project is pinned, but out-of-date\"\r\n }\r\n },\r\n {\r\n \"id\": \"out-of-date-project\",\r\n \"help\": {\r\n \"text\": \"The project is configured to always follow the latest version, There is a newer version available at the upstream of the project. Please update the project using 'dfetch update ' and commit the result to your repository.\"\r\n },\r\n \"shortDescription\": {\r\n \"text\": \"Project is out-of-date\"\r\n }\r\n },\r\n {\r\n \"id\": \"local-changes-in-project\",\r\n \"help\": {\r\n \"text\": \"The files of this project are different then when they were added, Please create a patch using 'dfetch diff ' and add it to the manifest using the 'patch:' attribute. Or better yet, upstream the changes and update your project. When running 'dfetch check' on a platform with different line endings, then this warning is likely a false positive.\"\r\n },\r\n \"shortDescription\": {\r\n \"text\": \"Project was locally changed\"\r\n }\r\n }\r\n ]\r\n }\r\n },\r\n \"artifacts\": [\r\n {\r\n \"location\": {\r\n \"uri\": \"dfetch.yaml\"\r\n },\r\n \"sourceLanguage\": \"yaml\"\r\n }\r\n ],\r\n \"results\": [\r\n {\r\n \"message\": {\r\n \"text\": \"cpputest : cpputest was never fetched!\"\r\n },\r\n \"level\": \"error\",\r\n \"locations\": [\r\n {\r\n \"physicalLocation\": {\r\n \"artifactLocation\": {\r\n \"index\": 0,\r\n \"uri\": \"dfetch.yaml\"\r\n },\r\n \"region\": {\r\n \"endColumn\": 19,\r\n \"endLine\": 9,\r\n \"startColumn\": 11,\r\n \"startLine\": 9\r\n }\r\n "] +[20.260797, "o", " }\r\n }\r\n ],\r\n \"ruleId\": \"unfetched-project\"\r\n },\r\n {\r\n \"message\": {\r\n \"text\": \"jsmn : jsmn was never fetched!\"\r\n },\r\n \"level\": \"error\",\r\n \"locations\": [\r\n {\r\n \"physicalLocation\": {\r\n \"artifactLocation\": {\r\n \"index\": 0,\r\n \"uri\": \"dfetch.yaml\"\r\n },\r\n \"region\": {\r\n \"endColumn\": 15,\r\n \"endLine\": 14,\r\n \"startColumn\": 11,\r\n \"startLine\": 14\r\n }\r\n }\r\n }\r\n ],\r\n \"ruleId\": \"unfetched-project\"\r\n }\r\n ]\r\n }\r\n ],\r\n \"version\": \"2.1.0\"\r\n}"] +[23.269029, "o", "$ "] +[23.270962, "o", "\u001b["] +[23.451259, "o", "1m"] +[23.541401, "o", "\u001b["] +[23.631528, "o", "0m"] +[23.632112, "o", "\r\n"] +[23.634972, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] diff --git a/doc/asciicasts/check.cast b/doc/asciicasts/check.cast index e890bcc9..6192839e 100644 --- a/doc/asciicasts/check.cast +++ b/doc/asciicasts/check.cast @@ -1,63 +1,54 @@ -{"version": 2, "width": 173, "height": 25, "timestamp": 1774773993, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} -[0.551372, "o", "\u001b[H\u001b[2J\u001b[3J"] -[0.555774, "o", "$ "] -[1.559107, "o", "\u001b"] -[1.739374, "o", "[1"] -[1.829516, "o", "mc"] -[1.919734, "o", "at"] -[2.009812, "o", " d"] -[2.099939, "o", "fe"] -[2.190088, "o", "tc"] -[2.280204, "o", "h."] -[2.370326, "o", "ya"] -[2.460532, "o", "ml"] -[2.640806, "o", "\u001b"] -[2.730932, "o", "[0"] -[2.821023, "o", "m"] -[3.822654, "o", "\r\n"] -[3.825876, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v3.4 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n"] -[3.831326, "o", "$ "] -[4.834557, "o", "\u001b["] -[5.014882, "o", "1m"] -[5.105038, "o", "df"] -[5.195165, "o", "et"] -[5.285305, "o", "ch"] -[5.375424, "o", " c"] -[5.465577, "o", "he"] -[5.555707, "o", "ck"] -[5.645841, "o", "\u001b["] -[5.735993, "o", "0m"] -[6.737743, "o", "\r\n"] -[7.210249, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[7.223727, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] -[7.22387, "o", "\u001b[?25l"] -[7.305116, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[7.385702, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[7.466485, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[7.547185, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[7.608235, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[7.608529, "o", "\r\n"] -[7.608613, "o", "\u001b[?25h\r\u001b[1A\u001b[2K"] -[7.610156, "o", " \u001b[1;34m> wanted (v3.4), available (v4.0)\u001b[0m"] -[7.610392, "o", "\r\n"] -[7.612145, "o", " \u001b[1;92mjsmn:\u001b[0m"] -[7.61251, "o", "\r\n"] -[7.612845, "o", "\u001b[?25l"] -[7.694073, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[7.774559, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[7.855118, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[7.935615, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[8.016287, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[8.096916, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[8.177491, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[8.258018, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Checking\u001b[0m"] -[8.32008, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Checking\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] -[8.320821, "o", " \u001b[1;34m> available (master - 25647e692c7906b96ffd2b05ca54c097948e879c)\u001b[0m\r\n"] -[11.389398, "o", "$ "] -[11.391474, "o", "\u001b"] -[11.571741, "o", "[1"] -[11.662017, "o", "m\u001b"] -[11.752155, "o", "[0"] -[11.842262, "o", "m"] -[11.842804, "o", "\r\n"] -[11.845949, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] +{"version": 2, "width": 111, "height": 25, "timestamp": 1774774797, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[0.530718, "o", "\u001b[H\u001b[2J\u001b[3J"] +[0.534834, "o", "$ "] +[1.538122, "o", "\u001b"] +[1.718464, "o", "[1"] +[1.808597, "o", "mc"] +[1.898749, "o", "at"] +[1.988967, "o", " "] +[2.078985, "o", "df"] +[2.169132, "o", "et"] +[2.25925, "o", "ch"] +[2.349401, "o", ".y"] +[2.439543, "o", "a"] +[2.619999, "o", "ml"] +[2.710156, "o", "\u001b["] +[2.800272, "o", "0m"] +[3.801824, "o", "\r\n"] +[3.804829, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v3.4 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n"] +[3.810776, "o", "$ "] +[4.813555, "o", "\u001b["] +[4.994135, "o", "1m"] +[5.084264, "o", "df"] +[5.174397, "o", "et"] +[5.264531, "o", "ch"] +[5.354661, "o", " c"] +[5.444795, "o", "he"] +[5.534956, "o", "ck"] +[5.62509, "o", "\u001b["] +[5.715221, "o", "0m"] +[6.716986, "o", "\r\n"] +[7.225892, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[7.239534, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] +[7.239665, "o", "\u001b[?25l"] +[7.320732, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[7.401339, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[7.481919, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[7.530894, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Checking\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] +[7.531676, "o", " \u001b[1;34m> wanted (v3.4), available (v4.0)\u001b[0m\r\n"] +[7.532508, "o", " \u001b[1;92mjsmn:\u001b[0m\r\n"] +[7.532632, "o", "\u001b[?25l"] +[7.613403, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[7.693972, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[7.774582, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[7.855307, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[7.935848, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[8.016365, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Checking\u001b[0m"] +[8.089455, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Checking\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K \u001b[1;34m> available (master - 25647e692c7906b96ffd2b05ca54c097948e879c)\u001b[0m\r\n"] +[11.204395, "o", "$ "] +[11.206315, "o", "\u001b["] +[11.386594, "o", "1m"] +[11.476728, "o", "\u001b["] +[11.566843, "o", "0m"] +[11.567355, "o", "\r\n"] +[11.570086, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] diff --git a/doc/asciicasts/diff.cast b/doc/asciicasts/diff.cast index e350d47a..3cd0c8d2 100644 --- a/doc/asciicasts/diff.cast +++ b/doc/asciicasts/diff.cast @@ -1,104 +1,113 @@ -{"version": 2, "width": 173, "height": 25, "timestamp": 1774774090, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} -[0.291332, "o", "\u001b[H\u001b[2J\u001b[3J"] -[0.297838, "o", "$ "] -[1.300927, "o", "\u001b["] -[1.481216, "o", "1m"] -[1.571342, "o", "ls"] -[1.661494, "o", " -"] -[1.75174, "o", "l "] -[1.841875, "o", ".\u001b"] -[1.931973, "o", "[0"] -[2.022134, "o", "m"] -[3.023735, "o", "\r\n"] -[3.027376, "o", "total 12\r\n"] -[3.027429, "o", "drwxr-xr-x+ 3 dev dev 4096 Mar 29 08:48 cpputest\r\n-rw-rw-rw- 1 dev dev 733 Mar 29 08:48 dfetch.yaml\r\ndrwxr-xr-x+ 4 dev dev 4096 Mar 29 08:48 jsmn\r\n"] -[3.033002, "o", "$ "] -[4.036262, "o", "\u001b["] -[4.216531, "o", "1m"] -[4.306702, "o", "ls"] -[4.396808, "o", " -"] -[4.486955, "o", "l "] -[4.577098, "o", "cp"] -[4.667274, "o", "pu"] -[4.757378, "o", "te"] -[4.847504, "o", "st"] -[4.937663, "o", "/s"] -[5.117893, "o", "rc"] -[5.208028, "o", "/R"] -[5.298126, "o", "EA"] -[5.388262, "o", "DM"] -[5.478394, "o", "E."] -[5.568526, "o", "md"] -[5.658672, "o", "\u001b["] -[5.748782, "o", "0m"] -[6.750333, "o", "\r\n"] -[6.753845, "o", "-rw-rw-rw- 1 dev dev 6777 Mar 29 08:48 cpputest/src/README.md\r\n"] -[6.759527, "o", "$ "] -[7.762518, "o", "\u001b["] -[7.942941, "o", "1m"] -[8.033097, "o", "se"] -[8.123218, "o", "d "] -[8.213355, "o", "-i "] -[8.303479, "o", "'s"] -[8.393616, "o", "/g"] -[8.483745, "o", "it"] -[8.573888, "o", "hu"] -[8.664018, "o", "b/g"] -[8.844264, "o", "it"] -[8.934447, "o", "la"] -[9.024586, "o", "b/"] -[9.114713, "o", "g'"] -[9.204835, "o", " cp"] -[9.294965, "o", "pu"] -[9.385097, "o", "te"] -[9.475347, "o", "st"] -[9.565711, "o", "/s"] -[9.745969, "o", "rc/"] -[9.836113, "o", "RE"] -[9.926223, "o", "AD"] -[10.01636, "o", "ME"] -[10.106503, "o", ".m"] -[10.196632, "o", "d\u001b["] -[10.286766, "o", "0m"] -[11.288496, "o", "\r\n"] -[11.303099, "o", "$ "] -[12.306749, "o", "\u001b["] -[12.487056, "o", "1m"] -[12.577154, "o", "df"] -[12.667299, "o", "et"] -[12.757432, "o", "ch "] -[12.847563, "o", "di"] -[12.937708, "o", "ff"] -[13.027806, "o", " c"] -[13.117938, "o", "pp"] -[13.208159, "o", "ute"] -[13.388383, "o", "st"] -[13.478513, "o", "\u001b["] -[13.568637, "o", "0m"] -[14.5703, "o", "\r\n"] -[15.105174, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[15.150307, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] -[15.150971, "o", " \u001b[1;34m> Generating patch cpputest.patch since 782d182fe535ff8e48cd466d5f1d1c889ade12d6 in /workspaces/dfetch/doc/generate-casts/diff\u001b[0m\r\n"] -[15.216187, "o", "$ "] -[16.219341, "o", "\u001b["] -[16.399677, "o", "1m"] -[16.489804, "o", "ca"] -[16.579934, "o", "t "] -[16.670068, "o", "cp"] -[16.760227, "o", "pu"] -[16.850354, "o", "te"] -[16.940506, "o", "st"] -[17.030625, "o", ".p"] -[17.120918, "o", "at"] -[17.301148, "o", "ch"] -[17.391316, "o", "\u001b["] -[17.481457, "o", "0m"] -[18.4835, "o", "\r\n"] -[18.486905, "o", "diff --git a/README.md b/README.md\r\nindex 2655a7b..fc6084e 100644\r\n--- a/README.md\r\n+++ b/README.md\r\n@@ -3,7 +3,7 @@ CppUTest\r\n \r\n CppUTest unit testing and mocking framework for C/C++\r\n \r\n-[More information on the project page](http://cpputest.github.com)\r\n+[More information on the project page](http://cpputest.gitlab.com)\r\n \r\n [![Build Status](https://travis-ci.org/cpputest/cpputest.png?branch=master)](https://travis-ci.org/cpputest/cpputest)\r\n \r\n"] -[21.495347, "o", "$ "] -[21.497255, "o", "\u001b["] -[21.677538, "o", "1m"] -[21.76776, "o", "\u001b["] -[21.857974, "o", "0m"] -[21.858526, "o", "\r\n"] -[21.861449, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] +{"version": 2, "width": 111, "height": 25, "timestamp": 1774774895, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[0.257193, "o", "\u001b[H\u001b[2J\u001b[3J"] +[0.260643, "o", "$ "] +[1.263806, "o", "\u001b["] +[1.44411, "o", "1m"] +[1.534151, "o", "ls"] +[1.624299, "o", " -"] +[1.714416, "o", "l ."] +[1.804564, "o", "\u001b["] +[1.894672, "o", "0m"] +[2.896237, "o", "\r\n"] +[2.899708, "o", "total 12\r\n"] +[2.899763, "o", "drwxr-xr-x+ 3 dev dev 4096 Mar 29 09:01 cpputest\r\n-rw-rw-rw- 1 dev dev 733 Mar 29 09:01 dfetch.yaml\r\ndrwxr-xr-x+ 4 dev dev 4096 Mar 29 09:01 jsmn\r\n"] +[2.90486, "o", "$ "] +[3.908003, "o", "\u001b["] +[4.089189, "o", "1m"] +[4.179336, "o", "ls"] +[4.269462, "o", " -"] +[4.359673, "o", "l c"] +[4.449802, "o", "pp"] +[4.539936, "o", "ut"] +[4.63007, "o", "es"] +[4.720209, "o", "t/"] +[4.810389, "o", "src"] +[4.990638, "o", "/R"] +[5.083509, "o", "EA"] +[5.173417, "o", "DM"] +[5.263543, "o", "E."] +[5.35374, "o", "md\u001b"] +[5.443862, "o", "[0"] +[5.533977, "o", "m"] +[6.535505, "o", "\r\n"] +[6.5392, "o", "-rw-rw-rw- 1 dev dev 6777 Mar 29 09:01 cpputest/src/README.md"] +[6.539351, "o", "\r\n"] +[6.546616, "o", "$ "] +[7.549874, "o", "\u001b["] +[7.730148, "o", "1m"] +[7.820286, "o", "se"] +[7.910407, "o", "d "] +[8.000547, "o", "-i"] +[8.090682, "o", " '"] +[8.180931, "o", "s/"] +[8.27104, "o", "gi"] +[8.361195, "o", "th"] +[8.451308, "o", "ub"] +[8.631653, "o", "/gi"] +[8.72189, "o", "tl"] +[8.812015, "o", "ab"] +[8.902152, "o", "/g"] +[8.992387, "o", "' "] +[9.08343, "o", "cp"] +[9.176235, "o", "pu"] +[9.265397, "o", "te"] +[9.357204, "o", "st"] +[9.535785, "o", "/s"] +[9.625871, "o", "rc/"] +[9.716005, "o", "RE"] +[9.806145, "o", "AD"] +[9.896265, "o", "ME"] +[9.986399, "o", ".m"] +[10.076536, "o", "d\u001b"] +[10.166669, "o", "[0"] +[10.256792, "o", "m"] +[11.258358, "o", "\r\n"] +[11.267334, "o", "$ "] +[12.270475, "o", "\u001b["] +[12.450732, "o", "1m"] +[12.540883, "o", "df"] +[12.631016, "o", "et"] +[12.721141, "o", "ch"] +[12.811269, "o", " d"] +[12.9014, "o", "if"] +[12.991531, "o", "f "] +[13.081664, "o", "cp"] +[13.171794, "o", "pu"] +[13.352027, "o", "te"] +[13.442169, "o", "st"] +[13.53229, "o", "\u001b["] +[13.62243, "o", "0m"] +[14.623888, "o", "\r\n"] +[15.102582, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[15.148186, "o", " \u001b[1;92mcpputest:\u001b[0m"] +[15.148232, "o", "\r\n"] +[15.148893, "o", " \u001b[1;34m> Generating patch cpputest.patch since 28f7406a2a43ed683c0f8722ba6e410d97c6aeb8 in \u001b[0m "] +[15.148978, "o", "\r\n"] +[15.149018, "o", "\u001b[1;34m/workspaces/dfetch/doc/generate-casts/diff\u001b[0m "] +[15.149055, "o", "\r\n"] +[15.214045, "o", "$ "] +[16.217264, "o", "\u001b"] +[16.397535, "o", "[1"] +[16.487677, "o", "mc"] +[16.577839, "o", "at"] +[16.668019, "o", " "] +[16.758132, "o", "cp"] +[16.848266, "o", "pu"] +[16.938389, "o", "te"] +[17.028529, "o", "st"] +[17.118652, "o", "."] +[17.298906, "o", "pa"] +[17.389077, "o", "tc"] +[17.479212, "o", "h\u001b"] +[17.569335, "o", "[0"] +[17.659468, "o", "m"] +[18.661017, "o", "\r\n"] +[18.664368, "o", "diff --git a/README.md b/README.md\r\nindex 2655a7b..fc6084e 100644\r\n--- a/README.md\r\n+++ b/README.md\r\n@@ -3,7 +3,7 @@ CppUTest\r\n \r\n CppUTest unit testing and mocking framework for C/C++\r\n \r\n-[More information on the project page](http://cpputest.github.com)\r\n+[More information on the project page](http://cpputest.gitlab.com)\r\n \r\n [![Build Status](https://travis-ci.org/cpputest/cpputest.png?branch=master)](https://travis-ci.org/cpputest/cpputest)\r\n \r\n"] +[21.673544, "o", "$ "] +[21.675476, "o", "\u001b"] +[21.855753, "o", "[1"] +[21.945884, "o", "m\u001b"] +[22.035963, "o", "[0"] +[22.126093, "o", "m"] +[22.12661, "o", "\r\n"] +[22.129622, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] diff --git a/doc/asciicasts/environment.cast b/doc/asciicasts/environment.cast index 3f067f52..33babf8c 100644 --- a/doc/asciicasts/environment.cast +++ b/doc/asciicasts/environment.cast @@ -1,29 +1,28 @@ -{"version": 2, "width": 173, "height": 25, "timestamp": 1774773977, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} -[0.02211, "o", "$ "] -[1.026177, "o", "\u001b"] -[1.206443, "o", "[1"] -[1.296579, "o", "md"] -[1.386694, "o", "fe"] -[1.476846, "o", "t"] -[1.566997, "o", "ch"] -[1.657124, "o", " e"] -[1.747281, "o", "nv"] -[1.837535, "o", "ir"] -[1.927665, "o", "o"] -[2.108227, "o", "nm"] -[2.198388, "o", "en"] -[2.288553, "o", "t\u001b"] -[2.378688, "o", "[0"] -[2.468851, "o", "m"] -[3.469413, "o", "\r\n"] -[3.956226, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[3.956994, "o", " \u001b[1;92mplatform :\u001b[0m\u001b[1;34m Linux 6.8.0-1044-azure\u001b[0m\r\n"] -[3.960809, "o", " \u001b[1;92mgit :\u001b[0m\u001b[1;34m 2.52.0\u001b[0m\r\n"] -[5.006079, "o", " \u001b[1;92msvn :\u001b[0m\u001b[1;34m 1.14.5 (r1922182)\u001b[0m\r\n"] -[8.068121, "o", "$ "] -[8.070061, "o", "\u001b"] -[8.250347, "o", "[1"] -[8.340651, "o", "m\u001b"] -[8.43079, "o", "[0"] -[8.520903, "o", "m"] -[8.521613, "o", "\r\n"] +{"version": 2, "width": 111, "height": 25, "timestamp": 1774774782, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[0.019582, "o", "$ "] +[1.022715, "o", "\u001b"] +[1.20297, "o", "[1"] +[1.293106, "o", "md"] +[1.383229, "o", "fe"] +[1.473387, "o", "t"] +[1.563508, "o", "ch"] +[1.653703, "o", " e"] +[1.743844, "o", "nv"] +[1.833997, "o", "ir"] +[1.924029, "o", "o"] +[2.104268, "o", "nm"] +[2.19442, "o", "en"] +[2.284526, "o", "t\u001b"] +[2.374675, "o", "[0"] +[2.464798, "o", "m"] +[3.466426, "o", "\r\n"] +[3.918173, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[3.918952, "o", " \u001b[1;92mplatform :\u001b[0m\u001b[1;34m Linux 6.8.0-1044-azure\u001b[0m\r\n"] +[3.921669, "o", " \u001b[1;92mgit :\u001b[0m\u001b[1;34m 2.52.0\u001b[0m\r\n"] +[3.935754, "o", " \u001b[1;92msvn :\u001b[0m\u001b[1;34m 1.14.5 (r1922182)\u001b[0m\r\n"] +[6.998085, "o", "$ "] +[6.999866, "o", "\u001b["] +[7.180177, "o", "1m"] +[7.27035, "o", "\u001b["] +[7.360493, "o", "0m"] +[7.361019, "o", "\r\n"] diff --git a/doc/asciicasts/format-patch.cast b/doc/asciicasts/format-patch.cast index 52e941d3..083a181a 100644 --- a/doc/asciicasts/format-patch.cast +++ b/doc/asciicasts/format-patch.cast @@ -1,124 +1,121 @@ -{"version": 2, "width": 173, "height": 25, "timestamp": 1774774157, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} -[0.843591, "o", "\u001b[H\u001b[2J\u001b[3J"] -[0.847124, "o", "$ "] -[1.850318, "o", "\u001b["] -[2.030604, "o", "1m"] -[2.120789, "o", "ls"] -[2.210896, "o", " -"] -[2.301036, "o", "l "] -[2.391172, "o", ".\u001b"] -[2.482903, "o", "[0"] -[2.573062, "o", "m"] -[3.574085, "o", "\r\n"] -[3.577822, "o", "total 16\r\n"] -[3.57787, "o", "drwxr-xr-x+ 3 dev dev 4096 Mar 29 08:49 cpputest\r\n-rw-rw-rw- 1 dev dev 229 Mar 29 08:49 dfetch.yaml\r\ndrwxr-xr-x+ 4 dev dev 4096 Mar 29 08:49 jsmn\r\ndrwxrwxrwx+ 2 dev dev 4096 Mar 29 08:49 patches\r\n"] -[3.583388, "o", "$ "] -[4.586555, "o", "\u001b["] -[4.766867, "o", "1m"] -[4.856892, "o", "ca"] -[4.947032, "o", "t "] -[5.037179, "o", "df"] -[5.127323, "o", "et"] -[5.21744, "o", "ch"] -[5.307572, "o", ".y"] -[5.397698, "o", "am"] -[5.487827, "o", "l\u001b"] -[5.66808, "o", "[0"] -[5.758211, "o", "m"] -[6.759845, "o", "\r\n"] -[6.763027, "o", "manifest:\r\n version: 0.0\r\n\r\n remotes:\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/\r\n repo-path: cpputest/cpputest.git\r\n tag: v3.4\r\n patch: patches/cpputest.patch\r\n\r\n"] -[6.76773, "o", "$ "] -[7.770913, "o", "\u001b["] -[7.951184, "o", "1m"] -[8.041323, "o", "ca"] -[8.13145, "o", "t "] -[8.221581, "o", "pa"] -[8.311734, "o", "tc"] -[8.401876, "o", "he"] -[8.492002, "o", "s/"] -[8.582246, "o", "cp"] -[8.672373, "o", "pu"] -[8.852633, "o", "te"] -[8.942785, "o", "st"] -[9.032897, "o", ".p"] -[9.123034, "o", "at"] -[9.213154, "o", "ch"] -[9.303291, "o", "\u001b["] -[9.393412, "o", "0m"] -[10.395052, "o", "\r\n"] -[10.39813, "o", "diff --git a/README.md b/README.md\r\nindex 2655a7b..fc6084e 100644\r\n--- a/README.md\r\n+++ b/README.md\r\n@@ -3,7 +3,7 @@ CppUTest"] -[10.39826, "o", "\r\n \r\n CppUTest unit testing and mocking framework for C/C++\r\n \r\n-[More information on the project page](http://cpputest.github.com)\r\n+[More information on the project page](http://cpputest.gitlab.com)\r\n \r\n [![Build Status](https://travis-ci.org/cpputest/cpputest.png?branch=master)](https://travis-ci.org/cpputest/cpputest)\r\n \r\n"] -[10.40299, "o", "$ "] -[11.406193, "o", "\u001b["] -[11.586475, "o", "1m"] -[11.676607, "o", "df"] -[11.766733, "o", "et"] -[11.856884, "o", "ch"] -[11.947015, "o", " f"] -[12.037151, "o", "or"] -[12.127314, "o", "ma"] -[12.217425, "o", "t-"] -[12.307562, "o", "pa"] -[12.48786, "o", "tch"] -[12.580055, "o", " c"] -[12.670085, "o", "pp"] -[12.760214, "o", "ut"] -[12.850347, "o", "es"] -[12.940488, "o", "t "] -[13.030618, "o", "--"] -[13.120757, "o", "ou"] -[13.210872, "o", "tp"] -[13.391124, "o", "ut"] -[13.481258, "o", "-di"] -[13.571393, "o", "re"] -[13.661521, "o", "ct"] -[13.751663, "o", "or"] -[13.841805, "o", "y "] -[13.931925, "o", "fo"] -[14.022079, "o", "rm"] -[14.112279, "o", "at"] -[14.292528, "o", "te"] -[14.382658, "o", "d-"] -[14.472798, "o", "pat"] -[14.562908, "o", "ch"] -[14.653045, "o", "es"] -[14.743175, "o", "\u001b["] -[14.833302, "o", "0m"] -[15.834845, "o", "\r\n"] -[16.326055, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[16.350536, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] -[16.351113, "o", " \u001b[1;34m> formatted patch written to formatted-patches/cpputest.patch\u001b[0m\r\n"] -[16.429275, "o", "$ "] -[17.432506, "o", "\u001b["] -[17.61279, "o", "1m"] -[17.702925, "o", "ca"] -[17.793038, "o", "t "] -[17.883172, "o", "fo"] -[17.97329, "o", "rm"] -[18.063427, "o", "at"] -[18.153553, "o", "te"] -[18.243684, "o", "d-"] -[18.333889, "o", "pa"] -[18.514215, "o", "tc"] -[18.604491, "o", "he"] -[18.694617, "o", "s/"] -[18.784738, "o", "cp"] -[18.874912, "o", "pu"] -[18.96504, "o", "te"] -[19.055175, "o", "st"] -[19.145308, "o", ".p"] -[19.235433, "o", "at"] -[19.415671, "o", "ch"] -[19.505833, "o", "\u001b["] -[19.595945, "o", "0m"] -[20.597671, "o", "\r\n"] -[20.600881, "o", "From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001\r\n"] -[20.601057, "o", "From: John Doe \r\nDate: Sun, 29 Mar 2026 08:49:33 +0000\r\nSubject: [PATCH] Patch for cpputest\r\n\r\nPatch for cpputest\r\n\r\ndiff --git a/README.md b/README.md\r\nindex 2655a7b..fc6084e 100644\r\n--- a/README.md\r\n+++ b/README.md\r\n@@ -3,7 +3,7 @@\r\n \r\n CppUTest unit testing and mocking framework for C/C++\r\n \r\n-[More information on the project page](http://cpputest.github.com)\r\n+[More information on the project page](http://cpputest.gitlab.com)\r\n \r\n [![Build Status](https://travis-ci.org/cpputest/cpputest.png?branch=master)](https://travis-ci.org/cpputest/cpputest)\r\n \r\n"] -[23.609609, "o", "$ "] -[23.611536, "o", "\u001b"] -[23.791812, "o", "[1"] -[23.88193, "o", "m\u001b"] -[23.972076, "o", "[0"] -[24.062211, "o", "m"] -[24.062707, "o", "\r\n"] -[24.065585, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] +{"version": 2, "width": 111, "height": 25, "timestamp": 1774774961, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[0.876993, "o", "\u001b[H\u001b[2J\u001b[3J"] +[0.880416, "o", "$ "] +[1.88357, "o", "\u001b["] +[2.063972, "o", "1m"] +[2.154052, "o", "ls"] +[2.244202, "o", " -"] +[2.33429, "o", "l "] +[2.424424, "o", ".\u001b"] +[2.514585, "o", "[0"] +[2.604698, "o", "m"] +[3.606471, "o", "\r\n"] +[3.60985, "o", "total 16\r\n"] +[3.609891, "o", "drwxr-xr-x+ 3 dev dev 4096 Mar 29 09:02 cpputest"] +[3.609918, "o", "\r\n-rw-rw-rw- 1 dev dev 229 Mar 29 09:02 dfetch.yaml\r\ndrwxr-xr-x+ 4 dev dev 4096 Mar 29 09:02 jsmn\r\ndrwxrwxrwx+ 2 dev dev 4096 Mar 29 09:02 patches\r\n"] +[3.615315, "o", "$ "] +[4.618348, "o", "\u001b["] +[4.798581, "o", "1m"] +[4.888735, "o", "ca"] +[4.978852, "o", "t "] +[5.06893, "o", "df"] +[5.159091, "o", "et"] +[5.249288, "o", "ch"] +[5.339378, "o", ".y"] +[5.429509, "o", "am"] +[5.519644, "o", "l\u001b"] +[5.699967, "o", "[0"] +[5.790096, "o", "m"] +[6.791355, "o", "\r\n"] +[6.794334, "o", "manifest:\r\n version: 0.0\r\n\r\n remotes:\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/\r\n repo-path: cpputest/cpputest.git\r\n tag: v3.4\r\n patch: patches/cpputest.patch\r\n\r\n"] +[6.799776, "o", "$ "] +[7.802827, "o", "\u001b["] +[7.98307, "o", "1m"] +[8.073224, "o", "ca"] +[8.163347, "o", "t "] +[8.253476, "o", "pat"] +[8.343586, "o", "ch"] +[8.433737, "o", "es"] +[8.523861, "o", "/c"] +[8.61399, "o", "pp"] +[8.704187, "o", "ute"] +[8.884448, "o", "st"] +[8.974601, "o", ".p"] +[9.064711, "o", "at"] +[9.154856, "o", "ch"] +[9.24499, "o", "\u001b[0"] +[9.335113, "o", "m"] +[10.336318, "o", "\r\n"] +[10.339282, "o", "diff --git a/README.md b/README.md\r\nindex 2655a7b..fc6084e 100644\r\n--- a/README.md\r\n+++ b/README.md\r\n@@ -3,7 +3,7 @@ CppUTest\r\n \r\n CppUTest unit testing and mocking framework for C/C++\r\n \r\n-[More information on the project page](http://cpputest.github.com)\r\n+[More information on the project page](http://cpputest.gitlab.com)\r\n"] +[10.339328, "o", " \r\n [![Build Status](https://travis-ci.org/cpputest/cpputest.png?branch=master)](https://travis-ci.org/cpputest/cpputest)\r\n \r\n"] +[10.344664, "o", "$ "] +[11.347695, "o", "\u001b["] +[11.534291, "o", "1m"] +[11.624273, "o", "df"] +[11.716283, "o", "et"] +[11.804451, "o", "ch"] +[11.894613, "o", " f"] +[11.98474, "o", "or"] +[12.074868, "o", "ma"] +[12.164989, "o", "t-"] +[12.255112, "o", "pa"] +[12.435499, "o", "tch"] +[12.525597, "o", " c"] +[12.615726, "o", "pp"] +[12.705871, "o", "ut"] +[12.795991, "o", "es"] +[12.888177, "o", "t "] +[12.97781, "o", "--"] +[13.068128, "o", "ou"] +[13.15849, "o", "tp"] +[13.248464, "o", "ut"] +[13.428783, "o", "-di"] +[13.518906, "o", "re"] +[13.609042, "o", "ct"] +[13.699163, "o", "or"] +[13.789923, "o", "y "] +[13.879436, "o", "fo"] +[13.969652, "o", "rm"] +[14.05977, "o", "at"] +[14.149918, "o", "te"] +[14.330177, "o", "d-"] +[14.42032, "o", "pat"] +[14.510443, "o", "ch"] +[14.600587, "o", "es"] +[14.690707, "o", "\u001b["] +[14.780889, "o", "0m"] +[15.782507, "o", "\r\n"] +[16.225638, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[16.25017, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] +[16.250779, "o", " \u001b[1;34m> formatted patch written to formatted-patches/cpputest.patch\u001b[0m\r\n"] +[16.310847, "o", "$ "] +[17.313933, "o", "\u001b["] +[17.494298, "o", "1m"] +[17.584503, "o", "ca"] +[17.674629, "o", "t "] +[17.764757, "o", "for"] +[17.854891, "o", "ma"] +[17.945027, "o", "tt"] +[18.035263, "o", "ed"] +[18.125368, "o", "-p"] +[18.215505, "o", "atc"] +[18.395887, "o", "he"] +[18.486243, "o", "s/"] +[18.576335, "o", "cp"] +[18.666461, "o", "pu"] +[18.756604, "o", "tes"] +[18.846717, "o", "t."] +[18.936854, "o", "pa"] +[19.026997, "o", "tc"] +[19.117144, "o", "h\u001b"] +[19.297386, "o", "[0m"] +[20.298934, "o", "\r\n"] +[20.301697, "o", "From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001\r\nFrom: John Doe \r\nDate: Sun, 29 Mar 2026 09:02:57 +0000\r\nSubject: [PATCH] Patch for cpputest\r\n\r\nPatch for cpputest\r\n\r\ndiff --git a/README.md b/README.md\r\nindex 2655a7b..fc6084e 100644\r\n--- a/README.md\r\n+++ b/README.md\r\n@@ -3,7 +3,7 @@\r\n \r\n CppUTest unit testing and mocking framework for C/C++\r\n \r\n-[More information on the project page](http://cpputest.github.com)\r\n+[More information on the project page](http://cpputest.gitlab.com)\r\n \r\n [![Build Status](https://travis-ci.org/cpputest/cpputest.png?branch=master)](https://travis-ci.org/cpputest/cpputest)\r\n \r\n"] +[23.309066, "o", "$ "] +[23.310979, "o", "\u001b"] +[23.491343, "o", "[1"] +[23.581693, "o", "m\u001b"] +[23.67181, "o", "[0"] +[23.761939, "o", "m"] +[23.762527, "o", "\r\n"] +[23.765631, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] diff --git a/doc/asciicasts/freeze.cast b/doc/asciicasts/freeze.cast index c6b15273..c6ad5bdf 100644 --- a/doc/asciicasts/freeze.cast +++ b/doc/asciicasts/freeze.cast @@ -1,83 +1,69 @@ -{"version": 2, "width": 173, "height": 25, "timestamp": 1774774074, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} -[0.041691, "o", "\u001b[H\u001b[2J\u001b[3J"] -[0.046702, "o", "$ "] -[1.050314, "o", "\u001b"] -[1.230496, "o", "[1"] -[1.320646, "o", "mc"] -[1.410781, "o", "at"] -[1.50093, "o", " d"] -[1.591036, "o", "fe"] -[1.681176, "o", "tc"] -[1.771289, "o", "h."] -[1.861437, "o", "ya"] -[1.951569, "o", "ml"] -[2.131819, "o", "\u001b"] -[2.221933, "o", "[0"] -[2.312074, "o", "m"] -[3.313399, "o", "\r\n"] -[3.317228, "o", "manifest:"] -[3.317265, "o", "\r\n"] -[3.317286, "o", " version: 0.0 # DFetch Module syntax version"] -[3.317304, "o", "\r\n"] -[3.317321, "o", "\r\n"] -[3.317339, "o", " remotes: # declare common sources in one place"] -[3.317356, "o", "\r\n"] -[3.317374, "o", " - name: github"] -[3.31739, "o", "\r\n"] -[3.317408, "o", " url-base: https://github.com/"] -[3.317433, "o", "\r\n"] -[3.31745, "o", "\r\n"] -[3.317467, "o", " projects:"] -[3.317553, "o", "\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v3.4 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n"] -[3.329646, "o", "$ "] -[4.332667, "o", "\u001b["] -[4.514772, "o", "1m"] -[4.604917, "o", "df"] -[4.695328, "o", "et"] -[4.785469, "o", "ch "] -[4.875601, "o", "fr"] -[4.965749, "o", "ee"] -[5.055878, "o", "ze"] -[5.146015, "o", "\u001b["] -[5.236139, "o", "0m"] -[6.237658, "o", "\r\n"] -[6.73802, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[6.754571, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] -[6.755131, "o", " \u001b[1;34m> Already pinned in manifest on version v3.4\u001b[0m\r\n"] -[6.757134, "o", " \u001b[1;92mjsmn:\u001b[0m\r\n"] -[6.757682, "o", " \u001b[1;34m> Frozen on version 25647e692c7906b96ffd2b05ca54c097948e879c\u001b[0m\r\n"] -[6.759236, "o", "Updated manifest (dfetch.yaml) in /workspaces/dfetch/doc/generate-casts/freeze\r\n"] -[6.822403, "o", "$ "] -[7.82558, "o", "\u001b["] -[8.005855, "o", "1m"] -[8.09597, "o", "ca"] -[8.186108, "o", "t "] -[8.276217, "o", "df"] -[8.367279, "o", "et"] -[8.456479, "o", "ch"] -[8.546627, "o", ".y"] -[8.636764, "o", "am"] -[8.726889, "o", "l\u001b"] -[8.907323, "o", "[0m"] -[9.909138, "o", "\r\n"] -[9.912099, "o", "manifest:\r\n version: '0.0'\r\n\r\n remotes:\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/\r\n tag: v3.4\r\n repo-path: cpputest/cpputest.git\r\n\r\n - name: jsmn\r\n revision: 25647e692c7906b96ffd2b05ca54c097948e879c\r\n branch: master\r\n repo-path: zserge/jsmn.git\r\n"] -[9.917439, "o", "$ "] -[10.920548, "o", "\u001b["] -[11.101311, "o", "1m"] -[11.191464, "o", "ls"] -[11.281735, "o", " -"] -[11.371746, "o", "l "] -[11.461878, "o", ".\u001b"] -[11.55201, "o", "[0"] -[11.642136, "o", "m"] -[12.643837, "o", "\r\n"] -[12.647357, "o", "total 16\r\n"] -[12.647473, "o", "drwxr-xr-x+ 3 dev dev 4096 Mar 29 08:47 cpputest\r\n-rw-rw-rw- 1 dev dev 317 Mar 29 08:48 dfetch.yaml\r\n-rw-rw-rw- 1 dev dev 733 Mar 29 08:47 dfetch.yaml.backup\r\ndrwxr-xr-x+ 4 dev dev 4096 Mar 29 08:47 jsmn\r\n"] -[15.659238, "o", "$ "] -[15.661125, "o", "\u001b"] -[15.841433, "o", "[1"] -[15.931575, "o", "m\u001b"] -[16.021695, "o", "[0"] -[16.111828, "o", "m"] -[16.112398, "o", "\r\n"] -[16.116992, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] +{"version": 2, "width": 111, "height": 25, "timestamp": 1774774879, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[0.039774, "o", "\u001b[H\u001b[2J\u001b[3J"] +[0.044786, "o", "$ "] +[1.047955, "o", "\u001b["] +[1.228135, "o", "1m"] +[1.318265, "o", "ca"] +[1.408446, "o", "t "] +[1.50053, "o", "df"] +[1.590654, "o", "et"] +[1.680793, "o", "ch"] +[1.770956, "o", ".y"] +[1.861127, "o", "am"] +[1.951317, "o", "l\u001b"] +[2.131699, "o", "[0m"] +[3.133482, "o", "\r\n"] +[3.136605, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v3.4 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n"] +[3.141478, "o", "$ "] +[4.144533, "o", "\u001b["] +[4.324819, "o", "1m"] +[4.415703, "o", "df"] +[4.505854, "o", "et"] +[4.595985, "o", "ch"] +[4.686122, "o", " f"] +[4.776245, "o", "re"] +[4.866389, "o", "ez"] +[4.958249, "o", "e\u001b"] +[5.047587, "o", "[0"] +[5.22782, "o", "m"] +[6.22938, "o", "\r\n"] +[6.710588, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[6.72748, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] +[6.727996, "o", " \u001b[1;34m> Already pinned in manifest on version v3.4\u001b[0m\r\n"] +[6.730007, "o", " \u001b[1;92mjsmn:\u001b[0m\r\n"] +[6.730543, "o", " \u001b[1;34m> Frozen on version 25647e692c7906b96ffd2b05ca54c097948e879c\u001b[0m\r\n"] +[6.732089, "o", "Updated manifest (dfetch.yaml) in /workspaces/dfetch/doc/generate-casts/freeze\r\n"] +[6.813142, "o", "$ "] +[7.816185, "o", "\u001b["] +[7.996489, "o", "1m"] +[8.086595, "o", "ca"] +[8.176712, "o", "t "] +[8.266817, "o", "dfe"] +[8.356958, "o", "tc"] +[8.447089, "o", "h."] +[8.537209, "o", "ya"] +[8.62734, "o", "ml"] +[8.717499, "o", "\u001b[0"] +[8.897793, "o", "m"] +[9.899381, "o", "\r\n"] +[9.902405, "o", "manifest:\r\n version: '0.0'\r\n\r\n remotes:\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/\r\n tag: v3.4\r\n repo-path: cpputest/cpputest.git\r\n\r\n - name: jsmn\r\n revision: 25647e692c7906b96ffd2b05ca54c097948e879c\r\n branch: master\r\n repo-path: zserge/jsmn.git\r\n"] +[9.907365, "o", "$ "] +[10.910562, "o", "\u001b"] +[11.09227, "o", "[1"] +[11.182331, "o", "ml"] +[11.272484, "o", "s "] +[11.362597, "o", "-"] +[11.452732, "o", "l "] +[11.542853, "o", ".\u001b"] +[11.632978, "o", "[0"] +[11.723118, "o", "m"] +[12.724685, "o", "\r\n"] +[12.728145, "o", "total 16\r\n"] +[12.72826, "o", "drwxr-xr-x+ 3 dev dev 4096 Mar 29 09:01 cpputest\r\n-rw-rw-rw- 1 dev dev 317 Mar 29 09:01 dfetch.yaml\r\n-rw-rw-rw- 1 dev dev 733 Mar 29 09:01 dfetch.yaml.backup\r\ndrwxr-xr-x+ 4 dev dev 4096 Mar 29 09:01 jsmn\r\n"] +[15.736845, "o", "$ "] +[15.738752, "o", "\u001b["] +[15.919011, "o", "1m"] +[16.00915, "o", "\u001b["] +[16.099282, "o", "0m"] +[16.099864, "o", "\r\n"] +[16.102552, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] diff --git a/doc/asciicasts/import.cast b/doc/asciicasts/import.cast index 4ddaf7dd..324e91b8 100644 --- a/doc/asciicasts/import.cast +++ b/doc/asciicasts/import.cast @@ -1,66 +1,67 @@ -{"version": 2, "width": 173, "height": 25, "timestamp": 1774774186, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} -[0.021865, "o", "$ "] -[1.024942, "o", "\u001b["] -[1.205267, "o", "1m"] -[1.295394, "o", "ls"] -[1.385541, "o", " -"] -[1.475678, "o", "l\u001b["] -[1.565787, "o", "0m"] -[2.567345, "o", "\r\n"] -[2.571397, "o", "total 580\r\n"] -[2.571644, "o", "-rw-rw-rw- 1 dev dev 1137 Mar 29 08:49 CMakeLists.txt\r\n-rw-rw-rw- 1 dev dev 35147 Mar 29 08:49 LICENSE\r\n-rw-rw-rw- 1 dev dev 1796 Mar 29 08:49 README.md\r\n-rw-rw-rw- 1 dev dev 1381 Mar 29 08:49 appveyor.yml\r\n-rwxrwxrwx 1 dev dev 229 Mar 29 08:49 create_doc.sh\r\ndrwxrwxrwx+ 2 dev dev 4096 Mar 29 08:49 data\r\ndrwxrwxrwx+ 4 dev dev 4096 Mar 29 08:49 doc\r\ndrwxrwxrwx+ 4 dev dev 4096 Mar 29 08:49 docs\r\ndrwxrwxrwx+ 2 dev dev 4096 Mar 29 08:49 installer\r\ndrwxrwxrwx+ 4 dev dev 4096 Mar 29 08:49 libraries\r\n-rw-rw-rw- 1 dev dev 505101 Mar 29 08:49 modbusscope_demo.gif\r\ndrwxrwxrwx+ 5 dev dev 4096 Mar 29 08:49 resources\r\ndrwxrwxrwx+ 9 dev dev 4096 Mar 29 08:49 src\r\ndrwxrwxrwx+ 9 dev dev 4096 Mar 29 08:49 tests\r\n"] -[2.577846, "o", "$ "] -[3.581172, "o", "\u001b"] -[3.762701, "o", "[1"] -[3.852848, "o", "mc"] -[3.94298, "o", "at"] -[4.033175, "o", " "] -[4.123313, "o", ".g"] -[4.213428, "o", "it"] -[4.303567, "o", "mo"] -[4.393725, "o", "du"] -[4.483863, "o", "l"] -[4.665716, "o", "es"] -[4.755831, "o", "\u001b["] -[4.845954, "o", "0m"] -[5.847536, "o", "\r\n"] -[5.850598, "o", "[submodule \"tests/googletest\"]\r\n\tpath = tests/googletest\r\n\turl = https://github.com/google/googletest.git\r\n[submodule \"libraries/muparser\"]\r\n\tpath = libraries/muparser\r\n\turl = https://github.com/beltoforion/muparser.git\r\n"] -[5.855515, "o", "$ "] -[6.859809, "o", "\u001b"] -[7.040107, "o", "[1"] -[7.13035, "o", "md"] -[7.220495, "o", "fe"] -[7.310607, "o", "tc"] -[7.400749, "o", "h "] -[7.4909, "o", "im"] -[7.581093, "o", "po"] -[7.671224, "o", "rt"] -[7.761423, "o", "\u001b["] -[7.941661, "o", "0"] -[8.031772, "o", "m"] -[9.033518, "o", "\r\n"] -[9.472966, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[10.201941, "o", "Found libraries/muparser\r\n"] -[10.202426, "o", "Found tests/googletest\r\n"] -[10.205022, "o", "Created manifest (dfetch.yaml) in /workspaces/dfetch/doc/generate-casts/ModbusScope\r\n"] -[10.265742, "o", "$ "] -[11.268276, "o", "\u001b["] -[11.448527, "o", "1m"] -[11.538679, "o", "ca"] -[11.628804, "o", "t "] -[11.718934, "o", "df"] -[11.809087, "o", "et"] -[11.899213, "o", "ch"] -[11.989335, "o", ".y"] -[12.079516, "o", "am"] -[12.169664, "o", "l\u001b"] -[12.349895, "o", "[0"] -[12.439999, "o", "m"] -[13.440602, "o", "\r\n"] -[13.44321, "o", "manifest:\r\n version: '0.0'\r\n\r\n remotes:\r\n - name: github-com-beltoforion\r\n url-base: https://github.com/beltoforion\r\n\r\n - name: github-com-google\r\n url-base: https://github.com/google\r\n\r\n projects:\r\n - name: libraries/muparser\r\n revision: 207d5b77c05c9111ff51ab91082701221220c477\r\n remote: github-com-beltoforion\r\n tag: v2.3.2\r\n repo-path: muparser.git\r\n\r\n - name: tests/googletest\r\n revision: dcc92d0ab6c4ce022162a23566d44f673251eee4\r\n remote: github-com-google\r\n repo-path: googletest.git\r\n"] -[16.453208, "o", "$ "] -[16.456227, "o", "\u001b["] -[16.636532, "o", "1m"] -[16.726695, "o", "\u001b["] -[16.817784, "o", "0m"] -[16.818137, "o", "\r\n"] +{"version": 2, "width": 111, "height": 25, "timestamp": 1774774990, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[0.018479, "o", "$ "] +[1.02162, "o", "\u001b"] +[1.201934, "o", "[1"] +[1.292035, "o", "ml"] +[1.382455, "o", "s "] +[1.472587, "o", "-l"] +[1.562753, "o", "\u001b["] +[1.652837, "o", "0m"] +[2.654367, "o", "\r\n"] +[2.657996, "o", "total 580\r\n"] +[2.65805, "o", "-rw-rw-rw- 1 dev dev 1137 Mar 29 09:03 CMakeLists.txt\r\n-rw-rw-rw- 1 dev dev 35147 Mar 29 09:03 LICENSE\r\n-rw-rw-rw- 1 dev dev 1796 Mar 29 09:03 README.md\r\n-rw-rw-rw- 1 dev dev 1381 Mar 29 09:03 appveyor.yml\r\n-rwxrwxrwx 1 dev dev 229 Mar 29 09:03 create_doc.sh\r\ndrwxrwxrwx+ 2 dev dev 4096 Mar 29 09:03 data\r\ndrwxrwxrwx+ 4 dev dev 4096 Mar 29 09:03 doc\r\ndrwxrwxrwx+ 4 dev dev 4096 Mar 29 09:03 docs\r\ndrwxrwxrwx+ 2 dev dev 4096 Mar 29 09:03 installer\r\ndrwxrwxrwx+ 4 dev dev 4096 Mar 29 09:03 libraries\r\n-rw-rw-rw- 1 dev dev 505101 Mar 29 09:03 modbusscope_demo.gif\r\ndrwxrwxrwx+ 5 dev dev 4096 Mar 29 09:03 resources\r\ndrwxrwxrwx+ 9 dev dev 4096 Mar 29 09:03 src\r\ndrwxrwxrwx+ 9 dev dev 4096 Mar 29 09:03 tests\r\n"] +[2.663114, "o", "$ "] +[3.666315, "o", "\u001b["] +[3.846588, "o", "1m"] +[3.936738, "o", "ca"] +[4.026855, "o", "t "] +[4.116978, "o", ".g"] +[4.207123, "o", "it"] +[4.297257, "o", "mo"] +[4.387381, "o", "du"] +[4.47751, "o", "le"] +[4.567656, "o", "s\u001b"] +[4.747897, "o", "[0"] +[4.838617, "o", "m"] +[5.83954, "o", "\r\n"] +[5.842593, "o", "[submodule \"tests/googletest\"]\r\n\tpath = tests/googletest\r\n\turl = https://github.com/google/googletest.git\r\n[submodule \"libraries/muparser\"]\r\n\tpath = libraries/muparser\r\n\turl = https://github.com/beltoforion/muparser.git\r\n"] +[5.848336, "o", "$ "] +[6.850501, "o", "\u001b["] +[7.031001, "o", "1m"] +[7.120927, "o", "df"] +[7.212149, "o", "et"] +[7.301522, "o", "ch"] +[7.391697, "o", " i"] +[7.481838, "o", "mp"] +[7.571967, "o", "or"] +[7.662181, "o", "t\u001b"] +[7.753437, "o", "[0"] +[7.933698, "o", "m"] +[8.935375, "o", "\r\n"] +[9.421828, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[10.232404, "o", "Found libraries/muparser\r\n"] +[10.23307, "o", "Found tests/googletest\r\n"] +[10.235064, "o", "Created manifest (dfetch.yaml) in /workspaces/dfetch/doc/generate-casts/ModbusScope\r\n"] +[10.298465, "o", "$ "] +[11.301376, "o", "\u001b"] +[11.481638, "o", "[1"] +[11.571784, "o", "mc"] +[11.661818, "o", "at"] +[11.751936, "o", " "] +[11.842325, "o", "df"] +[11.932471, "o", "et"] +[12.022769, "o", "ch"] +[12.112756, "o", ".y"] +[12.203183, "o", "a"] +[12.383276, "o", "ml"] +[12.473452, "o", "\u001b["] +[12.563576, "o", "0m"] +[13.565261, "o", "\r\n"] +[13.568165, "o", "manifest:\r\n version: '0.0'\r\n\r\n remotes:\r\n - name: github-com-beltoforion\r\n url-base: https://github.com/beltoforion\r\n\r\n - name: github-com-google\r\n url-base: https://github.com/google\r\n"] +[13.56836, "o", "\r\n projects:\r\n - name: libraries/muparser\r\n revision: 207d5b77c05c9111ff51ab91082701221220c477\r\n remote: github-com-beltoforion\r\n tag: v2.3.2\r\n repo-path: muparser.git\r\n\r\n - name: tests/googletest\r\n revision: dcc92d0ab6c4ce022162a23566d44f673251eee4\r\n remote: github-com-google\r\n repo-path: googletest.git\r\n"] +[16.577002, "o", "$ "] +[16.578831, "o", "\u001b["] +[16.759109, "o", "1m"] +[16.849231, "o", "\u001b["] +[16.93937, "o", "0m"] +[16.939809, "o", "\r\n"] diff --git a/doc/asciicasts/init.cast b/doc/asciicasts/init.cast index b04b15a2..7178947a 100644 --- a/doc/asciicasts/init.cast +++ b/doc/asciicasts/init.cast @@ -1,59 +1,59 @@ -{"version": 2, "width": 173, "height": 25, "timestamp": 1774773960, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} -[0.038871, "o", "\u001b[H\u001b[2J\u001b[3J"] -[0.046365, "o", "$ "] -[1.050138, "o", "\u001b["] -[1.230388, "o", "1m"] -[1.32065, "o", "ls"] -[1.410792, "o", " -"] -[1.500919, "o", "l\u001b["] -[1.591059, "o", "0m"] -[2.592577, "o", "\r\n"] -[2.595927, "o", "total 0\r\n"] -[2.600869, "o", "$ "] -[3.603963, "o", "\u001b["] -[3.784223, "o", "1m"] -[3.87438, "o", "df"] -[3.964504, "o", "et"] -[4.054624, "o", "ch"] -[4.144756, "o", " i"] -[4.235004, "o", "ni"] -[4.325134, "o", "t\u001b"] -[4.415271, "o", "[0"] -[4.505532, "o", "m"] -[5.507698, "o", "\r\n"] -[6.051616, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[6.052516, "o", "Created dfetch.yaml\r\n"] -[6.113311, "o", "$ "] -[7.116314, "o", "\u001b["] -[7.296842, "o", "1m"] -[7.387127, "o", "ls"] -[7.477205, "o", " -"] -[7.567336, "o", "l\u001b"] -[7.657476, "o", "[0"] -[7.747591, "o", "m"] -[8.748222, "o", "\r\n"] -[8.751743, "o", "total 4\r\n"] -[8.751794, "o", "-rw-rw-rw- 1 dev dev 733 Mar 29 08:46 dfetch.yaml\r\n"] -[8.757714, "o", "$ "] -[9.760367, "o", "\u001b["] -[9.940643, "o", "1m"] -[10.03077, "o", "ca"] -[10.120912, "o", "t "] -[10.211041, "o", "df"] -[10.301194, "o", "et"] -[10.391316, "o", "ch"] -[10.481445, "o", ".y"] -[10.571547, "o", "am"] -[10.661702, "o", "l\u001b"] -[10.841933, "o", "[0"] -[10.932649, "o", "m"] -[11.933711, "o", "\r\n"] -[11.93667, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v3.4 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n"] -[14.94415, "o", "$ "] -[14.946022, "o", "\u001b"] -[15.126374, "o", "[1"] -[15.21673, "o", "m\u001b"] -[15.306792, "o", "[0"] -[15.397056, "o", "m"] -[15.397555, "o", "\r\n"] -[15.400612, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] +{"version": 2, "width": 111, "height": 25, "timestamp": 1774774749, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[0.01997, "o", "\u001b[H\u001b[2J\u001b[3J"] +[0.025645, "o", "$ "] +[1.028232, "o", "\u001b"] +[1.208506, "o", "[1"] +[1.298633, "o", "ml"] +[1.38879, "o", "s "] +[1.478911, "o", "-l"] +[1.569025, "o", "\u001b["] +[1.659174, "o", "0m"] +[2.660665, "o", "\r\n"] +[2.664097, "o", "total 0\r\n"] +[2.669209, "o", "$ "] +[3.672702, "o", "\u001b"] +[3.852962, "o", "[1"] +[3.943099, "o", "md"] +[4.033217, "o", "fe"] +[4.123357, "o", "t"] +[4.213493, "o", "ch"] +[4.304553, "o", " i"] +[4.394237, "o", "ni"] +[4.484393, "o", "t\u001b"] +[4.574613, "o", "["] +[4.754762, "o", "0m"] +[5.756342, "o", "\r\n"] +[6.2007, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[6.201547, "o", "Created dfetch.yaml\r\n"] +[6.261313, "o", "$ "] +[7.264582, "o", "\u001b["] +[7.44485, "o", "1m"] +[7.53498, "o", "ls"] +[7.625104, "o", " -"] +[7.715249, "o", "l\u001b["] +[7.805373, "o", "0m"] +[8.805964, "o", "\r\n"] +[8.809378, "o", "total 4\r\n"] +[8.809496, "o", "-rw-rw-rw- 1 dev dev 733 Mar 29 08:59 dfetch.yaml\r\n"] +[8.815008, "o", "$ "] +[9.817529, "o", "\u001b["] +[9.997774, "o", "1m"] +[10.088313, "o", "ca"] +[10.178434, "o", "t "] +[10.268579, "o", "df"] +[10.358699, "o", "et"] +[10.450331, "o", "ch"] +[10.539015, "o", ".y"] +[10.629151, "o", "am"] +[10.719288, "o", "l\u001b"] +[10.899553, "o", "[0"] +[10.989869, "o", "m"] +[11.990998, "o", "\r\n"] +[11.994002, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v3.4 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n"] +[15.002874, "o", "$ "] +[15.004886, "o", "\u001b["] +[15.18517, "o", "1m"] +[15.275409, "o", "\u001b["] +[15.365546, "o", "0m"] +[15.366094, "o", "\r\n"] +[15.369642, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] diff --git a/doc/asciicasts/interactive-add.cast b/doc/asciicasts/interactive-add.cast index 94c708fc..2bce072c 100644 --- a/doc/asciicasts/interactive-add.cast +++ b/doc/asciicasts/interactive-add.cast @@ -1,115 +1,115 @@ -{"version": 2, "width": 173, "height": 36, "timestamp": 1774700938, "env": {"SHELL": "/usr/bin/zsh", "TERM": "xterm-256color"}} -[0.006587, "o", "/home/ben/Programming/dfetch/doc/generate-casts/interactive-add /home/ben/Programming/dfetch/doc/generate-casts\r\n"] -[0.008298, "o", "\u001b[H\u001b[2J\u001b[3J"] -[0.010979, "o", "$ "] -[1.015209, "o", "\u001b["] -[1.195997, "o", "1m"] -[1.286008, "o", "ca"] -[1.37609, "o", "t "] -[1.46629, "o", "df"] -[1.55649, "o", "et"] -[1.646659, "o", "ch"] -[1.73682, "o", ".y"] -[1.826954, "o", "am"] -[1.917222, "o", "l\u001b"] -[2.097806, "o", "[0"] -[2.188207, "o", "m"] -[3.190293, "o", "\r\n"] -[3.194309, "o", "manifest:\r\n version: '0.0'\r\n projects:\r\n - name: jsmn\r\n url: https://github.com/zserge/jsmn.git\r\n branch: master\r\n"] -[3.200461, "o", "$ "] -[4.204728, "o", "\u001b"] -[4.384922, "o", "[1"] -[4.475222, "o", "md"] -[4.565241, "o", "fe"] -[4.655321, "o", "t"] -[4.745924, "o", "ch"] -[4.835907, "o", " a"] -[4.926012, "o", "dd"] -[5.016554, "o", " -"] -[5.106995, "o", "i"] -[5.287819, "o", " h"] -[5.378229, "o", "tt"] -[5.46872, "o", "ps"] -[5.559124, "o", ":/"] -[5.649617, "o", "/"] -[5.740014, "o", "gi"] -[5.830347, "o", "th"] -[5.920526, "o", "ub"] -[6.010971, "o", ".c"] -[6.191347, "o", "o"] -[6.281511, "o", "m/"] -[6.371949, "o", "cp"] -[6.462498, "o", "pu"] -[6.552779, "o", "te"] -[6.643174, "o", "s"] -[6.73357, "o", "t/"] -[6.823951, "o", "cp"] -[6.914134, "o", "pu"] -[7.094652, "o", "te"] -[7.185069, "o", "s"] -[7.275514, "o", "t."] -[7.365747, "o", "gi"] -[7.45584, "o", "t\u001b"] -[7.546246, "o", "[0"] -[7.636649, "o", "m"] -[8.639035, "o", "\r\n"] -[8.826, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[9.348012, "o", " \u001b[1;92mhttps://github.com/cpputest/cpputest.git:\u001b[0m\r\n"] -[9.349009, "o", " \u001b[1;34m> Adding project through interactive wizard\u001b[0m\r\n \u001b[92m?\u001b[0m Name: \u001b[2mcpputest\u001b[0m"] -[23.37253, "o", "\r\n\u001b[1A\u001b[2K"] -[23.374059, "o", " - \u001b[34mname:\u001b[0m cpputest\r\n"] -[23.374889, "o", " \u001b[34murl:\u001b[0m https://github.com/cpputest/cpputest.git\r\n \u001b[92m?\u001b[0m Destination: \u001b[2mcpputest\u001b[0m"] -[25.161399, "o", "\r\n\u001b[1A\u001b[2K"] -[25.973393, "o", " \u001b[92m?\u001b[0m \u001b[1mVersion:\u001b[0m\r\n \u001b[92m▶\u001b[0m \u001b[1mmaster \u001b[2mbranch\u001b[0m \u001b[2m(default)\u001b[0m\u001b[0m\r\n 3.7.2 \u001b[2mtag\u001b[0m\r\n gh-pages \u001b[2mbranch\u001b[0m\r\n latest-passing-build \u001b[2mtag\u001b[0m\r\n ▸ revert-1598-fix\r\n separate_gtest \u001b[2mbranch\u001b[0m\r\n v3.3 \u001b[2mtag\u001b[0m\r\n v3.4 \u001b[2mtag\u001b[0m\r\n v3.5 \u001b[2mtag\u001b[0m\r\n v3.6 \u001b[2mtag\u001b[0m\r\n \u001b[2m↓ 4 more below\u001b[0m\r\n \u001b[2m↑/↓ navigate Enter select →/← expand/collapse Esc list\u001b[0m\r\n"] -[28.321672, "o", "\u001b[13A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mVersion:\u001b[0m\r\n master \u001b[2mbranch\u001b[0m \u001b[2m(default)\u001b[0m\r\n \u001b[92m▶\u001b[0m \u001b[1m3.7.2 \u001b[2mtag\u001b[0m\u001b[0m\r\n gh-pages \u001b[2mbranch\u001b[0m\r\n latest-passing-build \u001b[2mtag\u001b[0m\r\n ▸ revert-1598-fix\r\n separate_gtest \u001b[2mbranch\u001b[0m\r\n v3.3 \u001b[2mtag\u001b[0m\r\n v3.4 \u001b[2mtag\u001b[0m\r\n v3.5 \u001b[2mtag\u001b[0m\r\n v3.6 \u001b[2mtag\u001b[0m\r\n \u001b[2m↓ 4 more below\u001b[0m\r\n \u001b[2m↑/↓ navigate Enter select →/← expand/collapse Esc list\u001b[0m\r\n"] -[28.712652, "o", "\u001b[13A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mVersion:\u001b[0m\r\n master \u001b[2mbranch\u001b[0m \u001b[2m(default)\u001b[0m\r\n 3.7.2 \u001b[2mtag\u001b[0m\r\n \u001b[92m▶\u001b[0m \u001b[1mgh-pages \u001b[2mbranch\u001b[0m\u001b[0m\r\n latest-passing-build \u001b[2mtag\u001b[0m\r\n ▸ revert-1598-fix\r\n separate_gtest \u001b[2mbranch\u001b[0m\r\n v3.3 \u001b[2mtag\u001b[0m\r\n v3.4 \u001b[2mtag\u001b[0m\r\n v3.5 \u001b[2mtag\u001b[0m\r\n v3.6 \u001b[2mtag\u001b[0m\r\n \u001b[2m↓ 4 more below\u001b[0m\r\n \u001b[2m↑/↓ navigate Enter select →/← expand/collapse Esc list\u001b[0m\r\n"] -[29.637058, "o", "\u001b[13A\u001b[0J"] -[29.638165, "o", " \u001b[34mbranch:\u001b[0m gh-pages\r\n"] -[30.335169, "o", " \u001b[92m?\u001b[0m \u001b[1mSource path:\u001b[0m\r\n \u001b[92m▶\u001b[0m ▸ \u001b[1mimages\u001b[0m\r\n ▸ javascripts\r\n ▸ stylesheets\r\n index.html\r\n params.json\r\n \u001b[2m↑/↓ navigate Enter select →/← expand/collapse Esc skip\u001b[0m\r\n"] -[32.17768, "o", "\u001b[7A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mSource path:\u001b[0m\r\n \u001b[92m▶\u001b[0m ▾ \u001b[1mimages\u001b[0m\r\n bkg.png\r\n blacktocat.png\r\n ▸ javascripts\r\n ▸ stylesheets\r\n index.html\r\n params.json\r\n \u001b[2m↑/↓ navigate Enter select →/← expand/collapse Esc skip\u001b[0m\r\n"] -[32.884474, "o", "\u001b[9A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mSource path:\u001b[0m\r\n ▾ images\r\n \u001b[92m▶\u001b[0m \u001b[1mbkg.png\u001b[0m\r\n blacktocat.png\r\n ▸ javascripts\r\n ▸ stylesheets\r\n index.html\r\n params.json\r\n \u001b[2m↑/↓ navigate Enter select →/← expand/collapse Esc skip\u001b[0m\r\n"] -[33.455716, "o", "\u001b[9A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mSource path:\u001b[0m\r\n ▾ images\r\n bkg.png\r\n \u001b[92m▶\u001b[0m \u001b[1mblacktocat.png\u001b[0m\r\n ▸ javascripts\r\n ▸ stylesheets\r\n index.html\r\n params.json\r\n \u001b[2m↑/↓ navigate Enter select →/← expand/collapse Esc skip\u001b[0m\r\n"] -[34.978763, "o", "\u001b[9A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mSource path:\u001b[0m\r\n ▾ images\r\n \u001b[92m▶\u001b[0m \u001b[1mbkg.png\u001b[0m\r\n blacktocat.png\r\n ▸ javascripts\r\n ▸ stylesheets\r\n index.html\r\n params.json\r\n \u001b[2m↑/↓ navigate Enter select →/← expand/collapse Esc skip\u001b[0m\r\n"] -[35.218496, "o", "\u001b[9A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mSource path:\u001b[0m\r\n \u001b[92m▶\u001b[0m ▾ \u001b[1mimages\u001b[0m\r\n bkg.png\r\n blacktocat.png\r\n ▸ javascripts\r\n ▸ stylesheets\r\n index.html\r\n params.json\r\n \u001b[2m↑/↓ navigate Enter select →/← expand/collapse Esc skip\u001b[0m\r\n"] -[35.479506, "o", "\u001b[9A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mSource path:\u001b[0m\r\n \u001b[92m▶\u001b[0m ▾ \u001b[1mimages\u001b[0m\r\n bkg.png\r\n blacktocat.png\r\n ▸ javascripts\r\n ▸ stylesheets\r\n index.html\r\n params.json\r\n \u001b[2m↑/↓ navigate Enter select →/← expand/collapse Esc skip\u001b[0m\r\n"] -[35.898403, "o", "\u001b[9A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mSource path:\u001b[0m\r\n \u001b[92m▶\u001b[0m ▸ \u001b[1mimages\u001b[0m\r\n ▸ javascripts\r\n ▸ stylesheets\r\n index.html\r\n params.json\r\n \u001b[2m↑/↓ navigate Enter select →/← expand/collapse Esc skip\u001b[0m\r\n"] -[37.730104, "o", "\u001b[7A\u001b[0J"] -[37.733523, "o", " \u001b[92m?\u001b[0m \u001b[1mIgnore:\u001b[0m\r\n \u001b[92m▶\u001b[0m ▸ images\r\n ▸ javascripts\r\n ▸ stylesheets\r\n index.html\r\n params.json\r\n \u001b[2m↑/↓ navigate Space toggle Enter confirm →/← expand/collapse Esc skip\u001b[0m\r\n"] -[40.116121, "o", "\u001b[7A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mIgnore:\u001b[0m\r\n \u001b[92m▶\u001b[0m ▸ \u001b[2mimages\u001b[0m\r\n ▸ javascripts\r\n ▸ stylesheets\r\n index.html\r\n params.json\r\n \u001b[2m↑/↓ navigate Space toggle Enter confirm →/← expand/collapse Esc skip\u001b[0m\r\n"] -[40.298184, "o", "\u001b[7A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mIgnore:\u001b[0m\r\n ▸ \u001b[2mimages\u001b[0m\r\n \u001b[92m▶\u001b[0m ▸ javascripts\r\n ▸ stylesheets\r\n index.html\r\n params.json\r\n \u001b[2m↑/↓ navigate Space toggle Enter confirm →/← expand/collapse Esc skip\u001b[0m\r\n"] -[40.460012, "o", "\u001b[7A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mIgnore:\u001b[0m\r\n ▸ \u001b[2mimages\u001b[0m\r\n ▸ javascripts\r\n \u001b[92m▶\u001b[0m ▸ stylesheets\r\n index.html\r\n params.json\r\n \u001b[2m↑/↓ navigate Space toggle Enter confirm →/← expand/collapse Esc skip\u001b[0m\r\n"] -[40.790247, "o", "\u001b[7A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mIgnore:\u001b[0m\r\n ▸ \u001b[2mimages\u001b[0m\r\n ▸ javascripts\r\n \u001b[92m▶\u001b[0m ▸ \u001b[2mstylesheets\u001b[0m\r\n index.html\r\n params.json\r\n \u001b[2m↑/↓ navigate Space toggle Enter confirm →/← expand/collapse Esc skip\u001b[0m\r\n"] -[41.777204, "o", "\u001b[7A\u001b[0J"] -[41.778195, "o", " \u001b[34mignore:\u001b[0m\r\n"] -[41.77866, "o", " - images\r\n"] -[41.779063, "o", " - stylesheets\r\n"] -[41.781633, "o", "Add project to manifest? \u001b[1m(\u001b[0my\u001b[1m)\u001b[0m: "] -[42.332364, "o", "y"] -[42.392338, "o", "\r\n"] -[42.394097, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] -[42.39471, "o", " \u001b[1;34m> Added 'cpputest' to manifest '/home/ben/Programming/dfetch/doc/generate-casts/interactive-add/dfetch.yaml'\u001b[0m\r\n"] -[42.395114, "o", "Run \u001b[32m'dfetch update cpputest'\u001b[0m now? \u001b[1m(\u001b[0my\u001b[1m)\u001b[0m: "] -[42.945802, "o", "n"] -[43.005827, "o", "\r\n"] -[43.063622, "o", "$ "] -[44.068765, "o", "\u001b["] -[44.249547, "o", "1m"] -[44.339916, "o", "ca"] -[44.429885, "o", "t "] -[44.520499, "o", "df"] -[44.610923, "o", "et"] -[44.701311, "o", "ch"] -[44.791622, "o", ".y"] -[44.882051, "o", "am"] -[44.972463, "o", "l\u001b"] -[45.153102, "o", "[0m"] -[46.155253, "o", "\r\n"] -[46.159809, "o", "manifest:\r\n version: '0.0'\r\n projects:\r\n - name: jsmn\r\n url: https://github.com/zserge/jsmn.git\r\n branch: master\r\n\r\n - name: cpputest\r\n url: https://github.com/cpputest/cpputest.git\r\n branch: gh-pages\r\n ignore:\r\n - images\r\n - stylesheets\r\n"] -[49.169724, "o", "$ "] -[49.171498, "o", "\u001b"] -[49.351954, "o", "[1"] -[49.441951, "o", "m\u001b"] -[49.532505, "o", "[0"] -[49.622908, "o", "m"] -[49.623649, "o", "\r\n"] -[49.627644, "o", "/home/ben/Programming/dfetch/doc/generate-casts\r\n"] +{"version": 2, "width": 33, "height": 25, "timestamp": 1774774687, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[0.020763, "o", "\u001b[H\u001b[2J\u001b[3J"] +[0.025257, "o", "$ "] +[1.029119, "o", "\u001b"] +[1.209326, "o", "[1"] +[1.299481, "o", "mc"] +[1.389629, "o", "at"] +[1.47976, "o", " "] +[1.569896, "o", "df"] +[1.660035, "o", "et"] +[1.750192, "o", "ch"] +[1.840323, "o", ".y"] +[1.930438, "o", "a"] +[2.110741, "o", "ml"] +[2.200852, "o", "\u001b["] +[2.290977, "o", "0m"] +[3.292556, "o", "\r\n"] +[3.295576, "o", "manifest:\r\n version: '0.0'\r\n projects:\r\n - name: jsmn\r\n url: https://github.com/zserge/jsmn.git\r\n branch: master\r\n"] +[3.301532, "o", "$ "] +[3.596754, "r", "111x25"] +[4.304592, "o", "\u001b["] +[4.484932, "o", "1m"] +[4.57506, "o", "df"] +[4.665176, "o", "et"] +[4.755314, "o", "ch"] +[4.845442, "o", " a"] +[4.935555, "o", "dd"] +[5.025706, "o", " -"] +[5.115848, "o", "i "] +[5.205972, "o", "ht"] +[5.387564, "o", "tps"] +[5.476356, "o", ":/"] +[5.566459, "o", "/g"] +[5.656603, "o", "it"] +[5.746736, "o", "hu"] +[5.836865, "o", "b."] +[5.927003, "o", "co"] +[6.01714, "o", "m/"] +[6.107252, "o", "cp"] +[6.287544, "o", "pu"] +[6.377675, "o", "tes"] +[6.46779, "o", "t/"] +[6.558119, "o", "cp"] +[6.648201, "o", "pu"] +[6.738307, "o", "te"] +[6.828446, "o", "st"] +[6.918588, "o", ".g"] +[7.008799, "o", "it"] +[7.189192, "o", "\u001b["] +[7.279315, "o", "0m"] +[8.280907, "o", "\r\n"] +[8.85657, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[9.306349, "o", " \u001b[1;92mhttps://github.com/cpputest/cpputest.git:\u001b[0m\r\n"] +[9.306917, "o", " \u001b[1;34m> Adding project through interactive wizard\u001b[0m\r\n"] +[9.307253, "o", " \u001b[92m?\u001b[0m Name: \u001b[2mcpputest\u001b[0m"] +[13.834172, "o", "\r\n\u001b[1A\u001b[2K"] +[13.835052, "o", " - \u001b[34mname:\u001b[0m cpputest\r\n"] +[13.83617, "o", " \u001b[34murl:\u001b[0m https://github.com/cpputest/cpputest.git"] +[13.836356, "o", "\r\n \u001b[92m?\u001b[0m Destination: \u001b[2mcpputest\u001b[0m"] +[14.630908, "o", "\r\n"] +[14.63095, "o", "\u001b[1A\u001b[2K"] +[15.279036, "o", " \u001b[92m?\u001b[0m \u001b[1mVersion:\u001b[0m\r\n \u001b[92m▶\u001b[0m \u001b[1mmaster \u001b[2mbranch\u001b[0m \u001b[2m(default)\u001b[0m\u001b[0m\r\n 3.7.2 \u001b[2mtag\u001b[0m\r\n gh-pages \u001b[2mbranch\u001b[0m\r\n latest-passing-build \u001b[2mtag\u001b[0m\r\n ▸ revert-1598-fix\r\n separate_gtest \u001b[2mbranch\u001b[0m\r\n v3.3 \u001b[2mtag\u001b[0m\r\n v3.4 \u001b[2mtag\u001b[0m\r\n v3.5 \u001b[2mtag\u001b[0m\r\n v3.6 \u001b[2mtag\u001b[0m\r\n \u001b[2m↓ 4 more below\u001b[0m\r\n \u001b[2m↑/↓ navigate Enter select →/← expand/collapse Esc list\u001b[0m\r\n"] +[16.638518, "o", "\u001b[13A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mVersion:\u001b[0m\r\n master \u001b[2mbranch\u001b[0m \u001b[2m(default)\u001b[0m\r\n \u001b[92m▶\u001b[0m \u001b[1m3.7.2 \u001b[2mtag\u001b[0m\u001b[0m\r\n gh-pages \u001b[2mbranch\u001b[0m\r\n latest-passing-build \u001b[2mtag\u001b[0m\r\n ▸ revert-1598-fix\r\n separate_gtest \u001b[2mbranch\u001b[0m\r\n v3.3 \u001b[2mtag\u001b[0m\r\n v3.4 \u001b[2mtag\u001b[0m\r\n v3.5 \u001b[2mtag\u001b[0m\r\n v3.6 \u001b[2mtag\u001b[0m\r\n \u001b[2m↓ 4 more below\u001b[0m\r\n \u001b[2m↑/↓ navigate Enter select →/← expand/collapse Esc list\u001b[0m\r\n"] +[16.864556, "o", "\u001b[13A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mVersion:\u001b[0m\r\n master \u001b[2mbranch\u001b[0m \u001b[2m(default)\u001b[0m\r\n 3.7.2 \u001b[2mtag\u001b[0m\r\n \u001b[92m▶\u001b[0m \u001b[1mgh-pages \u001b[2mbranch\u001b[0m\u001b[0m\r\n latest-passing-build \u001b[2mtag\u001b[0m\r\n ▸ revert-1598-fix\r\n separate_gtest \u001b[2mbranch\u001b[0m\r\n v3.3 \u001b[2mtag\u001b[0m\r\n v3.4 \u001b[2mtag\u001b[0m\r\n v3.5 \u001b[2mtag\u001b[0m\r\n v3.6 \u001b[2mtag\u001b[0m\r\n \u001b[2m↓ 4 more below\u001b[0m\r\n \u001b[2m↑/↓ navigate Enter select →/← expand/collapse Esc list\u001b[0m\r\n"] +[16.998753, "o", "\u001b[13A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mVersion:\u001b[0m\r\n master \u001b[2mbranch\u001b[0m \u001b[2m(default)\u001b[0m\r\n 3.7.2 \u001b[2mtag\u001b[0m\r\n gh-pages \u001b[2mbranch\u001b[0m\r\n \u001b[92m▶\u001b[0m \u001b[1mlatest-passing-build \u001b[2mtag\u001b[0m\u001b[0m\r\n ▸ revert-1598-fix\r\n separate_gtest \u001b[2mbranch\u001b[0m\r\n v3.3 \u001b[2mtag\u001b[0m\r\n v3.4 \u001b[2mtag\u001b[0m\r\n v3.5 \u001b[2mtag\u001b[0m\r\n v3.6 \u001b[2mtag\u001b[0m\r\n \u001b[2m↓ 4 more below\u001b[0m\r\n \u001b[2m↑/↓ navigate Enter select →/← expand/collapse Esc list\u001b[0m\r\n"] +[17.253788, "o", "\u001b[13A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mVersion:\u001b[0m\r\n master \u001b[2mbranch\u001b[0m \u001b[2m(default)\u001b[0m\r\n 3.7.2 \u001b[2mtag\u001b[0m\r\n gh-pages \u001b[2mbranch\u001b[0m\r\n latest-passing-build \u001b[2mtag\u001b[0m\r\n \u001b[92m▶\u001b[0m ▸ \u001b[1mrevert-1598-fix\u001b[0m\r\n separate_gtest \u001b[2mbranch\u001b[0m\r\n v3.3 \u001b[2mtag\u001b[0m\r\n v3.4 \u001b[2mtag\u001b[0m\r\n v3.5 \u001b[2mtag\u001b[0m\r\n v3.6 \u001b[2mtag\u001b[0m\r\n \u001b[2m↓ 4 more below\u001b[0m\r\n \u001b[2m↑/↓ navigate Enter select →/← expand/collapse Esc list\u001b[0m\r\n"] +[17.539622, "o", "\u001b[13A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mVersion:\u001b[0m\r\n master \u001b[2mbranch\u001b[0m \u001b[2m(default)\u001b[0m\r\n 3.7.2 \u001b[2mtag\u001b[0m\r\n gh-pages \u001b[2mbranch\u001b[0m\r\n latest-passing-build \u001b[2mtag\u001b[0m\r\n \u001b[92m▶\u001b[0m ▾ \u001b[1mrevert-1598-fix\u001b[0m\r\n junit_newline_encoding \u001b[2mbranch\u001b[0m\r\n separate_gtest \u001b[2mbranch\u001b[0m\r\n v3.3 \u001b[2mtag\u001b[0m\r\n v3.4 \u001b[2mtag\u001b[0m\r\n v3.5 \u001b[2mtag\u001b[0m\r\n \u001b[2m↓ 5 more below\u001b[0m\r\n \u001b[2m↑/↓ navigate Enter select →/← expand/collapse Esc list\u001b[0m\r\n"] +[18.318319, "o", "\u001b[13A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mVersion:\u001b[0m\r\n master \u001b[2mbranch\u001b[0m \u001b[2m(default)\u001b[0m\r\n 3.7.2 \u001b[2mtag\u001b[0m\r\n gh-pages \u001b[2mbranch\u001b[0m\r\n latest-passing-build \u001b[2mtag\u001b[0m\r\n ▾ revert-1598-fix\r\n \u001b[92m▶\u001b[0m \u001b[1mjunit_newline_encoding \u001b[2mbranch\u001b[0m\u001b[0m\r\n separate_gtest \u001b[2mbranch\u001b[0m\r\n v3.3 \u001b[2mtag\u001b[0m\r\n v3.4 \u001b[2mtag\u001b[0m\r\n v3.5 \u001b[2mtag\u001b[0m\r\n \u001b[2m↓ 5 more below\u001b[0m\r\n \u001b[2m↑/↓ navigate Enter select →/← expand/collapse Esc list\u001b[0m\r\n"] +[18.669281, "o", "\u001b[13A\u001b[0J"] +[18.670395, "o", " \u001b[34mbranch:\u001b[0m revert-1598-fix/junit_newline_encoding\r\n"] +[19.277344, "o", " \u001b[92m?\u001b[0m \u001b[1mSource path:\u001b[0m\r\n \u001b[92m▶\u001b[0m \u001b[1m.\u001b[0m\r\n ▸ .circleci\r\n ▸ .github\r\n ▸ build\r\n ▸ builds\r\n ▸ cmake\r\n ▸ docker\r\n ▸ examples\r\n ▸ include\r\n ▸ m4\r\n \u001b[2m↓ 41 more below\u001b[0m\r\n \u001b[2m↑/↓ navigate Enter select →/← expand/collapse Esc skip\u001b[0m\r\n"] +[20.29916, "o", "\u001b[13A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mSource path:\u001b[0m\r\n .\r\n \u001b[92m▶\u001b[0m ▸ \u001b[1m.circleci\u001b[0m\r\n ▸ .github\r\n ▸ build\r\n ▸ builds\r\n ▸ cmake\r\n ▸ docker\r\n ▸ examples\r\n ▸ include\r\n ▸ m4\r\n \u001b[2m↓ 41 more below\u001b[0m\r\n \u001b[2m↑/↓ navigate Enter select →/← expand/collapse Esc skip\u001b[0m\r\n"] +[21.650081, "o", "\u001b[13A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mSource path:\u001b[0m\r\n \u001b[92m▶\u001b[0m \u001b[1m.\u001b[0m\r\n ▸ .circleci\r\n ▸ .github\r\n ▸ build\r\n ▸ builds\r\n ▸ cmake\r\n ▸ docker\r\n ▸ examples\r\n ▸ include\r\n ▸ m4\r\n \u001b[2m↓ 41 more below\u001b[0m\r\n \u001b[2m↑/↓ navigate Enter select →/← expand/collapse Esc skip\u001b[0m\r\n"] +[22.056607, "o", "\u001b[13A\u001b[0J"] +[22.061046, "o", " \u001b[92m?\u001b[0m \u001b[1mIgnore:\u001b[0m\r\n \u001b[92m▶\u001b[0m .\r\n ▸ .circleci\r\n ▸ .github\r\n ▸ build\r\n ▸ builds\r\n ▸ cmake\r\n ▸ docker\r\n ▸ examples\r\n ▸ include\r\n ▸ m4\r\n \u001b[2m↓ 41 more below\u001b[0m\r\n \u001b[2m↑/↓ navigate Space toggle Enter confirm →/← expand/collapse Esc skip\u001b[0m\r\n"] +[23.157548, "o", "\u001b[13A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mIgnore:\u001b[0m\r\n .\r\n \u001b[92m▶\u001b[0m ▸ .circleci\r\n ▸ .github\r\n ▸ build\r\n ▸ builds\r\n ▸ cmake\r\n ▸ docker\r\n ▸ examples\r\n ▸ include\r\n ▸ m4\r\n \u001b[2m↓ 41 more below\u001b[0m\r\n \u001b[2m↑/↓ navigate Space toggle Enter confirm →/← expand/collapse Esc skip\u001b[0m\r\n"] +[23.460659, "o", "\u001b[13A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mIgnore:\u001b[0m\r\n .\r\n ▸ .circleci\r\n \u001b[92m▶\u001b[0m ▸ .github\r\n ▸ build\r\n ▸ builds\r\n ▸ cmake\r\n ▸ docker\r\n ▸ examples\r\n ▸ include\r\n ▸ m4\r\n \u001b[2m↓ 41 more below\u001b[0m\r\n \u001b[2m↑/↓ navigate Space toggle Enter confirm →/← expand/collapse Esc skip\u001b[0m\r\n"] +[23.726259, "o", "\u001b[13A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mIgnore:\u001b[0m\r\n .\r\n \u001b[92m▶\u001b[0m ▸ .circleci\r\n ▸ .github\r\n ▸ build\r\n ▸ builds\r\n ▸ cmake\r\n ▸ docker\r\n ▸ examples\r\n ▸ include\r\n ▸ m4\r\n \u001b[2m↓ 41 more below\u001b[0m\r\n \u001b[2m↑/↓ navigate Space toggle Enter confirm →/← expand/collapse Esc skip\u001b[0m\r\n"] +[23.954263, "o", "\u001b[13A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mIgnore:\u001b[0m\r\n .\r\n \u001b[92m▶\u001b[0m ▸ \u001b[2m.circleci\u001b[0m\r\n ▸ .github\r\n ▸ build\r\n ▸ builds\r\n ▸ cmake\r\n ▸ docker"] +[23.954481, "o", "\r\n ▸ examples\r\n ▸ include\r\n ▸ m4\r\n \u001b[2m↓ 41 more below\u001b[0m\r\n \u001b[2m↑/↓ navigate Space toggle Enter confirm →/← expand/collapse Esc skip\u001b[0m\r\n"] +[24.090072, "o", "\u001b[13A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mIgnore:\u001b[0m\r\n .\r\n ▸ \u001b[2m.circleci\u001b[0m\r\n \u001b[92m▶\u001b[0m ▸ .github\r\n ▸ build\r\n ▸ builds\r\n ▸ cmake\r\n ▸ docker\r\n ▸ examples\r\n ▸ include\r\n ▸ m4\r\n \u001b[2m↓ 41 more below\u001b[0m\r\n \u001b[2m↑/↓ navigate Space toggle Enter confirm →/← expand/collapse Esc skip\u001b[0m\r\n"] +[24.297134, "o", "\u001b[13A\u001b[0J \u001b[92m?\u001b[0m \u001b[1mIgnore:\u001b[0m\r\n .\r\n ▸ \u001b[2m.circleci\u001b[0m\r\n \u001b[92m▶\u001b[0m ▸ \u001b[2m.github\u001b[0m\r\n ▸ build\r\n ▸ builds\r\n ▸ cmake\r\n ▸ docker\r\n ▸ examples\r\n ▸ include\r\n ▸ m4\r\n \u001b[2m↓ 41 more below\u001b[0m\r\n \u001b[2m↑/↓ navigate Space toggle Enter confirm →/← expand/collapse Esc skip\u001b[0m\r\n"] +[24.702155, "o", "\u001b[13A\u001b[0J"] +[24.702928, "o", " \u001b[34mignore:\u001b[0m\r\n"] +[24.703401, "o", " - .circleci\r\n"] +[24.705431, "o", " - .github\r\n"] +[24.707902, "o", "Add project to manifest? \u001b[1m(\u001b[0my\u001b[1m)\u001b[0m: "] +[25.258062, "o", "y"] +[25.318216, "o", "\r\n"] +[25.319821, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] +[25.320446, "o", " \u001b[1;34m> Added 'cpputest' to manifest '/workspaces/dfetch/doc/generate-casts/interactive-add/dfetch.yaml'\u001b[0m\r\n"] +[25.321004, "o", "Run \u001b[32m'dfetch update cpputest'\u001b[0m now? \u001b[1m(\u001b[0my\u001b[1m)\u001b[0m: "] +[25.871142, "o", "n"] +[25.934569, "o", "\r\n"] +[26.010632, "o", "$ "] +[27.014153, "o", "\u001b["] +[27.194507, "o", "1m"] +[27.284626, "o", "ca"] +[27.374768, "o", "t "] +[27.464886, "o", "dfe"] +[27.555015, "o", "tc"] +[27.64514, "o", "h."] +[27.735274, "o", "ya"] +[27.82541, "o", "ml"] +[27.915743, "o", "\u001b[0"] +[28.095966, "o", "m"] +[29.09752, "o", "\r\n"] +[29.100401, "o", "manifest:\r\n version: '0.0'\r\n projects:\r\n - name: jsmn\r\n url: https://github.com/zserge/jsmn.git\r\n branch: master\r\n\r\n - name: cpputest\r\n url: https://github.com/cpputest/cpputest.git\r\n branch: revert-1598-fix/junit_newline_encoding\r\n ignore:\r\n - .circleci\r\n - .github\r\n"] +[32.108775, "o", "$ "] +[32.110446, "o", "\u001b["] +[32.29078, "o", "1m"] +[32.380906, "o", "\u001b["] +[32.471023, "o", "0m"] +[32.471578, "o", "\r\n"] +[32.474817, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] diff --git a/doc/asciicasts/report.cast b/doc/asciicasts/report.cast index dce5dfeb..8a62f4c7 100644 --- a/doc/asciicasts/report.cast +++ b/doc/asciicasts/report.cast @@ -1,44 +1,46 @@ -{"version": 2, "width": 173, "height": 25, "timestamp": 1774774051, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} -[0.042994, "o", "\u001b[H\u001b[2J\u001b[3J"] -[0.048488, "o", "$ "] -[1.051645, "o", "\u001b["] -[1.231919, "o", "1m"] -[1.322237, "o", "ls"] -[1.412363, "o", " -"] -[1.50251, "o", "l\u001b"] -[1.592638, "o", "[0"] -[1.682985, "o", "m"] -[2.684576, "o", "\r\n"] -[2.688128, "o", "total 12\r\n"] -[2.688183, "o", "drwxr-xr-x+ 3 dev dev 4096 Mar 29 08:47 cpputest\r\n-rw-rw-rw- 1 dev dev 733 Mar 29 08:47 dfetch.yaml\r\ndrwxr-xr-x+ 4 dev dev 4096 Mar 29 08:47 jsmn\r\n"] -[2.693625, "o", "$ "] -[3.69675, "o", "\u001b"] -[3.879138, "o", "[1"] -[3.968006, "o", "md"] -[4.058163, "o", "fe"] -[4.148309, "o", "t"] -[4.238451, "o", "ch"] -[4.328569, "o", " r"] -[4.418696, "o", "ep"] -[4.508829, "o", "or"] -[4.598882, "o", "t"] -[4.77926, "o", "\u001b["] -[4.869945, "o", "0m"] -[5.870888, "o", "\r\n"] -[6.335615, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[6.37037, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] -[6.371012, "o", " \u001b[1;92m- remote :\u001b[0m\u001b[1;34m github\u001b[0m\r\n"] -[6.372468, "o", " \u001b[1;92m remote url :\u001b[0m\u001b[1;34m https://github.com/cpputest/cpputest.git\u001b[0m\r\n"] -[6.373653, "o", " \u001b[1;92m branch :\u001b[0m\u001b[1;34m master\u001b[0m\r\n"] -[6.374003, "o", " \u001b[1;92m tag :\u001b[0m\u001b[1;34m v3.4\u001b[0m\r\n"] -[6.374305, "o", " \u001b[1;92m last fetch :\u001b[0m\u001b[1;34m 29/03/2026, 08:47:20\u001b[0m\r\n"] -[6.376078, "o", " \u001b[1;92m revision :\u001b[0m\u001b[1;34m \u001b[0m\r\n \u001b[1;92m patch :\u001b[0m\u001b[1;34m \u001b[0m\r\n"] -[6.376194, "o", " \u001b[1;92m licenses :\u001b[0m\u001b[1;34m BSD 3-Clause \"New\" or \"Revised\" License\u001b[0m\r\n"] -[6.381695, "o", " \u001b[1;92mjsmn:\u001b[0m\r\n"] -[6.382366, "o", " \u001b[1;92m- remote :\u001b[0m\u001b[1;34m github\u001b[0m\r\n"] -[6.383722, "o", " \u001b[1;92m remote url :\u001b[0m\u001b[1;34m https://github.com/zserge/jsmn.git\u001b[0m\r\n"] -[6.385813, "o", " \u001b[1;92m branch :\u001b[0m\u001b[1;34m master\u001b[0m\r\n \u001b[1;92m tag :\u001b[0m\u001b[1;34m \u001b[0m\r\n \u001b[1;92m last fetch :\u001b[0m\u001b[1;34m 29/03/2026, 08:47:21\u001b[0m\r\n"] -[6.386109, "o", " \u001b[1;92m revision :\u001b[0m\u001b[1;34m 25647e692c7906b96ffd2b05ca54c097948e879c\u001b[0m\r\n"] -[6.386677, "o", " \u001b[1;92m patch :\u001b[0m\u001b[1;34m \u001b[0m\r\n"] -[6.390512, "o", " \u001b[1;92m licenses :\u001b[0m\u001b[1;34m MIT License\u001b[0m\r\n"] -[9.513043, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] +{"version": 2, "width": 111, "height": 25, "timestamp": 1774774856, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[0.037865, "o", "\u001b[H\u001b[2J\u001b[3J"] +[0.042295, "o", "$ "] +[1.044677, "o", "\u001b["] +[1.225016, "o", "1m"] +[1.315141, "o", "ls"] +[1.4053, "o", " -"] +[1.495434, "o", "l\u001b"] +[1.585561, "o", "[0"] +[1.67568, "o", "m"] +[2.67721, "o", "\r\n"] +[2.680757, "o", "total 12\r\n"] +[2.68088, "o", "drwxr-xr-x+ 3 dev dev 4096 Mar 29 09:00 cpputest\r\n-rw-rw-rw- 1 dev dev 733 Mar 29 09:00 dfetch.yaml\r\ndrwxr-xr-x+ 4 dev dev 4096 Mar 29 09:00 jsmn\r\n"] +[2.685775, "o", "$ "] +[3.689043, "o", "\u001b"] +[3.869705, "o", "[1"] +[3.96164, "o", "md"] +[4.050768, "o", "fe"] +[4.14092, "o", "t"] +[4.23106, "o", "ch"] +[4.321181, "o", " r"] +[4.411318, "o", "ep"] +[4.501561, "o", "or"] +[4.591682, "o", "t"] +[4.772053, "o", "\u001b["] +[4.866174, "o", "0m"] +[5.865844, "o", "\r\n"] +[6.302145, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[6.331751, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] +[6.33235, "o", " \u001b[1;92m- remote :\u001b[0m\u001b[1;34m github\u001b[0m\r\n"] +[6.333651, "o", " \u001b[1;92m remote url :\u001b[0m\u001b[1;34m https://github.com/cpputest/cpputest.git\u001b[0m\r\n"] +[6.334169, "o", " \u001b[1;92m branch :\u001b[0m\u001b[1;34m master\u001b[0m\r\n"] +[6.334681, "o", " \u001b[1;92m tag :\u001b[0m\u001b[1;34m v3.4\u001b[0m\r\n"] +[6.335178, "o", " \u001b[1;92m last fetch :\u001b[0m\u001b[1;34m 29/03/2026, 09:00:44\u001b[0m\r\n"] +[6.335666, "o", " \u001b[1;92m revision :\u001b[0m\u001b[1;34m \u001b[0m\r\n"] +[6.33616, "o", " \u001b[1;92m patch :\u001b[0m\u001b[1;34m \u001b[0m\r\n"] +[6.336677, "o", " \u001b[1;92m licenses :\u001b[0m\u001b[1;34m BSD 3-Clause \"New\" or \"Revised\" License\u001b[0m\r\n"] +[6.340863, "o", " \u001b[1;92mjsmn:\u001b[0m\r\n"] +[6.34139, "o", " \u001b[1;92m- remote :\u001b[0m\u001b[1;34m github\u001b[0m\r\n"] +[6.342645, "o", " \u001b[1;92m remote url :\u001b[0m\u001b[1;34m https://github.com/zserge/jsmn.git\u001b[0m\r\n"] +[6.343136, "o", " \u001b[1;92m branch :\u001b[0m\u001b[1;34m master\u001b[0m\r\n"] +[6.343719, "o", " \u001b[1;92m tag :\u001b[0m\u001b[1;34m \u001b[0m\r\n"] +[6.344382, "o", " \u001b[1;92m last fetch :\u001b[0m\u001b[1;34m 29/03/2026, 09:00:46\u001b[0m\r\n"] +[6.345512, "o", " \u001b[1;92m revision :\u001b[0m\u001b[1;34m 25647e692c7906b96ffd2b05ca54c097948e879c\u001b[0m\r\n \u001b[1;92m patch :\u001b[0m\u001b[1;34m \u001b[0m\r\n"] +[6.348514, "o", " \u001b[1;92m licenses :\u001b[0m\u001b[1;34m MIT License\u001b[0m\r\n"] +[9.422094, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] diff --git a/doc/asciicasts/sbom.cast b/doc/asciicasts/sbom.cast index 50566e57..b1dd4809 100644 --- a/doc/asciicasts/sbom.cast +++ b/doc/asciicasts/sbom.cast @@ -1,51 +1,52 @@ -{"version": 2, "width": 173, "height": 25, "timestamp": 1774774061, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} -[0.039778, "o", "\u001b[H\u001b[2J\u001b[3J"] -[0.044904, "o", "$ "] -[1.047823, "o", "\u001b"] -[1.228091, "o", "[1"] -[1.318232, "o", "ml"] -[1.408376, "o", "s "] -[1.498509, "o", "-l"] -[1.588651, "o", "\u001b["] -[1.678765, "o", "0m"] -[2.680389, "o", "\r\n"] -[2.683902, "o", "total 12\r\n"] -[2.68396, "o", "drwxr-xr-x+ 3 dev dev 4096 Mar 29 08:47 cpputest\r\n-rw-rw-rw- 1 dev dev 733 Mar 29 08:47 dfetch.yaml\r\ndrwxr-xr-x+ 4 dev dev 4096 Mar 29 08:47 jsmn\r\n"] -[2.688954, "o", "$ "] -[3.692097, "o", "\u001b["] -[3.872333, "o", "1m"] -[3.962473, "o", "df"] -[4.052597, "o", "et"] -[4.142768, "o", "ch"] -[4.232882, "o", " r"] -[4.323003, "o", "ep"] -[4.413137, "o", "or"] -[4.503278, "o", "t "] -[4.593406, "o", "-t"] -[4.773676, "o", " s"] -[4.863793, "o", "bo"] -[4.954002, "o", "m\u001b"] -[5.044098, "o", "[0"] -[5.134231, "o", "m"] -[6.135805, "o", "\r\n"] -[6.600737, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[6.638176, "o", "Generated SBoM report: report.json\r\n"] -[6.713095, "o", "$ "] -[7.716296, "o", "\u001b["] -[7.896585, "o", "1m"] -[7.986721, "o", "ca"] -[8.076851, "o", "t "] -[8.166951, "o", "re"] -[8.257124, "o", "po"] -[8.347262, "o", "rt"] -[8.437396, "o", ".j"] -[8.527509, "o", "so"] -[8.617675, "o", "n\u001b"] -[8.797888, "o", "[0"] -[8.888103, "o", "m"] -[9.889693, "o", "\r\n"] -[9.89288, "o", "{\r\n \"components\": [\r\n {\r\n \"bom-ref\": \"cpputest-v3.4\",\r\n \"evidence\": {\r\n \"identity\": [\r\n {\r\n \"concludedValue\": \"cpputest\",\r\n \"field\": \"name\",\r\n \"methods\": [\r\n {\r\n \"confidence\": 0.4,\r\n \"technique\": \"manifest-analysis\",\r\n \"value\": \"Name as used for project in dfetch.yaml\"\r\n }\r\n ],\r\n \"tools\": [\r\n \"dfetch-0.12.1\"\r\n ]\r\n },\r\n {\r\n \"concludedValue\": \"pkg:github/cpputest/cpputest@v3.4\",\r\n \"field\": \"purl\",\r\n \"methods\": [\r\n {\r\n \"confidence\": 0.4,\r\n \"technique\": \"manifest-analysis\",\r\n \"value\": \"Determined from https://github.com/cpputest/cpputest.git as used for the project cpputest in dfetch.yaml\"\r\n }\r\n ],\r\n \"tools\": [\r\n \"dfetch-0.12.1\"\r\n ]\r\n },\r\n {\r\n \"concludedValue\": \"v3.4\",\r\n \"field\": \"version\",\r\n \"methods\": [\r\n {\r\n \"confidence\": 0.4,\r\n \"technique\": \"manifest-analysis\",\r\n \"value\": \"Version as used for project in dfetch.yaml\"\r\n }\r\n ],\r\n \"tools\": [\r\n \"dfetch-0.12.1\"\r\n ]\r\n }\r\n ],\r\n \"licenses\": [\r\n {\r\n \"license\": {\r\n "] -[9.893194, "o", " \"id\": \"BSD-3-Clause\"\r\n }\r\n }\r\n ],\r\n \"occurrences\": [\r\n {\r\n \"line\": 9,\r\n \"location\": \"dfetch.yaml\",\r\n \"offset\": 11\r\n }\r\n ]\r\n },\r\n \"externalReferences\": [\r\n {\r\n \"type\": \"vcs\",\r\n \"url\": \"https://github.com/cpputest/cpputest\"\r\n }\r\n ],\r\n \"group\": \"cpputest\",\r\n \"licenses\": [\r\n {\r\n \"license\": {\r\n \"id\": \"BSD-3-Clause\"\r\n }\r\n }\r\n ],\r\n \"name\": \"cpputest\",\r\n \"purl\": \"pkg:github/cpputest/cpputest@v3.4\",\r\n \"type\": \"library\",\r\n \"version\": \"v3.4\"\r\n },\r\n {\r\n \"bom-ref\": \"jsmn-25647e692c7906b96ffd2b05ca54c097948e879c\",\r\n \"evidence\": {\r\n \"identity\": [\r\n {\r\n \"concludedValue\": \"jsmn\",\r\n \"field\": \"name\",\r\n \"methods\": [\r\n {\r\n \"confidence\": 0.4,\r\n \"technique\": \"manifest-analysis\",\r\n \"value\": \"Name as used for project in dfetch.yaml\"\r\n }\r\n ],\r\n \"tools\": [\r\n \"dfetch-0.12.1\"\r\n ]\r\n },\r\n {\r\n \"concludedValue\": \"pkg:github/zserge/jsmn@25647e692c7906b96ffd2b05ca54c097948e879c\",\r\n \"field\": \"purl\",\r\n \"methods\": [\r\n {\r\n \"confidence\": 0.4,\r\n \"technique\": \"manifest-analysis\",\r\n \"value\": \"Determined from https://github.com/zserge/jsmn.git as used for the project jsmn in dfetch.yaml\"\r\n }\r\n ],\r\n \"tools\": [\r\n \"dfetch-0.12.1\"\r\n ]\r\n },\r\n {\r\n \"concludedValue\": \"25647e692c7906b96ffd2b05ca54c097948e879c\",\r\n \"field\": \"version\",\r\n \"methods\": [\r\n {\r\n \"confidence\": 0.4,\r\n \"technique\": \"manifest-analysis\",\r\n \"value\": \"Version as used for project in dfetch.yaml\"\r\n }\r\n ],\r\n \"tools\": [\r\n \"dfetch-0.12.1\"\r\n ]\r\n }\r\n ],\r\n \"licenses\": [\r\n {\r\n \"license\": {\r\n \"id\": \"MIT\"\r\n }\r\n }\r\n ],\r\n \"occurrences\": [\r\n {\r\n \"line\": 14,\r\n \"location\": \"dfetch.yaml\",\r\n \"offset\": 11\r\n }\r\n ]\r\n },\r\n \"externalReferences\": [\r\n {\r\n \"type\": \"vcs\",\r\n \"url\": \"https://github.com/zserge/jsmn\"\r\n }\r\n ],\r\n \"group\": \"zserge\",\r\n \"licenses\": [\r\n {\r\n \"license\": {\r\n \"id\": \"MIT\"\r\n }\r\n }\r\n ],\r\n \"name\": \"jsmn\",\r\n \"purl\": \"pkg:github/zserge/jsmn@25647e692c7906b96ffd2b05ca54c097948e879c\",\r\n \"type\": \"library\",\r\n \"version\": \"25647e692c7906b96ffd2b05ca54c097948e879c\"\r\n }\r\n ],\r\n \"dependencies\": [\r\n {\r\n "] -[9.893301, "o", " \"ref\": \"cpputest-v3.4\"\r\n },\r\n {\r\n \"ref\": \"jsmn-25647e692c7906b96ffd2b05ca54c097948e879c\"\r\n }\r\n ],\r\n \"metadata\": {\r\n \"timestamp\": \"2026-03-29T08:47:47.661232+00:00\",\r\n \"tools\": {\r\n \"components\": [\r\n {\r\n \"bom-ref\": \"dfetch-0.12.1\",\r\n \"externalReferences\": [\r\n {\r\n \"type\": \"build-system\",\r\n \"url\": \"https://github.com/dfetch-org/dfetch/actions\"\r\n },\r\n {\r\n \"type\": \"distribution\",\r\n \"url\": \"https://pypi.org/project/dfetch/\"\r\n },\r\n {\r\n \"type\": \"documentation\",\r\n \"url\": \"https://dfetch.readthedocs.io/\"\r\n },\r\n {\r\n \"type\": \"issue-tracker\",\r\n \"url\": \"https://github.com/dfetch-org/dfetch/issues\"\r\n },\r\n {\r\n \"type\": \"license\",\r\n \"url\": \"https://github.com/dfetch-org/dfetch/blob/main/LICENSE\"\r\n },\r\n {\r\n \"type\": \"release-notes\",\r\n \"url\": \"https://github.com/dfetch-org/dfetch/blob/main/CHANGELOG.rst\"\r\n },\r\n {\r\n \"type\": \"vcs\",\r\n \"url\": \"https://github.com/dfetch-org/dfetch\"\r\n },\r\n {\r\n \"type\": \"website\",\r\n \"url\": \"https://dfetch-org.github.io/\"\r\n }\r\n ],\r\n \"licenses\": [\r\n {\r\n \"license\": {\r\n \"acknowledgement\": \"declared\",\r\n \"id\": \"MIT\"\r\n }\r\n }\r\n ],\r\n \"name\": \"dfetch\",\r\n \"supplier\": {\r\n \"name\": \"dfetch-org\"\r\n },\r\n \"type\": \"application\",\r\n \"version\": \"0.12.1\"\r\n },\r\n {\r\n \"description\": \"Python library for CycloneDX\",\r\n \"externalReferences\": [\r\n {\r\n \"type\": \"build-system\",\r\n \"url\": \"https://github.com/CycloneDX/cyclonedx-python-lib/actions\"\r\n },\r\n {\r\n \"type\": \"distribution\",\r\n \"url\": \"https://pypi.org/project/cyclonedx-python-lib/\"\r\n },\r\n {\r\n \"type\": \"documentation\",\r\n \"url\": \"https://cyclonedx-python-library.readthedocs.io/\"\r\n },\r\n {\r\n \"type\": \"issue-tracker\",\r\n \"url\": \"https://github.com/CycloneDX/cyclonedx-python-lib/issues\"\r\n },\r\n {\r\n \"type\": \"license\",\r\n \"url\": \"https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/LICENSE\"\r\n },\r\n {\r\n \"type\": \"release-notes\",\r\n \"url\": \"https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/CHANGELOG.md\"\r\n },\r\n {\r\n \"type\": \"vcs\",\r\n \"url\": \"https://github.com/CycloneDX/cyclonedx-python-lib\"\r\n },\r\n {\r\n \"type\": \"website\",\r\n \"url\": \"https:"] -[9.893323, "o", "//github.com/CycloneDX/cyclonedx-python-lib/#readme\"\r\n }\r\n ],\r\n \"group\": \"CycloneDX\",\r\n \"licenses\": [\r\n {\r\n \"license\": {\r\n \"acknowledgement\": \"declared\",\r\n \"id\": \"Apache-2.0\"\r\n }\r\n }\r\n ],\r\n \"name\": \"cyclonedx-python-lib\",\r\n \"type\": \"library\",\r\n \"version\": \"11.7.0\"\r\n }\r\n ]\r\n }\r\n },\r\n \"serialNumber\": \"urn:uuid:bd57b26d-db19-4e87-bcd4-e3eeda5af76c\",\r\n \"version\": 1,\r\n \"$schema\": \"http://cyclonedx.org/schema/bom-1.6.schema.json\",\r\n \"bomFormat\": \"CycloneDX\",\r\n \"specVersion\": \"1.6\"\r\n}"] -[12.895873, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] +{"version": 2, "width": 111, "height": 25, "timestamp": 1774774865, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[0.040968, "o", "\u001b[H\u001b[2J\u001b[3J"] +[0.045964, "o", "$ "] +[1.048967, "o", "\u001b"] +[1.229236, "o", "[1"] +[1.319363, "o", "ml"] +[1.409507, "o", "s "] +[1.499609, "o", "-"] +[1.589766, "o", "l\u001b"] +[1.679916, "o", "[0"] +[1.770034, "o", "m"] +[2.771549, "o", "\r\n"] +[2.774909, "o", "total 12\r\n"] +[2.774962, "o", "drwxr-xr-x+ 3 dev dev 4096 Mar 29 09:01 cpputest\r\n-rw-rw-rw- 1 dev dev 733 Mar 29 09:01 dfetch.yaml\r\ndrwxr-xr-x+ 4 dev dev 4096 Mar 29 09:01 jsmn\r\n"] +[2.779947, "o", "$ "] +[3.791667, "o", "\u001b["] +[3.971413, "o", "1m"] +[4.061562, "o", "df"] +[4.151692, "o", "et"] +[4.241816, "o", "ch "] +[4.331944, "o", "re"] +[4.422098, "o", "po"] +[4.512308, "o", "rt"] +[4.602741, "o", " -"] +[4.692843, "o", "t s"] +[4.873105, "o", "bo"] +[4.963226, "o", "m\u001b"] +[5.053361, "o", "[0"] +[5.143501, "o", "m"] +[6.145265, "o", "\r\n"] +[6.596424, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[6.632634, "o", "Generated SBoM report: report.json\r\n"] +[6.702399, "o", "$ "] +[7.705495, "o", "\u001b"] +[7.885758, "o", "[1"] +[7.975923, "o", "mc"] +[8.066057, "o", "at"] +[8.156166, "o", " r"] +[8.246316, "o", "ep"] +[8.336447, "o", "or"] +[8.426571, "o", "t."] +[8.516813, "o", "js"] +[8.606839, "o", "on"] +[8.787069, "o", "\u001b"] +[8.877364, "o", "[0"] +[8.967486, "o", "m"] +[9.968411, "o", "\r\n"] +[9.971389, "o", "{\r\n \"components\": [\r\n {\r\n \"bom-ref\": \"cpputest-v3.4\",\r\n \"evidence\": {\r\n"] +[9.97156, "o", " \"identity\": [\r\n {\r\n \"concludedValue\": \"cpputest\",\r\n \"field\": \"name\",\r\n \"methods\": [\r\n {\r\n \"confidence\": 0.4,\r\n \"technique\": \"manifest-analysis\",\r\n \"value\": \"Name as used for project in dfetch.yaml\"\r\n }\r\n ],\r\n \"tools\": [\r\n \"dfetch-0.12.1\"\r\n ]\r\n },\r\n {\r\n \"concludedValue\": \"pkg:github/cpputest/cpputest@v3.4\",\r\n \"field\": \"purl\",\r\n \"methods\": [\r\n {\r\n \"confidence\": 0.4,\r\n \"technique\": \"manifest-analysis\",\r\n \"value\": \"Determined from https://github.com/cpputest/cpputest.git as used for the project cpputest in dfetch.yaml\"\r\n }\r\n ],\r\n \"tools\": [\r\n \"dfetch-0.12.1\"\r\n ]\r\n },\r\n {\r\n \"concludedValue\": \"v3.4\",\r\n \"field\": \"version\",\r\n \"methods\": [\r\n {\r\n \"confidence\": 0.4,\r\n \"technique\": \"manifest-analysis\",\r\n \"value\": \"Version as used for project in dfetch.yaml\"\r\n }\r\n ],\r\n \"tools\": [\r\n \"dfetch-0.12.1\"\r\n ]\r\n }\r\n ],\r\n \"licenses\": [\r\n {\r\n \"license\": {\r\n \"id\": \"BSD-3-Clause\"\r\n }\r\n }\r\n ],\r\n \"occurrences\": [\r\n {\r\n \"line\": 9,\r\n \"location\": \"dfetch.yaml\",\r\n \"offset\": 11\r\n }\r\n ]\r\n },\r\n \"externalReferences\": [\r\n {\r\n \"type\": \"vcs\",\r\n \"url\": \"https://github.com/cpputest/cpputest\"\r\n }\r\n ],\r\n \"group\": \"cpputest\",\r\n \"licenses\": [\r\n {\r\n \"license\": {\r\n \"id\": \"BSD-3-Clause\"\r\n }\r\n }\r\n ],\r\n \"name\": \"cpputest\",\r\n \"purl\": \"pkg:github/cpputest/cpputest@v3.4\",\r\n \"type\": \"library\",\r\n \"version\": \"v3.4\"\r\n },\r\n {\r\n \"bom-ref\": \"jsmn-25647e692c7906b96ffd2b05ca54c097948e879c\",\r\n \"evidence\": {\r\n \"identity\": [\r\n {\r\n \"concludedValue\": \"jsmn\",\r\n \"field\": \"name\",\r\n \"methods\": [\r\n {\r\n \"confidence\": 0.4,\r\n \"technique\": \"manifest-analysis\",\r\n \"value\": \"Name as used for project in dfetch.yaml\"\r\n }\r\n ],\r\n \"tools\": [\r\n \"dfetch-0.12.1\"\r\n ]\r\n },\r\n {\r\n \"concludedValue\": \"pkg:github/zserge/jsmn@25647e692c7906b96ffd2b05ca54c097948e879c\",\r\n \"field\": \"purl\",\r\n \"methods\": [\r\n {\r\n \"confidence\": 0.4,\r\n \"technique\": \"manifest-analysis\",\r\n \"value\": \"Determined from https://github.com/zserge/jsmn."] +[9.971591, "o", "git as used for the project jsmn in dfetch.yaml\"\r\n }\r\n ],\r\n \"tools\": [\r\n \"dfetch-0.12.1\"\r\n ]\r\n },\r\n {\r\n \"concludedValue\": \"25647e692c7906b96ffd2b05ca54c097948e879c\",\r\n \"field\": \"version\",\r\n \"methods\": [\r\n {\r\n \"confidence\": 0.4,\r\n \"technique\": \"manifest-analysis\",\r\n \"value\": \"Version as used for project in dfetch.yaml\"\r\n }\r\n ],\r\n \"tools\": [\r\n \"dfetch-0.12.1\"\r\n ]\r\n }\r\n ],\r\n \"licenses\": [\r\n {\r\n \"license\": {\r\n \"id\": \"MIT\"\r\n }\r\n }\r\n ],\r\n \"occurrences\": [\r\n {\r\n \"line\": 14,\r\n \"location\": \"dfetch.yaml\",\r\n \"offset\": 11\r\n }\r\n ]\r\n },\r\n \"externalReferences\": [\r\n {\r\n \"type\": \"vcs\",\r\n \"url\": \"https://github.com/zserge/jsmn\"\r\n }\r\n ],\r\n \"group\": \"zserge\",\r\n \"licenses\": [\r\n {\r\n \"license\": {\r\n \"id\": \"MIT\"\r\n }\r\n }\r\n ],\r\n \"name\": \"jsmn\",\r\n \"purl\": \"pkg:github/zserge/jsmn@25647e692c7906b96ffd2b05ca54c097948e879c\",\r\n \"type\": \"library\",\r\n \"version\": \"25647e692c7906b96ffd2b05ca54c097948e879c\"\r\n }\r\n ],\r\n \"dependencies\": [\r\n {\r\n \"ref\": \"cpputest-v3.4\"\r\n },\r\n {\r\n \"ref\": \"jsmn-25647e692c7906b96ffd2b05ca54c097948e879c\"\r\n }\r\n ],\r\n \"metadata\": {\r\n \"timestamp\": \"2026-03-29T09:01:12.601922+00:00\",\r\n \"tools\": {\r\n \"components\": [\r\n {\r\n \"bom-ref\": \"dfetch-0.12.1\",\r\n \"externalReferences\": [\r\n {\r\n \"type\": \"build-system\",\r\n \"url\": \"https://github.com/dfetch-org/dfetch/actions\"\r\n },\r\n {\r\n \"type\": \"distribution\",\r\n \"url\": \"https://pypi.org/project/dfetch/\"\r\n },\r\n {\r\n \"type\": \"documentation\",\r\n \"url\": \"https://dfetch.readthedocs.io/\"\r\n },\r\n {\r\n \"type\": \"issue-tracker\",\r\n \"url\": \"https://github.com/dfetch-org/dfetch/issues\"\r\n },\r\n {\r\n \"type\": \"license\",\r\n \"url\": \"https://github.com/dfetch-org/dfetch/blob/main/LICENSE\"\r\n },\r\n {\r\n \"type\": \"release-notes\",\r\n \"url\": \"https://github.com/dfetch-org/dfetch/blob/main/CHANGELOG.rst\"\r\n },\r\n {\r\n \"type\": \"vcs\",\r\n \"url\": \"https://github.com/dfetch-org/dfetch\"\r\n },\r\n {\r\n \"type\": \"website\",\r\n \"url\": \"https://dfetch-org.github.io/\"\r\n }\r\n ],\r\n \"licenses\": [\r\n {\r\n \"license\": {\r\n \"acknowledgement\": \"declared\",\r\n \"id\": \"MI"] +[9.971639, "o", "T\"\r\n }\r\n }\r\n ],\r\n \"name\": \"dfetch\",\r\n \"supplier\": {\r\n \"name\": \"dfetch-org\"\r\n },\r\n \"type\": \"application\",\r\n \"version\": \"0.12.1\"\r\n },\r\n {\r\n \"description\": \"Python library for CycloneDX\",\r\n \"externalReferences\": [\r\n {\r\n \"type\": \"build-system\",\r\n \"url\": \"https://github.com/CycloneDX/cyclonedx-python-lib/actions\"\r\n },\r\n {\r\n \"type\": \"distribution\",\r\n \"url\": \"https://pypi.org/project/cyclonedx-python-lib/\"\r\n },\r\n {\r\n \"type\": \"documentation\",\r\n \"url\": \"https://cyclonedx-python-library.readthedocs.io/\"\r\n },\r\n {\r\n \"type\": \"issue-tracker\",\r\n \"url\": \"https://github.com/CycloneDX/cyclonedx-python-lib/issues\"\r\n },\r\n {\r\n \"type\": \"license\",\r\n \"url\": \"https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/LICENSE\"\r\n },\r\n {\r\n \"type\": \"release-notes\",\r\n \"url\": \"https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/CHANGELOG.md\"\r\n },\r\n {\r\n \"type\": \"vcs\",\r\n \"url\": \"https://github.com/CycloneDX/cyclonedx-python-lib\"\r\n },\r\n {\r\n \"type\": \"website\",\r\n \"url\": \"https://github.com/CycloneDX/cyclonedx-python-lib/#readme\"\r\n }\r\n ],\r\n \"group\": \"CycloneDX\",\r\n \"licenses\": [\r\n {\r\n \"license\": {\r\n \"acknowledgement\": \"declared\",\r\n \"id\": \"Apache-2.0\"\r\n }\r\n }\r\n ],\r\n \"name\": \"cyclonedx-python-lib\",\r\n \"type\": \"library\",\r\n \"version\": \"11.7.0\"\r\n }\r\n ]\r\n }\r\n },\r\n \"serialNumber\": \"urn:uuid:c98bbea9-4f0b-4b77-a969-ebbd1daa7785\",\r\n \"version\": 1,\r\n \"$schema\": \"http://cyclonedx.org/schema/bom-1.6.schema.json\",\r\n \"bomFormat\": \"CycloneDX\",\r\n \"specVersion\": \"1.6\"\r\n}"] +[12.976212, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] diff --git a/doc/asciicasts/update-patch.cast b/doc/asciicasts/update-patch.cast index 5e691c34..7d9f524c 100644 --- a/doc/asciicasts/update-patch.cast +++ b/doc/asciicasts/update-patch.cast @@ -1,248 +1,234 @@ -{"version": 2, "width": 173, "height": 25, "timestamp": 1774774112, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} -[2.74536, "o", "\u001b[H\u001b[2J\u001b[3J"] -[2.749101, "o", "$ "] -[3.752136, "o", "\u001b"] -[3.932434, "o", "[1"] -[4.022544, "o", "ml"] -[4.112677, "o", "s "] -[4.202814, "o", "-l"] -[4.292949, "o", " ."] -[4.38337, "o", "\u001b["] -[4.473485, "o", "0m"] -[5.47523, "o", "\r\n"] -[5.478706, "o", "total 16\r\n"] -[5.478827, "o", "drwxr-xr-x+ 3 dev dev 4096 Mar 29 08:48 cpputest\r\n-rw-rw-rw- 1 dev dev 229 Mar 29 08:48 dfetch.yaml\r\ndrwxr-xr-x+ 4 dev dev 4096 Mar 29 08:48 jsmn\r\ndrwxrwxrwx+ 2 dev dev 4096 Mar 29 08:48 patches\r\n"] -[5.484052, "o", "$ "] -[6.487204, "o", "\u001b"] -[6.667527, "o", "[1"] -[6.75767, "o", "mc"] -[6.847791, "o", "at"] -[6.937925, "o", " "] -[7.02805, "o", "df"] -[7.118193, "o", "et"] -[7.20839, "o", "ch"] -[7.298506, "o", ".y"] -[7.388639, "o", "a"] -[7.568998, "o", "ml"] -[7.659218, "o", "\u001b["] -[7.749356, "o", "0m"] -[8.750911, "o", "\r\n"] -[8.753993, "o", "manifest:\r\n version: 0.0\r\n\r\n remotes:\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/\r\n repo-path: cpputest/cpputest.git\r\n tag: v3.4\r\n patch: patches/cpputest.patch\r\n\r\n"] -[8.759005, "o", "$ "] -[9.762111, "o", "\u001b["] -[9.942368, "o", "1m"] -[10.032497, "o", "ca"] -[10.122637, "o", "t "] -[10.21277, "o", "pa"] -[10.30291, "o", "tc"] -[10.393053, "o", "he"] -[10.483183, "o", "s/"] -[10.573321, "o", "cp"] -[10.663529, "o", "pu"] -[10.843802, "o", "tes"] -[10.933923, "o", "t."] -[11.024058, "o", "pa"] -[11.115495, "o", "tc"] -[11.205, "o", "h\u001b"] -[11.29513, "o", "[0"] -[11.385913, "o", "m"] -[12.386996, "o", "\r\n"] -[12.390233, "o", "diff --git a/README.md b/README.md\r\nindex 2655a7b..fc6084e 100644\r\n--- a/README.md\r\n+++ b/README.md\r\n@@ -3,7 +3,7 @@ CppUTest\r\n \r\n CppUTest unit testing and mocking framework for C/C++\r\n \r\n-[More information on the project page](http://cpputest.github.com)\r\n+[More information on the project page](http://cpputest.gitlab.com)\r\n \r\n [![Build Status](https://travis-ci.org/cpputest/cpputest.png?branch=master)](https://travis-ci.org/cpputest/cpputest)\r\n \r\n"] -[12.395767, "o", "$ "] -[13.398864, "o", "\u001b"] -[13.579131, "o", "[1"] -[13.669301, "o", "mg"] -[13.759419, "o", "it"] -[13.849544, "o", " s"] -[13.939665, "o", "ta"] -[14.029795, "o", "tu"] -[14.119914, "o", "s\u001b"] -[14.210198, "o", "[0"] -[14.300317, "o", "m"] -[15.301848, "o", "\r\n"] -[15.307985, "o", "On branch main\r\nnothing to commit, working tree clean\r\n"] -[15.314129, "o", "$ "] -[16.317213, "o", "\u001b"] -[16.497525, "o", "[1"] -[16.587669, "o", "ms"] -[16.677787, "o", "ed"] -[16.767929, "o", " "] -[16.858047, "o", "-i"] -[16.948346, "o", " '"] -[17.038494, "o", "s/"] -[17.12906, "o", "gi"] -[17.219079, "o", "t"] -[17.399344, "o", "la"] -[17.489507, "o", "b/"] -[17.579655, "o", "gi"] -[17.669798, "o", "te"] -[17.759956, "o", "a"] -[17.850075, "o", "/g"] -[17.940218, "o", "' "] -[18.030355, "o", "cp"] -[18.120513, "o", "pu"] -[18.300774, "o", "t"] -[18.39093, "o", "es"] -[18.481045, "o", "t/"] -[18.571191, "o", "sr"] -[18.661309, "o", "c/"] -[18.751465, "o", "R"] -[18.84157, "o", "EA"] -[18.931714, "o", "DM"] -[19.021851, "o", "E."] -[19.202109, "o", "md"] -[19.292425, "o", "\u001b"] -[19.382525, "o", "[0"] -[19.472652, "o", "m"] -[20.47439, "o", "\r\n"] -[20.48538, "o", "$ "] -[21.48886, "o", "\u001b["] -[21.669093, "o", "1m"] -[21.75924, "o", "gi"] -[21.849375, "o", "t "] -[21.939565, "o", "ad"] -[22.029712, "o", "d "] -[22.119831, "o", ".\u001b"] -[22.209962, "o", "[0"] -[22.300096, "o", "m"] -[23.301316, "o", "\r\n"] -[23.313667, "o", "$ "] -[24.316778, "o", "\u001b"] -[24.497069, "o", "[1"] -[24.587329, "o", "mg"] -[24.677361, "o", "it"] -[24.7675, "o", " c"] -[24.857619, "o", "om"] -[24.947747, "o", "mi"] -[25.037886, "o", "t "] -[25.128009, "o", "-a"] -[25.218502, "o", " -"] -[25.39865, "o", "m"] -[25.488881, "o", " '"] -[25.578928, "o", "Fi"] -[25.669058, "o", "x "] -[25.759179, "o", "vc"] -[25.849304, "o", "s "] -[25.939439, "o", "ho"] -[26.029568, "o", "st"] -[26.119747, "o", "'\u001b"] -[26.300152, "o", "[0"] -[26.390322, "o", "m"] -[27.391957, "o", "\r\n"] -[27.402814, "o", "[main 5303947] Fix vcs host\r\n 1 file changed, 1 insertion(+), 1 deletion(-)\r\n"] -[27.408385, "o", "$ "] -[28.41153, "o", "\u001b["] -[28.591785, "o", "1m"] -[28.68198, "o", "df"] -[28.772088, "o", "et"] -[28.862225, "o", "ch"] -[28.952344, "o", " u"] -[29.0425, "o", "pd"] -[29.132628, "o", "at"] -[29.222779, "o", "e-"] -[29.312933, "o", "pa"] -[29.494341, "o", "tch"] -[29.583868, "o", " c"] -[29.673764, "o", "pp"] -[29.763912, "o", "ut"] -[29.854044, "o", "es"] -[29.944171, "o", "t\u001b"] -[30.034302, "o", "[0"] -[30.124426, "o", "m"] -[31.125766, "o", "\r\n"] -[31.58142, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[31.621668, "o", " \u001b[1;92mcpputest:\u001b[0m"] -[31.621792, "o", "\r\n"] -[31.622041, "o", "\u001b[?25l"] -[31.703067, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[31.783688, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[31.86431, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[31.944825, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[32.02541, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[32.10601, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[32.18658, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[32.267097, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[32.348143, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[32.428744, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[32.510265, "o", "\r\u001b[2K\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[32.591039, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[32.67167, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[32.752157, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[32.832744, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[32.913334, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[32.993891, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[33.074466, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[33.088007, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] -[33.088748, "o", " \u001b[1;34m> Fetched v3.4\u001b[0m\r\n"] -[33.175633, "o", " \u001b[1;34m> Updating patch \"patches/cpputest.patch\"\u001b[0m\r\n"] -[33.1938, "o", "\u001b[?25l"] -[33.274783, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[33.355409, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[33.435965, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[33.516809, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[33.597278, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[33.679039, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[33.759881, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[33.840452, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[33.921007, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[34.001585, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[34.082144, "o", "\r\u001b[2K\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[34.162721, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[34.243291, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[34.323828, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[34.40435, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[34.485066, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[34.569919, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[34.650365, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[34.733333, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[34.818328, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[34.873497, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[34.87395, "o", "\r\n"] -[34.874188, "o", "\u001b[?25h\r\u001b[1A\u001b[2K"] -[34.875171, "o", " \u001b[1;34m> Fetched v3.4\u001b[0m"] -[34.875417, "o", "\r\n"] -[34.877279, "o", " \u001b[1;34m> Applying patch \"patches/cpputest.patch\"\u001b[0m"] -[34.877311, "o", "\r\n"] -[34.883686, "o", " \u001b[34msuccessfully patched 1/1: \u001b[0m\u001b[34m \u001b[0m "] -[34.883903, "o", "\r\n"] -[34.884078, "o", "\u001b[34mb'README.md'\u001b[0m "] -[34.884235, "o", "\r\n"] -[35.106871, "o", "$ "] -[36.111006, "o", "\u001b["] -[36.290851, "o", "1m"] -[36.381051, "o", "ca"] -[36.4712, "o", "t "] -[36.561319, "o", "pat"] -[36.651448, "o", "ch"] -[36.741569, "o", "es"] -[36.831719, "o", "/c"] -[36.921822, "o", "pp"] -[37.012071, "o", "ute"] -[37.192368, "o", "st"] -[37.282493, "o", ".p"] -[37.372656, "o", "at"] -[37.462801, "o", "ch"] -[37.552943, "o", "\u001b[0"] -[37.643081, "o", "m"] -[38.644435, "o", "\r\n"] -[38.647496, "o", "diff --git a/README.md b/README.md\r\nindex 2655a7b..da133cb 100644\r\n--- a/README.md\r\n+++ b/README.md\r\n@@ -3,7 +3,7 @@ CppUTest\r\n \r\n CppUTest unit testing and mocking framework for C/C++\r\n \r\n-[More information on the project page](http://cpputest.github.com)\r\n+[More information on the project page](http://cpputest.gitea.com)\r\n \r\n [![Build Status](https://travis-ci.org/cpputest/cpputest.png?branch=master)](https://travis-ci.org/cpputest/cpputest)\r\n \r\n"] -[38.652952, "o", "$ "] -[39.656309, "o", "\u001b["] -[39.836544, "o", "1m"] -[39.926672, "o", "gi"] -[40.016808, "o", "t "] -[40.106929, "o", "sta"] -[40.197061, "o", "tu"] -[40.287209, "o", "s\u001b"] -[40.377358, "o", "[0"] -[40.467485, "o", "m"] -[41.469018, "o", "\r\n"] -[41.491465, "o", "On branch main\r\nChanges not staged for commit:\r\n (use \"git add ...\" to update what will be committed)\r\n (use \"git restore ...\" to discard changes in working directory)\r\n\t\u001b[31mmodified: cpputest/src/.dfetch_data.yaml\u001b[m\r\n\t\u001b[31mmodified: patches/cpputest.patch\u001b[m\r\n\r\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\r\n"] -[44.50053, "o", "$ "] -[44.502823, "o", "\u001b"] -[44.683147, "o", "[1"] -[44.773291, "o", "m\u001b"] -[44.863387, "o", "[0"] -[44.95353, "o", "m"] -[44.953922, "o", "\r\n"] -[44.957223, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] +{"version": 2, "width": 111, "height": 25, "timestamp": 1774774917, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[2.515807, "o", "\u001b[H\u001b[2J\u001b[3J"] +[2.519555, "o", "$ "] +[3.52265, "o", "\u001b"] +[3.703098, "o", "[1"] +[3.793236, "o", "ml"] +[3.883397, "o", "s "] +[3.973506, "o", "-l"] +[4.063636, "o", " ."] +[4.153775, "o", "\u001b["] +[4.243875, "o", "0m"] +[5.245459, "o", "\r\n"] +[5.248864, "o", "total 16\r\n"] +[5.248972, "o", "drwxr-xr-x+ 3 dev dev 4096 Mar 29 09:01 cpputest\r\n-rw-rw-rw- 1 dev dev 229 Mar 29 09:01 dfetch.yaml\r\ndrwxr-xr-x+ 4 dev dev 4096 Mar 29 09:01 jsmn\r\ndrwxrwxrwx+ 2 dev dev 4096 Mar 29 09:01 patches\r\n"] +[5.254118, "o", "$ "] +[6.257455, "o", "\u001b"] +[6.437741, "o", "[1"] +[6.527858, "o", "mc"] +[6.617994, "o", "at"] +[6.708151, "o", " "] +[6.79829, "o", "df"] +[6.888417, "o", "et"] +[6.978538, "o", "ch"] +[7.068682, "o", ".y"] +[7.158805, "o", "a"] +[7.339059, "o", "ml"] +[7.429192, "o", "\u001b["] +[7.519529, "o", "0m"] +[8.521074, "o", "\r\n"] +[8.524082, "o", "manifest:\r\n version: 0.0\r\n\r\n remotes:\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/\r\n repo-path: cpputest/cpputest.git\r\n tag: v3.4\r\n patch: patches/cpputest.patch\r\n\r\n"] +[8.52964, "o", "$ "] +[9.532797, "o", "\u001b"] +[9.713039, "o", "[1"] +[9.803212, "o", "mc"] +[9.893367, "o", "at"] +[9.98349, "o", " "] +[10.073629, "o", "pa"] +[10.163769, "o", "tc"] +[10.253885, "o", "he"] +[10.344022, "o", "s/"] +[10.434282, "o", "c"] +[10.614515, "o", "pp"] +[10.704836, "o", "ut"] +[10.794955, "o", "es"] +[10.88508, "o", "t."] +[10.975212, "o", "p"] +[11.06535, "o", "at"] +[11.155495, "o", "ch"] +[11.245598, "o", "\u001b["] +[11.335724, "o", "0m"] +[12.337404, "o", "\r\n"] +[12.341146, "o", "diff --git a/README.md b/README.md\r\nindex 2655a7b..fc6084e 100644\r\n--- a/README.md\r\n+++ b/README.md\r\n@@ -3,7 +3,7 @@ CppUTest\r\n \r\n CppUTest unit testing and mocking framework for C/C++\r\n \r\n-[More information on the project page](http://cpputest.github.com)\r\n+[More information on the project page](http://cpputest.gitlab.com)\r\n \r\n [![Build Status](https://travis-ci.org/cpputest/cpputest.png?branch=master)](https://travis-ci.org/cpputest/cpputest)\r\n \r\n"] +[12.346899, "o", "$ "] +[13.349948, "o", "\u001b["] +[13.530223, "o", "1m"] +[13.62035, "o", "gi"] +[13.710483, "o", "t "] +[13.800688, "o", "sta"] +[13.89081, "o", "tu"] +[13.980933, "o", "s\u001b"] +[14.071157, "o", "[0"] +[14.161445, "o", "m"] +[15.163365, "o", "\r\n"] +[15.169465, "o", "On branch main\r\nnothing to commit, working tree clean\r\n"] +[15.175456, "o", "$ "] +[16.178158, "o", "\u001b["] +[16.35844, "o", "1m"] +[16.44858, "o", "se"] +[16.5387, "o", "d "] +[16.628949, "o", "-i"] +[16.719068, "o", " '"] +[16.809223, "o", "s/"] +[16.899362, "o", "gi"] +[16.98948, "o", "tl"] +[17.079604, "o", "ab"] +[17.259949, "o", "/g"] +[17.350071, "o", "it"] +[17.440204, "o", "ea"] +[17.530355, "o", "/g"] +[17.620465, "o", "' "] +[17.710743, "o", "cp"] +[17.803253, "o", "pu"] +[17.893412, "o", "te"] +[17.983534, "o", "st"] +[18.163891, "o", "/s"] +[18.254024, "o", "rc"] +[18.344155, "o", "/R"] +[18.434274, "o", "EA"] +[18.524399, "o", "DM"] +[18.61452, "o", "E."] +[18.70477, "o", "md"] +[18.794886, "o", "\u001b["] +[18.885006, "o", "0m"] +[19.888219, "o", "\r\n"] +[19.901796, "o", "$ "] +[20.905307, "o", "\u001b"] +[21.085562, "o", "[1"] +[21.175803, "o", "mg"] +[21.26585, "o", "it"] +[21.355979, "o", " "] +[21.446108, "o", "ad"] +[21.536237, "o", "d "] +[21.626409, "o", ".\u001b"] +[21.716717, "o", "[0"] +[21.807078, "o", "m"] +[22.808659, "o", "\r\n"] +[22.820686, "o", "$ "] +[23.824074, "o", "\u001b"] +[24.004417, "o", "[1"] +[24.094409, "o", "mg"] +[24.184533, "o", "it"] +[24.274668, "o", " "] +[24.364872, "o", "co"] +[24.455001, "o", "mm"] +[24.545129, "o", "it"] +[24.635284, "o", " -"] +[24.725406, "o", "a"] +[24.905659, "o", " -"] +[24.995785, "o", "m "] +[25.08593, "o", "'F"] +[25.176057, "o", "ix"] +[25.26627, "o", " "] +[25.356347, "o", "vc"] +[25.446469, "o", "s "] +[25.536593, "o", "ho"] +[25.62673, "o", "st"] +[25.807062, "o", "'"] +[25.897214, "o", "\u001b["] +[25.987312, "o", "0m"] +[26.988857, "o", "\r\n"] +[26.999009, "o", "[main 76c5f8d] Fix vcs host\r\n 1 file changed, 1 insertion(+), 1 deletion(-)\r\n"] +[27.004326, "o", "$ "] +[28.00725, "o", "\u001b["] +[28.187527, "o", "1m"] +[28.277665, "o", "df"] +[28.367777, "o", "et"] +[28.457917, "o", "ch "] +[28.548052, "o", "up"] +[28.638234, "o", "da"] +[28.728382, "o", "te"] +[28.818557, "o", "-p"] +[28.908727, "o", "atc"] +[29.088961, "o", "h "] +[29.1791, "o", "cp"] +[29.269257, "o", "pu"] +[29.359351, "o", "te"] +[29.449483, "o", "st\u001b"] +[29.539618, "o", "[0"] +[29.629741, "o", "m"] +[30.631287, "o", "\r\n"] +[31.090644, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[31.130398, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] +[31.131206, "o", "\u001b[?25l"] +[31.211896, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[31.292532, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[31.373102, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[31.453685, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[31.534261, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[31.61483, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[31.695412, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[31.775975, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[31.856717, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[31.937274, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[32.019424, "o", "\r\u001b[2K\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[32.098722, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[32.180447, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[32.26406, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[32.344759, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[32.426109, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[32.506086, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[32.586674, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[32.602969, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[32.603109, "o", "\r\n"] +[32.603255, "o", "\u001b[?25h\r\u001b[1A\u001b[2K"] +[32.604372, "o", " \u001b[1;34m> Fetched v3.4\u001b[0m"] +[32.604484, "o", "\r\n"] +[32.69484, "o", " \u001b[1;34m> Updating patch \"patches/cpputest.patch\"\u001b[0m\r\n"] +[32.713609, "o", "\u001b[?25l"] +[32.793872, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[32.87444, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[32.954968, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[33.035558, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[33.116093, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[33.196854, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[33.277426, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[33.358951, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[33.440609, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[33.521359, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[33.552934, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] +[33.553743, "o", " \u001b[1;34m> Fetched v3.4\u001b[0m\r\n"] +[33.5545, "o", " \u001b[1;34m> Applying patch \"patches/cpputest.patch\"\u001b[0m\r\n"] +[33.557634, "o", " \u001b[34msuccessfully patched 1/1: \u001b[0m\u001b[34m \u001b[0m \r\n\u001b[34mb'README.md'\u001b[0m \r\n"] +[33.646855, "o", "$ "] +[34.650021, "o", "\u001b"] +[34.830395, "o", "[1"] +[34.920518, "o", "mc"] +[35.010668, "o", "at"] +[35.10079, "o", " p"] +[35.190926, "o", "at"] +[35.281047, "o", "ch"] +[35.371224, "o", "es"] +[35.461332, "o", "/c"] +[35.551454, "o", "pp"] +[35.731702, "o", "u"] +[35.822028, "o", "te"] +[35.91216, "o", "st"] +[36.002275, "o", ".p"] +[36.092428, "o", "at"] +[36.182564, "o", "ch"] +[36.27287, "o", "\u001b["] +[36.363055, "o", "0m"] +[37.366466, "o", "\r\n"] +[37.369565, "o", "diff --git a/README.md b/README.md\r\nindex 2655a7b..da133cb 100644\r\n--- a/README.md\r\n+++ b/README.md\r\n@@ -3,7 +3,7 @@ CppUTest\r\n \r\n CppUTest unit testing and mocking framework for C/C++\r\n \r\n-[More information on the project page](http://cpputest.github.com)\r\n+[More information on the project page](http://cpputest.gitea.com)\r\n \r\n [![Build Status](https://travis-ci.org/cpputest/cpputest.png?branch=master)](https://travis-ci.org/cpputest/cpputest)\r\n \r\n"] +[37.374416, "o", "$ "] +[38.378066, "o", "\u001b"] +[38.560684, "o", "[1"] +[38.650836, "o", "mg"] +[38.74096, "o", "it"] +[38.8311, "o", " s"] +[38.921239, "o", "ta"] +[39.011385, "o", "tu"] +[39.101524, "o", "s\u001b"] +[39.191648, "o", "[0"] +[39.281771, "o", "m"] +[40.283426, "o", "\r\n"] +[40.312246, "o", "On branch main\r\nChanges not staged for commit:\r\n (use \"git add ...\" to update what will be committed)\r\n (use \"git restore ...\" to discard changes in working directory)\r\n\t\u001b[31mmodified: cpputest/src/.dfetch_data.yaml\u001b[m\r\n\t\u001b[31mmodified: patches/cpputest.patch\u001b[m\r\n\r\nno changes added to commit (use \"git add\" and/or \"git commit -a\")\r\n"] +[43.319943, "o", "$ "] +[43.321855, "o", "\u001b"] +[43.502343, "o", "[1"] +[43.592566, "o", "m\u001b"] +[43.682698, "o", "[0"] +[43.77282, "o", "m"] +[43.773469, "o", "\r\n"] +[43.776329, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] diff --git a/doc/asciicasts/update.cast b/doc/asciicasts/update.cast index ac5b84ec..d8cdc247 100644 --- a/doc/asciicasts/update.cast +++ b/doc/asciicasts/update.cast @@ -1,112 +1,121 @@ -{"version": 2, "width": 173, "height": 25, "timestamp": 1774774028, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} -[0.553192, "o", "\u001b[H\u001b[2J\u001b[3J"] -[0.556734, "o", "$ "] -[1.559936, "o", "\u001b"] -[1.740303, "o", "[1"] -[1.830446, "o", "ml"] -[1.920602, "o", "s "] -[2.010716, "o", "-l"] -[2.100847, "o", "\u001b["] -[2.191436, "o", "0m"] -[3.192371, "o", "\r\n"] -[3.197142, "o", "total 4\r\n"] -[3.197469, "o", "-rw-rw-rw- 1 dev dev 733 Mar 29 08:47 dfetch.yaml\r\n"] -[3.204475, "o", "$ "] -[4.207428, "o", "\u001b["] -[4.388733, "o", "1m"] -[4.478693, "o", "ca"] -[4.568838, "o", "t "] -[4.65896, "o", "df"] -[4.74909, "o", "et"] -[4.839211, "o", "ch"] -[4.929448, "o", ".y"] -[5.01959, "o", "am"] -[5.109725, "o", "l\u001b"] -[5.289975, "o", "[0"] -[5.380296, "o", "m"] -[6.381879, "o", "\r\n"] -[6.384989, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v3.4 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n"] -[6.390044, "o", "$ "] -[7.393426, "o", "\u001b"] -[7.57371, "o", "[1"] -[7.66383, "o", "md"] -[7.753954, "o", "fe"] -[7.844101, "o", "tc"] -[7.934251, "o", "h "] -[8.024381, "o", "up"] -[8.114505, "o", "da"] -[8.204616, "o", "te"] -[8.294773, "o", "\u001b["] -[8.475024, "o", "0"] -[8.565151, "o", "m"] -[9.566758, "o", "\r\n"] -[10.019955, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[10.034467, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] -[10.034769, "o", "\u001b[?25l"] -[10.115707, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[10.196245, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[10.276803, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[10.357386, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[10.441249, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[10.519289, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[10.599811, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[10.680531, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[10.761169, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[10.842035, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[10.922481, "o", "\r\u001b[2K\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[11.003092, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[11.083601, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[11.164186, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[11.244761, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[11.325648, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[11.333264, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] -[11.33406, "o", "\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] -[11.334712, "o", " \u001b[1;34m> Fetched v3.4\u001b[0m"] -[11.334932, "o", "\r\n"] -[11.358511, "o", " \u001b[1;92mjsmn:\u001b[0m\r\n"] -[11.358954, "o", "\u001b[?25l"] -[11.439304, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[11.519861, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[11.60038, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[11.680859, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[11.761443, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[11.841914, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[11.92251, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[12.003054, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[12.084112, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[12.166099, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[12.245571, "o", "\r\u001b[2K\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[12.326146, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[12.406725, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[12.488122, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] -[12.510908, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching \u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] -[12.511774, "o", " \u001b[1;34m> Fetched master - 25647e692c7906b96ffd2b05ca54c097948e879c\u001b[0m\r\n"] -[12.600058, "o", "$ "] -[13.603268, "o", "\u001b["] -[13.783357, "o", "1m"] -[13.873471, "o", "ls"] -[13.963614, "o", " -"] -[14.053735, "o", "l\u001b"] -[14.143879, "o", "[0"] -[14.233998, "o", "m"] -[15.235545, "o", "\r\n"] -[15.238965, "o", "total 12\r\n"] -[15.239016, "o", "drwxrwxrwx+ 3 dev dev 4096 Mar 29 08:47 cpputest\r\n-rw-rw-rw- 1 dev dev 733 Mar 29 08:47 dfetch.yaml\r\ndrwxrwxrwx+ 4 dev dev 4096 Mar 29 08:47 jsmn\r\n"] -[15.244373, "o", "$ "] -[16.247629, "o", "\u001b["] -[16.429452, "o", "1m"] -[16.519625, "o", "df"] -[16.609741, "o", "et"] -[16.699887, "o", "ch "] -[16.790025, "o", "up"] -[16.880177, "o", "da"] -[16.97028, "o", "te"] -[17.060415, "o", "\u001b["] -[17.150546, "o", "0m"] -[18.153077, "o", "\r\n"] -[18.623887, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[18.647875, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] -[18.651458, "o", " \u001b[1;34m> up-to-date (v3.4)\u001b[0m\r\n"] -[19.349776, "o", " \u001b[1;92mjsmn:\u001b[0m\r\n"] -[19.350523, "o", " \u001b[1;34m> up-to-date (master - 25647e692c7906b96ffd2b05ca54c097948e879c)\u001b[0m\r\n"] -[22.410675, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] +{"version": 2, "width": 111, "height": 25, "timestamp": 1774774833, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[0.554078, "o", "\u001b[H\u001b[2J\u001b[3J"] +[0.557538, "o", "$ "] +[1.560759, "o", "\u001b["] +[1.741055, "o", "1m"] +[1.831172, "o", "ls"] +[1.921317, "o", " -"] +[2.011452, "o", "l\u001b"] +[2.101571, "o", "[0"] +[2.192299, "o", "m"] +[3.193504, "o", "\r\n"] +[3.197332, "o", "total 4\r\n-rw-rw-rw- 1 dev dev 733 Mar 29 09:00 dfetch.yaml"] +[3.197736, "o", "\r\n"] +[3.206448, "o", "$ "] +[4.209017, "o", "\u001b"] +[4.389333, "o", "[1"] +[4.479458, "o", "mc"] +[4.56958, "o", "at"] +[4.659715, "o", " d"] +[4.749857, "o", "fe"] +[4.839978, "o", "tc"] +[4.930106, "o", "h."] +[5.020233, "o", "ya"] +[5.11047, "o", "ml"] +[5.290733, "o", "\u001b"] +[5.380868, "o", "[0"] +[5.471423, "o", "m"] +[6.472484, "o", "\r\n"] +[6.475594, "o", "manifest:\r\n version: 0.0 # DFetch Module syntax version\r\n\r\n remotes: # declare common sources in one place\r\n - name: github\r\n url-base: https://github.com/\r\n\r\n projects:\r\n - name: cpputest\r\n dst: cpputest/src/ # Destination of this project (relative to this file)\r\n repo-path: cpputest/cpputest.git # Use default github remote\r\n tag: v3.4 # tag\r\n\r\n - name: jsmn # without destination, defaults to project name\r\n repo-path: zserge/jsmn.git # only repo-path is enough\r\n"] +[6.480214, "o", "$ "] +[7.483852, "o", "\u001b["] +[7.66394, "o", "1m"] +[7.754033, "o", "df"] +[7.844197, "o", "et"] +[7.934333, "o", "ch"] +[8.02447, "o", " u"] +[8.114667, "o", "pd"] +[8.2048, "o", "at"] +[8.294939, "o", "e\u001b"] +[8.385074, "o", "[0"] +[8.565454, "o", "m"] +[9.567222, "o", "\r\n"] +[10.002609, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[10.016352, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] +[10.016703, "o", "\u001b[?25l"] +[10.09763, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[10.178237, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[10.259016, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[10.339575, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[10.420167, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[10.500731, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[10.581306, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[10.661871, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[10.74375, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[10.824315, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[10.905423, "o", "\r\u001b[2K\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[10.987213, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[11.067567, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[11.148152, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[11.22871, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[11.310258, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[11.390396, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[11.470625, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m"] +[11.521172, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching v3.4\u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] +[11.521838, "o", " \u001b[1;34m> Fetched v3.4\u001b[0m\r\n"] +[11.564396, "o", " \u001b[1;92mjsmn:\u001b[0m\r\n"] +[11.564746, "o", "\u001b[?25l"] +[11.646333, "o", "\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[11.727044, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[11.807586, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[11.888169, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[11.969091, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[12.052086, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[12.13039, "o", "\r\u001b[2K\u001b[32m⠦\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[12.211428, "o", "\r\u001b[2K\u001b[32m⠧\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[12.292023, "o", "\r\u001b[2K\u001b[32m⠇\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[12.372575, "o", "\r\u001b[2K\u001b[32m⠏\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[12.453149, "o", "\r\u001b[2K\u001b[32m⠋\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[12.534467, "o", "\r\u001b[2K\u001b[32m⠙\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[12.615589, "o", "\r\u001b[2K\u001b[32m⠹\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[12.696111, "o", "\r\u001b[2K\u001b[32m⠸\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[12.776664, "o", "\r\u001b[2K\u001b[32m⠼\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[12.857247, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching \u001b[0m"] +[12.917367, "o", "\r\u001b[2K\u001b[32m⠴\u001b[0m \u001b[1;94m> Fetching \u001b[0m\r\n\u001b[?25h\r\u001b[1A\u001b[2K"] +[12.918174, "o", " \u001b[1;34m> Fetched master - 25647e692c7906b96ffd2b05ca54c097948e879c\u001b[0m\r\n"] +[12.98567, "o", "$ "] +[13.988661, "o", "\u001b"] +[14.168939, "o", "[1"] +[14.259164, "o", "ml"] +[14.34919, "o", "s "] +[14.439332, "o", "-"] +[14.529445, "o", "l\u001b"] +[14.619592, "o", "[0"] +[14.710135, "o", "m"] +[15.713043, "o", "\r\n"] +[15.721899, "o", "total 12"] +[15.722159, "o", "\r\n"] +[15.723309, "o", "drwxrwxrwx+ 3 dev dev 4096 Mar 29 09:00 cpputest"] +[15.72354, "o", "\r\n"] +[15.723707, "o", "-rw-rw-rw- 1 dev dev 733 Mar 29 09:00 dfetch.yaml\r\ndrwxrwxrwx+ 4 dev dev 4096 Mar 29 09:00 jsmn\r\n"] +[15.741241, "o", "$ "] +[16.746393, "o", "\u001b"] +[16.926855, "o", "[1"] +[17.016987, "o", "md"] +[17.107896, "o", "fe"] +[17.197224, "o", "tc"] +[17.287367, "o", "h "] +[17.377493, "o", "up"] +[17.467623, "o", "da"] +[17.557742, "o", "te"] +[17.647931, "o", "\u001b["] +[17.828288, "o", "0"] +[17.918431, "o", "m"] +[18.920037, "o", "\r\n"] +[19.350193, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[19.365799, "o", " \u001b[1;92mcpputest:\u001b[0m\r\n"] +[19.366342, "o", " \u001b[1;34m> up-to-date (v3.4)\u001b[0m\r\n"] +[20.018322, "o", " \u001b[1;92mjsmn:\u001b[0m\r\n"] +[20.018893, "o", " \u001b[1;34m> up-to-date (master - 25647e692c7906b96ffd2b05ca54c097948e879c)\u001b[0m"] +[20.019201, "o", "\r\n"] +[23.076645, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] diff --git a/doc/asciicasts/validate.cast b/doc/asciicasts/validate.cast index d4ffa560..bf775e6c 100644 --- a/doc/asciicasts/validate.cast +++ b/doc/asciicasts/validate.cast @@ -1,18 +1,20 @@ -{"version": 2, "width": 173, "height": 25, "timestamp": 1774773986, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} -[0.532595, "o", "\u001b[H\u001b[2J\u001b[3J"] -[0.536526, "o", "$ "] -[1.539781, "o", "\u001b["] -[1.720034, "o", "1m"] -[1.810206, "o", "df"] -[1.900318, "o", "et"] -[1.990479, "o", "ch "] -[2.080585, "o", "va"] -[2.170813, "o", "li"] -[2.260961, "o", "da"] -[2.351095, "o", "te"] -[2.441365, "o", "\u001b[0"] -[2.621666, "o", "m"] -[3.630467, "o", "\r\n"] -[4.3584, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] -[4.365156, "o", " \u001b[1;92mdfetch.yaml :\u001b[0m\u001b[1;34m valid\u001b[0m\r\n"] -[7.424524, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] +{"version": 2, "width": 111, "height": 25, "timestamp": 1774774790, "env": {"SHELL": "/bin/sh", "TERM": "xterm-256color"}} +[0.545765, "o", "\u001b[H\u001b[2J\u001b[3J"] +[0.549334, "o", "$ "] +[1.552512, "o", "\u001b"] +[1.732747, "o", "[1"] +[1.822869, "o", "md"] +[1.913007, "o", "fe"] +[2.00315, "o", "t"] +[2.093254, "o", "ch"] +[2.183396, "o", " v"] +[2.273557, "o", "al"] +[2.363664, "o", "id"] +[2.453805, "o", "a"] +[2.634142, "o", "te"] +[2.724301, "o", "\u001b["] +[2.814418, "o", "0m"] +[3.815961, "o", "\r\n"] +[4.298398, "o", "\u001b[1;34mDfetch (0.12.1)\u001b[0m\r\n"] +[4.305204, "o", " \u001b[1;92mdfetch.yaml :\u001b[0m\u001b[1;34m valid\u001b[0m\r\n"] +[7.386545, "o", "/workspaces/dfetch/doc/generate-casts\r\n"] diff --git a/doc/generate-casts/add-demo.sh b/doc/generate-casts/add-demo.sh index f00479cc..781648cc 100755 --- a/doc/generate-casts/add-demo.sh +++ b/doc/generate-casts/add-demo.sh @@ -1,7 +1,6 @@ #!/usr/bin/env bash -set -euo pipefail -source ./demo-magic/demo-magic.sh +source "$(dirname "${BASH_SOURCE[0]}")/demo-magic/demo-magic.sh" PROMPT_TIMEOUT=1 diff --git a/doc/generate-casts/interactive-add-demo.sh b/doc/generate-casts/interactive-add-demo.sh index 7088748b..5a30266a 100755 --- a/doc/generate-casts/interactive-add-demo.sh +++ b/doc/generate-casts/interactive-add-demo.sh @@ -1,5 +1,4 @@ #!/usr/bin/env bash -set -euo pipefail # Demo of dfetch add -i (interactive wizard mode). # # Uses the real cpputest repository so the viewer sees dfetch fetching live From ed51c5292ebff3dd17173ccbbb13a4e6bdda4771 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 29 Mar 2026 10:47:56 +0000 Subject: [PATCH 29/29] Improve feature tests --- dfetch/commands/add.py | 11 ++++++- features/add-project-through-cli.feature | 28 +++++++++-------- features/steps/add_steps.py | 38 +++++++----------------- features/steps/generic_steps.py | 5 +++- tests/test_add.py | 2 +- 5 files changed, 41 insertions(+), 43 deletions(-) diff --git a/dfetch/commands/add.py b/dfetch/commands/add.py index 7b83aee9..b58a478d 100644 --- a/dfetch/commands/add.py +++ b/dfetch/commands/add.py @@ -314,7 +314,16 @@ def _finalize_add( def _non_interactive_entry(ctx: _AddContext, overrides: _Overrides) -> ProjectEntry: """Build a ``ProjectEntry`` using inferred defaults (no user interaction).""" if overrides.version: - version = _resolve_raw_version(overrides.version, []) or Version( + branches = ctx.subproject.list_of_branches() + tags = ctx.subproject.list_of_tags() + choices: list[Version] = [ + *[ + Version(branch=b) + for b in prioritise_default(branches, ctx.default_branch) + ], + *[Version(tag=t) for t in sort_tags_newest_first(tags)], + ] + version = _resolve_raw_version(overrides.version, choices) or Version( branch=ctx.default_branch ) else: diff --git a/features/add-project-through-cli.feature b/features/add-project-through-cli.feature index 5023febb..8c462852 100644 --- a/features/add-project-through-cli.feature +++ b/features/add-project-through-cli.feature @@ -24,7 +24,7 @@ Feature: Add a project to the manifest via the CLI - name: ext/existing url: some-remote-server/existing.git """ - When I add "some-remote-server/MyLib.git" + When I run "dfetch add some-remote-server/MyLib.git" Then the manifest 'dfetch.yaml' contains entry """ - name: MyLib @@ -42,7 +42,7 @@ Feature: Add a project to the manifest via the CLI - name: MyLib url: some-remote-server/MyLib.git """ - When I add "some-remote-server/MyLib.git" + When I run "dfetch add some-remote-server/MyLib.git" Then the manifest 'dfetch.yaml' contains entry """ - name: MyLib-1 @@ -61,7 +61,7 @@ Feature: Add a project to the manifest via the CLI - name: ext/lib-b url: some-remote-server/lib-b.git """ - When I add "some-remote-server/MyLib.git" + When I run "dfetch add some-remote-server/MyLib.git" Then the manifest 'dfetch.yaml' contains entry """ - name: MyLib @@ -79,7 +79,7 @@ Feature: Add a project to the manifest via the CLI - name: ext/existing url: some-remote-server/existing.git """ - When I interactively add "some-remote-server/MyLib.git" with inputs + When I run "dfetch add -i some-remote-server/MyLib.git" with inputs | Question | Answer | | Project name | my-lib | | Destination path | libs/my | @@ -105,7 +105,7 @@ Feature: Add a project to the manifest via the CLI - name: existing url: some-remote-server/existing.git """ - When I interactively add "some-remote-server/MyLib.git" with inputs + When I run "dfetch add -i some-remote-server/MyLib.git" with inputs | Question | Answer | | Project name | my-lib | | Destination path | my-lib | @@ -130,7 +130,7 @@ Feature: Add a project to the manifest via the CLI - name: existing url: some-remote-server/existing.git """ - When I interactively add "some-remote-server/MyLib.git" with inputs + When I run "dfetch add -i some-remote-server/MyLib.git" with inputs | Question | Answer | | Project name | my-lib | | Destination path | my-lib | @@ -156,7 +156,7 @@ Feature: Add a project to the manifest via the CLI - name: existing url: some-remote-server/existing.git """ - When I interactively add "some-remote-server/MyLib.git" with inputs + When I run "dfetch add -i some-remote-server/MyLib.git" with inputs | Question | Answer | | Project name | my-lib | | Destination path | my-lib | @@ -184,7 +184,7 @@ Feature: Add a project to the manifest via the CLI - name: existing url: some-remote-server/existing.git """ - When I interactively add "some-remote-server/MyLib.git" with inputs + When I run "dfetch add -i some-remote-server/MyLib.git" with inputs | Question | Answer | | Project name | MyLib | | Destination path | MyLib | @@ -210,7 +210,7 @@ Feature: Add a project to the manifest via the CLI - name: existing url: some-remote-server/existing.git """ - When I interactively add "some-remote-server/MyLib.git" with inputs + When I run "dfetch add -i some-remote-server/MyLib.git" with inputs | Question | Answer | | Project name | MyLib | | Destination path | MyLib | @@ -236,7 +236,7 @@ Feature: Add a project to the manifest via the CLI - name: existing url: some-remote-server/existing.git """ - When I interactively add "some-remote-server/MyLib.git" with inputs + When I run "dfetch add -i some-remote-server/MyLib.git" with inputs | Question | Answer | | Project name | MyLib | | Destination path | MyLib | @@ -258,9 +258,11 @@ Feature: Add a project to the manifest via the CLI """ manifest: version: '0.0' - projects: [] + projects: + - name: ext/existing + url: some-remote-server/existing.git """ - When I add "some-remote-server/MyLib.git" with options "--name my-lib --dst libs/my-lib" + When I run "dfetch add some-remote-server/MyLib.git --name my-lib --dst libs/my-lib" Then the manifest 'dfetch.yaml' contains entry """ - name: my-lib @@ -278,7 +280,7 @@ Feature: Add a project to the manifest via the CLI - name: existing url: some-remote-server/existing.git """ - When I interactively add "some-remote-server/MyLib.git" with options "--name my-lib --dst libs/my" and inputs + When I run "dfetch add -i some-remote-server/MyLib.git --name my-lib --dst libs/my" with inputs | Question | Answer | | Version | master | | Source path | | diff --git a/features/steps/add_steps.py b/features/steps/add_steps.py index 47d78f11..ba17eca0 100644 --- a/features/steps/add_steps.py +++ b/features/steps/add_steps.py @@ -1,7 +1,7 @@ """Steps for the 'dfetch add' feature tests.""" -# pylint: disable=function-redefined, missing-function-docstring, import-error, not-callable -# pyright: reportRedeclaration=false, reportAttributeAccessIssue=false, reportCallIssue=false +# pylint: disable=missing-function-docstring, import-error, not-callable +# pyright: reportAttributeAccessIssue=false, reportCallIssue=false from collections import deque from unittest.mock import patch @@ -17,18 +17,6 @@ def _resolve_url(url: str, context) -> str: return url.replace("some-remote-server", f"file:///{remote_server_path(context)}") -@when('I add "{remote_url}"') -def step_impl(context, remote_url): - url = _resolve_url(remote_url, context) - call_command(context, ["add", url]) - - -@when('I add "{remote_url}" with options "{options}"') -def step_impl(context, remote_url, options): - url = _resolve_url(remote_url, context) - call_command(context, ["add"] + options.split() + [url]) - - def _run_interactive_add(context, cmd: list[str]) -> None: """Run an interactive add command, driving prompts from ``context.table``.""" # Parse the answer table into three buckets: @@ -70,20 +58,16 @@ def _auto_prompt(_prompt: str, **kwargs) -> str: # type: ignore[return] call_command(context, cmd) -@when('I interactively add "{remote_url}" with options "{options}" and inputs') -def step_impl(context, remote_url, options): - url = _resolve_url(remote_url, context) - _run_interactive_add(context, ["add", "--interactive"] + options.split() + [url]) - - -@when('I interactively add "{remote_url}" with inputs') -def step_impl(context, remote_url): - url = _resolve_url(remote_url, context) - _run_interactive_add(context, ["add", "--interactive", url]) +@when('I run "dfetch {add_args}" with inputs') +def step_interactive_add(context, add_args): + resolved = add_args.replace( + "some-remote-server", f"file:///{remote_server_path(context)}" + ) + _run_interactive_add(context, resolved.split()) @then("the manifest '{name}' contains entry") -def step_impl(context, name): +def step_manifest_contains_entry(context, name): expected = apply_manifest_substitutions(context, context.text) with open(name, "r", encoding="utf-8") as fh: actual = fh.read() @@ -105,7 +89,7 @@ def step_impl(context, name): @then('the command fails with "{message}"') -def step_impl(context, message): +def step_command_fails_with(context, message): assert context.cmd_returncode != 0, "Expected command to fail, but it succeeded" assert ( message in context.cmd_output @@ -113,7 +97,7 @@ def step_impl(context, message): @then("the manifest '{name}' does not contain '{text}'") -def step_impl(_, name, text): +def step_manifest_not_contain(_, name, text): with open(name, "r", encoding="utf-8") as fh: actual = fh.read() diff --git a/features/steps/generic_steps.py b/features/steps/generic_steps.py index dfdadda1..65902072 100644 --- a/features/steps/generic_steps.py +++ b/features/steps/generic_steps.py @@ -365,7 +365,10 @@ def step_impl(context, path=None): @when('I run "dfetch {args}"') def step_impl(context, args, path=None): """Call a command.""" - call_command(context, args.split(), path) + resolved = args.replace( + "some-remote-server", f"file:///{remote_server_path(context)}" + ) + call_command(context, resolved.split(), path) @given('"{path}" in {directory} is changed locally') diff --git a/tests/test_add.py b/tests/test_add.py index 9090e773..4b41b836 100644 --- a/tests/test_add.py +++ b/tests/test_add.py @@ -867,6 +867,7 @@ def test_add_create_menu_field_overrides(): parsed = parser.parse_args( [ "add", + "https://example.com/repo.git", "--name", "mylib", "--dst", @@ -878,7 +879,6 @@ def test_add_create_menu_field_overrides(): "--ignore", "docs", "tests", - "https://example.com/repo.git", ] ) assert parsed.name == "mylib"