diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d9579b15..535a7dcb 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,3 +1,8 @@ +Release 0.13.0 (unreleased) +==================================== + +* Introduce new ``add`` command (#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 52476ffa..2ab5bc7a 100644 --- a/dfetch/log.py +++ b/dfetch/log.py @@ -74,6 +74,13 @@ def print_warning_line(self, name: str, info: str) -> None: line = 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 0f30e983..7b3d4c58 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 @@ -385,3 +386,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 1e0bb95f..0eedbb57 100755 --- a/doc/generate-casts/generate-casts.sh +++ b/doc/generate-casts/generate-casts.sh @@ -7,6 +7,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 -------------- diff --git a/features/add-project-through-cli.feature b/features/add-project-through-cli.feature new file mode 100644 index 00000000..294bf09c --- /dev/null +++ b/features/add-project-through-cli.feature @@ -0,0 +1,64 @@ +Feature: Add project via CLI + + Dfetch can add projects through the command line without requiring + manual editing of the manifest file. When a project is added, it is + appended to the manifest and fetched to disk immediately. + + Scenario Outline: Add a project to the manifest + + Given the manifest 'dfetch.yaml' + """ + manifest: + version: '0.0' + + remotes: + - name: origin + url: https://github.com/example/ + + projects: + - name: ext/core-lib + url: https://github.com/example/core-lib + - name: ext/utils + url: https://github.com/example/utils + """ + When I run "dfetch add " + Then the manifest 'dfetch.yaml' contains a project + | name | url | branch | dst | remote | + | | | main | | origin | + And the following projects are fetched + | path | + | | + + Examples: + | remote_url | project | destination | force_flag | + | https://github.com/example/new-lib.git | new-lib | ext/new-lib | | + | https://github.com/example/tools.git | tools | ext/tools | --force | + | https://gitlab.com/other/standalone.git | standalone | | | + + Scenario: Abort adding a project when confirmation is declined + + Given the manifest 'dfetch.yaml' + """ + manifest: + version: '0.0' + projects: [] + """ + When I run "dfetch add https://github.com/example/demo.git" + And I answer "n" to the confirmation prompt + Then the manifest 'dfetch.yaml' is unchanged + And no projects are fetched + + Scenario: Adding a project with an existing name fails + + Given the manifest 'dfetch.yaml' + """ + manifest: + version: '0.0' + + projects: + - name: ext/core-lib + url: https://github.com/example/core-lib + """ + When I run "dfetch add https://github.com/example/core-lib.git" + Then the command fails with an error + And the manifest 'dfetch.yaml' is unchanged