Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
Release 0.13.0 (unreleased)
====================================

* Introduce new ``add`` command (#25)

Release 0.12.1 (released 2026-02-24)
====================================

Expand Down
2 changes: 2 additions & 0 deletions dfetch/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
154 changes: 154 additions & 0 deletions dfetch/commands/add.py
Original file line number Diff line number Diff line change
@@ -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="<remote_url>",
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
7 changes: 7 additions & 0 deletions dfetch/log.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]")
Expand Down
19 changes: 19 additions & 0 deletions dfetch/manifest/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
81 changes: 81 additions & 0 deletions doc/asciicasts/add.cast
Original file line number Diff line number Diff line change
@@ -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"]
24 changes: 24 additions & 0 deletions doc/generate-casts/add-demo.sh
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions doc/generate-casts/generate-casts.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions doc/manual.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------------
Expand Down
Loading
Loading