+
+
+
+
+Formularz rejestracji nowego pluginu.
+
+
+
+
+
+
+
+
+## Architektura
+
+### Struktura projektu
+
+Projekt `meva` zorganizowany jest jako _Rust Workspace_ i został podzielony jest na 6 modułów (ang. _crates_).
```
meva/
-├── Cargo.toml
-├── Cargo.lock
-├── engine/
-│ ├── Cargo.toml
-│ └── src/
-│ └── ...
-├── cli/
-│ ├── Cargo.toml
-│ └── src/
-│ └── ...
├── gui/
-│ ├── Cargo.toml
-│ └── src/
-│ └── ...
+│ └── ...
+├── cli/
+│ └── ...
+├── server/
+│ └── ...
+├── engine/
+│ └── ...
├── plugins/
-│ ├── Cargo.toml
-│ └── src/
-│ └── ...
+│ └── ...
├── shared/
-│ ├── Cargo.toml
-│ └── src/
-│ └── ...
-├── tests/
│ └── ...
-└── target/
- └── ...
+├── Cargo.toml
+├── LICENSE
+└── README.md
```
-It consists of 5 crates:
+| Moduł | Opis |
+| :-------- | :--------------------------------------------------------------------------------------------------------------------------------------------- |
+| `cli` | Interfejs wiersza poleceń, który pozwala na skryptowe zarządzanie repozytorium. |
+| `gui` | Interfejs graficzny dla użytkownika końcowego w formie aplikacji desktopowej, wspierający podzbiór funkcjonalności interfejsu wiersza poleceń. |
+| `server` | Moduł sieciowy. Obsługuje protokoły synchronizacji z repozytorium zdalnym, a także uwierzytelnianie i autoryzację użytkowników. |
+| `engine` | Główny moduł implementujący logikę poszczególnych operacji na repozytorium. |
+| `plugins` | System rozszerzeń w postaci skryptów użytkownika, uruchamianych jako procesy potomne. |
+| `shared` | Biblioteka pomocnicza, przechowująca logikę wspólną dla wszystkich modułów. |
-- Library crates:
- - `shared`,
- - `engine`,
- - `plugins`.
-- Binary crates:
- - `cli`,
- - `gui`.
+Projekt generuje trzy niezależne pliki wykonywalne: `meva`, `meva-gui` oraz `meva-server` odpowiednio dla modułów `cli`, `gui` oraz `server`.
-## Getting Started
+### Model rozproszony i architektura repozytoriów
-> The installation guide assumes you have already installed [Rust](https://www.rust-lang.org/learn/get-started).
+System kontroli wersji MEVA należy do klasy systemów rozproszonych (DVCS ang. _Distributed Version Control System_). W przeciwieństwie do systemów scentralizowanych (ang. _centralized_), takich jak SVN, gdzie pełna historia zmian znajduje się wyłącznie na głównym serwerze, w modelu przyjętym przez system MEVA każdy użytkownik posiada własną, kompletną kopię zdalnego repozytorium.
-### Building the project
+Diagram ilustruje współpracę dwóch niezależnych użytkowników za pośrednictwem węzła centralnego:
-To build all crates in the workspace:
+
+
+
-```bash
-cargo build
+Węzeł oznaczony jako `Repozytorium zdalne` pełni funkcję źródła danych (ang. _single source of truth_). Przechowuje ono wspólną historię projektu, do której dostęp mają wyłącznie uprawnieni użytkownicy. `Użytkownik 2`, za pośrednictwem aplikacji desktopowej (`Klient GUI`), rozpoczyna pracę poprzez wykonania operacji `clone`. Jej wynikiem jest lokalna kopii zdalnego repozytorium, oznaczona na schemacie jako `Repozytorium lokalne 2`.
+
+Zarówno `Użytkownik 1`, korzystający z interfejsu konsolowego (`Klient CLI`), jak i `Użytkownik 2` przesyłają swoje zatwierdzone zmiany za pomocą polecenia `push`. Operacja ta aktualizuje stan zdalnego repozytorium.
+
+W celu zsynchronizowania stanu swojego lokalnego repozytorium z postępami innych członków zespołu, użytkownicy wykorzystują polecenia `fetch` (pobranie brakujących obiektów bez integracji zmian) oraz `pull` (pobranie zmian połączone ze ich scalaniem).
+
+### Warstwa sieciowa i protokoły synchronizacji
+
+System MEVA nie implementuje własnego, niskopoziomowego protokołu transportowego opartego bezpośrednio na gniazdach TCP. Zamiast tego, przyjęto architekturę opartą na tunelowaniu ruchu przez protokół SSH (ang. _Secure Shell_). Całość wymiany danych, w tym negocjacja historii oraz transfer obiektów, odbywa się wewnątrz bezpiecznego kanału.
+
+#### Protokół pobierania danych
+
+Protokół pobierania danych (`meva-upload-pack`) stanowi fundament operacji `fetch` oraz `clone`. Jego zadaniem jest synchronizacja stanu repozytorium lokalnego z repozytorium zdalnym poprzez pobranie jedynie brakujących obiektów historii. Protokół ten jest uruchamiany bezpośrednio po zakończeniu wstępnej fazy rozgłaszania referencji i składa się z dwóch etapów: negocjacji historii oraz transferu danych (generowania i wysyłania paczki).
+
+Diagram sekwencji dla protokołu `meva-upload-pack`:
+
+
+
+
+
+#### Protokół wysyłania danych
+
+Protokół wysyłania danych (`meva-receive-pack`) stanowi fundament operacji `push`. Jego zadaniem jest synchronizacja stanu repozytorium zdalnego z repozytorium lokalnym klienta, a także zarządzanie stanem zdalnych referencji. Protokół ten jest uruchamiany bezpośrednio po zakończeniu wstępnej fazy rozgłaszania referencji i składa się z 3 etapów: przesyłania poleceń aktualizacji referencji, transferu danych (generowania i wysyłania paczki) oraz raportowania aktualizacji referencji.
+
+Diagram sekwencji dla protokołu `meva-receive-pack`:
+
+
+
+
+
+## Skrypty użytkownika
+
+System kontroli wersji MEVA został zaprojektowany jako rozwiązanie otwarte na rozszerzenia. Oprócz wbudowanego zestawu poleceń, udostępnia on mechanizm skryptów użytkownika (nazywanych również pluginami), który pozwala na automatyzację zadań, walidację danych oraz integrację zewnętrznych narzędzi bez konieczności ingerencji w kod źródłowy samej aplikacji.
+
+### Założenia systemu skryptów użytkownika
+
+Projektując moduł skryptów użytkownika, przyjęto szereg założeń definiujących jego zachowanie, zakres oraz sposób interakcji z otoczeniem:
+
+- **Model zdarzeń**: System obsługuje dwa rodzaje zdarzeń:
+ - `pre-execute`: Występujące przed wykonaniem właściwej operacji (np. `commit`, `push`). Służy do walidacji oraz modyfikacji danych wejściowych. Błąd zwrócony przez plugin w tej fazie skutkuje natychmiastowym przerwaniem całej operacji.
+ - `post-execute`: Występujące po pomyślnym wykonaniu operacji. Służy do raportowania i analizowania wyników polecenia. Błąd w tej fazie nie wpływa na wynik operacji wykonanej już na repozytorium.
+
+- **Zakresy**: Pluginy mogą być rejestrowane na dwóch poziomach widoczności:
+ - **Lokalny**: Ogranicza działanie skryptu do pojedynczego repozytorium. Konfiguracja i skrypty przechowywane są wewnątrz katalogu `.meva/plugins/`.
+ - **Globalny**: Rozszerza widoczność na wszystkie repozytoria danego użytkownika w systemie. Konfiguracja i skrypty przechowywane są w katalogu domowym użytkownika (`~/.meva/plugins/`).
+
+- **Niezależna komunikacja**: Interfejs wymiany danych między systemem a pluginem jest zrealizowany poprzez system plików. W momencie wywołania, skrypt otrzymuje ścieżkę do tymczasowego pliku JSON (tzw. pliku kontekstu), co uniezależnia mechanizm od języka programowania, w którym napisano rozszerzenie.
+
+- **Synchroniczne wykonanie**: Pluginy są uruchamiane synchronicznie, jeden po drugim. Kolejność ich wykonywania wynika z priorytetu (`order`) nadanego podczas rejestracji.
+
+- **Kontrola czasu wykonania**: Istnieje możliwość zdefiniowania maksymalnego czasu wykonania osobno dla każdego zarejestrowanego skryptu. Jeżeli wykonanie logiki zapisanej w pluginie potrwa dłużej niż określony limit, system automatycznie przerywa wykonanie, informując o przekroczeniu czasu.
+
+- **Przechwytywanie wejścia/wyjścia**: System w pełni zarządza standardowymi strumieniami (`stdin`, `stdout`, `stderr`) każdego skryptu. Strumienie wyjściowe są przechwytywane w czasie rzeczywistym, dzięki czemu komunikaty wypisywane na standardowe wyjście (`stdout`) i standardowe wyjście błędów (`stderr`) są jednocześnie prezentowane użytkownikowi w konsoli oraz zapisywane w logach wywołania. Skrypty mogą pobierać dane od użytkownika poprzez standardowe wejście (`stdin`).
+
+- **Konfiguracja**: Działanie całego systemu rozszerzeń jest kontrolowane przez wpisy w głównych plikach konfiguracyjnych (sekcja `[plugins]`), co pozwala na ich szybkie włączenie lub wyłączenie bez konieczności odinstalowywania skryptów czy rekompilacji kodu.
+
+### Koncepcja niezależności językowej
+
+Istotną cechą systemu rozszerzeń jest brak wymogu tworzenia pluginów w konkretnym języku programowania. Zamiast dostarczać wewnętrzne API lub biblioteki dynamiczne (`.dll`, ang. _dynamic link libraries_), system **MEVA** wykorzystuje mechanizm procesów potomnych oraz komunikację opartą na plikach. Procesy potomne komunikują się poprzez ustandaryzowany plik kontekstu operacji w formacie [JSON](https://json.org/json-en.html).
+
+Plugin został zdefiniowany jako dowolny plik wykonywalny lub skrypt interpretowany (na przykład w języku `Python`, `JavaScript`, `PowerShell` czy `Bash`).
+
+Proces wymiany danych wygląda następująco:
+
+1. System MEVA generuje plik tymczasowy w formacie JSON, zawierający metadane polecenia (kontekst wykonywanej operacji).
+2. System uruchamia skrypt użytkownika jako osobny proces, przekazując ścieżkę do pliku kontekstu jako pierwszy argument wywołania.
+3. Skrypt odczytuje dane, wykonuje swoją logikę i (opcjonalnie) modyfikuje plik kontekstu. Może również zapisać w nim informację o błędzie oraz zwrócić niezerowy kod wyjścia, przerywając wykonanie kolejnych skryptów lub samego polecenia.
+
+Dzięki takiemu podejściu użytkownik ma pełną swobodę w doborze technologii do tworzenia rozszerzeń, o ile w danym środowisku systemowym dostępny jest odpowiedni interpreter.
+
+### Fizyczna organizacja i konfiguracja skyptów użytkownika
+
+System pluginów posiada własną, ustrukturyzowaną hierarchię plików, która umożliwia logiczną organizację skryptów i ich metadanych. Zdefiniowano dwa poziomy zasięgu pluginów: globalny (dla użytkownika systemu operacyjnego) oraz lokalny (dla konkretnego repozytorium).
+
+```
+.meva/plugins/
+├── .invocations/
+│ ├── commit/
+│ │ ├── 20260112-202222/
+│ │ │ ├── invocation.log
+│ │ │ ├── pre-execute-context.json
+│ │ │ ├── post-execute-context.json
+│ │ │ ├── stdout.log
+│ │ │ └── stderr.log
+│ │ └── ...
+│ └── ...
+├── commit/
+│ ├── plugins.json
+│ ├── validate_message.py
+│ └── ...
+├── config/
+│ ├── set/
+│ │ ├── plugins.json
+│ │ └── ...
+│ └── unset/
+│ ├── plugins.json
+│ └── ...
+└── ...
+```
+
+### Struktura obiektów kontekstu
+
+Komunikacja między systemem MEVA a skryptami użytkownika odbywa się poprzez wymianę sformalizowanych obiektów danych w formacie JSON.
+
+Każdy plik kontekstu operacji, do którego ścieżka jest przekazywana jako pierwszy argument pozycyjny podczas uruchamiania skryptu, zawiera cztery główne sekcje: metadane (`context`), dane wejściowe (`pre-payload`), dane wyjściowe (`post-payload`) oraz kanał błędów (`error`).
+
+```json
+{
+ "context": {
+ "command": "commit",
+ "event": "pre-execute",
+ "timestamp": "2026-01-12T12:34:56Z",
+ "working_dir": "/home/john/company_project"
+ },
+ "pre-payload": {
+ "message": "Implement user authentication.",
+ "author": {
+ "name": "John Doe",
+ "email": "john.doe@example.com"
+ },
+ "amend": false
+ },
+ "post-payload": {
+ "commit_hash": "a1b2c3d4e5f6...",
+ "changes": [
+ {
+ "added": {
+ "new_path": "src/auth.rs",
+ "insertions": 45
+ }
+ }
+ ]
+ },
+ "error": null
+}
```
-To build a specific crate only:
+Rola poszczególnych sekcji obiektu JSON jest następująca:
-```bash
-cargo build -p
+1. `context`: Sekcja zawierająca podstawowe informacje o kontekście wywołania skryptu użytkownika. Znajdują się tu informacje o typie polecenia (`command`), rodzaju zdarzenia (`event`), czasie wywołania (`timestamp`) oraz katalogu roboczym (`working_dir`).
+2. `pre-payload`: Sekcja zawierająca dane wejściowe specyficzne dla danego polecenia. Są one dostępne zarówno w fazie `pre-execute`, jak i `post-execute`.
+3. `post-payload`: Sekcja przeznaczona na wynik działania operacji. W fazie `pre-execute` wartość ta zawsze wynosi `null`. W fazie `post-execute` (jak na rysunku przedstawiającym kontekst polecenia `commit`) zawiera ona informacje dotyczące rezultatu polecenia.
+4. `error`: Opcjonalna sekcja, która w przypadku poprawnego wykonania skryptu użytkownika przyjmuje wartość `null`. Plugin może ją zmodyfikować w celu zgłoszenia błędu wykonania.
+
+W przypadku polecenia `commit` informacje zawarte w `pre-payload` obejmują między innymi treść wiadomości, dane autora oraz flagi sterujące (np. `amend`). Sekcja `post-payload` zawiera natomiast między innymi skrót zatwierdzenia, a także listę plików objętych zatwierdzeniem wraz z metadanymi (np. liczbę zmodyfikowanych linii).
+
+#### Struktura obiektów błędu
+
+Skrypt użytkownika ma możliwość przerwania łańcucha wywołań następujących po nim pluginów, a w fazie `pre-execute` — dodatkowo zablokowania wykonania głównego polecenia (np. uniemożliwienia utworzenia zatwierdzenia, którego format wiadomości nie spełnia określonych wymogów).
+
+Sygnałem do przerwania operacji jest zakończenie procesu pluginu z kodem wyjścia (ang. _exit code_) różnym od zera. W takiej sytuacji system **MEVA** analizuje pole `error` w pliku kontekstu, aby wyświetlić użytkownikowi przyczynę blokady.
+
+```json
+{
+ "code": "VALIDATION_ERROR",
+ "message": "Commit message must reference an Azure DevOps work item (e.g. AB#1234)",
+ "details": "Regex pattern '^AB#\\d+' not matched"
+}
```
-Replace `` with one of: `engine`, `cli`, `gui`, `plugins`, or `shared`.
+Obiekt ten składa się z trzech pól:
-### Running the project
+1. `code`: Stały identyfikator typu błędu (np. `VALIDATION_ERROR`).
+2. `message`: Komunikat przeznaczony bezpośrednio dla użytkownika końcowego, wyjaśniający, dlaczego operacja została przerwana.
+3. `details`: Opcjonalne pole zawierające szczegóły techniczne, pomocne w diagnostyce.
-To run the CLI binary:
+### Przykład implementacji
-```bash
-cargo run -p cli
+W celu zaprezentowania praktycznego zastosowania omówionych mechanizmów, przedstawiona zostanie przykładowa implementacja pluginu walidacyjnego. Scenariusz ten zakłada integrację systemu MEVA z platformą Azure DevOps. Jedną z cech tej platformy jest identyfikowanie elementów roboczych (ang. _work items_) poprzez unikalny identyfikator (liczbę).
+
+Wymaganiem biznesowym może być oznaczanie każdego zatwierdzenia identyfikatorem odpowiadającego mu elementu roboczego poprzez umieszczenie tego identyfikatora na początku wiadomości zatwierdzenia.
+
+Poniżej przedstawiono kompletny kod skryptu napisanego w języku Python. Realizuje on logikę walidacji w fazie `pre-execute`, wykorzystując mechanizm pliku kontekstu do komunikacji z systemem MEVA.
+
+```python
+#!/usr/bin/env python3
+import sys, json, re
+
+filename = sys.argv[1]
+with open(filename, "r", encoding="utf-8") as f:
+ data = json.load(f)
+
+msg = data.get("pre-payload", {}).get("message", "")
+
+pattern = r"^AB#[0-9]+"
+
+if not re.match(pattern, msg):
+ data["error"] = {
+ "code": "VALIDATION_ERROR",
+ "message": "Commit message must reference an Azure DevOps work item (e.g. AB#1234)",
+ "details": f"Regex pattern '{pattern}' not matched"
+ }
+ with open(filename, "w", encoding="utf-8") as f:
+ json.dump(data, f, indent=2)
+ sys.exit(1)
+
+data["error"] = None
+with open(filename, "w", encoding="utf-8") as f:
+ json.dump(data, f, indent=2)
+
+sys.exit(0)
```
-To run the GUI binary:
+Wszystkie pliki związane z rozszerzeniami przechowywane są w katalogu `plugins/`. Dla zasięgu lokalnego katalog ten znajduje się wewnątrz folderu `.meva/`, natomiast dla zasięgu globalnego jego lokalizacja to katalog domowy użytkownika (ścieżka ``~/.meva/plugins/`).
-```bash
-cargo run -p gui
+Pluginy są grupowane w katalogach odpowiadających nazwom poleceń, na które mają one reagować (np. `commit/`, `config/set/`). Taka struktura umożliwa szybkie zlokalizowanie wszystkich skryptów zarejestrowanych na wywołanie konkretnego polecenia.
+
+## Instalacja
+
+Zarówno narzędzie wiersza poleceń (CLI), jak i interfejs graficzny (GUI) są dystrybuowane jako
+samodzielne pliki wykonywalne, co eliminuje konieczność instalowania skomplikowanych zależności
+w systemie użytkownika końcowego.
+
+### Wymagania wstępne i środowisko budowania
+
+System MEVA został napisany w języku [Rust](https://www.rust-lang.org/learn/get-started), dlatego do skompilowania kodu źródłowego wymagana jest obecność dedykowanych dla tego języka narzędzi (ang. _Rust toolchain_). Zalecana wersja kompilatora to 1.88.0 lub nowsza.
+
+Aby zweryfikować poprawność instalacji środowiska, należy wywołać w wierszu poleceń polecenia sprawdzające wersje kompilatora [rustc](https://cargo-book.irust.net/en-us/getting-started/installation.html) oraz menedżera pakietów [cargo](https://cargo-book.irust.net/en-us/getting-started/installation.html):
+
+```sh
+rustc --version
+# rustc 1.88.0 (6b00bc388 2025-06-23)
+
+cargo --version
+# cargo 1.88.0 (873a06493 2025-05-10)
```
-### Running tests
+### Instalacja klienta CLI
-To run all tests in the workspace:
+Klient wiersza poleceń jest podstawowym narzędziem do interakcji z systemem MEVA. Jego instalacja polega na skompilowaniu kodu źródłowego oraz skonfigurowaniu ścieżek systemowych.
-```bash
-cargo test
+Kompilację należy wykonać z poziomu katalogu głównego projektu, wykorzystując menedżer pa
+kietów cargo:
+
+```sh
+cargo build --release --bin meva
```
-To run tests for a specific crate:
+Wygenerowany plik wykonywalny zostanie domyślnie umieszczony w katalogu `target/release/`.
+
+Uruchomienie systemu MEVA za pomocą wiersza poleceń z dowolnego miejsca systemu operacyjnego wymaga dodania ścieżki z plikiem wykonywalnym do zmiennej środowiskowej `PATH`.
+
+#### Konfiguracja globalna użytkownika
+
+Po zainstalowaniu oprogramowania, należy zdefiniować tożsamość użytkownika. Konfiguracja tożsamości polega na określeniu wartości dla kluczy `user.name` oraz `user.email` w globalnym pliku konfiguracyjnym w katalogu domowym użytkownika.
+
+W celu stworzenia pliku konfiguracyjnego oraz ustawienia wartości `user.name` oraz `user.email` należy wykonać następujące polecenia:
-```bash
-cargo test -p
+```
+meva config create
+meva config set --global user.name "John Doe"
+meva config set --global user.email "john.doe@example.com"
```
-### Adding dependencies
+### Instalacja klienta GUI
-You can manage dependencies in your workspace efficiently with Cargo's CLI.
+Aplikacja kliencka z graficznym interfejsem użytkownika MEVA GUI stanowi alternatywę dla narzędzi wiersza poleceń, oferując wizualną reprezentację historii zmian i statusu repozytorium. W celu skompilowanie interfejsu graficznego, należy użyć polecenia:
-To add a regular dependency to a chosen crate:
+```sh
+cargo build --release --bin meva-gui
+```
-```bash
-cargo add -p
+Podobnie jak w przypadku klienta konsolowego, wynikowy plik wykonywalny (`meva-gui` lub `meva-gui.exe`) zostanie domyślnie umieszczony w katalogu `target/release/`.
+
+Uruchomienie aplikacji desktopowej MEVA GUI za pomocą wiersza poleceń z dowolnego miejsca systemu operacyjnego wymaga dodania ścieżki z plikiem wykonywalnym do zmiennej środowi
+skowej `PATH`.
+
+### Instalacja i konfiguracja serwera SSH
+
+Proces budowania wersji produkcyjnej serwera przebiega analogicznie do procedur opisanych dla narzędzi klienckich. W przypadku serwera SSH cel binarny nazywa się `meva-server`. Właściwe polecenie kompilacji przyjmuje postać:
+
+```sh
+cargo build --release --bin meva-server
```
-To add a development-only dependency:
+#### Generowanie kluczy kryptograficznych
-```bash
-cargo add --dev -p
+Fundamentem bezpieczeństwa serwera MEVA jest asymetryczna kryptografia oparta na protokole SSH. Aby serwer mógł bezpiecznie zestawiać połączenia i potwierdzać swoją tożsamość przed klientami, konieczne jest wygenerowanie pary kluczy hosta.
+
+Polecenie generujące parę kluczy wygląda następująco:
+
+```sh
+ssh-keygen -t ed25519 -f ./server_host_key -C "meva-server-host"
```
-To remove a dependency from a crate:
+#### Struktura katalogów serwera
-```bash
-cargo remove -p
+```
+opt/meva-server/
+├── bin/
+│ └── meva-server
+├── config/
+│ ├── mevaserverconfig.toml
+│ ├── access.toml
+│ └── authorized_keys
+├── secrets/
+│ ├── server_host_key
+│ └── server_host_key.pub
+├── logs/
+│ └── ...
+└── repositories/
+ └── ...
```
-Examples:
+| Katalog | Przeznaczenie |
+| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `bin/` | Katalog, w którym przechowywany jest plik wykonywalny zdalnego serwera SSH. |
+| `config/` | Katalog, w którym przechowywane są wszystkie pliki sterujące zachowaniem serwera. |
+| `secrets/` | Katalog dla danych krytycznych z punktu widzenia bezpieczeństwa, w szczególności dla klucza prywatnego serwera. |
+| `logs/` | Katalog na pliki z logami serwera, umożliwiające monitorowanie jego pracy oraz diagnostykę błędów. |
+| `repositories/` | Katalog, w którym serwer przechowuje pliki i historię wszystkich obsługiwanych repozytoriów. Każde repozytorium otrzymuje własny podkatalog, wewnątrz którego znajduje się właściwa baza danych systemu MEVA. |
-```bash
-cargo add regex -p engine # Regular dependency for 'engine'
-cargo add insta --dev -p plugins # Dev dependency for 'plugins'
-cargo remove regex -p engine # Removes regular regex dependency
+W systemie MS Windows odpowiednikiem katalogu `opt/` jest folder `Program Files`.
+
+#### Plik z konfiguracją podstawową
+
+```toml
+[server]
+port = 2223
+address = "0.0.0.0"
+host_key = "./secrets/server_host_key"
+repositories_root = "./repositories"
+
+[access]
+authorized_keys = "./config/authorized_keys"
+policy_file = "./config/access.toml"
+
+[logging]
+level = "info"
+detailed = true
+log_file = "./logs/server.log"
+rotate_size = 10485760 # Rotacja przy 10MiB
+keep_logs = 7 # Przechowywanie 7 ostatnich plików
+```
+
+**Sekcja `[server]`:**
+
+Sekcja ta definiuje podstawowe parametry pracy usługi sieciowej oraz lo
+kalizację danych.
+
+- `port`: Numer portu TCP, na którym serwer nasłuchuje połączeń przychodzących (domyślnie 2223, aby uniknąć konfliktu z systemowym SSH na porcie 22).
+- `address`: Adres IP interfejsu sieciowego. Wartość `0.0.0.0` oznacza nasłuchiwanie na wszystkich dostępnych interfejsach sieciowych.
+- `host_key`: Względna lub bezwzględna ścieżka do pliku zawierającego klucz prywatny serwera.
+- `repositories_root`: Ścieżka do katalogu głównego, w którym przechowywane będą wszystkie repozytoria systemu MEVA.
+
+**Sekcja [access]:**
+
+Wskazuje lokalizację plików odpowiedzialnych za uwierzytelnianie i autory
+zację.
+
+- `authorized_keys`: Ścieżka do pliku zawierającego listę kluczy publicznych użytkowników uprawnionych do nawiązania połączenia.
+- `policy_file`: Ścieżka do pliku access.toml, definiującego szczegółowe uprawnienia dla poszczególnych użytkowników i repozytoriów.
+
+**Sekcja [logging]:**
+
+Konfiguruje sposób zbierania logów w trakcie działania serwera.
+
+- `level`: Określa minimalny poziom ważności komunikatów zapisywanych w logach. Możliwe wartości (od najmniej do najbardziej szczegółowych) to:
+ - `error`: Tylko błędy krytyczne uniemożliwiające dalsze działanie.
+ - `warn`: Ostrzeżenia o potencjalnych problemach.
+ - `info`: Standardowe informacje o przebiegu działania operacji.
+ - `debug`: Szczegółowe informacje diagnostyczne przydatne w trakcie diagnozowania błędów.
+- `detailed`: Wartość logiczna (`true`/`false`). Określa sposób formatowania wpisów. Jeśli ustawiona na `true`, logi zawierają dodatkowe metadane, takie jak sygnatura czasowa, nazwa pliku źródłowego i numer linii kodu, w której wystąpiło zdarzenie.
+- `log_file`: Ścieżka do pliku wyjściowego, w którym zapisywane są logi.
+- `rotate_size`: Maksymalny rozmiar pliku logów w bajtach, po przekroczeniu którego następuje jego rotacja (zamknięcie i zmiana nazwy). Wartość 10485760 odpowiada 10 MiB (mebibajtów).
+- `keep_logs`: Liczba plików archiwalnych przechowywanych po rotacji. Starsze pliki są automatycznie usuwane, co pozwala na kontrolowanie zużycia przestrzeni dyskowej.
+
+#### Plik z definicją kontroli dostępu
+
+Wsystemie MEVA zaimplementowano model uprwanień oparty na rolach, definiowanych w pliku `access.toml`. Model ten umożliwia przypisywanie uprawnień odczytu (`read`) oraz zapisu (`write`) zdefiniowanym użytkownikom do poszczególnych repozytoriów. Wpisy w pliku `access.toml` mają postać:
+
+```toml
+[company_project.john-doe]
+read = true
+write = true
+```
+
+#### Plik z kluczami publicznymi klientów
+
+Plik ten przechowuje listę kluczy publicznych (w formacie SSH Ed25519) użytkowników uprawnionych do łączenia się z serwerem.
+Przykładowy wpis w pliku `authorized_keys` wygląda następująco:
+
+```txt
+ssh-ed25519 john-doe
+```
+
+gdzie `john-doe` to nazwa użytkownika, która posłuży do jego identyfikacji w systemie uprawnień.
+
+#### Struktura katalogu repozytoriów
+
+Katalog zdefiniowany w pliku konfiguracyjnym `mevaserverconfig.toml` jako `repositories_root` jest miejscem, w którym serwer przechowuje pliki i historię wszystkich obsługiwanych repozytoriów. Każde repozytoium otrzymuje własny podkatalog, wewnątrz którego znajduje się właściwa baza danych systemu MEVA. Gdy użytkownik końcowy wykonuje operację push, przy pomocy interfejsu CLI lub GUI, serwer autoryzuje żądanie, a następnie zapisuje nowe obiekty w odpowiednim podkatalogu.
+
+#### Uruchomienie serwera SSH
+
+Aplikacja `meva-server` przyjmuje ścieżkę do głównego pliku konfiguracyjnego (plik `mevaserverconfig.toml`) jako argument pozycyjny. Dodatkowo, poziom szczegółowości komunikatów diagnostycznych wyświetlanych na standardowym wyjściu (`stdout`) sterowany jest poprzez zmienną środowiskową `RUST_LOG`.
+
+Polecenie uruchomienia serwera w systemie operacyjnym MS Windows:
+
+```
+$env:RUST_LOG = "info"; .\bin\meva-server.exe ‘
+"C:\meva\server\config\mevaserverconfig.toml"
+```
+
+Polecenie uruchomienia serwera w systemie operacyjnym Linux:
+
+```
+RUST_LOG=info ./bin/meva-server \
+"/opt/meva-server/config/mevaserverconfig.toml"
+```
+
+### Weryfikacja instalacji
+
+Aby uruchomić zestaw testów jednostkowych zdefiniowanych w kodzie źródłowym, należy w głównym katalogu projektu wywołać polecenie:
+
+```
+cargo test
+```
+
+Polecenie to automatycznie skompiluje kod w trybie testowym (który może zawierać dodatkowe asercje niewidoczne w wersji produkcyjnej), a następnie uruchomi wszystkie funkcje oznaczone atrybutem `#[test]`.
+
+**Pomyślny przebieg testów:**
+
+```
+...
+test repositories::meva_repository::tests::init_creates_expected_structure ... ok
+test repositories::meva_repository::tests::init_fails_if_repo_already_exists ... ok
+test result: ok. 47 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out;
+finished in 0.04s
```
-To share a dependency (or dev-dependency) version across multiple crates, declare it in your workspace root `Cargo.toml` using `[workspace.dependencies]`:
+Jeśli środowisko nie spełnia wymagań, testy zakończą się niepowodzeniem, oznaczonym komunikatem `FAILED`:
+
+```
+...
+test repositories::meva_repository::tests::init_creates_expected_structure ... ok
+test repositories::meva_repository::tests::init_fails_if_repo_already_exists ... FAILED
+test result: FAILED. 46 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out;
+finished in 0.01s
+```
+
+## Instrukcja użytkownika
+
+### Praca z interfejsem CLI
+
+#### Wyświetlanie pomocy
+
+Interfejs linii poleceń został wyposażony w mechanizm pomocy o ustandaryzowanym formacie komunikatów oraz walidację wprowadzanych argumentów.
+System pomocy dostępny jest na dwóch poziomach:
+
+- Pomoc globalna: Wywołanie `meva –-help` (lub w wersji skróconej `-h`) wyświetla listę wszystkich dostępnych poleceń podrzędnych wraz z ich krótkim opisem oraz dostępne flagi globalne.
+- Pomoc kontekstowa: Każde polecenie (np. `commit`, `clone`) posiada własny ekran pomocy, dostępny poprzez wywołanie: `meva –-help`. Prezentuje on składnię danej operacji, wymagane i opcjonalne argumenty pozycyjne oraz flagi.
+
+#### Edycja plików konfiguracyjnych
+
+System MEVA opiera swoje działanie na plikach konfiguracyjnych w formacie [TOML](https://toml.io/pl/v1.0.0). Umożliwiają one dostosowanie zachowania narzędzia, od podstawowych danych użytkownika po ustawienia sieciowe i parametry skryptów użytkownika.
+Zarządzanie konfiguracją odbywa się za pomocą polecenia zbiorczego `meva config`.
+
+**Domyślna zawartość globalnego pliku konfiguracyjnego:**
```toml
-[workspace.dependencies]
- =
+# Meva Configuration File
+# Edit this file to customize your global settings
+[user]
+name = "Your Name"
+email = "your.email@example.com"
+signing_key = "path/to/client/signing/key"
+
+[editor]
+default = "vim"
+
+[plugins]
+enabled = false
+collect_logs = false
+```
+
+### Praca z interfejsem GUI
+
+Oprócz interfejsu wiersza poleceń, system MEVA oferuje dedykowaną aplikację desktopową, która ułatwia zarządzanie repozytoriami użytkownikom preferującym środowiska graficzne. GUI stanowi nakładkę na silnik CLI, zapewniając dostęp do ograniczonego zbioru funkcjonalności systemu.
+
+### Praca z serwerem SSH
+
+Bieżące utrzymanie serwera MEVA sprowadza się do zarządzania plikami konfiguracyjnymi znajdującymi się w katalogu `config/`. Poniżej opisano kluczowe procedury administracyjne na przykładzie wdrożenia projektu o nazwie `company_project` dla użytkownika `john-doe`.
+
+#### Tworzenie repozytorium
+
+Fizyczne utworzenie struktury danych repozytorium odbywa się poprzez polecenie `meva init` wywołane w stworzonym dla projektu katalogu umieszczonym wewnątrz katalogu `repositories/`. Wprzypadku projektu o nazwie `company_project` polecenie powinno zostać wywołane z poziomu `repositories/company_project/`.
+
+#### Dodawanie użytkowników
+
+Proces rejestracji nowego użytkownika w systemie składa się z dwóch etapów: wygenerowania pary kluczy po stronie klienta oraz zarejestrowania klucza publicznego na serwerze.
+
+**Generowanie klucza po stronie klienta:**
+
+```sh
+ssh-keygen -t ed25519 -f ./id_ed25519 -C "john-doe"
```
-In any crate that should use a workspace-provided dependency, reference it like so in its own `Cargo.toml`:
+Wwyniku tej operacji użytkownik uzyskuje plik klucza publicznego `id_ed25519.pub`, którego zawartość musi bezpiecznym kanałem przekazać administratorowi serwera.
+
+Administrator, po otrzymaniu klucza publicznego, musi dodać go do pliku `authorized_keys`, znajdującego się w katalogu konfiguracyjnym serwera
+(np. `/opt/meva-server/config/authorized_keys`). Każdy klucz powinien znajdować się w osobnej linii i kończyć się identyfikatorem użytkownika, który będzie później wykorzystywany w polityce dostępu.
+
+Ostatnim etapem jest zdefiniowanie uprawnień w pliku access.toml. W celu umożliwienia użytkownikowi `john-doe` pracy z repozytorium `company_project`, należy dodać nową sekcję o schematycznej nazwie `[nazwa_projektu.nazwa_użytkownika]`.
+
+**Konfiguracja uprawnień w pliku `access.toml`:**
```toml
-[dependencies]
- = { workspace = true }
+[company_project.john-doe]
+read = true
+write = true
+```
+
+
-### Generating docs
+## Generowanie dokumentacji projektu
-To generate documentation for all crates and open it:
+Aby wygenerować dokumentację publicznego API poszczególnych modułów projektu i otworzyć ją w domyślnej przeglądarce należy użyć polecenia:
```bash
cargo doc --open
```
-To include documentation for private items:
+Aby wygenerować kompletną dokumentację, uwzględniającą również elementy prywatne (przydatne dla deweloperów rozwijających projekt) i otworzyć ją w przeglądarce należy użyć polecenia:
```bash
cargo doc --document-private-items --open
diff --git a/cli/Cargo.toml b/cli/Cargo.toml
index 0917a37a..1890361b 100644
--- a/cli/Cargo.toml
+++ b/cli/Cargo.toml
@@ -4,10 +4,26 @@ version = "0.1.0"
edition = "2024"
authors.workspace = true
+[[bin]]
+name = "meva"
+path = "src/main.rs"
+
[dependencies]
shared = { path = "../shared" }
engine = { path = "../engine" }
plugins = { path = "../plugins" }
+clap.workspace = true
+dateparser.workspace = true
+thiserror.workspace = true
+miette = { version = "7.6.0", features = ["fancy"] }
+globset.workspace = true
+strum.workspace = true
+strum_macros.workspace = true
+owo-colors.workspace = true
+regex.workspace = true
+async-trait.workspace = true
+tokio.workspace = true
+url.workspace = true
[dev-dependencies]
rstest.workspace = true
diff --git a/cli/src/commands.rs b/cli/src/commands.rs
new file mode 100644
index 00000000..d67d5bb8
--- /dev/null
+++ b/cli/src/commands.rs
@@ -0,0 +1,98 @@
+pub mod add;
+pub mod branch;
+pub mod checkout;
+pub mod clone;
+pub mod commit;
+pub mod config;
+pub mod diff;
+pub mod fetch;
+pub mod ignore;
+pub mod init;
+pub mod log;
+pub mod ls_files;
+pub mod ls_tree;
+pub mod merge;
+pub mod meva_command;
+pub mod plugins;
+pub mod pull;
+pub mod push;
+pub mod remote;
+pub mod restore;
+pub mod show;
+pub mod status;
+
+pub use add::AddCommand;
+pub use branch::BranchCommand;
+pub use checkout::CheckoutCommand;
+pub use clone::CloneCommand;
+pub use commit::CommitCommand;
+pub use config::ConfigCommand;
+pub use diff::DiffCommand;
+pub use fetch::FetchCommand;
+pub use ignore::IgnoreCommand;
+pub use init::InitCommand;
+pub use log::LogCommand;
+pub use ls_files::LsFilesCommand;
+pub use ls_tree::LsTreeCommand;
+pub use merge::MergeCommand;
+pub use plugins::PluginsCommand;
+pub use pull::PullCommand;
+pub use push::PushCommand;
+pub use remote::RemoteCommand;
+pub use restore::RestoreCommand;
+pub use show::ShowCommand;
+pub use status::StatusCommand;
+
+use clap::ArgMatches;
+use miette::{Context, Result};
+
+use engine::engine_container::MevaContainer;
+pub use meva_command::MevaCommand;
+
+/// Executes the appropriate subcommand based on the provided matches.
+pub async fn execute_multiple(
+ matches: &ArgMatches,
+ container: &MevaContainer,
+ subcommands: Vec>>,
+) -> Result<()> {
+ if let Some((name, sub_matches)) = matches.subcommand()
+ && let Some(sub) = subcommands.into_iter().find(|c| c.name() == name)
+ {
+ return sub
+ .execute(sub_matches, container)
+ .await
+ .wrap_err(format!("Error running `{name}` subcommand"));
+ }
+
+ Ok(())
+}
+
+/// Collection type for Meva commands.
+pub type CommandsCollection = Vec>>;
+
+/// Collects all available Meva commands into a single collection.
+pub fn collect_commands() -> CommandsCollection {
+ vec![
+ Box::new(InitCommand),
+ Box::new(ConfigCommand),
+ Box::new(IgnoreCommand),
+ Box::new(PluginsCommand),
+ Box::new(AddCommand),
+ Box::new(LsFilesCommand),
+ Box::new(ShowCommand),
+ Box::new(StatusCommand),
+ Box::new(CommitCommand),
+ Box::new(DiffCommand),
+ Box::new(LsTreeCommand),
+ Box::new(LogCommand),
+ Box::new(RestoreCommand),
+ Box::new(CloneCommand),
+ Box::new(RemoteCommand),
+ Box::new(FetchCommand),
+ Box::new(BranchCommand),
+ Box::new(PushCommand),
+ Box::new(PullCommand),
+ Box::new(CheckoutCommand),
+ Box::new(MergeCommand),
+ ]
+}
diff --git a/cli/src/commands/add.rs b/cli/src/commands/add.rs
new file mode 100644
index 00000000..45f0cb32
--- /dev/null
+++ b/cli/src/commands/add.rs
@@ -0,0 +1,143 @@
+use std::path::PathBuf;
+
+use async_trait::async_trait;
+use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command, value_parser};
+use miette::{IntoDiagnostic, Result};
+
+use crate::commands::MevaCommand;
+use crate::extensions::WithVerbose;
+use engine::EngineContainer;
+use engine::engine_container::MevaContainer;
+use engine::handlers::add::AddRequest;
+
+/// CLI command for adding files to the Meva staging area.
+///
+/// The `AddCommand` allows
+/// users to select files or directories to add to the repository's staging area.
+///
+/// # Supported arguments
+/// - **`path`**: Optional path to file or directory. Use `.` to add the current directory.
+/// - **`--all, -a`**: Add all files in the repository.
+/// - **`--force, -f`**: Add files even if they are normally ignored.
+/// - **`--update, -u`**: Add modified and deleted files only. Conflicts with `--all`.
+/// - **`--dry-run, -n`**: Show which files *would* be added, without actually staging them.
+/// - **`--verbose, -v`**: Increase verbosity of output.
+#[derive(Default)]
+pub struct AddCommand;
+
+impl AddCommand {
+ /// Path argument key.
+ const ARG_PATH: &'static str = "path";
+
+ /// `--all` flag key.
+ const ARG_ALL: &'static str = "all";
+
+ /// `--force` flag key.
+ const ARG_FORCE: &'static str = "force";
+
+ /// `--update` flag key.
+ const ARG_UPDATE: &'static str = "update";
+
+ /// `--dry-run` flag key.
+ const ARG_DRY_RUN: &'static str = "dry-run";
+}
+
+#[async_trait]
+impl MevaCommand for AddCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "add"
+ }
+
+ fn about(&self) -> &'static str {
+ "Add files to the staging area"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ /// Builds the underlying Clap [`Command`] definition for this command.
+ ///
+ /// This defines supported arguments, their flags, and argument groups.
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .arg(
+ Arg::new(Self::ARG_PATH)
+ .value_name("PATH")
+ .help("Path to file or directory to add, use '.' for current catalog")
+ .value_parser(value_parser!(PathBuf))
+ .index(1),
+ )
+ .arg(
+ Arg::new(Self::ARG_ALL)
+ .short('a')
+ .long(Self::ARG_ALL)
+ .help("Adds all files to staging area")
+ .action(ArgAction::SetTrue),
+ )
+ .arg(
+ Arg::new(Self::ARG_FORCE)
+ .short('f')
+ .long(Self::ARG_FORCE)
+ .help("Add file to staging area despite it's ignored")
+ .action(ArgAction::SetTrue),
+ )
+ .arg(
+ Arg::new(Self::ARG_UPDATE)
+ .short('u')
+ .help("Add modified and deleted files only")
+ .action(ArgAction::SetTrue)
+ .conflicts_with(Self::ARG_ALL),
+ )
+ .arg(
+ Arg::new(Self::ARG_DRY_RUN)
+ .short('n')
+ .long(Self::ARG_DRY_RUN)
+ .help("Show files to be added without adding them")
+ .action(ArgAction::SetTrue),
+ )
+ .with_verbose_arg("Enable verbose output")
+ .group(
+ ArgGroup::new("path_group")
+ .args([Self::ARG_PATH, Self::ARG_ALL, Self::ARG_UPDATE])
+ .required(true)
+ .multiple(true),
+ )
+ }
+
+ /// Executes the `add` command.
+ ///
+ /// - Reads all flags and arguments from `matches`.
+ /// - Constructs an [`AddRequest`] with the provided options.
+ /// - Delegates to the repository's [`add_handler`] to perform the operation.
+ ///
+ /// Returns a [`miette::Result`] with diagnostic information if an error occurs.
+ async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> {
+ let all_flag = matches.get_flag(Self::ARG_ALL);
+ let force_flag = matches.get_flag(Self::ARG_FORCE);
+ let update_flag = matches.get_flag(Self::ARG_UPDATE);
+ let dry_run_flag = matches.get_flag(Self::ARG_DRY_RUN);
+ let verbose_flag = matches.get_flag(Command::ARG_VERBOSE);
+ let path_arg = matches.get_one::(Self::ARG_PATH);
+
+ let handler = container.add_handler().into_diagnostic()?;
+ let interceptor = container.plugins_interceptor().into_diagnostic()?;
+
+ let request = AddRequest {
+ all_flag,
+ force_flag,
+ update_flag,
+ dry_run_flag,
+ verbose_flag,
+ path_arg: path_arg.cloned(),
+ };
+
+ handler
+ .handle_add(request, &interceptor)
+ .into_diagnostic()?;
+
+ Ok(())
+ }
+}
diff --git a/cli/src/commands/branch.rs b/cli/src/commands/branch.rs
new file mode 100644
index 00000000..7d6fed49
--- /dev/null
+++ b/cli/src/commands/branch.rs
@@ -0,0 +1,237 @@
+use crate::commands::MevaCommand;
+use async_trait::async_trait;
+use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command};
+use engine::engine_container::{EngineContainer, MevaContainer};
+use engine::handlers::branch::{
+ BranchOperations, CreateRequest, DeleteRequest, ListRequest, RenameRequest, Request,
+ SetUpstream,
+};
+use engine::revision_parsing::Revision;
+use miette::{IntoDiagnostic, Result};
+
+/// Implements the `branch` command for Meva DVCS.
+///
+/// Allows listing, creating, deleting, and renaming branches within the repository.
+/// It handles argument parsing to dispatch the appropriate request (Create, Delete,
+/// Rename, or List) to the [`BranchHandler`].
+#[derive(Default)]
+pub struct BranchCommand;
+
+impl BranchCommand {
+ /// First positional argument: branch name (creation/deletion) or old branch name (renaming).
+ const ARG_NAME: &'static str = "branch-name";
+
+ /// Second positional argument: start point (creation) or new branch name (renaming).
+ const ARG_TARGET: &'static str = "target";
+
+ /// Flag to delete a branch.
+ const ARG_DELETE: &'static str = "delete";
+
+ /// Flag to force delete a branch (even if unmerged).
+ const ARG_FORCE_DELETE: &'static str = "force-delete";
+
+ /// Flag to rename a branch.
+ const ARG_MOVE: &'static str = "move";
+
+ /// Flag to show verbose output (sha1 and commit subject).
+ const ARG_VERBOSE: &'static str = "verbose";
+
+ /// Flag to explicitly list branches.
+ const ARG_LIST: &'static str = "list";
+
+ /// Flag to list both local and remote-tracking branches.
+ const ARG_ALL: &'static str = "all";
+
+ /// Flag to list only remote-tracking branches.
+ const ARG_REMOTES: &'static str = "remotes";
+
+ /// Flag to configure local branch upstream.
+ const ARG_SET_UPSTREAM: &'static str = "set-upstream-to";
+}
+
+#[async_trait]
+impl MevaCommand for BranchCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "branch"
+ }
+
+ fn about(&self) -> &'static str {
+ "List, create, or delete branches"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ /// Builds the CLI argument parser for the `branch` command using `clap`.
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .arg(
+ Arg::new(Self::ARG_NAME)
+ .value_name("NAME")
+ .help("The name of the branch to create, delete, or rename")
+ .index(1)
+ .required(false),
+ )
+ .arg(
+ Arg::new(Self::ARG_TARGET)
+ .value_name("START_POINT/NEW_NAME")
+ .help("The new branch head (creation) or new name (rename)")
+ .index(2)
+ .required(false),
+ )
+ .arg(
+ Arg::new(Self::ARG_DELETE)
+ .short('d')
+ .long(Self::ARG_DELETE)
+ .help("Delete a branch")
+ .action(ArgAction::SetTrue)
+ .conflicts_with(Self::ARG_MOVE)
+ .conflicts_with(Self::ARG_TARGET)
+ .requires(Self::ARG_NAME),
+ )
+ .arg(
+ Arg::new(Self::ARG_FORCE_DELETE)
+ .short('D')
+ .help("Force delete a branch")
+ .action(ArgAction::SetTrue)
+ .conflicts_with(Self::ARG_MOVE)
+ .conflicts_with(Self::ARG_TARGET)
+ .requires(Self::ARG_NAME),
+ )
+ .arg(
+ Arg::new(Self::ARG_MOVE)
+ .short('m')
+ .long(Self::ARG_MOVE)
+ .help("Move/rename a branch")
+ .action(ArgAction::SetTrue)
+ .requires(Self::ARG_NAME)
+ .requires(Self::ARG_TARGET),
+ )
+ .arg(
+ Arg::new(Self::ARG_LIST)
+ .short('l')
+ .long(Self::ARG_LIST)
+ .help("List branches")
+ .action(ArgAction::SetTrue)
+ .conflicts_with_all([Self::ARG_DELETE, Self::ARG_FORCE_DELETE, Self::ARG_MOVE]),
+ )
+ .arg(
+ Arg::new(Self::ARG_ALL)
+ .short('a')
+ .long(Self::ARG_ALL)
+ .help("List both remote-tracking and local branches")
+ .action(ArgAction::SetTrue),
+ )
+ .arg(
+ Arg::new(Self::ARG_REMOTES)
+ .short('r')
+ .long(Self::ARG_REMOTES)
+ .help("List the remote-tracking branches")
+ .action(ArgAction::SetTrue),
+ )
+ .arg(
+ Arg::new(Self::ARG_VERBOSE)
+ .short('v')
+ .long(Self::ARG_VERBOSE)
+ .help("Show sha1 and commit subject line")
+ .action(ArgAction::SetTrue),
+ )
+ .arg(
+ Arg::new(Self::ARG_SET_UPSTREAM)
+ .short('u')
+ .long(Self::ARG_SET_UPSTREAM)
+ .value_name("UPSTREAM")
+ .num_args(1)
+ .conflicts_with_all([
+ Self::ARG_DELETE,
+ Self::ARG_FORCE_DELETE,
+ Self::ARG_MOVE,
+ Self::ARG_LIST,
+ ]),
+ )
+ .group(
+ ArgGroup::new("delete_mode")
+ .args([Self::ARG_DELETE, Self::ARG_FORCE_DELETE])
+ .multiple(false),
+ )
+ }
+
+ /// Executes the `branch` command.
+ ///
+ /// Maps the CLI arguments to a specific `Request` variant (`Delete`, `Rename`, `Create`, or `List`)
+ /// and delegates execution to the `BranchHandler`.
+ ///
+ /// # Returns
+ /// * `Result<()>`: Indicates success or returns a detailed error if the operation fails.
+ async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> {
+ let branch_name = matches.get_one::(Self::ARG_NAME);
+ let second_arg = matches.get_one::(Self::ARG_TARGET);
+ let delete = matches.get_flag(Self::ARG_DELETE);
+ let force_delete = matches.get_flag(Self::ARG_FORCE_DELETE);
+ let move_arg = matches.get_flag(Self::ARG_MOVE);
+ let verbose = matches.get_flag(Self::ARG_VERBOSE);
+ let all = matches.get_flag(Self::ARG_ALL);
+ let remotes = matches.get_flag(Self::ARG_REMOTES);
+ let upstream = matches.get_one::(Self::ARG_SET_UPSTREAM);
+
+ let mut print_list = false;
+
+ let request = if delete || force_delete {
+ let request = DeleteRequest {
+ branch_name: branch_name.cloned().unwrap(),
+ force: force_delete,
+ };
+
+ Request::Delete(request)
+ } else if move_arg {
+ let request = RenameRequest {
+ old_name: branch_name.cloned().unwrap(),
+ new_name: second_arg.cloned().unwrap(),
+ };
+
+ Request::Rename(request)
+ } else if let Some(upstream) = upstream {
+ let request = SetUpstream {
+ local_branch: branch_name.cloned(),
+ remote_branch: upstream.clone(),
+ };
+
+ Request::SetUpstream(request)
+ } else if let Some(branch_name) = branch_name {
+ let start_point = match second_arg {
+ None => Revision::head(Vec::new()),
+ Some(start_point) => start_point.parse::().into_diagnostic()?,
+ };
+
+ let request = CreateRequest {
+ branch_name: branch_name.clone(),
+ start_point,
+ };
+
+ Request::Create(request)
+ } else {
+ let request = ListRequest {
+ verbose,
+ local: !remotes,
+ remotes: remotes || all,
+ };
+
+ print_list = true;
+
+ Request::List(request)
+ };
+
+ let handler = container.branch_handler().into_diagnostic()?;
+
+ let response = handler.branch(request).into_diagnostic()?;
+
+ if print_list && response.branches.is_some() {
+ println!("{}", response.branches.unwrap());
+ }
+
+ Ok(())
+ }
+}
diff --git a/cli/src/commands/checkout.rs b/cli/src/commands/checkout.rs
new file mode 100644
index 00000000..e4aec18a
--- /dev/null
+++ b/cli/src/commands/checkout.rs
@@ -0,0 +1,154 @@
+use crate::commands::MevaCommand;
+use async_trait::async_trait;
+use clap::{Arg, ArgAction, ArgMatches, Command, value_parser};
+use engine::{
+ EngineContainer,
+ engine_container::MevaContainer,
+ handlers::{
+ branch::{self, BranchOperations, CreateRequest},
+ checkout::{self, CheckoutOperations},
+ },
+ revision_parsing::Revision,
+};
+use miette::IntoDiagnostic;
+
+/// Implements the `checkout` command for Meva DVCS.
+///
+/// Updates files in the working tree to match the version in the index or the specified tree.
+/// This command is used to switch between branches, restore files, or move the `HEAD`
+/// pointer to a specific commit.
+///
+/// ### States of HEAD
+/// - **Attached HEAD**: Switching to a local branch (e.g., `master`) updates `HEAD` to point
+/// symbolically to that branch.
+/// - **Detached HEAD**: Checking out a commit hash, tag, or using the `--detach` flag
+/// points `HEAD` directly to a commit, leaving no active branch.
+#[derive(Default)]
+pub struct CheckoutCommand;
+
+impl CheckoutCommand {
+ /// Positional argument: the target to check out.
+ /// Can be a branch name (e.g., "main"), a commit hash, or a relative
+ /// reference like "HEAD~1".
+ const ARG_TARGET: &'static str = "target";
+
+ /// Flag key for creating a new branch and switching to it.
+ /// Corresponds to the `-b ` option.
+ /// If a target is provided, the new branch starts there; otherwise, it starts at `HEAD`.
+ const ARG_NEW_BRANCH: &'static str = "new-branch";
+
+ /// Flag key for forcing the checkout.
+ /// Corresponds to the `-f, --force` flag.
+ /// Overwrites local changes in the working tree without safety checks.
+ const ARG_FORCE: &'static str = "force";
+
+ /// Flag key for explicitly entering a detached HEAD state.
+ /// Corresponds to the `--detach` flag.
+ /// Useful when you want to inspect a branch's tip without switching to it.
+ const ARG_DETACH: &'static str = "detach";
+
+ /// Flag key for performing a three-way merge when switching branches.
+ /// Corresponds to the `-m, --merge` flag.
+ /// Attempts to carry your local changes over to the new branch by merging them.
+ const ARG_MERGE: &'static str = "merge";
+}
+
+#[async_trait]
+impl MevaCommand for CheckoutCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "checkout"
+ }
+
+ fn about(&self) -> &'static str {
+ "Switch branches or restore working tree files"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .arg(
+ Arg::new(Self::ARG_NEW_BRANCH)
+ .short('b')
+ .value_name("NEW_BRANCH")
+ .help("Create and checkout a new branch"),
+ )
+ .arg(
+ Arg::new(Self::ARG_FORCE)
+ .short('f')
+ .long(Self::ARG_FORCE)
+ .help("Force checkout (discard local changes)")
+ .action(ArgAction::SetTrue),
+ )
+ .arg(
+ Arg::new(Self::ARG_DETACH)
+ .long(Self::ARG_DETACH)
+ .help("Detach HEAD at named commit")
+ .action(ArgAction::SetTrue),
+ )
+ .arg(
+ Arg::new(Self::ARG_MERGE)
+ .short('m')
+ .long(Self::ARG_MERGE)
+ .help("Perform a three-way merge between current branch, working tree, and new branch")
+ .action(ArgAction::SetTrue),
+ )
+ .arg(
+ Arg::new(Self::ARG_TARGET)
+ .value_name("BRANCH_OR_COMMIT")
+ .value_parser(value_parser!(Revision))
+ .default_value("HEAD")
+ .help("Branch name or commit hash to switch to")
+ .required_unless_present(Self::ARG_NEW_BRANCH)
+ .index(1)
+ )
+ }
+
+ async fn execute(
+ &self,
+ matches: &ArgMatches,
+ container: &Self::Container,
+ ) -> miette::Result<()> {
+ let mut target = matches
+ .get_one::(Self::ARG_TARGET)
+ .unwrap()
+ .clone();
+ let new_branch = matches.get_one::(Self::ARG_NEW_BRANCH);
+ let force = matches.get_flag(Self::ARG_FORCE);
+ let detach = matches.get_flag(Self::ARG_DETACH);
+ let merge = matches.get_flag(Self::ARG_MERGE);
+
+ // Create new branch
+ if let Some(branch_name) = new_branch {
+ let request = CreateRequest {
+ branch_name: branch_name.to_string(),
+ start_point: target,
+ };
+
+ container
+ .branch_handler()
+ .into_diagnostic()?
+ .branch(branch::Request::Create(request))
+ .into_diagnostic()?;
+
+ target = branch_name.parse::().unwrap();
+ }
+
+ let request = checkout::Request {
+ force,
+ detach,
+ merge,
+ target,
+ };
+
+ let handler = container.checkout_handler().into_diagnostic()?;
+
+ handler.checkout(request).into_diagnostic()?;
+
+ Ok(())
+ }
+}
diff --git a/cli/src/commands/clone.rs b/cli/src/commands/clone.rs
new file mode 100644
index 00000000..7e54937e
--- /dev/null
+++ b/cli/src/commands/clone.rs
@@ -0,0 +1,111 @@
+use crate::{commands::MevaCommand, extensions::WithVerbose};
+use async_trait::async_trait;
+use clap::{Arg, ArgMatches, Command, ValueHint};
+use engine::{EngineContainer, engine_container::MevaContainer, handlers::clone::Request};
+use miette::{IntoDiagnostic, Result};
+use std::path::PathBuf;
+use url::Url;
+
+/// Implements the `clone` command for Meva DVCS.
+///
+/// Clones a remote repository into a new directory, creates a
+/// tracking connection to the remote repository (origin), and checks out
+/// the default branch.
+#[derive(Default)]
+pub struct CloneCommand;
+
+impl CloneCommand {
+ /// Argument name for specifying the remote repository URL.
+ const ARG_REPOSITORY: &'static str = "repository";
+
+ /// Argument name for specifying the target directory.
+ const ARG_DIRECTORY: &'static str = "directory";
+
+ /// Argument name for specifying the origin name.
+ const ARG_ORIGIN: &'static str = "origin";
+
+ /// Argument name for specifying the server public key.
+ const ARG_SERVER_KEY: &'static str = "server-key";
+}
+
+#[async_trait]
+impl MevaCommand for CloneCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "clone"
+ }
+
+ fn about(&self) -> &'static str {
+ "Clones a remote repository into a local directory"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ /// Builds the CLI argument parser for the `clone` command using `clap`.
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .with_verbose_arg("Enable verbose output")
+ .arg(
+ Arg::new(Self::ARG_REPOSITORY)
+ .value_name("REPOSITORY")
+ .index(1)
+ .required(true)
+ .value_parser(clap::value_parser!(Url))
+ .help("Address of the repository to clone"),
+ )
+ .arg(
+ Arg::new(Self::ARG_DIRECTORY)
+ .value_name("DIRECTORY")
+ .index(2)
+ .value_hint(ValueHint::DirPath)
+ .value_parser(clap::value_parser!(PathBuf))
+ .help("Directory to clone into"),
+ )
+ .arg(
+ Arg::new(Self::ARG_ORIGIN)
+ .long(Self::ARG_ORIGIN)
+ .value_name("ORIGIN")
+ .default_value("origin")
+ .help("Optional origin name to set for the cloned repository"),
+ )
+ .arg(
+ Arg::new(Self::ARG_SERVER_KEY)
+ .long(Self::ARG_SERVER_KEY)
+ .short('s')
+ .required(true)
+ .value_hint(ValueHint::FilePath)
+ .value_parser(clap::value_parser!(PathBuf))
+ .help("Path to server's public key"),
+ )
+ }
+
+ /// Executes the `clone` command.
+ ///
+ /// # Returns
+ /// * `Result<()>`: Indicates success or returns a detailed error if the clone fails.
+ async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> {
+ let request = Request {
+ url: matches
+ .get_one::(Self::ARG_REPOSITORY)
+ .unwrap()
+ .clone(),
+ directory: matches.get_one::(Self::ARG_DIRECTORY).cloned(),
+ origin: matches.get_one::(Self::ARG_ORIGIN).unwrap().clone(),
+ server_key: matches
+ .get_one::(Self::ARG_SERVER_KEY)
+ .unwrap()
+ .clone(),
+ quiet: !matches.get_flag(Command::ARG_VERBOSE),
+ };
+
+ let handler = container.clone_handler().into_diagnostic()?;
+ let response = handler.handle_clone(request).await.into_diagnostic()?;
+
+ print!("{response}");
+
+ Ok(())
+ }
+}
diff --git a/cli/src/commands/commit.rs b/cli/src/commands/commit.rs
new file mode 100644
index 00000000..eaa1069c
--- /dev/null
+++ b/cli/src/commands/commit.rs
@@ -0,0 +1,244 @@
+use crate::commands::MevaCommand;
+use async_trait::async_trait;
+use clap::{Arg, ArgAction, ArgMatches, Command};
+use engine::diff_builder::{DiffStat, FileChangeKind};
+use engine::errors::{CommitError, EngineError};
+use engine::handlers::{add::AddRequest, commit::Request, commit::Response};
+use engine::objects::Person;
+use engine::{EngineContainer, engine_container::MevaContainer};
+use miette::IntoDiagnostic;
+use owo_colors::OwoColorize;
+
+/// Implements the `commit` command for Meva DVCS.
+///
+/// Creates a new commit object that records the current stage contents,
+/// along with a user-supplied message and optional metadata (author, date, etc.).
+/// Supports automatically staging changes, amending the last commit, or performing a dry run
+/// to preview the commit without persisting it.
+#[derive(Default)]
+pub struct CommitCommand;
+
+impl CommitCommand {
+ /// Argument key for specifying the commit message.
+ /// Corresponds to the `-m, --message ` option.
+ const ARG_MESSAGE: &'static str = "message";
+
+ /// Argument key for overriding the commit author.
+ /// Format: `"Name Surname "`
+ /// Corresponds to the `--author ` option.
+ const ARG_AUTHOR: &'static str = "author";
+
+ /// Flag key for staging all modified and deleted files before commiting.
+ /// Corresponds to the `-a, --all` flag.
+ const ARG_ALL: &'static str = "all";
+
+ /// Flag key for performing a dry run (no commit is actually created).
+ /// Corresponds to the `-n --dry-run` flag.
+ const ARG_DRY_RUN: &'static str = "dry-run";
+
+ /// Flag key for amending the tip of the current branch instead of creating a new commit.
+ /// Corresponds to the `--amend` flag.
+ const ARG_AMEND: &'static str = "amend";
+
+ /// Displays a formatted summary of a successfully created commit.
+ ///
+ /// Includes:
+ /// - Shortened commit hash and message,
+ /// - Diff statistics summary (added/deleted lines),
+ /// - List of file changes (created, deleted, modified).
+ ///
+ /// # Arguments
+ /// * `response` - The [`Response`] returned by the commit handler.
+ fn display_real_commit_response(&self, response: Response) {
+ let stat = DiffStat::from(response.changes.as_ref());
+ println!(
+ "[{}] {}",
+ response
+ .commit_hash
+ .unwrap_or_default()
+ .get(..7)
+ .unwrap_or("-"),
+ response.commit.message.cyan()
+ );
+ println!("{}", stat.summary_string());
+ for change in response.changes {
+ match change.kind {
+ FileChangeKind::Added { new_path, mode, .. } => match mode {
+ Some(mode) => println!(
+ "{} mode {:o} {}",
+ "create".green(),
+ mode,
+ new_path.display()
+ ),
+ None => println!("create {}", new_path.display()),
+ },
+ FileChangeKind::Deleted { old_path, mode, .. } => match mode {
+ Some(mode) => {
+ println!("{} mode {:o} {}", "delete".red(), mode, old_path.display())
+ }
+ None => println!("delete {}", old_path.display()),
+ },
+ FileChangeKind::Modified { .. } => {}
+ }
+ }
+ }
+
+ /// Displays the results of a dry-run commit.
+ ///
+ /// Shows which files *would* be committed, but does not perform
+ /// any actual repository changes.
+ ///
+ /// # Arguments
+ /// * `response` - The [`Response`] produced by the commit handler during a dry run.
+ fn display_dry_run_response(&self, response: Response) {
+ println!("Changes to be committed:");
+ for change in response.changes {
+ match change.kind {
+ FileChangeKind::Added { new_path, .. } => {
+ println!("\t{} {}", "new file".green(), new_path.display().green());
+ }
+ FileChangeKind::Deleted { old_path, .. } => {
+ println!("\t{} {}", "deleted".red(), old_path.display().red());
+ }
+ FileChangeKind::Modified { new_path, .. } => {
+ println!("\t{} {}", "modified".yellow(), new_path.display().yellow());
+ }
+ };
+ }
+ }
+}
+
+#[async_trait]
+impl MevaCommand for CommitCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "commit"
+ }
+
+ fn about(&self) -> &'static str {
+ "Create a new commit object containing the current stage contents and message"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ /// Builds the CLI argument parser for the `commit` command using `clap`.
+ ///
+ /// Adds the following options and flags:
+ /// - `-m, --message `: Commit message describing the changes.
+ /// - `--author `: Override the default author (`"Name "` format).
+ /// - `-n, --dry-run`: Show what would be committed without actually creating the commit.
+ /// - `--amend`: Amend the tip of the current branch instead of creating a new commit.
+ /// - `-a, --all`: Stage all modified and deleted files before committing.
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .arg(
+ Arg::new(Self::ARG_MESSAGE)
+ .value_name("MESSAGE")
+ .short('m')
+ .long(Self::ARG_MESSAGE)
+ .help("Commit message describing the changes")
+ .required(true),
+ )
+ .arg(
+ Arg::new(Self::ARG_AUTHOR)
+ .value_name("AUTHOR")
+ .long(Self::ARG_AUTHOR)
+ .help("Override the default author (format: \"Name Surname \")")
+ .value_parser(clap::value_parser!(Person)),
+ )
+ .arg(
+ Arg::new(Self::ARG_DRY_RUN)
+ .value_name("DRY_RUN")
+ .short('n')
+ .long(Self::ARG_DRY_RUN)
+ .help("Show what would be committed without creating a commit")
+ .action(ArgAction::SetTrue),
+ )
+ .arg(
+ Arg::new(Self::ARG_AMEND)
+ .value_name("AMEND")
+ .long(Self::ARG_AMEND)
+ .help("Amend the tip of the current branch instead of creating a new commit")
+ .action(ArgAction::SetTrue),
+ )
+ .arg(
+ Arg::new(Self::ARG_ALL)
+ .value_name("ALL")
+ .short('a')
+ .long(Self::ARG_ALL)
+ .help("Stage all modified and deleted files before committing")
+ .action(ArgAction::SetTrue),
+ )
+ }
+
+ /// Executes the `commit` command.
+ ///
+ /// Reads the provided CLI options (message, author, etc.) and performs
+ /// the necessary repository operations:
+ ///
+ /// * If the `--all` flag is set, stages all modified and deleted files
+ /// before creating the commit.
+ /// * Builds a [`Request`] using the collected arguments
+ /// (message, author, date, dry-run, amend).
+ /// * Invokes the commit handler to either create the commit
+ /// or show a preview when `--dry-run` is used.
+ ///
+ /// # Returns
+ /// * [`miette::Result`]: Indicates success or a diagnostic error if the commit operation fails.
+ async fn execute(
+ &self,
+ matches: &ArgMatches,
+ container: &Self::Container,
+ ) -> miette::Result<()> {
+ let message = matches.get_one::(Self::ARG_MESSAGE).unwrap();
+ let author = matches.get_one::(Self::ARG_AUTHOR);
+ let all_arg = matches.get_flag(Self::ARG_ALL);
+ let dry_run = matches.get_flag(Self::ARG_DRY_RUN);
+ let amend = matches.get_flag(Self::ARG_AMEND);
+
+ let interceptor = container.plugins_interceptor().into_diagnostic()?;
+
+ if all_arg {
+ let add_handler = container.add_handler().into_diagnostic()?;
+ let add_request = AddRequest {
+ all_flag: true,
+ force_flag: false,
+ update_flag: false,
+ dry_run_flag: false,
+ verbose_flag: false,
+ path_arg: None,
+ };
+ add_handler
+ .handle_add(add_request, &interceptor)
+ .into_diagnostic()?;
+ }
+
+ let commit_request = Request {
+ dry_run,
+ amend,
+ message: message.clone(),
+ author: author.cloned(),
+ };
+
+ let commit_handler = container.commit_handler(dry_run).into_diagnostic()?;
+
+ let response = match commit_handler.handle_commit(commit_request, &interceptor) {
+ Ok(val) => val,
+ Err(err @ EngineError::Commit(CommitError::NothingToCommit)) => {
+ println!("{}", err.yellow());
+ return Ok(());
+ }
+ Err(e) => return Err(miette::miette!(e)),
+ };
+
+ match response.commit_hash {
+ None => self.display_dry_run_response(response),
+ Some(_) => self.display_real_commit_response(response),
+ }
+
+ Ok(())
+ }
+}
diff --git a/cli/src/commands/config.rs b/cli/src/commands/config.rs
new file mode 100644
index 00000000..63da9401
--- /dev/null
+++ b/cli/src/commands/config.rs
@@ -0,0 +1,69 @@
+pub mod subcommands;
+
+use crate::commands::{MevaCommand, execute_multiple};
+
+use async_trait::async_trait;
+use clap::ArgMatches;
+use engine::engine_container::MevaContainer;
+use miette::Result;
+use subcommands::*;
+
+/// Implements the `config` command for Meva DVCS.
+///
+/// Serves as a namespace for configuration-related subcommands:
+/// list, get, set, unset, and edit settings at global/local/file scopes.
+#[derive(Default)]
+pub struct ConfigCommand;
+
+#[async_trait]
+impl MevaCommand for ConfigCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "config"
+ }
+
+ fn about(&self) -> &'static str {
+ "Manage Meva configuration (system, global, or local)"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ /// Define the set of subcommands under `config` namespace.
+ ///
+ /// Returns boxed instances of each config operation command.
+ fn subcommands(&self) -> Vec>> {
+ vec![
+ Box::new(ConfigListCommand),
+ Box::new(ConfigGetCommand),
+ Box::new(ConfigSetCommand),
+ Box::new(ConfigUnsetCommand),
+ Box::new(ConfigEditCommand),
+ Box::new(ConfigCreateCommand),
+ ]
+ }
+
+ async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> {
+ execute_multiple(matches, container, self.subcommands()).await
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = ConfigCommand;
+ assert_eq!(cmd.name(), "config");
+ assert_eq!(
+ cmd.about(),
+ "Manage Meva configuration (system, global, or local)"
+ );
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+}
diff --git a/cli/src/commands/config/subcommands.rs b/cli/src/commands/config/subcommands.rs
new file mode 100644
index 00000000..18411eea
--- /dev/null
+++ b/cli/src/commands/config/subcommands.rs
@@ -0,0 +1,13 @@
+pub mod create;
+pub mod edit;
+pub mod get;
+pub mod list;
+pub mod set;
+pub mod unset;
+
+pub use create::ConfigCreateCommand;
+pub use edit::ConfigEditCommand;
+pub use get::ConfigGetCommand;
+pub use list::ConfigListCommand;
+pub use set::ConfigSetCommand;
+pub use unset::ConfigUnsetCommand;
diff --git a/cli/src/commands/config/subcommands/create.rs b/cli/src/commands/config/subcommands/create.rs
new file mode 100644
index 00000000..ffe397a5
--- /dev/null
+++ b/cli/src/commands/config/subcommands/create.rs
@@ -0,0 +1,70 @@
+use std::path::PathBuf;
+
+use async_trait::async_trait;
+use clap::{ArgMatches, Command};
+use engine::EngineContainer;
+use engine::engine_container::MevaContainer;
+use engine::handlers::config::CreateRequest;
+use miette::IntoDiagnostic;
+
+use crate::commands::MevaCommand;
+use crate::extensions::WithFile;
+
+/// Implements the `create` subcommand for Meva configuration management.
+///
+/// Creates a new configuration file at the specified path or the default global location.
+#[derive(Default)]
+pub struct ConfigCreateCommand;
+
+#[async_trait]
+impl MevaCommand for ConfigCreateCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "create"
+ }
+
+ fn about(&self) -> &'static str {
+ "Create a new configuration file"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .with_file_arg("Path to the configuration file to create")
+ }
+
+ async fn execute(
+ &self,
+ matches: &ArgMatches,
+ container: &Self::Container,
+ ) -> miette::Result<()> {
+ let file = matches.get_one::(Command::ARG_FILE);
+
+ let request = CreateRequest {
+ file: file.cloned(),
+ };
+
+ let config_handler = container.config_handler().into_diagnostic()?;
+ config_handler.handle_create(request).into_diagnostic()?;
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = ConfigCreateCommand;
+ assert_eq!(cmd.name(), "create");
+ assert_eq!(cmd.about(), "Create a new configuration file");
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+}
diff --git a/cli/src/commands/config/subcommands/edit.rs b/cli/src/commands/config/subcommands/edit.rs
new file mode 100644
index 00000000..cacbbae2
--- /dev/null
+++ b/cli/src/commands/config/subcommands/edit.rs
@@ -0,0 +1,76 @@
+use async_trait::async_trait;
+use clap::{ArgMatches, Command};
+use engine::engine_container::MevaContainer;
+use engine::{ConfigDocument, ConfigLoader, MevaConfigLoader};
+use miette::{Context, IntoDiagnostic};
+use shared::OpenInEditor;
+
+use crate::commands::MevaCommand;
+use crate::extensions::{LocationSelection, WithLocations};
+
+/// Implements the `edit` subcommand for Meva configuration management.
+///
+/// Opens the chosen configuration file in the user's preferred editor,
+/// respecting any `editor.default` override in config or falling back to OS defaults.
+#[derive(Default)]
+pub struct ConfigEditCommand;
+
+#[async_trait]
+impl MevaCommand for ConfigEditCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "edit"
+ }
+
+ fn about(&self) -> &'static str {
+ "Open the config file in your default editor"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ fn build_command(&self) -> Command {
+ self.build_base_command().with_location_args(
+ "Use the global (user) config file",
+ "Use the repository-local config file",
+ "Path to an arbitrary config file",
+ )
+ }
+
+ async fn execute(
+ &self,
+ matches: &ArgMatches,
+ _container: &Self::Container,
+ ) -> miette::Result<()> {
+ let loader = MevaConfigLoader::default();
+ let override_cmd = loader.get("editor.default", None).ok();
+ let location = matches
+ .get_config_location()
+ .get_default_path()
+ .into_diagnostic()
+ .wrap_err("Failed to determine configuration file path")?;
+
+ ConfigDocument::validate_existing_file(&location).into_diagnostic()?;
+
+ location.open_in_editor(override_cmd).into_diagnostic()?;
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = ConfigEditCommand;
+ assert_eq!(cmd.name(), "edit");
+ assert_eq!(cmd.about(), "Open the config file in your default editor");
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+}
diff --git a/cli/src/commands/config/subcommands/get.rs b/cli/src/commands/config/subcommands/get.rs
new file mode 100644
index 00000000..3eeaf040
--- /dev/null
+++ b/cli/src/commands/config/subcommands/get.rs
@@ -0,0 +1,101 @@
+use async_trait::async_trait;
+use clap::{Arg, ArgMatches, Command};
+use engine::EngineContainer;
+use engine::engine_container::MevaContainer;
+use engine::handlers::config::GetRequest;
+use miette::IntoDiagnostic;
+
+use crate::commands::MevaCommand;
+use crate::extensions::{LocationSelection, WithLocations};
+
+#[derive(Default)]
+pub struct ConfigGetCommand;
+
+/// Implements the `get` subcommand for Meva configuration management.
+///
+/// Retrieves the value of a specified TOML key from the chosen
+/// configuration scope, with an optional default fallback if the key is missing.
+impl ConfigGetCommand {
+ const ARG_KEY: &'static str = "key";
+
+ const ARG_DEFAULT: &'static str = "default";
+}
+
+#[async_trait]
+impl MevaCommand for ConfigGetCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "get"
+ }
+
+ fn about(&self) -> &'static str {
+ "Get the value for a given configuration key"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .with_location_args(
+ "Use the global (user) config file",
+ "Use the repository-local config file",
+ "Path to an arbitrary config file",
+ )
+ .arg(
+ Arg::new(Self::ARG_KEY)
+ .value_name("KEY")
+ .help("TOML path to the config entry")
+ .required(true)
+ .index(1),
+ )
+ .arg(
+ Arg::new(Self::ARG_DEFAULT)
+ .short('d')
+ .long("default")
+ .value_name("DEFAULT")
+ .help("Default value if entry is missing"),
+ )
+ }
+
+ async fn execute(
+ &self,
+ matches: &ArgMatches,
+ container: &Self::Container,
+ ) -> miette::Result<()> {
+ let location = matches.get_config_location();
+ let key = matches.get_one::(Self::ARG_KEY).unwrap();
+ let default = matches.get_one::(Self::ARG_DEFAULT);
+
+ let config_handler = container.config_handler().into_diagnostic()?;
+
+ let request = GetRequest {
+ location,
+ key: key.clone(),
+ default: default.cloned(),
+ };
+
+ let response = config_handler.handle_get(request).into_diagnostic()?;
+
+ println!("{}", response.value);
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = ConfigGetCommand;
+ assert_eq!(cmd.name(), "get");
+ assert_eq!(cmd.about(), "Get the value for a given configuration key");
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+}
diff --git a/cli/src/commands/config/subcommands/list.rs b/cli/src/commands/config/subcommands/list.rs
new file mode 100644
index 00000000..4246f604
--- /dev/null
+++ b/cli/src/commands/config/subcommands/list.rs
@@ -0,0 +1,93 @@
+use async_trait::async_trait;
+use clap::{ArgMatches, Command};
+use engine::EngineContainer;
+use engine::engine_container::MevaContainer;
+use engine::handlers::config::ListRequest;
+use miette::IntoDiagnostic;
+use owo_colors::OwoColorize;
+
+use crate::commands::MevaCommand;
+use crate::extensions::{LocationSelection, WithLocations};
+
+/// Implements the `list` subcommand for Meva configuration management.
+///
+/// Retrieves and displays all key/value pairs from the selected
+/// configuration scope.
+#[derive(Default)]
+pub struct ConfigListCommand;
+
+#[async_trait]
+impl MevaCommand for ConfigListCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "list"
+ }
+
+ fn about(&self) -> &'static str {
+ "Print all available configuration entries"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ fn build_command(&self) -> Command {
+ self.build_base_command().with_location_args(
+ "Use the global (user) config file",
+ "Use the repository-local config file",
+ "Path to an arbitrary config file",
+ )
+ }
+
+ async fn execute(
+ &self,
+ matches: &ArgMatches,
+ container: &Self::Container,
+ ) -> miette::Result<()> {
+ let location = matches.get_config_location();
+
+ let config_handler = container.config_handler().into_diagnostic()?;
+
+ let request = ListRequest { location };
+
+ let response = config_handler.handle_list(request).into_diagnostic()?;
+
+ if response.key_values.is_empty() {
+ println!(
+ "{}",
+ "Config file has no key-value pairs to display.".yellow()
+ );
+ }
+
+ let max_key_len = response
+ .key_values
+ .iter()
+ .map(|(k, _)| k.len())
+ .max()
+ .unwrap_or(0);
+
+ for (key, value) in response.key_values {
+ let padded_key = format!("{key: &'static str {
+ "set"
+ }
+
+ fn about(&self) -> &'static str {
+ "Set the value for a given configuration key"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .with_location_args(
+ "Use the global (user) config file",
+ "Use the repository-local config file",
+ "Path to an arbitrary config file",
+ )
+ .with_key_arg("TOML path to the config entry")
+ .arg(
+ Arg::new(Self::ARG_VALUE)
+ .value_name("VALUE")
+ .help("Value to assign")
+ .required(true)
+ .index(2),
+ )
+ }
+
+ async fn execute(
+ &self,
+ matches: &ArgMatches,
+ container: &Self::Container,
+ ) -> miette::Result<()> {
+ let key = matches.get_one::(Command::ARG_KEY).unwrap();
+ let value = matches.get_one::(Self::ARG_VALUE).unwrap();
+ let location = matches.get_config_location();
+
+ let config_handler = container.config_handler().into_diagnostic()?;
+ let interceptor = container.plugins_interceptor().into_diagnostic()?;
+
+ let request = SetRequest {
+ location,
+ key: key.clone(),
+ value: value.clone(),
+ };
+
+ let response = config_handler
+ .handle_set(request, &interceptor)
+ .into_diagnostic()?;
+
+ println!(
+ "{} {} {}",
+ response.key.green().bold(),
+ "=".dimmed(),
+ response.value
+ );
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = ConfigSetCommand;
+ assert_eq!(cmd.name(), "set");
+ assert_eq!(cmd.about(), "Set the value for a given configuration key");
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+}
diff --git a/cli/src/commands/config/subcommands/unset.rs b/cli/src/commands/config/subcommands/unset.rs
new file mode 100644
index 00000000..76aa1bc6
--- /dev/null
+++ b/cli/src/commands/config/subcommands/unset.rs
@@ -0,0 +1,82 @@
+use async_trait::async_trait;
+use clap::{ArgMatches, Command};
+use engine::EngineContainer;
+use engine::engine_container::MevaContainer;
+use engine::handlers::config::UnsetRequest;
+use miette::IntoDiagnostic;
+
+use crate::commands::MevaCommand;
+use crate::extensions::{LocationSelection, WithKey, WithLocations};
+
+/// Implements the `unset` subcommand for Meva configuration management.
+///
+/// Removes a specified TOML key from the chosen configuration scope.
+#[derive(Default)]
+pub struct ConfigUnsetCommand;
+
+#[async_trait]
+impl MevaCommand for ConfigUnsetCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "unset"
+ }
+
+ fn about(&self) -> &'static str {
+ "Remove a configuration entry"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .with_location_args(
+ "Use the global (user) config file",
+ "Use the repository-local config file",
+ "Path to an arbitrary config file",
+ )
+ .with_key_arg("TOML path to the config entry")
+ }
+
+ async fn execute(
+ &self,
+ matches: &ArgMatches,
+ container: &Self::Container,
+ ) -> miette::Result<()> {
+ let key = matches.get_one::(Command::ARG_KEY).unwrap();
+ let location = matches.get_config_location();
+
+ let config_handler = container.config_handler().into_diagnostic()?;
+ let interceptor = container.plugins_interceptor().into_diagnostic()?;
+
+ let request = UnsetRequest {
+ location,
+ key: key.clone(),
+ };
+
+ let response = config_handler
+ .handle_unset(request, &interceptor)
+ .into_diagnostic()?;
+
+ println!("{}", response.value);
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = ConfigUnsetCommand;
+ assert_eq!(cmd.name(), "unset");
+ assert_eq!(cmd.about(), "Remove a configuration entry");
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+}
diff --git a/cli/src/commands/diff.rs b/cli/src/commands/diff.rs
new file mode 100644
index 00000000..91288fac
--- /dev/null
+++ b/cli/src/commands/diff.rs
@@ -0,0 +1,192 @@
+use std::path::PathBuf;
+
+use async_trait::async_trait;
+use clap::{Arg, ArgAction, ArgMatches, Command, value_parser};
+use engine::{
+ EngineContainer,
+ diff_builder::DiffMode,
+ engine_container::MevaContainer,
+ handlers::diff::{Request, Response},
+ revision_parsing::Revision,
+};
+
+use miette::{IntoDiagnostic, Result};
+use shared::PathToString;
+
+use crate::commands::MevaCommand;
+
+/// Implements the `diff` command for Meva DVCS.
+///
+/// Shows changes between commits, the index, and the working tree.
+#[derive(Default)]
+pub struct DiffCommand;
+
+impl DiffCommand {
+ /// Argument name for the `--cached` flag.
+ /// When set, the command compares the index to the specified commit (default: HEAD).
+ const ARG_CACHED: &'static str = "cached";
+
+ /// Argument name for the `--name-only` flag.
+ /// When set, only the names of changed files are printed, not their contents.
+ const ARG_NAME_ONLY: &'static str = "name-only";
+
+ /// Argument name for the `--stat` flag.
+ /// When set, print a summary diffstat instead of the full diff output.
+ const ARG_STAT: &'static str = "stat";
+
+ /// Positional argument name for the first revision/range.
+ /// Examples: `HEAD`, `HEAD~1`, a commit id, or other revision expressions.
+ const ARG_RANGE1: &'static str = "range1";
+
+ /// Positional argument name for the second revision/range.
+ /// When present, the diff is shown between RANGE1 and RANGE2.
+ const ARG_RANGE2: &'static str = "range2";
+
+ /// Positional argument name for paths limiting the diff.
+ /// Any number of paths can be provided; use `--` to separate ranges from paths.
+ const ARG_PATHS: &'static str = "paths";
+
+ /// Displays the diff response based on the selected DiffMode.
+ ///
+ /// Provides different output formats depending on the requested comparison type.
+ fn display_response(&self, mode: &DiffMode, response: &Response) {
+ match mode {
+ DiffMode::NameOnly => {
+ if let Some(files) = &response.files {
+ for file in files {
+ println!("{}", file.path().to_utf8_string());
+ }
+ }
+ }
+ DiffMode::Stat => {
+ if let Some(stat) = &response.stat {
+ println!("{stat}")
+ }
+ }
+ DiffMode::Full => {
+ if let Some(files) = &response.files {
+ for file in files {
+ file.display_full();
+ }
+ }
+ }
+ }
+ }
+}
+
+#[async_trait]
+impl MevaCommand for DiffCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "diff"
+ }
+
+ fn about(&self) -> &'static str {
+ "Show changes between commits, the index, and the working tree"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ /// Builds the CLI argument parser for the `diff` command using `clap`.
+ ///
+ /// Adds flags and positional arguments with helpful descriptions and value
+ /// parsers. The positional revision arguments use the `Revision` parser
+ /// defined in the `engine::revision_parsing` module.
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .arg(
+ Arg::new(Self::ARG_CACHED)
+ .short('c')
+ .long(Self::ARG_CACHED)
+ .action(ArgAction::SetTrue)
+ .help("Compare the index to the specified commit (default: HEAD). If HEAD doesn't exist, show all changes")
+ )
+ .arg(
+ Arg::new(Self::ARG_NAME_ONLY)
+ .short('n')
+ .long(Self::ARG_NAME_ONLY)
+ .action(ArgAction::SetTrue)
+ .conflicts_with(Self::ARG_STAT)
+ .help("Show only names of changed files")
+ )
+ .arg(
+ Arg::new(Self::ARG_STAT)
+ .short('s')
+ .long(Self::ARG_STAT)
+ .action(ArgAction::SetTrue)
+ .help("Show summary diffstat instead of the full diff")
+ )
+ .arg(
+ Arg::new(Self::ARG_RANGE1)
+ .value_name("RANGE1")
+ .index(1)
+ .value_parser(value_parser!(Revision))
+ .required(false)
+ .help("First range (e.g. commit, HEAD, HEAD~1)")
+ )
+ .arg(
+ Arg::new(Self::ARG_RANGE2)
+ .value_name("RANGE2")
+ .index(2)
+ .value_parser(value_parser!(Revision))
+ .required(false)
+ .help("Second range (e.g. commit)")
+ )
+ .arg(
+ Arg::new(Self::ARG_PATHS)
+ .value_name("PATHS")
+ .index(3)
+ .num_args(0..)
+ .value_parser(value_parser!(PathBuf))
+ .last(true)
+ .help("Limit the diff to the given paths (use -- to separate ranges from paths)")
+ )
+ }
+
+ /// Executes the `diff` command.
+ async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> {
+ let cached = matches.get_flag(Self::ARG_CACHED);
+ let from = matches.get_one::(Self::ARG_RANGE1);
+ let to = matches.get_one::(Self::ARG_RANGE2);
+ let paths = matches
+ .get_many::(Self::ARG_PATHS)
+ .map(|vals| vals.cloned().collect::>());
+
+ let mode = if matches.get_flag(Self::ARG_NAME_ONLY) {
+ DiffMode::NameOnly
+ } else if matches.get_flag(Self::ARG_STAT) {
+ DiffMode::Stat
+ } else {
+ DiffMode::Full
+ };
+
+ let request = Request::new(from.cloned(), to.cloned(), cached, mode, paths);
+
+ let handler = container.diff_handler().into_diagnostic()?;
+ let response = handler.handle_diff(request).into_diagnostic()?;
+ self.display_response(&mode, &response);
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = DiffCommand;
+ assert_eq!(cmd.name(), "diff");
+ assert_eq!(
+ cmd.about(),
+ "Show changes between commits, the index, and the working tree"
+ );
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+}
diff --git a/cli/src/commands/fetch.rs b/cli/src/commands/fetch.rs
new file mode 100644
index 00000000..1fcf28eb
--- /dev/null
+++ b/cli/src/commands/fetch.rs
@@ -0,0 +1,113 @@
+use async_trait::async_trait;
+use clap::{Arg, ArgAction, ArgMatches, Command};
+use engine::{EngineContainer, engine_container::MevaContainer, handlers::fetch::Request};
+
+use miette::{IntoDiagnostic, Result};
+
+use crate::{commands::MevaCommand, extensions::WithVerbose};
+
+/// Represents the `fetch` command in the CLI.
+///
+/// This command is responsible for downloading objects and references from a remote
+/// repository. It serves as the entry point for the fetch logic, handling argument
+/// parsing and delegating the execution to the appropriate engine handler.
+#[derive(Default)]
+pub struct FetchCommand;
+
+impl FetchCommand {
+ /// The name of the argument used to specify the prune flag.
+ const ARG_PRUNE: &'static str = "prune";
+
+ /// The name of the argument used to specify the remote origin.
+ const ARG_ORIGIN: &'static str = "origin";
+
+ /// The name of the argument used to specify a specific branch.
+ const ARG_BRANCH: &'static str = "branch";
+}
+
+#[async_trait]
+impl MevaCommand for FetchCommand {
+ type Container = MevaContainer;
+
+ /// Returns the name of the command as invoked from the command line.
+ fn name(&self) -> &'static str {
+ "fetch"
+ }
+
+ /// Returns a brief description of the command's purpose, displayed in help messages.
+ fn about(&self) -> &'static str {
+ "Download objects and refs from a remote repository"
+ }
+
+ /// Returns the current version of the command.
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ /// Builds the CLI argument parser for the `fetch` command using `clap`.
+ ///
+ /// This method defines the accepted arguments:
+ /// - `[ORIGIN]`: The remote repository to fetch from (defaults to "origin").
+ /// - `[BRANCH]`: An optional specific branch to fetch.
+ /// - `--prune` (`-p`): A flag to remove remote-tracking branches that no longer exist on the remote.
+ /// - `--verbose`: Inherited from `WithVerbose` extension.
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .arg(
+ Arg::new(Self::ARG_ORIGIN)
+ .value_name("ORIGIN")
+ .index(1)
+ .required(false)
+ .default_value("origin")
+ .help("Remote name to fetch from"),
+ )
+ .arg(
+ Arg::new(Self::ARG_BRANCH)
+ .value_name("BRANCH")
+ .index(2)
+ .required(false)
+ .help("Branch to fetch"),
+ )
+ .arg(
+ Arg::new(Self::ARG_PRUNE)
+ .long(Self::ARG_PRUNE)
+ .short('p')
+ .action(ArgAction::SetTrue)
+ .help("Remove remote-tracking branches that no longer exist on the remote"),
+ )
+ .with_verbose_arg("Enable verbose output")
+ }
+
+ /// Executes the `fetch` command logic.
+ async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> {
+ let request = Request {
+ origin: matches.get_one::(Self::ARG_ORIGIN).unwrap().clone(),
+ branch: matches.get_one::(Self::ARG_BRANCH).cloned(),
+ prune: matches.get_flag(Self::ARG_PRUNE),
+ verbose: matches.get_flag(Command::ARG_VERBOSE),
+ };
+
+ let handler = container.fetch_handler().into_diagnostic()?;
+ let _ = handler.handle_fetch(request).await.into_diagnostic()?;
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = FetchCommand;
+ assert_eq!(cmd.name(), "fetch");
+ assert_eq!(
+ cmd.about(),
+ "Download objects and refs from a remote repository"
+ );
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+}
diff --git a/cli/src/commands/ignore.rs b/cli/src/commands/ignore.rs
new file mode 100644
index 00000000..0554c370
--- /dev/null
+++ b/cli/src/commands/ignore.rs
@@ -0,0 +1,67 @@
+pub mod subcommands;
+
+use crate::commands::{MevaCommand, execute_multiple};
+
+use async_trait::async_trait;
+use clap::ArgMatches;
+use engine::engine_container::MevaContainer;
+use miette::Result;
+use subcommands::*;
+
+/// Implements the `ignore` command for Meva DVCS.
+///
+/// Serves as a namespace for subcommands managing ignore patterns:
+/// add, remove, check, and edit ignore rules for files and directories.
+#[derive(Default)]
+pub struct IgnoreCommand;
+
+#[async_trait]
+impl MevaCommand for IgnoreCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "ignore"
+ }
+
+ fn about(&self) -> &'static str {
+ "Manage ignore patterns for files and directories"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ /// Define the set of subcommands under `ignore` namespace.
+ ///
+ /// Returns boxed instances of each ignore operation command.
+ fn subcommands(&self) -> Vec>> {
+ vec![
+ Box::new(IgnoreAddCommand),
+ Box::new(IgnoreCheckCommand),
+ Box::new(IgnoreEditCommand),
+ Box::new(IgnoreRemoveCommand),
+ ]
+ }
+
+ async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> {
+ execute_multiple(matches, container, self.subcommands()).await
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = IgnoreCommand;
+ assert_eq!(cmd.name(), "ignore");
+ assert_eq!(
+ cmd.about(),
+ "Manage ignore patterns for files and directories"
+ );
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+}
diff --git a/cli/src/commands/ignore/subcommands.rs b/cli/src/commands/ignore/subcommands.rs
new file mode 100644
index 00000000..26a70046
--- /dev/null
+++ b/cli/src/commands/ignore/subcommands.rs
@@ -0,0 +1,9 @@
+pub mod add;
+pub mod check;
+pub mod edit;
+pub mod remove;
+
+pub use add::IgnoreAddCommand;
+pub use check::IgnoreCheckCommand;
+pub use edit::IgnoreEditCommand;
+pub use remove::IgnoreRemoveCommand;
diff --git a/cli/src/commands/ignore/subcommands/add.rs b/cli/src/commands/ignore/subcommands/add.rs
new file mode 100644
index 00000000..214db6f9
--- /dev/null
+++ b/cli/src/commands/ignore/subcommands/add.rs
@@ -0,0 +1,84 @@
+use std::path::PathBuf;
+
+use async_trait::async_trait;
+use clap::{ArgMatches, Command};
+use engine::{
+ IgnoreOperations, IgnoreService, RepositoryLayout, engine_container::MevaContainer,
+ repositories::meva_repository_layout::MevaRepositoryLayout,
+};
+use globset::Glob;
+use miette::IntoDiagnostic;
+use owo_colors::OwoColorize;
+use shared::PathToString;
+
+use crate::{
+ commands::MevaCommand,
+ extensions::{WithFile, WithPattern},
+};
+
+/// Implements the `add` subcommand for Meva ignored files management.
+///
+/// Adds the specified pattern to the chosen ignore file,
+/// ensuring the ignore rule is appended without duplicates or formatting errors.
+#[derive(Default)]
+pub struct IgnoreAddCommand;
+
+#[async_trait]
+impl MevaCommand for IgnoreAddCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "add"
+ }
+
+ fn about(&self) -> &'static str {
+ "Appends a pattern to the ignore file"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .with_pattern_arg("Pattern to append to the ignore file")
+ .with_file_arg("Path to a specific ignore file")
+ }
+
+ async fn execute(
+ &self,
+ matches: &ArgMatches,
+ _container: &Self::Container,
+ ) -> miette::Result<()> {
+ let pattern = matches.get_one::(Command::ARG_PATTERN).unwrap();
+ let file = matches.get_one::(Command::ARG_FILE);
+
+ let layout = MevaRepositoryLayout::from_env().into_diagnostic()?;
+ let ignore_service = IgnoreService::new(layout.ignore_file_name());
+
+ let result_path = ignore_service.add(pattern, file).into_diagnostic()?;
+
+ println!(
+ "Pattern '{}' appended to {}",
+ pattern.green(),
+ result_path.to_utf8_string().cyan()
+ );
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = IgnoreAddCommand;
+ assert_eq!(cmd.name(), "add");
+ assert_eq!(cmd.about(), "Appends a pattern to the ignore file");
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+}
diff --git a/cli/src/commands/ignore/subcommands/check.rs b/cli/src/commands/ignore/subcommands/check.rs
new file mode 100644
index 00000000..542b5443
--- /dev/null
+++ b/cli/src/commands/ignore/subcommands/check.rs
@@ -0,0 +1,129 @@
+use std::path::{Path, PathBuf};
+
+use async_trait::async_trait;
+use clap::{Arg, ArgAction, ArgMatches, Command};
+use engine::{
+ IgnoreOperations, IgnoreResult, IgnoreService, RepositoryLayout,
+ engine_container::MevaContainer, repositories::meva_repository_layout::MevaRepositoryLayout,
+};
+use miette::IntoDiagnostic;
+
+use crate::{commands::MevaCommand, extensions::WithFile};
+
+/// Implements the `check` subcommand for Meva ignored files management.
+///
+/// Checks whether the specified path is ignored according to the rules of the chosen ignore file,
+/// optionally providing an explanation of which patterns matched.
+#[derive(Default)]
+pub struct IgnoreCheckCommand;
+
+impl IgnoreCheckCommand {
+ const ARG_PATH: &'static str = "path";
+
+ const ARG_EXPLAIN: &'static str = "explain";
+
+ #[allow(dead_code)]
+ fn print_check_result(result: &IgnoreResult, checked_path: &Path, explain: bool) {
+ match result {
+ IgnoreResult::Ignored { patterns, path } => {
+ if explain {
+ println!(
+ "'{}' is ignored by the following pattern{} in {}:",
+ checked_path.to_string_lossy(),
+ if patterns.len() == 1 { "" } else { "s" },
+ path.to_string_lossy()
+ );
+ for pattern in patterns {
+ println!("{pattern}");
+ }
+ } else {
+ println!("ignored");
+ }
+ }
+ IgnoreResult::NotIgnored { path } => {
+ if explain {
+ println!(
+ "'{}' is not ignored by {}",
+ checked_path.to_string_lossy(),
+ path.to_string_lossy()
+ );
+ } else {
+ println!("not ignored");
+ }
+ }
+ }
+ }
+}
+
+#[async_trait]
+impl MevaCommand for IgnoreCheckCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "check"
+ }
+
+ fn about(&self) -> &'static str {
+ "Check if a path is ignored by the rules of the ignore file"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .with_file_arg("Path to a specific ignore file")
+ .arg(
+ Arg::new(Self::ARG_EXPLAIN)
+ .short('e')
+ .long(Self::ARG_EXPLAIN)
+ .help("Explain why the path is ignored")
+ .action(ArgAction::SetTrue),
+ )
+ .arg(
+ Arg::new(Self::ARG_PATH)
+ .value_name("PATH")
+ .help("Path to check for ignore rule match")
+ .value_parser(clap::value_parser!(PathBuf))
+ .index(1),
+ )
+ }
+
+ async fn execute(
+ &self,
+ matches: &ArgMatches,
+ _container: &Self::Container,
+ ) -> miette::Result<()> {
+ let file = matches.get_one::(Command::ARG_FILE);
+ let explain = matches.get_flag(Self::ARG_EXPLAIN);
+ let checked_path = matches.get_one::(Self::ARG_PATH).unwrap();
+
+ let layout = MevaRepositoryLayout::from_env().into_diagnostic()?;
+ let ignore_service = IgnoreService::new(layout.ignore_file_name());
+
+ let ignore_result = ignore_service.check(checked_path, file).into_diagnostic()?;
+
+ Self::print_check_result(&ignore_result, checked_path, explain);
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = IgnoreCheckCommand;
+ assert_eq!(cmd.name(), "check");
+ assert_eq!(
+ cmd.about(),
+ "Check if a path is ignored by the rules of the ignore file"
+ );
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+}
diff --git a/cli/src/commands/ignore/subcommands/edit.rs b/cli/src/commands/ignore/subcommands/edit.rs
new file mode 100644
index 00000000..579bbaa7
--- /dev/null
+++ b/cli/src/commands/ignore/subcommands/edit.rs
@@ -0,0 +1,79 @@
+use std::path::PathBuf;
+
+use async_trait::async_trait;
+use clap::{ArgMatches, Command};
+use engine::{
+ ConfigLoader, IgnoreOperations, IgnoreService, MevaConfigLoader, RepositoryLayout,
+ engine_container::MevaContainer, repositories::meva_repository_layout::MevaRepositoryLayout,
+};
+use miette::IntoDiagnostic;
+use shared::OpenInEditor;
+
+use crate::{commands::MevaCommand, extensions::WithFile};
+
+/// Implements the `edit` subcommand for Meva ignored files management.
+///
+/// Opens the chosen configuration file in the user's preferred editor,
+/// respecting any `editor.default` override in config or falling back to OS defaults.
+#[derive(Default)]
+pub struct IgnoreEditCommand;
+
+#[async_trait]
+impl MevaCommand for IgnoreEditCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "edit"
+ }
+
+ fn about(&self) -> &'static str {
+ "Open the ignore file in your default editor"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .with_file_arg("Path to a specific ignore file")
+ }
+
+ async fn execute(
+ &self,
+ matches: &ArgMatches,
+ _container: &Self::Container,
+ ) -> miette::Result<()> {
+ let file = matches.get_one::(Command::ARG_FILE);
+
+ let layout = MevaRepositoryLayout::from_env().into_diagnostic()?;
+ let ignore_service = IgnoreService::new(layout.ignore_file_name());
+
+ let loader = MevaConfigLoader::default();
+ let override_cmd = loader.get("editor.default", None).ok();
+
+ let ignore_file = match file {
+ Some(p) => p.to_path_buf(),
+ None => ignore_service.find_ignore_file(None).into_diagnostic()?,
+ };
+
+ ignore_file.open_in_editor(override_cmd).into_diagnostic()?;
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = IgnoreEditCommand;
+ assert_eq!(cmd.name(), "edit");
+ assert_eq!(cmd.about(), "Open the ignore file in your default editor");
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+}
diff --git a/cli/src/commands/ignore/subcommands/remove.rs b/cli/src/commands/ignore/subcommands/remove.rs
new file mode 100644
index 00000000..f0b8d408
--- /dev/null
+++ b/cli/src/commands/ignore/subcommands/remove.rs
@@ -0,0 +1,94 @@
+use std::path::PathBuf;
+
+use async_trait::async_trait;
+use clap::{ArgMatches, Command};
+use engine::{
+ IgnoreOperations, IgnoreService, RepositoryLayout, engine_container::MevaContainer,
+ repositories::meva_repository_layout::MevaRepositoryLayout,
+};
+use globset::Glob;
+use miette::IntoDiagnostic;
+
+use crate::{
+ commands::MevaCommand,
+ extensions::{WithFile, WithPattern},
+};
+
+/// Implements the `remove` subcommand for Meva ignored files management.
+///
+/// Removes the specified pattern from the chosen ignore file,
+/// reporting how many lines were deleted or if the pattern was not present.
+#[derive(Default)]
+pub struct IgnoreRemoveCommand;
+
+#[async_trait]
+impl MevaCommand for IgnoreRemoveCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "remove"
+ }
+
+ fn about(&self) -> &'static str {
+ "Remove a pattern from the ignore file"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .with_pattern_arg("Pattern to remove from the ignore file")
+ .with_file_arg("Path to a specific ignore file")
+ }
+
+ async fn execute(
+ &self,
+ matches: &ArgMatches,
+ _container: &Self::Container,
+ ) -> miette::Result<()> {
+ let pattern = matches.get_one::(Command::ARG_PATTERN).unwrap();
+ let file = matches.get_one::(Command::ARG_FILE);
+
+ let layout = MevaRepositoryLayout::from_env().into_diagnostic()?;
+ let ignore_service = IgnoreService::new(layout.ignore_file_name());
+
+ let (result_path, removed_patterns) =
+ ignore_service.remove(pattern, file).into_diagnostic()?;
+
+ if removed_patterns.is_empty() {
+ println!(
+ "No lines matching “{}” were found in {}.",
+ pattern.glob(),
+ result_path.to_string_lossy()
+ );
+ } else {
+ println!(
+ "Removed {} line{} from {}:",
+ removed_patterns.len(),
+ if removed_patterns.len() == 1 { "" } else { "s" },
+ result_path.to_string_lossy()
+ );
+ for pat in &removed_patterns {
+ println!("{pat}");
+ }
+ }
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = IgnoreRemoveCommand;
+ assert_eq!(cmd.name(), "remove");
+ assert_eq!(cmd.about(), "Remove a pattern from the ignore file");
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+}
diff --git a/cli/src/commands/init.rs b/cli/src/commands/init.rs
new file mode 100644
index 00000000..53ad4cbf
--- /dev/null
+++ b/cli/src/commands/init.rs
@@ -0,0 +1,187 @@
+use std::path::PathBuf;
+
+use async_trait::async_trait;
+use clap::{Arg, ArgMatches, Command, ValueHint};
+use engine::{EngineContainer, engine_container::MevaContainer, handlers::init::Request};
+use miette::{IntoDiagnostic, Result};
+
+use crate::commands::MevaCommand;
+
+/// Implements the `init` command for Meva DVCS.
+///
+/// Initializes a new repository at a specified path,
+/// optionally setting the initial branch name.
+#[derive(Default)]
+pub struct InitCommand;
+
+impl InitCommand {
+ /// Argument name for specifying the initial branch.
+ const ARG_INITIAL_BRANCH: &'static str = "initial-branch";
+
+ /// Argument name for specifying the repository path.
+ const ARG_PATH: &'static str = "path";
+}
+
+#[async_trait]
+impl MevaCommand for InitCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "init"
+ }
+
+ fn about(&self) -> &'static str {
+ "Create an empty Meva repository"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ /// Builds the CLI argument parser for the `init` command using `clap`.
+ ///
+ /// Adds two arguments:
+ /// - `-b, --initial-branch `: Name of the initial branch (default: "master")
+ /// - ``: Path to initialize the repository (default: current directory)
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .arg(
+ Arg::new(Self::ARG_INITIAL_BRANCH)
+ .short('b')
+ .long(Self::ARG_INITIAL_BRANCH)
+ .value_name("BRANCH")
+ .help("Name of the initial branch")
+ .default_value("master")
+ .require_equals(false),
+ )
+ .arg(
+ Arg::new(Self::ARG_PATH)
+ .value_name("PATH")
+ .help("Path to initialize repository")
+ .default_value(".")
+ .value_parser(clap::value_parser!(PathBuf))
+ .value_hint(ValueHint::FilePath)
+ .index(1),
+ )
+ }
+
+ /// Executes the `init` command.
+ ///
+ /// Initializes a new Meva repository at the specified path using the provided
+ /// initial branch name. If the repository already exists or an error occurs
+ /// during initialization, the error is reported.
+ ///
+ /// # Arguments
+ /// * `matches`: Parsed command-line arguments containing:
+ /// - `initial-branch`: The name of the initial branch (defaults to "master").
+ /// - `path`: The target directory to initialize the repository (defaults to current dir).
+ ///
+ /// # Returns
+ /// * `Result<()>`: Indicates success or detailed error if initialization fails.
+ async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> {
+ let branch = matches.get_one::(Self::ARG_INITIAL_BRANCH).unwrap();
+ let target = matches.get_one::(Self::ARG_PATH).unwrap();
+
+ let handler = container.init_handler().into_diagnostic()?;
+ let interceptor = container.plugins_interceptor().into_diagnostic()?;
+
+ let request = Request {
+ working_dir: target.clone(),
+ initial_branch: branch.clone(),
+ };
+
+ let response = handler
+ .handle_init(request, &interceptor)
+ .into_diagnostic()?;
+
+ println!(
+ "Repository initialized successfully at: {}",
+ response.repository_dir.to_string_lossy()
+ );
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+ use std::path::PathBuf;
+
+ fn get_matches_from(args: &[&str]) -> ArgMatches {
+ let cmd = InitCommand;
+ cmd.build_command().try_get_matches_from(args).unwrap()
+ }
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = InitCommand;
+ assert_eq!(cmd.name(), "init");
+ assert_eq!(cmd.about(), "Create an empty Meva repository");
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+
+ #[rstest]
+ fn test_command_builds_with_expected_args() {
+ let cmd = InitCommand;
+ let clap_cmd = cmd.build_command();
+
+ // Check command name
+ assert_eq!(clap_cmd.get_name(), "init");
+
+ // Check arguments exist
+ assert!(
+ clap_cmd
+ .get_arguments()
+ .any(|a| a.get_id() == InitCommand::ARG_INITIAL_BRANCH)
+ );
+ assert!(
+ clap_cmd
+ .get_arguments()
+ .any(|a| a.get_id() == InitCommand::ARG_PATH)
+ );
+
+ // Check default values for args
+ let branch_arg = clap_cmd
+ .get_arguments()
+ .find(|a| a.get_id() == InitCommand::ARG_INITIAL_BRANCH)
+ .unwrap();
+ assert_eq!(
+ branch_arg.get_default_values().first().map(|v| v.to_str()),
+ Some(Some("master"))
+ );
+
+ let path_arg = clap_cmd
+ .get_arguments()
+ .find(|a| a.get_id() == InitCommand::ARG_PATH)
+ .unwrap();
+ assert_eq!(
+ path_arg.get_default_values().first().map(|v| v.to_str()),
+ Some(Some("."))
+ );
+ }
+
+ #[rstest]
+ #[case(&["init"], "master", ".")]
+ #[case(&["init", "-b", "develop"], "develop", ".")]
+ #[case(&["init", "--initial-branch=feature"], "feature", ".")]
+ #[case(&["init", "-b", "dev", "./repo_path"], "dev", "./repo_path")]
+ #[case(&["init", "./some_path"], "master", "./some_path")]
+ fn test_init_parses_args_correctly(
+ #[case] args: &[&str],
+ #[case] expected_branch: &str,
+ #[case] expected_path: &str,
+ ) {
+ let matches = get_matches_from(args);
+
+ let branch = matches
+ .get_one::(InitCommand::ARG_INITIAL_BRANCH)
+ .unwrap();
+ let path = matches.get_one::(InitCommand::ARG_PATH).unwrap();
+
+ assert_eq!(branch, expected_branch);
+ assert_eq!(path.to_str().unwrap(), expected_path);
+ }
+}
diff --git a/cli/src/commands/log.rs b/cli/src/commands/log.rs
new file mode 100644
index 00000000..9f471ee2
--- /dev/null
+++ b/cli/src/commands/log.rs
@@ -0,0 +1,201 @@
+use crate::commands::MevaCommand;
+use async_trait::async_trait;
+use clap::{Arg, ArgAction, ArgMatches, Command, value_parser};
+use dateparser::DateTimeUtc;
+use engine::EngineContainer;
+use engine::engine_container::MevaContainer;
+use engine::handlers::log::{LogOperations, Request, Response};
+use engine::revision_parsing::Revision;
+use miette::{IntoDiagnostic, Result};
+use owo_colors::OwoColorize;
+use regex::Regex;
+
+/// Implements the `log` command for Meva DVCS.
+///
+/// Displays the repository's branch history, allowing inspection of commit metadata,
+/// messages, and optional change statistics.
+/// Supports flexible output formatting and filtering options, such as compact one-line view,
+/// commit range limits, time-based filters, and pattern matching on commit messages.
+#[derive(Default)]
+pub struct LogCommand;
+
+impl LogCommand {
+ /// Flag for displaying commits in a condensed, one-line format.
+ const ARG_ONELINE: &'static str = "oneline";
+
+ /// Flag for including file-level statistics (added/deleted lines per file).
+ const ARG_STAT: &'static str = "stat";
+
+ /// Argument key for limiting the number of displayed commits.
+ const ARG_MAX_COUNT: &'static str = "max-count";
+
+ /// Skips the first N commits in the history.
+ const ARG_SKIP: &'static str = "skip";
+
+ /// Filters commits created after (and including) the specified date.
+ const ARG_AFTER: &'static str = "after";
+
+ /// Filters commits created before (and including) the specified date.
+ const ARG_BEFORE: &'static str = "before";
+
+ /// Shows only commits with messages matching the provided regular expression.
+ const ARG_GREP: &'static str = "grep";
+
+ /// Restricts output to commits with multiple parents (merge commits).
+ const ARG_MERGES: &'static str = "merges";
+
+ /// Excludes merge commits from the output.
+ const ARG_NO_MERGES: &'static str = "no-merges";
+
+ /// Optional starting revision (e.g., branch, tag, or commit hash).
+ const ARG_REVISION: &'static str = "revision";
+
+ /// Renders the command output in the appropriate format.
+ ///
+ /// Depending on the `oneline` flag, this method prints either
+ /// a condensed or detailed view of each commit.
+ fn display_response(&self, response: Response, oneline: bool) {
+ if response.entries.is_empty() {
+ println!("{}", "No commits found.".yellow());
+ return;
+ }
+
+ for entry in response.entries {
+ match oneline {
+ false => println!("{}", entry.snapshot),
+ true => {
+ let hash = entry.snapshot.hash.get(0..7).unwrap_or("-");
+ let message = entry.snapshot.message;
+ println!("{} {message}", hash.yellow());
+ }
+ }
+ if entry.stats.is_some() {
+ println!("{}", entry.stats.unwrap());
+ }
+ }
+ }
+}
+
+#[async_trait]
+impl MevaCommand for LogCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "log"
+ }
+
+ fn about(&self) -> &'static str {
+ "Display commit history of the repository"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ /// Builds the CLI argument parser for the `log` command.
+ ///
+ /// Adds all filtering, formatting and range options to control commit history display.
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .arg(
+ Arg::new(Self::ARG_ONELINE)
+ .short('o')
+ .long(Self::ARG_ONELINE)
+ .action(ArgAction::SetTrue)
+ .conflicts_with(Self::ARG_STAT)
+ .help("Condensed view: show each commit on one line (``)"),
+ )
+ .arg(
+ Arg::new(Self::ARG_STAT)
+ .long(Self::ARG_STAT)
+ .action(ArgAction::SetTrue)
+ .help("Show diffstat (number of added and deleted lines per file)"),
+ )
+ .arg(
+ Arg::new(Self::ARG_MAX_COUNT)
+ .short('n')
+ .long(Self::ARG_MAX_COUNT)
+ .value_name("NUMBER")
+ .value_parser(value_parser!(u32))
+ .help("Limit the number of commits to show"),
+ )
+ .arg(
+ Arg::new(Self::ARG_SKIP)
+ .long(Self::ARG_SKIP)
+ .value_name("N")
+ .value_parser(value_parser!(u32))
+ .help("Skip the first N commits"),
+ )
+ .arg(
+ Arg::new(Self::ARG_AFTER)
+ .long(Self::ARG_AFTER)
+ .value_name("DATE")
+ .value_parser(value_parser!(DateTimeUtc))
+ .help("Show commits made after (and including) the given date"),
+ )
+ .arg(
+ Arg::new(Self::ARG_BEFORE)
+ .long(Self::ARG_BEFORE)
+ .value_name("DATE")
+ .value_parser(value_parser!(DateTimeUtc))
+ .help("Show commits made before (and including) the given date"),
+ )
+ .arg(
+ Arg::new(Self::ARG_GREP)
+ .long(Self::ARG_GREP)
+ .value_name("PATTERN")
+ .value_parser(value_parser!(Regex))
+ .help("Show only commits with a message matching the given regular expression"),
+ )
+ .arg(
+ Arg::new(Self::ARG_MERGES)
+ .long(Self::ARG_MERGES)
+ .action(ArgAction::SetTrue)
+ .conflicts_with(Self::ARG_NO_MERGES)
+ .help("Show only merge commits (commits with two or more parents)"),
+ )
+ .arg(
+ Arg::new(Self::ARG_NO_MERGES)
+ .long(Self::ARG_NO_MERGES)
+ .action(ArgAction::SetTrue)
+ .conflicts_with(Self::ARG_MERGES)
+ .help("Exclude merge commits (show only commits with one or zero parents)"),
+ )
+ .arg(
+ Arg::new(Self::ARG_REVISION)
+ .value_name("REVISION")
+ .value_parser(value_parser!(Revision))
+ .required(false)
+ .help("Optional revision to start the log from (e.g. HEAD, branch, tag, or hash)"),
+ )
+ }
+
+ /// Executes the `log` command.
+ ///
+ /// Collects CLI options and builds a [`Request`] to pass to the log handler.
+ /// The handler is responsible for querying commits and formatting output
+ /// according to the specified options.
+ async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> {
+ let oneline = matches.get_flag(Self::ARG_ONELINE);
+ let handler = container.log_handler().into_diagnostic()?;
+
+ let request = Request {
+ oneline,
+ stat: matches.get_flag(Self::ARG_STAT),
+ merges: matches.get_flag(Self::ARG_MERGES),
+ no_merges: matches.get_flag(Self::ARG_NO_MERGES),
+ max_count: matches.get_one::(Self::ARG_MAX_COUNT).cloned(),
+ skip: matches.get_one::(Self::ARG_SKIP).copied(),
+ after: matches.get_one::(Self::ARG_AFTER).map(|a| a.0),
+ before: matches
+ .get_one::(Self::ARG_BEFORE)
+ .map(|b| b.0),
+ grep: matches.get_one::(Self::ARG_GREP).cloned(),
+ revision: matches.get_one::(Self::ARG_REVISION).cloned(),
+ };
+
+ let response = handler.log(request).into_diagnostic()?;
+ self.display_response(response, oneline);
+ Ok(())
+ }
+}
diff --git a/cli/src/commands/ls_files.rs b/cli/src/commands/ls_files.rs
new file mode 100644
index 00000000..4548cf0a
--- /dev/null
+++ b/cli/src/commands/ls_files.rs
@@ -0,0 +1,164 @@
+use async_trait::async_trait;
+use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command, value_parser};
+use engine::{
+ EngineContainer,
+ engine_container::MevaContainer,
+ handlers::ls_files::{LsFilesFilter, Request},
+};
+use miette::{IntoDiagnostic, Result};
+
+use crate::commands::MevaCommand;
+
+/// Implements the `ls-files` command for Meva DVCS.
+///
+/// Displays information about files in the index and working directory.
+#[derive(Default)]
+pub struct LsFilesCommand;
+
+impl LsFilesCommand {
+ /// Argument name for `--cached` flag.
+ /// When provided, shows only files that are tracked (present in the index).
+ const ARG_CACHED: &'static str = "cached";
+
+ /// Argument name for `--deleted` flag.
+ /// When set, lists files removed from the working tree but still in the index.
+ const ARG_DELETED: &'static str = "deleted";
+
+ /// Argument name for `--others` flag.
+ /// When set, lists files that are not tracked (untracked files including ignored ones).
+ const ARG_OTHERS: &'static str = "others";
+
+ /// Argument name for `--stage` flag.
+ /// Displays extended information about file statuses i.e., mode bits, object names, and stage numbers
+ const ARG_STAGE: &'static str = "stage";
+
+ /// Argument name for `--abbrev` option.
+ /// With `--stage`, this limits the printed object identifier to the
+ /// first N characters (range: 1–40).
+ const ARG_ABBREV: &'static str = "abbrev";
+
+ /// Argument name for `--full-name` flag.
+ /// Always prints full file paths, regardless of the current working directory.
+ const ARG_FULL_NAME: &'static str = "full-name";
+}
+
+#[async_trait]
+impl MevaCommand for LsFilesCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "ls-files"
+ }
+
+ fn about(&self) -> &'static str {
+ "Show information about files in the index and the working tree"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ /// Builds the CLI definition for the `ls-files` command using `clap`.
+ ///
+ /// Adds several mutually exclusive filter options (`--cached`, `--deleted`, `--others`)
+ /// as well as options for controlling output format (`--stage`, `--abbrev`, `--full-name`).
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .arg(
+ Arg::new(Self::ARG_CACHED)
+ .short('c')
+ .long(Self::ARG_CACHED)
+ .help("Show only files in the index (tracked)")
+ .action(ArgAction::SetTrue),
+ )
+ .arg(
+ Arg::new(Self::ARG_DELETED)
+ .short('d')
+ .long(Self::ARG_DELETED)
+ .help("Show files that have been deleted from the working tree but are still in the index")
+ .action(ArgAction::SetTrue),
+ )
+ .arg(
+ Arg::new(Self::ARG_OTHERS)
+ .short('o')
+ .long(Self::ARG_OTHERS)
+ .help("Show untracked files (not in the index)")
+ .action(ArgAction::SetTrue),
+ )
+ .arg(
+ Arg::new(Self::ARG_STAGE)
+ .short('s')
+ .long(Self::ARG_STAGE)
+ .help("Display extended information about file status")
+ .action(ArgAction::SetTrue),
+ )
+ .arg(
+ Arg::new(Self::ARG_ABBREV)
+ .long(Self::ARG_ABBREV)
+ .value_name("N")
+ .value_parser(value_parser!(u8).range(1..=40))
+ .requires(Self::ARG_STAGE)
+ .help("With --stage, prints only the first N characters of object identifiers"),
+ )
+ .arg(
+ Arg::new(Self::ARG_FULL_NAME)
+ .long(Self::ARG_FULL_NAME)
+ .help("Always show full paths (regardless of the calling directory)")
+ .action(ArgAction::SetTrue),
+ ).group(
+ ArgGroup::new("filters")
+ .args([
+ Self::ARG_CACHED,
+ Self::ARG_DELETED,
+ Self::ARG_OTHERS,
+ ])
+ .multiple(false)
+ )
+ }
+
+ /// Executes the `ls-files` command.
+ async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> {
+ let filter = if matches.get_flag(Self::ARG_DELETED) {
+ LsFilesFilter::Deleted
+ } else if matches.get_flag(Self::ARG_OTHERS) {
+ LsFilesFilter::Others
+ } else {
+ LsFilesFilter::Cached
+ };
+
+ let request = Request {
+ stage: matches.get_flag(Self::ARG_STAGE),
+ abbrev: matches.get_one::(Self::ARG_ABBREV).cloned(),
+ full_name: matches.get_flag(Self::ARG_FULL_NAME),
+ filter,
+ };
+
+ let handler = container.ls_files_handler().into_diagnostic()?;
+
+ let response = handler.handle_ls_files(request).into_diagnostic()?;
+
+ for file in response.files {
+ println!("{file}");
+ }
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = LsFilesCommand;
+ assert_eq!(cmd.name(), "ls-files");
+ assert_eq!(
+ cmd.about(),
+ "Show information about files in the index and the working tree"
+ );
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+}
diff --git a/cli/src/commands/ls_tree.rs b/cli/src/commands/ls_tree.rs
new file mode 100644
index 00000000..ab7cc05c
--- /dev/null
+++ b/cli/src/commands/ls_tree.rs
@@ -0,0 +1,168 @@
+use crate::commands::MevaCommand;
+use async_trait::async_trait;
+use clap::{Arg, ArgAction, ArgMatches, Command, value_parser};
+use engine::EngineContainer;
+use engine::engine_container::MevaContainer;
+use engine::handlers::ls_tree::Request;
+use engine::revision_parsing::Revision;
+use miette::{IntoDiagnostic, Result};
+use std::path::PathBuf;
+
+/// Implements the `ls-tree` command for Meva DVCS.
+///
+/// Lists the contents of a tree object or snapshot (commit) in the repository.
+/// This command lists the contents of a given tree object.
+///
+/// Supports recursive listing, filtering by path, and multiple display modes:
+/// - **default view** – shows object type, mode, and SHA-1 hash,
+/// - **long view (`-l`)** – includes file size for blob entries,
+/// - **name-only view (`--name-only`)** – prints only filenames and directories.
+///
+/// Can be used to inspect historical snapshots, branches, or arbitrary commit hashes.
+#[derive(Default)]
+pub struct LsTreeCommand;
+
+impl LsTreeCommand {
+ /// Argument key for specifying the commit, tag, or branch to inspect.
+ /// Corresponds to the `` argument (e.g., `HEAD`, branch name, or hash).
+ const ARG_SNAPSHOT_ID: &'static str = "snapshot_id";
+
+ /// Argument key for specifying optional path filters.
+ /// Allows restricting the listing to specific files or directories.
+ const ARG_PATHS: &'static str = "paths";
+
+ /// Flag key for listing entries recursively.
+ /// Corresponds to the `-r, --recursive` flag.
+ const ARG_RECURSIVE: &'static str = "recursive";
+
+ /// Flag key for including tree (directory) entries in the output.
+ /// Corresponds to the `-t` flag.
+ const ARG_TREE: &'static str = "tree";
+
+ /// Flag key for displaying additional information such as file size.
+ /// Corresponds to the `-l` flag.
+ const ARG_LONG: &'static str = "long";
+
+ /// Flag key for listing only names (without metadata).
+ /// Corresponds to the `--name-only` flag.
+ const ARG_NAME_ONLY: &'static str = "name-only";
+}
+
+#[async_trait]
+impl MevaCommand for LsTreeCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "ls-tree"
+ }
+
+ fn about(&self) -> &'static str {
+ "List the contents of a tree object or snapshot (commit) in the repository"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ /// Builds the CLI argument parser for the `ls-tree` command using `clap`.
+ ///
+ /// Adds the following options and flags:
+ /// - `-r, --recursive`: Recursively list all tree entries.
+ /// - `-t`: Include tree (directory) entries in the output.
+ /// - `-l`: Show detailed output including file sizes (for blobs).
+ /// - `--name-only`: Display only names of files and directories (no metadata).
+ /// - ``: Required identifier of the commit, branch, or tag.
+ /// - `[PATHS...]`: Optional file or directory paths to narrow the scope.
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .arg(
+ Arg::new(Self::ARG_RECURSIVE)
+ .short('r')
+ .long(Self::ARG_RECURSIVE)
+ .action(ArgAction::SetTrue)
+ .help("List tree entries recursively, including subdirectories"),
+ )
+ .arg(
+ Arg::new(Self::ARG_TREE)
+ .short('t')
+ .action(ArgAction::SetTrue)
+ .help("Show tree (directory) entries as well. Without this flag, only file (blob) entries are shown"),
+ )
+ .arg(
+ Arg::new(Self::ARG_LONG)
+ .short('l')
+ .action(ArgAction::SetTrue)
+ .conflicts_with(Self::ARG_NAME_ONLY)
+ .help("Show file sizes (in bytes) for blob entries. Cannot be used together with --name-only"),
+ )
+ .arg(
+ Arg::new(Self::ARG_NAME_ONLY)
+ .long(Self::ARG_NAME_ONLY)
+ .action(ArgAction::SetTrue)
+ .conflicts_with(Self::ARG_LONG)
+ .help("List only filenames (and directory names), without type, mode, or object ID"),
+ )
+ .arg(
+ Arg::new(Self::ARG_SNAPSHOT_ID)
+ .value_name("SNAPSHOT-ID")
+ .value_parser(value_parser!(Revision))
+ .required(true)
+ .help("Snapshot identifier (e.g. HEAD, commit hash, tag, or branch)"),
+ )
+ .arg(
+ Arg::new(Self::ARG_PATHS)
+ .value_name("PATHS")
+ .num_args(0..)
+ .last(true)
+ .help("Optional path to a directory or file within the tree"),
+ )
+ }
+
+ /// Executes the `ls-tree` command.
+ ///
+ /// Reads the provided CLI arguments (revision, paths, flags) and performs
+ /// a repository tree listing. Depending on the options, it may:
+ ///
+ /// * List the entire tree (`--recursive`),
+ /// * Include directories (`-t`),
+ /// * Show file sizes (`-l`),
+ /// * Or display only file and directory names (`--name-only`).
+ ///
+ /// # Returns
+ ///
+ /// * [`Result`]: Indicates success or a diagnostic error
+ /// if the tree resolution or listing fails.
+ async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> {
+ let recursive = matches.get_flag(Self::ARG_RECURSIVE);
+ let tree = matches.get_flag(Self::ARG_TREE);
+ let long = matches.get_flag(Self::ARG_LONG);
+ let name_only = matches.get_flag(Self::ARG_NAME_ONLY);
+ let revision = matches
+ .get_one::(Self::ARG_SNAPSHOT_ID)
+ .cloned()
+ .unwrap();
+ let paths = match matches.get_many::(Self::ARG_PATHS) {
+ Some(vals) => vals.map(PathBuf::from).collect(),
+ None => Vec::new(),
+ };
+
+ let request = Request {
+ recursive,
+ tree,
+ long,
+ name_only,
+ revision,
+ paths,
+ };
+
+ let handler = container.ls_tree_handler().into_diagnostic()?;
+
+ let response = handler.handle_ls_tree(request).into_diagnostic()?;
+
+ for entry in response.display_entries {
+ println!("{entry}");
+ }
+
+ Ok(())
+ }
+}
diff --git a/cli/src/commands/merge.rs b/cli/src/commands/merge.rs
new file mode 100644
index 00000000..0b989618
--- /dev/null
+++ b/cli/src/commands/merge.rs
@@ -0,0 +1,130 @@
+use async_trait::async_trait;
+use clap::{Arg, ArgAction, ArgMatches, Command};
+use miette::Result;
+
+use engine::engine_container::MevaContainer;
+
+use crate::{commands::MevaCommand, extensions::WithVerbose};
+
+/// Implements the `merge` command for Meva DVCS.
+#[derive(Default)]
+pub struct MergeCommand;
+
+impl MergeCommand {
+ /// The branch to merge into the current branch
+ const ARG_BRANCH: &'static str = "branch";
+
+ /// Merge changes but do not create a merge commit (squash)
+ const ARG_SQUASH: &'static str = "squash";
+
+ /// Abort the current merge process and restore the previous state
+ const ARG_ABORT: &'static str = "abort";
+
+ /// Use the given message as the merge commit message
+ const ARG_MESSAGE: &'static str = "message";
+
+ /// Allow fast-forward merge if possible
+ const ARG_FAST_FORWARD: &'static str = "ff";
+
+ /// Create a merge commit even when fast-forward is possible
+ const ARG_NO_FAST_FORWARD: &'static str = "no-ff";
+}
+
+#[async_trait]
+impl MevaCommand for MergeCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "merge"
+ }
+
+ fn about(&self) -> &'static str {
+ "Join development histories together"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ /// Builds the CLI argument parser for the `merge` command.
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .with_verbose_arg("Enable verbose output during the merge process")
+ .arg(
+ Arg::new(Self::ARG_BRANCH)
+ .help("The branch to merge into the current branch")
+ .value_name("BRANCH")
+ .required_unless_present(Self::ARG_ABORT),
+ )
+ .arg(
+ Arg::new(Self::ARG_ABORT)
+ .long(Self::ARG_ABORT)
+ .short('a')
+ .help("Abort the current merge process and restore the previous state")
+ .action(ArgAction::SetTrue)
+ .conflicts_with_all([
+ Self::ARG_BRANCH,
+ Self::ARG_SQUASH,
+ Self::ARG_MESSAGE,
+ Self::ARG_FAST_FORWARD,
+ Self::ARG_NO_FAST_FORWARD,
+ ]),
+ )
+ .arg(
+ Arg::new(Self::ARG_SQUASH)
+ .long(Self::ARG_SQUASH)
+ .short('s')
+ .help("Merge changes but do not create a merge commit (squash)")
+ .action(ArgAction::SetTrue),
+ )
+ .arg(
+ Arg::new(Self::ARG_FAST_FORWARD)
+ .long(Self::ARG_FAST_FORWARD)
+ .help("Allow fast-forward merge if possible")
+ .action(ArgAction::SetTrue)
+ .conflicts_with(Self::ARG_NO_FAST_FORWARD),
+ )
+ .arg(
+ Arg::new(Self::ARG_NO_FAST_FORWARD)
+ .long(Self::ARG_NO_FAST_FORWARD)
+ .help("Create a merge commit even when fast-forward is possible")
+ .action(ArgAction::SetTrue)
+ .conflicts_with(Self::ARG_FAST_FORWARD),
+ )
+ .arg(
+ Arg::new(Self::ARG_MESSAGE)
+ .long(Self::ARG_MESSAGE)
+ .short('m')
+ .value_name("MESSAGE")
+ .help("Use the given message as the merge commit message")
+ .requires(Self::ARG_BRANCH),
+ )
+ }
+
+ /// Executes the `merge` command.
+ ///
+ /// # Arguments
+ /// * `matches`: Parsed command-line arguments.
+ /// * `container`: Dependency injection container.
+ ///
+ /// # Returns
+ /// * `Result<()>`: Success or error during execution.
+ async fn execute(&self, _matches: &ArgMatches, _container: &Self::Container) -> Result<()> {
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = MergeCommand;
+ assert_eq!(cmd.name(), "merge");
+ assert_eq!(cmd.about(), "Join development histories together");
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+}
diff --git a/cli/src/commands/meva_command.rs b/cli/src/commands/meva_command.rs
new file mode 100644
index 00000000..6aa2235c
--- /dev/null
+++ b/cli/src/commands/meva_command.rs
@@ -0,0 +1,62 @@
+use async_trait::async_trait;
+use clap::{ArgMatches, Command};
+use engine::{EngineContainer, engine_container::MevaContainer};
+use miette::Result;
+
+/// A trait representing a top-level command in the Meva CLI.
+#[async_trait]
+pub trait MevaCommand: Send + Sync {
+ type Container: EngineContainer + Send + Sync;
+
+ /// Returns the unique name of the command.
+ fn name(&self) -> &'static str;
+
+ /// Returns a brief description of what the command does.
+ fn about(&self) -> &'static str;
+
+ /// Returns the version string for the command.
+ fn version(&self) -> &'static str;
+
+ /// Builds and returns the `clap::Command` for this command.
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ }
+
+ /// Builds a basic `clap::Command` with name, description and version.
+ fn build_base_command(&self) -> Command {
+ let cmd = Command::new(self.name())
+ .about(self.about())
+ .version(self.version());
+
+ self.register_subcommands(cmd)
+ }
+
+ /// Adds subcommands for this command to the given command mutable instance.
+ fn register_subcommands(&self, mut cmd: Command) -> Command {
+ for sub in self.subcommands() {
+ cmd = cmd.subcommand(sub.build_command());
+ }
+ cmd
+ }
+
+ /// Executes the command logic.
+ ///
+ /// This is the entry point for the command's runtime behavior. Implementations
+ /// should use the provided `matches` to extract arguments and the `container`
+ /// to access necessary services or handlers.
+ ///
+ /// # Arguments
+ /// * `matches`: The parsed CLI arguments for this command.
+ /// * `container`: The dependency injection container providing access to core engine features.
+ ///
+ /// # Returns
+ /// * `Result<()>`: Indicates whether the execution succeeded. Errors are propagated via `miette`.
+ async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()>;
+
+ /// Returns a vector of boxed subcommands for this command.
+ ///
+ /// Default implementation returns an empty vector.
+ fn subcommands(&self) -> Vec>> {
+ Vec::new()
+ }
+}
diff --git a/cli/src/commands/plugins.rs b/cli/src/commands/plugins.rs
new file mode 100644
index 00000000..f6f35615
--- /dev/null
+++ b/cli/src/commands/plugins.rs
@@ -0,0 +1,66 @@
+pub mod subcommands;
+
+use crate::commands::{MevaCommand, execute_multiple};
+
+use async_trait::async_trait;
+use clap::ArgMatches;
+use engine::engine_container::MevaContainer;
+use miette::Result;
+use subcommands::*;
+
+/// Implements the `plugins` top-level command for Meva.
+///
+/// The `plugins` command serves as a namespace for all plugin-related operations
+/// such as listing, editing, registering, unregistering, or retrieving information
+/// about plugins.
+#[derive(Default)]
+pub struct PluginsCommand;
+
+#[async_trait]
+impl MevaCommand for PluginsCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "plugins"
+ }
+
+ fn about(&self) -> &'static str {
+ "Manage plugins"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ /// Define the set of subcommands under `plugins` namespace.
+ ///
+ /// Returns boxed instances of each ignore operation command.
+ fn subcommands(&self) -> Vec>> {
+ vec![
+ Box::new(PluginsEditCommand),
+ Box::new(PluginsInfoCommand),
+ Box::new(PluginsListCommand),
+ Box::new(PluginsRegisterCommand),
+ Box::new(PluginsUnregisterCommand),
+ ]
+ }
+
+ async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> {
+ execute_multiple(matches, container, self.subcommands()).await
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = PluginsCommand;
+ assert_eq!(cmd.name(), "plugins");
+ assert_eq!(cmd.about(), "Manage plugins");
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+}
diff --git a/cli/src/commands/plugins/subcommands.rs b/cli/src/commands/plugins/subcommands.rs
new file mode 100644
index 00000000..4a82e4e3
--- /dev/null
+++ b/cli/src/commands/plugins/subcommands.rs
@@ -0,0 +1,11 @@
+mod edit;
+mod info;
+mod list;
+mod register;
+mod unregister;
+
+pub use edit::PluginsEditCommand;
+pub use info::PluginsInfoCommand;
+pub use list::PluginsListCommand;
+pub use register::PluginsRegisterCommand;
+pub use unregister::PluginsUnregisterCommand;
diff --git a/cli/src/commands/plugins/subcommands/edit.rs b/cli/src/commands/plugins/subcommands/edit.rs
new file mode 100644
index 00000000..c99fcfd3
--- /dev/null
+++ b/cli/src/commands/plugins/subcommands/edit.rs
@@ -0,0 +1,126 @@
+use async_trait::async_trait;
+use clap::{Arg, ArgAction, ArgMatches, Command};
+use engine::{
+ ConfigLoader, EngineContainer, MevaConfigLoader,
+ engine_container::MevaContainer,
+ handlers::plugins::{EditRequest, PluginsOperations},
+};
+use miette::IntoDiagnostic;
+use plugins::{CommandType, ScopeType};
+use shared::OpenInEditor;
+
+use crate::{
+ commands::MevaCommand,
+ extensions::{WithCommandPlugin, WithScope},
+};
+
+#[derive(Default)]
+pub struct PluginsEditCommand;
+
+impl PluginsEditCommand {
+ const ARG_ENABLE: &'static str = "enable";
+
+ const ARG_DISABLE: &'static str = "disable";
+}
+
+#[async_trait]
+impl MevaCommand for PluginsEditCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "edit"
+ }
+
+ fn about(&self) -> &'static str {
+ "Open the file with plugin's source code in your default editor"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .with_command_and_plugin_arg(
+ "The command associated with the plugin",
+ "The name of the plugin to edit",
+ )
+ .with_scope_arg("Scope of the plugin")
+ .arg(
+ Arg::new(Self::ARG_ENABLE)
+ .short('e')
+ .long(Self::ARG_ENABLE)
+ .action(ArgAction::SetTrue)
+ .conflicts_with(Self::ARG_DISABLE)
+ .help("Enable plugin"),
+ )
+ .arg(
+ Arg::new(Self::ARG_DISABLE)
+ .short('d')
+ .long(Self::ARG_DISABLE)
+ .action(ArgAction::SetTrue)
+ .conflicts_with(Self::ARG_ENABLE)
+ .help("Disable plugin"),
+ )
+ }
+
+ async fn execute(
+ &self,
+ matches: &ArgMatches,
+ container: &Self::Container,
+ ) -> miette::Result<()> {
+ let command_str = matches.get_one::(Command::ARG_COMMAND).unwrap();
+ let name = matches.get_one::(Command::ARG_PLUGIN).unwrap();
+ let scope_str = matches.get_one::(Command::ARG_SCOPE).unwrap();
+ let enable = matches.get_flag(Self::ARG_ENABLE);
+ let disable = matches.get_flag(Self::ARG_DISABLE);
+
+ let enabled = if enable || disable {
+ Some(!disable)
+ } else {
+ None
+ };
+
+ let request = EditRequest {
+ command: command_str.parse::().unwrap(),
+ name: name.clone(),
+ scope: scope_str.parse::().unwrap(),
+ enabled,
+ };
+
+ let handler = container.plugins_handler().into_diagnostic()?;
+
+ let response = handler.edit(request).into_diagnostic()?;
+
+ if enabled.is_none() {
+ let loader = MevaConfigLoader::default();
+ let override_cmd = loader.get("editor.default", None).ok();
+ response
+ .source_file
+ .open_in_editor(override_cmd)
+ .into_diagnostic()?;
+ } else {
+ println!("{response}");
+ }
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = PluginsEditCommand;
+ assert_eq!(cmd.name(), "edit");
+ assert_eq!(
+ cmd.about(),
+ "Open the file with plugin's source code in your default editor"
+ );
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+}
diff --git a/cli/src/commands/plugins/subcommands/info.rs b/cli/src/commands/plugins/subcommands/info.rs
new file mode 100644
index 00000000..4720af8c
--- /dev/null
+++ b/cli/src/commands/plugins/subcommands/info.rs
@@ -0,0 +1,87 @@
+use async_trait::async_trait;
+use clap::{ArgMatches, Command};
+use engine::{
+ EngineContainer,
+ engine_container::MevaContainer,
+ handlers::plugins::{InfoRequest, PluginsOperations},
+};
+use miette::IntoDiagnostic;
+use plugins::{CommandType, ScopeType};
+
+use crate::{
+ commands::MevaCommand,
+ extensions::{WithCommandPlugin, WithScope},
+};
+
+#[derive(Default)]
+pub struct PluginsInfoCommand;
+
+#[async_trait]
+impl MevaCommand for PluginsInfoCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "info"
+ }
+
+ fn about(&self) -> &'static str {
+ "Display detailed information about a registered plugin"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .with_command_and_plugin_arg(
+ "The command associated with the plugin",
+ "The name of the plugin to show details for",
+ )
+ .with_scope_arg("Scope of the plugin")
+ }
+
+ async fn execute(
+ &self,
+ matches: &ArgMatches,
+ container: &Self::Container,
+ ) -> miette::Result<()> {
+ let command_str = matches.get_one::(Command::ARG_COMMAND).unwrap();
+ let scope_str = matches.get_one::(Command::ARG_SCOPE).unwrap();
+
+ let request = InfoRequest {
+ name: matches
+ .get_one::(Command::ARG_PLUGIN)
+ .unwrap()
+ .clone(),
+ command: command_str.parse::().unwrap(),
+ scope: scope_str.parse::().unwrap(),
+ };
+
+ let handler = container.plugins_handler().into_diagnostic()?;
+
+ let response = handler.info(request).into_diagnostic()?;
+
+ println!("{response}");
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = PluginsInfoCommand;
+ assert_eq!(cmd.name(), "info");
+ assert_eq!(
+ cmd.about(),
+ "Display detailed information about a registered plugin"
+ );
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+}
diff --git a/cli/src/commands/plugins/subcommands/list.rs b/cli/src/commands/plugins/subcommands/list.rs
new file mode 100644
index 00000000..05b98bd5
--- /dev/null
+++ b/cli/src/commands/plugins/subcommands/list.rs
@@ -0,0 +1,127 @@
+use async_trait::async_trait;
+use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command, builder::PossibleValuesParser};
+use engine::{
+ EngineContainer,
+ engine_container::MevaContainer,
+ handlers::plugins::{ListRequest, PluginsOperations},
+};
+use miette::IntoDiagnostic;
+use plugins::{CommandType, EventType, ScopeType};
+use strum::VariantNames;
+
+use crate::{commands::MevaCommand, extensions::WithScope};
+
+#[derive(Default)]
+pub struct PluginsListCommand;
+
+impl PluginsListCommand {
+ const ARG_COMMAND: &'static str = "command";
+
+ const ARG_EVENT: &'static str = "event";
+
+ const ARG_DISABLED: &'static str = "disabled";
+
+ const ARG_ENABLED: &'static str = "enabled";
+}
+
+#[async_trait]
+impl MevaCommand for PluginsListCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "list"
+ }
+
+ fn about(&self) -> &'static str {
+ "List registered plugins with optional filters"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .arg(
+ Arg::new(Self::ARG_COMMAND)
+ .index(1)
+ .required(true)
+ .value_name("COMMAND")
+ .value_parser(PossibleValuesParser::new(CommandType::VARIANTS))
+ .help("Filter plugins by associated command"),
+ )
+ .arg(
+ Arg::new(Self::ARG_EVENT)
+ .index(2)
+ .required(true)
+ .value_name("EVENT")
+ .value_parser(PossibleValuesParser::new(EventType::VARIANTS))
+ .help("Filter plugins by event"),
+ )
+ .with_scope_arg("Scope of the plugin")
+ .arg(
+ Arg::new(Self::ARG_ENABLED)
+ .short('E')
+ .long(Self::ARG_ENABLED)
+ .action(ArgAction::SetTrue)
+ .conflicts_with(Self::ARG_DISABLED)
+ .help("Include enabled plugins in the output"),
+ )
+ .arg(
+ Arg::new(Self::ARG_DISABLED)
+ .short('D')
+ .long(Self::ARG_DISABLED)
+ .action(ArgAction::SetTrue)
+ .conflicts_with(Self::ARG_ENABLED)
+ .help("Include disabled plugins in the output"),
+ )
+ .group(
+ ArgGroup::new("filter")
+ .args([Self::ARG_ENABLED, Self::ARG_DISABLED])
+ .multiple(true),
+ )
+ }
+
+ async fn execute(
+ &self,
+ matches: &ArgMatches,
+ container: &Self::Container,
+ ) -> miette::Result<()> {
+ let command_str = matches.get_one::(Self::ARG_COMMAND).unwrap();
+ let event_str = matches.get_one::(Self::ARG_EVENT).unwrap();
+ let scope_str = matches.get_one::(Command::ARG_SCOPE).unwrap();
+
+ let include_disabled = matches.get_flag(Self::ARG_DISABLED);
+ let include_enabled = matches.get_flag(Self::ARG_ENABLED) || !include_disabled;
+
+ let request = ListRequest {
+ command: command_str.parse::().unwrap(),
+ event: event_str.parse::().unwrap(),
+ scope: scope_str.parse::().unwrap(),
+ include_disabled,
+ include_enabled,
+ };
+
+ let handler = container.plugins_handler().into_diagnostic()?;
+ let response = handler.list(request).into_diagnostic()?;
+
+ println!("{response}");
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = PluginsListCommand;
+ assert_eq!(cmd.name(), "list");
+ assert_eq!(cmd.about(), "List registered plugins with optional filters");
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+}
diff --git a/cli/src/commands/plugins/subcommands/register.rs b/cli/src/commands/plugins/subcommands/register.rs
new file mode 100644
index 00000000..6142831f
--- /dev/null
+++ b/cli/src/commands/plugins/subcommands/register.rs
@@ -0,0 +1,178 @@
+use async_trait::async_trait;
+use clap::{Arg, ArgAction, ArgMatches, Command, ValueHint, builder::PossibleValuesParser};
+use engine::{
+ EngineContainer,
+ engine_container::MevaContainer,
+ handlers::plugins::{PluginsOperations, RegisterRequest},
+};
+use miette::IntoDiagnostic;
+use owo_colors::OwoColorize;
+use plugins::{CommandType, EventType, ScopeType};
+use shared::PathToString;
+use std::path::PathBuf;
+use strum::VariantNames;
+
+use crate::{
+ commands::MevaCommand,
+ extensions::{WithCommandPlugin, WithFile, WithScope},
+};
+
+#[derive(Default)]
+pub struct PluginsRegisterCommand;
+
+impl PluginsRegisterCommand {
+ const ARG_PATH: &'static str = "path";
+
+ const ARG_DESCRIPTION: &'static str = "description";
+
+ const ARG_EVENT: &'static str = "event";
+
+ const ARG_ORDER: &'static str = "order";
+
+ const ARG_TIMEOUT: &'static str = "timeout";
+
+ const ARG_DISABLED: &'static str = "disabled";
+
+ const ARG_INTERPRETER: &'static str = "interpreter";
+}
+
+#[async_trait]
+impl MevaCommand for PluginsRegisterCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "register"
+ }
+
+ fn about(&self) -> &'static str {
+ "Register a new plugin"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .with_command_and_plugin_arg("Command type (kebab-case)", "Plugin name")
+ .arg(
+ Arg::new(Self::ARG_PATH)
+ .value_name("PATH")
+ .index(3)
+ .required(true)
+ .value_parser(clap::value_parser!(PathBuf))
+ .value_hint(ValueHint::FilePath)
+ .help("Path to the script to register"),
+ )
+ .with_file_arg("Relative path to the file")
+ .with_scope_arg("Scope of the plugin")
+ .arg(
+ Arg::new(Self::ARG_DESCRIPTION)
+ .short('d')
+ .long(Self::ARG_DESCRIPTION)
+ .value_name("DESCRIPTION")
+ .help("Plugin description"),
+ )
+ .arg(
+ Arg::new(Self::ARG_EVENT)
+ .short('e')
+ .long(Self::ARG_EVENT)
+ .value_name("EVENT")
+ .default_value("post-execute")
+ .value_parser(PossibleValuesParser::new(EventType::VARIANTS))
+ .help("Event type (kebab-case)"),
+ )
+ .arg(
+ Arg::new(Self::ARG_ORDER)
+ .short('o')
+ .long(Self::ARG_ORDER)
+ .value_name("ORDER")
+ .default_value("1")
+ .value_parser(clap::value_parser!(u32))
+ .help("Execution order (integer)"),
+ )
+ .arg(
+ Arg::new(Self::ARG_TIMEOUT)
+ .short('t')
+ .long(Self::ARG_TIMEOUT)
+ .value_name("TIMEOUT")
+ .value_parser(clap::value_parser!(u64))
+ .help("Execution timeout in milliseconds (integer)"),
+ )
+ .arg(
+ Arg::new(Self::ARG_DISABLED)
+ .short('D')
+ .long(Self::ARG_DISABLED)
+ .action(ArgAction::SetTrue)
+ .help("Disable plugin"),
+ )
+ .arg(
+ Arg::new(Self::ARG_INTERPRETER)
+ .short('i')
+ .long(Self::ARG_INTERPRETER)
+ .value_name("INTERPRETER")
+ .required(false)
+ .help("Interpreter used use to run the script (e.g. 'python3')"),
+ )
+ }
+
+ async fn execute(
+ &self,
+ matches: &ArgMatches,
+ container: &Self::Container,
+ ) -> miette::Result<()> {
+ let command_str = matches.get_one::(Command::ARG_COMMAND).unwrap();
+ let event_str = matches.get_one::(Self::ARG_EVENT).unwrap();
+ let scope_str = matches.get_one::(Command::ARG_SCOPE).unwrap();
+
+ let request = RegisterRequest {
+ path: matches.get_one::(Self::ARG_PATH).unwrap().clone(),
+ scope: scope_str.parse::().unwrap(),
+ name: matches
+ .get_one::(Command::ARG_PLUGIN)
+ .unwrap()
+ .clone(),
+ description: matches.get_one::(Self::ARG_DESCRIPTION).cloned(),
+ file: matches.get_one::(Command::ARG_FILE).cloned(),
+ command: command_str.parse::().unwrap(),
+ event: event_str.parse::().unwrap(),
+ order: *matches.get_one::(Self::ARG_ORDER).unwrap(),
+ timeout: matches.get_one::(Self::ARG_TIMEOUT).cloned(),
+ enabled: !matches.get_flag(Self::ARG_DISABLED),
+ interpreter: matches.get_one::(Self::ARG_INTERPRETER).cloned(),
+ };
+
+ let handler = container.plugins_handler().into_diagnostic()?;
+
+ let response = handler.register(request).into_diagnostic()?;
+
+ println!("{}", "Plugin registered successfully!".green().bold());
+
+ println!(
+ "Source code copied to: {}",
+ response.plugin_source_file.to_utf8_string().cyan()
+ );
+
+ println!(
+ "Metadata saved at: {}",
+ response.plugins_metadata_file.to_utf8_string().cyan()
+ );
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = PluginsRegisterCommand;
+ assert_eq!(cmd.name(), "register");
+ assert_eq!(cmd.about(), "Register a new plugin");
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+}
diff --git a/cli/src/commands/plugins/subcommands/unregister.rs b/cli/src/commands/plugins/subcommands/unregister.rs
new file mode 100644
index 00000000..82e05941
--- /dev/null
+++ b/cli/src/commands/plugins/subcommands/unregister.rs
@@ -0,0 +1,98 @@
+use async_trait::async_trait;
+use clap::{ArgMatches, Command};
+use engine::{
+ EngineContainer,
+ engine_container::MevaContainer,
+ handlers::plugins::{PluginsOperations, UnregisterRequest},
+};
+use miette::IntoDiagnostic;
+use owo_colors::OwoColorize;
+use plugins::{CommandType, ScopeType};
+
+use crate::{
+ commands::MevaCommand,
+ extensions::{WithCommandPlugin, WithScope},
+};
+
+#[derive(Default)]
+pub struct PluginsUnregisterCommand;
+
+#[async_trait]
+impl MevaCommand for PluginsUnregisterCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "unregister"
+ }
+
+ fn about(&self) -> &'static str {
+ "Unregister a previously registered plugin"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .with_command_and_plugin_arg(
+ "The command associated with the plugin",
+ "The plugin name to unregister",
+ )
+ .with_scope_arg("Scope of the plugin")
+ }
+
+ async fn execute(
+ &self,
+ matches: &ArgMatches,
+ container: &Self::Container,
+ ) -> miette::Result<()> {
+ let command_str = matches.get_one::(Command::ARG_COMMAND).unwrap();
+ let default_scope = ScopeType::Local.to_string();
+ let scope_str = matches
+ .get_one::(Command::ARG_SCOPE)
+ .unwrap_or(&default_scope);
+
+ let request = UnregisterRequest {
+ command: command_str.parse::().unwrap().clone(),
+ name: matches
+ .get_one::(Command::ARG_PLUGIN)
+ .unwrap()
+ .clone(),
+ scope: scope_str.parse::().unwrap(),
+ };
+
+ let handler = container.plugins_handler().into_diagnostic()?;
+
+ let response = handler.unregister(request).into_diagnostic()?;
+
+ println!("{}", "Plugin unregistered successfully!".green().bold());
+
+ println!(
+ "Removed entry from configuration file: {}",
+ response.plugins_metadata_file.display().cyan()
+ );
+
+ println!(
+ "Deleted source code file: {}",
+ response.plugin_source_file.display().cyan()
+ );
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = PluginsUnregisterCommand;
+ assert_eq!(cmd.name(), "unregister");
+ assert_eq!(cmd.about(), "Unregister a previously registered plugin");
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+}
diff --git a/cli/src/commands/pull.rs b/cli/src/commands/pull.rs
new file mode 100644
index 00000000..71167a6e
--- /dev/null
+++ b/cli/src/commands/pull.rs
@@ -0,0 +1,85 @@
+use async_trait::async_trait;
+use clap::{Arg, ArgMatches, Command};
+use miette::Result;
+
+use engine::engine_container::MevaContainer;
+
+use crate::{commands::MevaCommand, extensions::WithVerbose};
+
+/// Implements the `pull` command for Meva DVCS.
+#[derive(Default)]
+pub struct PullCommand;
+
+impl PullCommand {
+ const ARG_ORIGIN: &'static str = "origin";
+
+ const ARG_BRANCH: &'static str = "branch";
+}
+
+#[async_trait]
+impl MevaCommand for PullCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "pull"
+ }
+
+ fn about(&self) -> &'static str {
+ "Fetch and integrate the latest changes from the remote repository into your local branch"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ /// Builds the CLI argument parser for the `pull` command.
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .with_verbose_arg("Enable verbose output when pulling from remote server")
+ .arg(
+ Arg::new(Self::ARG_ORIGIN)
+ .value_name("ORIGIN")
+ .index(1)
+ .required(false)
+ .default_value("origin")
+ .help("Name of the remote repository to pull from"),
+ )
+ .arg(
+ Arg::new(Self::ARG_BRANCH)
+ .value_name("BRANCH")
+ .index(2)
+ .required(false)
+ .help("Name of the branch to pull from"),
+ )
+ }
+
+ /// Executes the `pull` command.
+ ///
+ /// # Arguments
+ /// * `matches`: Parsed command-line arguments.
+ /// * `container`: Dependency injection container.
+ ///
+ /// # Returns
+ /// * `Result<()>`: Success or error during execution.
+ async fn execute(&self, _matches: &ArgMatches, _container: &Self::Container) -> Result<()> {
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = PullCommand;
+ assert_eq!(cmd.name(), "pull");
+ assert_eq!(
+ cmd.about(),
+ "Fetch and integrate the latest changes from the remote repository into your local branch"
+ );
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+}
diff --git a/cli/src/commands/push.rs b/cli/src/commands/push.rs
new file mode 100644
index 00000000..dd9e54c0
--- /dev/null
+++ b/cli/src/commands/push.rs
@@ -0,0 +1,120 @@
+use async_trait::async_trait;
+use clap::{Arg, ArgAction, ArgMatches, Command};
+use miette::{IntoDiagnostic, Result};
+
+use engine::{EngineContainer, engine_container::MevaContainer, handlers::push::Request};
+
+use crate::{commands::MevaCommand, extensions::WithVerbose};
+
+/// Implements the `push` command for Meva DVCS.
+///
+/// This command pushes local commits to a remote repository.
+/// It supports pushing specific branches or all local branches
+/// Deletion of remote branches is also supported.
+#[derive(Default)]
+pub struct PushCommand;
+
+impl PushCommand {
+ const ARG_ORIGIN: &'static str = "origin";
+
+ const ARG_BRANCH: &'static str = "branch";
+
+ const ARG_ALL: &'static str = "all";
+
+ const ARG_DELETE: &'static str = "delete";
+}
+
+#[async_trait]
+impl MevaCommand for PushCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "push"
+ }
+
+ fn about(&self) -> &'static str {
+ "Push local commits to a remote repository"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ /// Builds the CLI argument parser for the `push` command.
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .with_verbose_arg("Enable verbose output when pushing to remote server")
+ .arg(
+ Arg::new(Self::ARG_ORIGIN)
+ .value_name("ORIGIN")
+ .index(1)
+ .required(false)
+ .default_value("origin")
+ .help("Name of the remote repository to push to"),
+ )
+ .arg(
+ Arg::new(Self::ARG_BRANCH)
+ .value_name("BRANCH")
+ .index(2)
+ .required(false)
+ .help("Name of the branch to push to"),
+ )
+ .arg(
+ Arg::new(Self::ARG_ALL)
+ .long(Self::ARG_ALL)
+ .short('a')
+ .help("Push all local branches to the specified remote")
+ .action(ArgAction::SetTrue)
+ .conflicts_with_all([Self::ARG_BRANCH, Self::ARG_DELETE]),
+ )
+ .arg(
+ Arg::new(Self::ARG_DELETE)
+ .long(Self::ARG_DELETE)
+ .short('d')
+ .help("Delete the given remote branch.")
+ .action(ArgAction::SetTrue)
+ .requires(Self::ARG_BRANCH),
+ )
+ }
+
+ /// Executes the `push` command.
+ ///
+ /// # Arguments
+ /// * `matches`: Parsed command-line arguments.
+ /// * `container`: Dependency injection container.
+ ///
+ /// # Returns
+ /// * `Result<()>`: Success or error during execution.
+ async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> {
+ let request = Request {
+ origin: matches.get_one::(Self::ARG_ORIGIN).unwrap().clone(),
+ branch: matches.get_one::(Self::ARG_BRANCH).cloned(),
+ all: matches.get_flag(Self::ARG_ALL),
+ delete: matches.get_flag(Self::ARG_DELETE),
+ verbose: matches.get_flag(Command::ARG_VERBOSE),
+ };
+
+ let handler = container.push_handler().into_diagnostic()?;
+
+ let response = handler.handle_push(request).await.into_diagnostic()?;
+
+ println!();
+ println!("{response}");
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = PushCommand;
+ assert_eq!(cmd.name(), "push");
+ assert_eq!(cmd.about(), "Push local commits to a remote repository");
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+}
diff --git a/cli/src/commands/remote.rs b/cli/src/commands/remote.rs
new file mode 100644
index 00000000..2ed0f790
--- /dev/null
+++ b/cli/src/commands/remote.rs
@@ -0,0 +1,113 @@
+mod subcommands;
+
+use subcommands::*;
+
+use async_trait::async_trait;
+use clap::{ArgMatches, Command};
+use miette::{IntoDiagnostic, Result};
+
+use engine::{
+ EngineContainer, engine_container::MevaContainer, handlers::remote::RemoteOperations,
+};
+
+use crate::{
+ commands::{MevaCommand, execute_multiple},
+ extensions::WithVerbose,
+};
+
+/// Implements the `remote` command for Meva DVCS.
+///
+/// This command manages the set of tracked repositories ("remotes").
+/// It acts as a parent command for operations like adding, removing, or renaming remotes.
+/// If no subcommand is provided, it defaults to listing the configured remotes.
+#[derive(Default)]
+pub struct RemoteCommand;
+
+#[async_trait]
+impl MevaCommand for RemoteCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "remote"
+ }
+
+ fn about(&self) -> &'static str {
+ "Manage remote repositories"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ /// Builds the CLI argument parser for the `remote` command.
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .with_verbose_arg("Enable verbose output when listing remotes")
+ }
+
+ /// Registers the list of available subcommands for `remote`.
+ ///
+ /// Includes: `add`, `get-url`, `remove`, `rename`, `set-url`, and `show`.
+ fn subcommands(&self) -> Vec>> {
+ vec![
+ Box::new(RemoteAddCommand),
+ Box::new(RemoteGetUrlCommand),
+ Box::new(RemoteRemoveCommand),
+ Box::new(RemoteRenameCommand),
+ Box::new(RemoteSetUrlCommand),
+ Box::new(RemoteShowCommand),
+ ]
+ }
+
+ /// Executes the `remote` command.
+ ///
+ /// Delegates execution to a subcommand if one is specified in `matches`.
+ /// If no subcommand is provided, it performs the default action of listing
+ /// the configured remotes (checking `ARG_VERBOSE` for detailed output).
+ ///
+ /// # Arguments
+ /// * `matches`: Parsed command-line arguments.
+ /// * `container`: Dependency injection container.
+ ///
+ /// # Returns
+ /// * `Result<()>`: Success or error during execution.
+ async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> {
+ // Try to execute a subcommand first
+ let handled = execute_multiple(matches, container, self.subcommands()).await?;
+
+ // If no subcommand was invoked, fall back to listing remotes
+ if matches.subcommand_name().is_none() {
+ let verbose = matches.get_flag(Command::ARG_VERBOSE);
+ let handler = container.remote_handler().into_diagnostic()?;
+ let response = handler.list().into_diagnostic()?;
+
+ let mut remotes_vec: Vec<_> = response.remotes.iter().collect();
+ remotes_vec.sort_by(|(name_a, _), (name_b, _)| name_a.cmp(name_b));
+
+ for (name, entry) in remotes_vec {
+ if verbose {
+ println!("{}", entry.display_verbose(name));
+ } else {
+ println!("{name}");
+ }
+ }
+ }
+
+ Ok(handled)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = RemoteCommand;
+ assert_eq!(cmd.name(), "remote");
+ assert_eq!(cmd.about(), "Manage remote repositories");
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+}
diff --git a/cli/src/commands/remote/subcommands.rs b/cli/src/commands/remote/subcommands.rs
new file mode 100644
index 00000000..bdba6f51
--- /dev/null
+++ b/cli/src/commands/remote/subcommands.rs
@@ -0,0 +1,13 @@
+mod add;
+mod get_url;
+mod remove;
+mod rename;
+mod set_url;
+mod show;
+
+pub use add::RemoteAddCommand;
+pub use get_url::RemoteGetUrlCommand;
+pub use remove::RemoteRemoveCommand;
+pub use rename::RemoteRenameCommand;
+pub use set_url::RemoteSetUrlCommand;
+pub use show::RemoteShowCommand;
diff --git a/cli/src/commands/remote/subcommands/add.rs b/cli/src/commands/remote/subcommands/add.rs
new file mode 100644
index 00000000..4e43d93b
--- /dev/null
+++ b/cli/src/commands/remote/subcommands/add.rs
@@ -0,0 +1,114 @@
+use std::path::PathBuf;
+
+use async_trait::async_trait;
+use clap::{Arg, ArgMatches, Command, ValueHint};
+use engine::{
+ EngineContainer,
+ engine_container::MevaContainer,
+ handlers::remote::{AddRequest, RemoteOperations},
+};
+use miette::IntoDiagnostic;
+use url::Url;
+
+use crate::{commands::MevaCommand, extensions::WithName};
+
+/// Implements the `remote add` subcommand for Meva DVCS.
+///
+/// This command registers a new remote repository with a specific name and URL
+/// in the local repository configuration. It optionally performs an immediate fetch.
+#[derive(Default)]
+pub struct RemoteAddCommand;
+
+impl RemoteAddCommand {
+ /// Argument name for the remote URL.
+ const ARG_URL: &'static str = "url";
+
+ /// Argument name for specifying the server public key.
+ const ARG_SERVER_KEY: &'static str = "server-key";
+}
+
+#[async_trait]
+impl MevaCommand for RemoteAddCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "add"
+ }
+
+ fn about(&self) -> &'static str {
+ "Add a new remote repository"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ /// Builds the CLI argument parser for the `remote add` command.
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .with_name_arg("Name for the remote")
+ .arg(
+ Arg::new(Self::ARG_URL)
+ .value_name("URL")
+ .index(2)
+ .required(true)
+ .value_parser(clap::value_parser!(Url))
+ .help("Repository URL (e.g. ssh://user@host:port/repository)"),
+ )
+ .arg(
+ Arg::new(Self::ARG_SERVER_KEY)
+ .value_name("SERVER_KEY")
+ .index(3)
+ .required(true)
+ .value_hint(ValueHint::FilePath)
+ .value_parser(clap::value_parser!(PathBuf))
+ .help("Path to server's public key"),
+ )
+ }
+
+ /// Executes the `remote add` command.
+ ///
+ /// Registers the new remote configuration. If the configuration is successful
+ /// and the fetch flag is set, it initiates a fetch operation from the new remote.
+ async fn execute(
+ &self,
+ matches: &ArgMatches,
+ container: &Self::Container,
+ ) -> miette::Result<()> {
+ let name = matches
+ .get_one::(Command::ARG_NAME)
+ .unwrap()
+ .to_string();
+
+ let request = AddRequest {
+ name: name.clone(),
+ url: matches.get_one::(Self::ARG_URL).unwrap().clone(),
+ pub_key: matches
+ .get_one::(Self::ARG_SERVER_KEY)
+ .unwrap()
+ .clone(),
+ };
+
+ let handler = container.remote_handler().into_diagnostic()?;
+
+ let response = handler.add(request).into_diagnostic()?;
+
+ println!("{}", response.remote.display_verbose(&name));
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = RemoteAddCommand;
+ assert_eq!(cmd.name(), "add");
+ assert_eq!(cmd.about(), "Add a new remote repository");
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+}
diff --git a/cli/src/commands/remote/subcommands/get_url.rs b/cli/src/commands/remote/subcommands/get_url.rs
new file mode 100644
index 00000000..0c570ce6
--- /dev/null
+++ b/cli/src/commands/remote/subcommands/get_url.rs
@@ -0,0 +1,96 @@
+use async_trait::async_trait;
+use clap::{Arg, ArgMatches, Command, builder::PossibleValuesParser};
+use engine::{
+ EngineContainer,
+ engine_container::MevaContainer,
+ handlers::remote::{GetUrlRequest, RemoteOperations},
+ network::RemoteDirection,
+};
+use miette::IntoDiagnostic;
+use strum::VariantNames;
+
+use crate::{commands::MevaCommand, extensions::WithName};
+
+/// Implements the `remote get-url` subcommand for Meva DVCS.
+///
+/// This command retrieves and displays the URL associated with a tracked remote.
+/// It allows filtering based on whether the URL is used for fetching or pushing.
+#[derive(Default)]
+pub struct RemoteGetUrlCommand;
+
+impl RemoteGetUrlCommand {
+ /// Argument name for the direction selection (fetch/push).
+ const ARG_DIRECTION: &'static str = "direction";
+}
+
+#[async_trait]
+impl MevaCommand for RemoteGetUrlCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "get-url"
+ }
+
+ fn about(&self) -> &'static str {
+ "Show the URL of a remote"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ /// Builds the CLI argument parser for the `remote get-url` command.
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .with_name_arg("Name for the remote")
+ .arg(
+ Arg::new(Self::ARG_DIRECTION)
+ .long(Self::ARG_DIRECTION)
+ .short('d')
+ .value_name("DIRECTION")
+ .default_value("fetch")
+ .value_parser(PossibleValuesParser::new(RemoteDirection::VARIANTS))
+ .help("Select which URL(s) to show"),
+ )
+ }
+
+ /// Executes the `remote get-url` command.
+ ///
+ /// Looks up the specified remote in the configuration and prints the requested URL.
+ async fn execute(
+ &self,
+ matches: &ArgMatches,
+ container: &Self::Container,
+ ) -> miette::Result<()> {
+ let direction_str = matches.get_one::(Self::ARG_DIRECTION).unwrap();
+ let request = GetUrlRequest {
+ name: matches
+ .get_one::(Command::ARG_NAME)
+ .unwrap()
+ .clone(),
+ direction: direction_str.parse::().unwrap(),
+ };
+
+ let handler = container.remote_handler().into_diagnostic()?;
+ let response = handler.get_url(request).into_diagnostic()?;
+
+ println!("{}", response.url);
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = RemoteGetUrlCommand;
+ assert_eq!(cmd.name(), "get-url");
+ assert_eq!(cmd.about(), "Show the URL of a remote");
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+}
diff --git a/cli/src/commands/remote/subcommands/remove.rs b/cli/src/commands/remote/subcommands/remove.rs
new file mode 100644
index 00000000..61c0e238
--- /dev/null
+++ b/cli/src/commands/remote/subcommands/remove.rs
@@ -0,0 +1,78 @@
+use async_trait::async_trait;
+use clap::{ArgMatches, Command};
+use engine::{
+ EngineContainer,
+ engine_container::MevaContainer,
+ handlers::remote::{RemoteOperations, RemoveRequest},
+};
+use miette::IntoDiagnostic;
+
+use crate::{commands::MevaCommand, extensions::WithName};
+
+/// Implements the `remote remove` subcommand for Meva DVCS.
+///
+/// This command removes a remote repository configuration from the local repository.
+/// Once removed, the repository will no longer track changes from that specific remote.
+#[derive(Default)]
+pub struct RemoteRemoveCommand;
+
+#[async_trait]
+impl MevaCommand for RemoteRemoveCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "remove"
+ }
+
+ fn about(&self) -> &'static str {
+ "Remove an existing remote"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ /// Builds the CLI argument parser for the `remote remove` command.
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .with_name_arg("Name for the remote")
+ }
+
+ /// Executes the `remote remove` command.
+ ///
+ /// Deletes the configuration for the specified remote name.
+ async fn execute(
+ &self,
+ matches: &ArgMatches,
+ container: &Self::Container,
+ ) -> miette::Result<()> {
+ let request = RemoveRequest {
+ name: matches
+ .get_one::(Command::ARG_NAME)
+ .unwrap()
+ .clone(),
+ };
+
+ let handler = container.remote_handler().into_diagnostic()?;
+ let response = handler.remove(request).into_diagnostic()?;
+
+ println!("{}", response.removed_value);
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = RemoteRemoveCommand;
+ assert_eq!(cmd.name(), "remove");
+ assert_eq!(cmd.about(), "Remove an existing remote");
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+}
diff --git a/cli/src/commands/remote/subcommands/rename.rs b/cli/src/commands/remote/subcommands/rename.rs
new file mode 100644
index 00000000..08b5aec3
--- /dev/null
+++ b/cli/src/commands/remote/subcommands/rename.rs
@@ -0,0 +1,108 @@
+use async_trait::async_trait;
+use clap::{Arg, ArgMatches, Command};
+use engine::{
+ EngineContainer,
+ engine_container::MevaContainer,
+ handlers::remote::{RemoteOperations, RenameRequest},
+};
+use miette::IntoDiagnostic;
+
+use crate::commands::MevaCommand;
+
+/// Implements the `remote rename` subcommand for Meva DVCS.
+///
+/// This command updates the identifier of an existing remote repository.
+/// It changes the name used to reference the remote in other commands
+/// (like fetch or push) without altering the remote URL itself.
+#[derive(Default)]
+pub struct RemoteRenameCommand;
+
+impl RemoteRenameCommand {
+ /// Argument name for the new name of the remote.
+ const ARG_NEW_NAME: &'static str = "new-name";
+
+ /// Argument name for the current (old) name of the remote.
+ const ARG_OLD_NAME: &'static str = "old-name";
+}
+
+#[async_trait]
+impl MevaCommand for RemoteRenameCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "rename"
+ }
+
+ fn about(&self) -> &'static str {
+ "Rename a remote"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ /// Builds the CLI argument parser for the `remote rename` command.
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .arg(
+ Arg::new(Self::ARG_OLD_NAME)
+ .value_name("OLD_NAME")
+ .index(1)
+ .required(true)
+ .help("Current name of the remote"),
+ )
+ .arg(
+ Arg::new(Self::ARG_NEW_NAME)
+ .value_name("NEW_NAME")
+ .index(2)
+ .required(true)
+ .help("New name for the remote"),
+ )
+ }
+
+ /// Executes the `remote rename` command.
+ ///
+ /// Validates that the old name exists and the new name is not already taken,
+ /// then updates the repository configuration to reflect the name change.
+ async fn execute(
+ &self,
+ matches: &ArgMatches,
+ container: &Self::Container,
+ ) -> miette::Result<()> {
+ let request = RenameRequest {
+ old_name: matches
+ .get_one::(Self::ARG_OLD_NAME)
+ .unwrap()
+ .clone(),
+ new_name: matches
+ .get_one::(Self::ARG_NEW_NAME)
+ .unwrap()
+ .clone(),
+ };
+
+ let handler = container.remote_handler().into_diagnostic()?;
+ let response = handler.rename(request).into_diagnostic()?;
+
+ println!(
+ "Remote renamed: {} -> {}",
+ response.old_name, response.new_name
+ );
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = RemoteRenameCommand;
+ assert_eq!(cmd.name(), "rename");
+ assert_eq!(cmd.about(), "Rename a remote");
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+}
diff --git a/cli/src/commands/remote/subcommands/set_url.rs b/cli/src/commands/remote/subcommands/set_url.rs
new file mode 100644
index 00000000..f2674383
--- /dev/null
+++ b/cli/src/commands/remote/subcommands/set_url.rs
@@ -0,0 +1,129 @@
+use std::path::PathBuf;
+
+use async_trait::async_trait;
+use clap::{Arg, ArgMatches, Command, ValueHint, builder::PossibleValuesParser};
+use engine::{
+ EngineContainer,
+ engine_container::MevaContainer,
+ handlers::remote::{RemoteOperations, SetUrlRequest},
+ network::RemoteDirection,
+};
+use miette::IntoDiagnostic;
+use strum::VariantNames;
+use url::Url;
+
+use crate::{commands::MevaCommand, extensions::WithName};
+
+/// Implements the `remote set-url` subcommand for Meva DVCS.
+///
+/// This command updates the URL for an existing remote repository.
+/// It supports changing either the fetch URL (default) or the push URL,
+/// and can selectively replace a specific URL if the remote has multiple.
+#[derive(Default)]
+pub struct RemoteSetUrlCommand;
+
+impl RemoteSetUrlCommand {
+ /// Argument name for the direction selection (fetch/push).
+ const ARG_DIRECTION: &'static str = "direction";
+
+ /// Argument name for the new URL.
+ const ARG_NEW_URL: &'static str = "new-url";
+
+ /// Argument name for specifying the server public key.
+ const ARG_NEW_SERVER_KEY: &'static str = "new-server-key";
+}
+
+#[async_trait]
+impl MevaCommand for RemoteSetUrlCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "set-url"
+ }
+
+ fn about(&self) -> &'static str {
+ "Set the URL of a remote"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ /// Builds the CLI argument parser for the `remote set-url` command.
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .with_name_arg("Name for the remote")
+ .arg(
+ Arg::new(Self::ARG_NEW_URL)
+ .value_name("NEW_URL")
+ .index(2)
+ .required(true)
+ .value_parser(clap::value_parser!(Url))
+ .help("New repository URL (e.g. ssh://user@host:port/repository)"),
+ )
+ .arg(
+ Arg::new(Self::ARG_NEW_SERVER_KEY)
+ .value_name("NEW_SERVER_KEY")
+ .index(3)
+ .required(true)
+ .value_hint(ValueHint::FilePath)
+ .value_parser(clap::value_parser!(PathBuf))
+ .help("Path to new server's public key"),
+ )
+ .arg(
+ Arg::new(Self::ARG_DIRECTION)
+ .long(Self::ARG_DIRECTION)
+ .short('d')
+ .value_name("DIRECTION")
+ .default_value("fetch")
+ .value_parser(PossibleValuesParser::new(RemoteDirection::VARIANTS))
+ .help("Select which URL(s) to show"),
+ )
+ }
+
+ /// Executes the `remote set-url` command.
+ ///
+ /// Updates the configuration for the specified remote.
+ async fn execute(
+ &self,
+ matches: &ArgMatches,
+ container: &Self::Container,
+ ) -> miette::Result<()> {
+ let direction_str = matches.get_one::(Self::ARG_DIRECTION).unwrap();
+
+ let request = SetUrlRequest {
+ name: matches
+ .get_one::(Command::ARG_NAME)
+ .unwrap()
+ .clone(),
+ new_url: matches.get_one::(Self::ARG_NEW_URL).unwrap().clone(),
+ new_server_key: matches
+ .get_one::(Self::ARG_NEW_SERVER_KEY)
+ .unwrap()
+ .clone(),
+ direction: direction_str.parse::().unwrap(),
+ };
+
+ let handler = container.remote_handler().into_diagnostic()?;
+ handler.set_url(request).into_diagnostic()?;
+
+ println!("Remote URL changed successfully.");
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = RemoteSetUrlCommand;
+ assert_eq!(cmd.name(), "set-url");
+ assert_eq!(cmd.about(), "Set the URL of a remote");
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+}
diff --git a/cli/src/commands/remote/subcommands/show.rs b/cli/src/commands/remote/subcommands/show.rs
new file mode 100644
index 00000000..cc6536dc
--- /dev/null
+++ b/cli/src/commands/remote/subcommands/show.rs
@@ -0,0 +1,100 @@
+use async_trait::async_trait;
+use clap::{Arg, ArgAction, ArgMatches, Command};
+use engine::{
+ EngineContainer,
+ engine_container::MevaContainer,
+ handlers::remote::{RemoteOperations, ShowRequest},
+};
+use miette::IntoDiagnostic;
+use owo_colors::OwoColorize;
+
+use crate::{
+ commands::MevaCommand,
+ extensions::{WithName, WithVerbose},
+};
+
+/// Implements the `remote show` subcommand for Meva DVCS.
+///
+/// This command displays detailed information about a specific remote repository,
+/// such as its URLs and the status of tracked branches. It can optionally retrieve
+/// live data from the remote or rely solely on local configuration.
+#[derive(Default)]
+pub struct RemoteShowCommand;
+
+impl RemoteShowCommand {
+ /// Argument name for the no-fetch flag.
+ const ARG_NO_FETCH: &'static str = "no-fetch";
+}
+
+#[async_trait]
+impl MevaCommand for RemoteShowCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "show"
+ }
+
+ fn about(&self) -> &'static str {
+ "Show the URL of a remote"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ /// Builds the CLI argument parser for the `remote show` command.
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .with_name_arg("Name for the remote")
+ .with_verbose_arg("Enable verbose output")
+ .arg(
+ Arg::new(Self::ARG_NO_FETCH)
+ .short('n')
+ .long("no-fetch")
+ .action(ArgAction::SetTrue)
+ .help("Use local configuration for tracked branches"),
+ )
+ }
+
+ /// Executes the `remote show` command.
+ ///
+ /// Retrieves and prints details about the specified remote.
+ /// Unless `--no-fetch` is specified, this command may attempt to contact
+ /// the remote server to display the most up-to-date branch status.
+ async fn execute(
+ &self,
+ matches: &ArgMatches,
+ container: &Self::Container,
+ ) -> miette::Result<()> {
+ let name = matches.get_one::(Command::ARG_NAME).unwrap();
+
+ let request = ShowRequest {
+ name: name.clone(),
+ no_fetch: matches.get_flag(Self::ARG_NO_FETCH),
+ verbose: matches.get_flag(Command::ARG_VERBOSE),
+ };
+
+ let handler = container.remote_handler().into_diagnostic()?;
+ let response = handler.show(request).await.into_diagnostic()?;
+
+ println!("* Remote {}", name.bold());
+ println!("{response}");
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = RemoteShowCommand;
+ assert_eq!(cmd.name(), "show");
+ assert_eq!(cmd.about(), "Show the URL of a remote");
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+}
diff --git a/cli/src/commands/restore.rs b/cli/src/commands/restore.rs
new file mode 100644
index 00000000..bec2131a
--- /dev/null
+++ b/cli/src/commands/restore.rs
@@ -0,0 +1,121 @@
+use crate::commands::MevaCommand;
+use async_trait::async_trait;
+use clap::{Arg, ArgAction, ArgMatches, Command, value_parser};
+use engine::engine_container::{EngineContainer, MevaContainer};
+use engine::handlers::restore::Request;
+use engine::revision_parsing::Revision;
+use miette::{IntoDiagnostic, Result};
+use std::path::PathBuf;
+
+/// CLI command for restoring files in the working tree or staging area.
+///
+/// The [RestoreCommand] allows users to restore files from a specific snapshot (commit)
+/// either to the working directory, the staging area, or both.
+///
+/// # Supported arguments
+/// - **`--staged`**: Restore changes in the staging area (index).
+/// - **`--worktree`**: Restore changes in the working tree.
+/// - **`--source `**: Specify the source snapshot (commit) to restore from.
+/// Defaults to `HEAD` if not provided.
+/// - **``**: Optional list of file or directory paths to restore.
+/// If omitted, the entire repository is restored.
+#[derive(Default)]
+pub struct RestoreCommand;
+
+impl RestoreCommand {
+ /// `--staged` flag key.
+ const ARG_STAGED: &'static str = "staged";
+
+ /// `--worktree` flag key.
+ const ARG_WORKTREE: &'static str = "worktree";
+
+ /// `--source` option key.
+ const ARG_SOURCE: &'static str = "source";
+
+ /// `` argument key.
+ const ARG_PATHS: &'static str = "paths";
+}
+
+#[async_trait]
+impl MevaCommand for RestoreCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "restore"
+ }
+ fn about(&self) -> &'static str {
+ "Restore files in the working tree or staging area from a snapshot"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ /// Builds the underlying Clap [`Command`] definition for this command.
+ ///
+ /// This defines supported flags, arguments, and default behaviors
+ /// for restoring files from a snapshot.
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .arg(
+ Arg::new(Self::ARG_STAGED)
+ .long(Self::ARG_STAGED)
+ .help("Restore changes in the staging area (index)")
+ .action(ArgAction::SetTrue),
+ )
+ .arg(
+ Arg::new(Self::ARG_WORKTREE)
+ .long(Self::ARG_WORKTREE)
+ .help("Restore changes in the working tree")
+ .action(ArgAction::SetTrue),
+ )
+ .arg(
+ Arg::new(Self::ARG_SOURCE)
+ .value_name("SOURCE")
+ .long(Self::ARG_SOURCE)
+ .help("Specify the source snapshot (commit) to restore from. Defaults to HEAD.")
+ .default_value("HEAD")
+ .value_parser(value_parser!(Revision)),
+ )
+ .arg(
+ Arg::new(Self::ARG_PATHS)
+ .value_name("PATHS")
+ .help("Optional paths to restore. If not specified, the whole repository is restored.")
+ .num_args(0..)
+ .last(true),
+ )
+ }
+
+ /// Executes the `restore` command.
+ ///
+ /// - Reads all flags and arguments from `matches`.
+ /// - Constructs a [`Request`] with the selected restore options.
+ /// - Delegates to the repository’s [`restore_handler`] to perform the restore operation.
+ ///
+ /// Returns a [`miette::Result`] with diagnostic information if an error occurs.
+ async fn execute(&self, matches: &ArgMatches, container: &MevaContainer) -> Result<()> {
+ let staged = matches.get_flag(Self::ARG_STAGED);
+ let worktree = matches.get_flag(Self::ARG_WORKTREE);
+ let source = matches
+ .get_one::(Self::ARG_SOURCE)
+ .cloned()
+ .unwrap();
+ let paths = match matches.get_many::(Self::ARG_PATHS) {
+ Some(vals) => vals.map(PathBuf::from).collect(),
+ None => Vec::new(),
+ };
+
+ let handler = container.restore_handler().into_diagnostic()?;
+
+ let request = Request {
+ staged,
+ worktree,
+ source,
+ paths,
+ };
+
+ handler.handle_restore(request).into_diagnostic()?;
+
+ Ok(())
+ }
+}
diff --git a/cli/src/commands/show.rs b/cli/src/commands/show.rs
new file mode 100644
index 00000000..ed9f1da1
--- /dev/null
+++ b/cli/src/commands/show.rs
@@ -0,0 +1,227 @@
+use async_trait::async_trait;
+use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command, value_parser};
+use engine::{
+ EngineContainer,
+ diff_builder::ChangeKind,
+ engine_container::MevaContainer,
+ handlers::show::{PatchMode, Request, Response},
+ revision_parsing::Revision,
+};
+use miette::{IntoDiagnostic, Result};
+
+use crate::commands::MevaCommand;
+
+/// Implements the `show` command for Meva DVCS.
+///
+/// Displays information about a specific snapshot (commit) in the repository.
+#[derive(Default)]
+pub struct ShowCommand;
+
+impl ShowCommand {
+ /// Argument name for specifying the snapshot identifier to display.
+ const ARG_SNAPSHOT_ID: &'static str = "snapshot-id";
+
+ /// Argument name for the `--patch` flag.
+ /// Displays the patch (diff) between the selected snapshot and its parent.
+ const ARG_PATCH: &'static str = "patch";
+
+ /// Argument name for the `--no-patch` flag.
+ /// Shows only the snapshot header (author, date, message) without the diff.
+ const ARG_NO_PATCH: &'static str = "no-patch";
+
+ /// Argument name for the `--name-only` flag.
+ /// Displays only the names of modified files in the snapshot.
+ const ARG_NAME_ONLY: &'static str = "name-only";
+
+ /// Argument name for the `--name-status` flag.
+ /// Displays modified file names along with their change types (A, M, D).
+ const ARG_NAME_STATUS: &'static str = "name-status";
+
+ /// Argument name for the `--stat` flag.
+ /// Displays a summary with the number of changed lines per file.
+ const ARG_STAT: &'static str = "stat";
+
+ /// Helper method for rendering the `Response` object depending on the selected `PatchMode`.
+ ///
+ /// This function handles multiple display modes:
+ /// - [PatchMode::Patch]: shows unified diffs or per-file diffs.
+ /// - [PatchMode::NameOnly]: prints only filenames.
+ /// - [PatchMode::NameStatus]: prints filenames with change kinds.
+ /// - [PatchMode::Stat]: prints summary statistics.
+ fn display_response(&self, mode: &PatchMode, response: &Response) {
+ println!("{}", response.snapshot);
+
+ match mode {
+ PatchMode::Patch => self.display_patch(response),
+ PatchMode::NameOnly => self.display_name_only(response),
+ PatchMode::NameStatus => self.display_name_status(response),
+ PatchMode::Stat => self.display_stat(response),
+ _ => (),
+ };
+ }
+
+ /// Shows unified diffs or per-file diffs.
+ fn display_patch(&self, response: &Response) {
+ if let Some(files) = &response.files {
+ for file in files {
+ file.display_full();
+ }
+ }
+ }
+
+ /// Prints only filenames.
+ fn display_name_only(&self, response: &Response) {
+ if let Some(files) = &response.files {
+ for file in files {
+ println!("{}", file.path().display());
+ }
+ }
+ }
+
+ /// Prints filenames with change kinds.
+ fn display_name_status(&self, response: &Response) {
+ if let Some(files) = &response.files {
+ for file in files {
+ println!(
+ "{}\t{}",
+ ChangeKind::from(&file.kind),
+ file.path().display()
+ );
+ }
+ }
+ }
+
+ /// Prints summary statistics.
+ fn display_stat(&self, response: &Response) {
+ if let Some(stat) = &response.stat {
+ println!("{stat}");
+ }
+ }
+}
+
+#[async_trait]
+impl MevaCommand for ShowCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "show"
+ }
+
+ fn about(&self) -> &'static str {
+ "Show information about a snapshot"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ /// Builds the CLI command structure for the `show` command.
+ ///
+ /// Defines options controlling the output format and filtering behavior:
+ /// - `--patch` (default) displays the diff.
+ /// - `--no-patch` hides diff output.
+ /// - `--name-only` and `--name-status` show affected files in different formats.
+ /// - `--stat` displays change statistics.
+ ///
+ /// Uses `ArgGroup` to make these format options mutually exclusive.
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .arg(
+ Arg::new(Self::ARG_SNAPSHOT_ID)
+ .value_name("SNAPSHOT_ID")
+ .help("The snapshot identifier")
+ .default_value("HEAD")
+ .value_parser(value_parser!(Revision))
+ .index(1),
+ )
+ .arg(
+ Arg::new(Self::ARG_PATCH)
+ .short('p')
+ .long(Self::ARG_PATCH)
+ .help("Show the patch (diff) between the snapshot and its parent")
+ .action(ArgAction::SetTrue),
+ )
+ .arg(
+ Arg::new(Self::ARG_NO_PATCH)
+ .long(Self::ARG_NO_PATCH)
+ .help("Show only the snapshot header (author, date, message), without the diff")
+ .action(ArgAction::SetTrue),
+ )
+ .arg(
+ Arg::new(Self::ARG_NAME_ONLY)
+ .long(Self::ARG_NAME_ONLY)
+ .help("List only the files modified in the snapshot")
+ .action(ArgAction::SetTrue),
+ )
+ .arg(
+ Arg::new(Self::ARG_NAME_STATUS)
+ .long(Self::ARG_NAME_STATUS)
+ .help("Show the list of files with the type of change (A, M, D)")
+ .action(ArgAction::SetTrue),
+ )
+ .arg(
+ Arg::new(Self::ARG_STAT)
+ .long(Self::ARG_STAT)
+ .help("Add statistics about the number of changed lines for each file")
+ .action(ArgAction::SetTrue),
+ )
+ .group(
+ ArgGroup::new("format")
+ .args([
+ Self::ARG_PATCH,
+ Self::ARG_NO_PATCH,
+ Self::ARG_NAME_ONLY,
+ Self::ARG_NAME_STATUS,
+ Self::ARG_STAT,
+ ])
+ .multiple(false),
+ )
+ }
+
+ /// Executes the `show` command.
+ async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> {
+ let snapshot_id = matches.get_one::(Self::ARG_SNAPSHOT_ID).unwrap();
+
+ let handler = container.show_handler().into_diagnostic()?;
+
+ let mode = if matches.get_flag(Self::ARG_PATCH) {
+ PatchMode::Patch
+ } else if matches.get_flag(Self::ARG_NO_PATCH) {
+ PatchMode::NoPatch
+ } else if matches.get_flag(Self::ARG_NAME_ONLY) {
+ PatchMode::NameOnly
+ } else if matches.get_flag(Self::ARG_NAME_STATUS) {
+ PatchMode::NameStatus
+ } else if matches.get_flag(Self::ARG_STAT) {
+ PatchMode::Stat
+ } else {
+ PatchMode::Patch
+ };
+
+ let request = Request {
+ snapshot_id: snapshot_id.clone(),
+ mode,
+ };
+
+ let response = handler.handle_show(request).into_diagnostic()?;
+
+ self.display_response(&mode, &response);
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = ShowCommand;
+ assert_eq!(cmd.name(), "show");
+ assert_eq!(cmd.about(), "Show information about a snapshot");
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+}
diff --git a/cli/src/commands/status.rs b/cli/src/commands/status.rs
new file mode 100644
index 00000000..3ee3a435
--- /dev/null
+++ b/cli/src/commands/status.rs
@@ -0,0 +1,130 @@
+use async_trait::async_trait;
+use clap::{Arg, ArgAction, ArgMatches, Command};
+use engine::{EngineContainer, engine_container::MevaContainer, handlers::status::Request};
+
+use crate::commands::MevaCommand;
+
+use miette::{IntoDiagnostic, Result};
+
+/// Implements the `status` command for Meva DVCS.
+///
+/// Displays the current working tree status, including tracked, untracked,
+/// and ignored files. Supports short output format and branch information.
+#[derive(Default)]
+pub struct StatusCommand;
+
+impl StatusCommand {
+ /// Argument name for enabling short format (`-s` / `--short`).
+ const ARG_SHORT: &'static str = "short";
+
+ /// Argument name for showing branch information (`-b` / `--branch`).
+ const ARG_BRANCH: &'static str = "branch";
+
+ /// Argument name for hiding branch information (`--no-branch`).
+ const ARG_NO_BRANCH: &'static str = "no-branch";
+
+ /// Argument name for including ignored files in the output (`-i` / `--ignored`).
+ const ARG_IGNORED: &'static str = "ignored";
+}
+
+#[async_trait]
+impl MevaCommand for StatusCommand {
+ type Container = MevaContainer;
+
+ fn name(&self) -> &'static str {
+ "status"
+ }
+
+ fn about(&self) -> &'static str {
+ "Display the current state of the working directory"
+ }
+
+ fn version(&self) -> &'static str {
+ "1.0.0"
+ }
+
+ /// Builds the CLI argument parser for the `status` command using `clap`.
+ ///
+ /// This command supports several flags:
+ /// - `-s, --short` – Display output in short format.
+ /// - `-b, --branch` – Include branch and tracking information.
+ /// - `--no-branch` – Suppress branch and tracking info.
+ /// - `-u, --untracked` – Include untracked files in output.
+ /// - `-i, --ignored` – Include ignored files in output.
+ ///
+ /// Conflicts:
+ /// - `--branch` and `--no-branch` cannot be used together.
+ fn build_command(&self) -> Command {
+ self.build_base_command()
+ .arg(
+ Arg::new(Self::ARG_SHORT)
+ .short('s')
+ .long(Self::ARG_SHORT)
+ .action(ArgAction::SetTrue)
+ .help("Give the output in the short-format"),
+ )
+ .arg(
+ Arg::new(Self::ARG_BRANCH)
+ .short('b')
+ .long(Self::ARG_BRANCH)
+ .action(ArgAction::SetTrue)
+ .help("Show the branch and tracking info"),
+ )
+ .arg(
+ Arg::new(Self::ARG_NO_BRANCH)
+ .long(Self::ARG_NO_BRANCH)
+ .action(ArgAction::SetTrue)
+ .conflicts_with(Self::ARG_BRANCH)
+ .help("Do not show the branch and tracking info"),
+ )
+ .arg(
+ Arg::new(Self::ARG_IGNORED)
+ .short('i')
+ .long(Self::ARG_IGNORED)
+ .action(ArgAction::SetTrue)
+ .help("Show ignored files"),
+ )
+ }
+
+ /// Executes the `status` command.
+ async fn execute(&self, matches: &ArgMatches, container: &Self::Container) -> Result<()> {
+ let request = Request::from_flags(
+ matches.get_flag(Self::ARG_SHORT),
+ matches.get_flag(Self::ARG_BRANCH),
+ matches.get_flag(Self::ARG_NO_BRANCH),
+ matches.get_flag(Self::ARG_IGNORED),
+ );
+
+ let short_format = request.short_format;
+ let show_branch = request.show_branch;
+
+ let handler = container.status_handler().into_diagnostic()?;
+
+ let response = handler.handle_status(request.clone()).into_diagnostic()?;
+
+ println!(
+ "{}",
+ response.render_status(Some(short_format), Some(show_branch))
+ );
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ #[rstest]
+ fn test_command_name_about_version() {
+ let cmd = StatusCommand;
+ assert_eq!(cmd.name(), "status");
+ assert_eq!(
+ cmd.about(),
+ "Display the current state of the working directory"
+ );
+ assert_eq!(cmd.version(), "1.0.0");
+ }
+}
diff --git a/cli/src/extensions.rs b/cli/src/extensions.rs
new file mode 100644
index 00000000..da95e640
--- /dev/null
+++ b/cli/src/extensions.rs
@@ -0,0 +1,5 @@
+pub mod arg_matches;
+pub mod command;
+
+pub use arg_matches::*;
+pub use command::*;
diff --git a/cli/src/extensions/arg_matches.rs b/cli/src/extensions/arg_matches.rs
new file mode 100644
index 00000000..37e1a44d
--- /dev/null
+++ b/cli/src/extensions/arg_matches.rs
@@ -0,0 +1,3 @@
+pub mod location_selection;
+
+pub use location_selection::LocationSelection;
diff --git a/cli/src/extensions/arg_matches/location_selection.rs b/cli/src/extensions/arg_matches/location_selection.rs
new file mode 100644
index 00000000..6eee4ff5
--- /dev/null
+++ b/cli/src/extensions/arg_matches/location_selection.rs
@@ -0,0 +1,79 @@
+use std::path::PathBuf;
+
+use clap::{ArgMatches, Command};
+
+use engine::ConfigLocation;
+
+use crate::extensions::WithLocations;
+
+/// Trait to extract the chosen configuration source from CLI matches.
+///
+/// Provides a method to map parsed arguments into a `ConfigLocation` enum.
+pub trait LocationSelection {
+ /// Inspect parsed arguments and return the corresponding `ConfigLocation`.
+ fn get_config_location(&self) -> ConfigLocation;
+}
+
+impl LocationSelection for ArgMatches {
+ fn get_config_location(&self) -> ConfigLocation {
+ if self.get_flag(Command::ARG_GLOBAL) {
+ ConfigLocation::Global
+ } else if self.get_flag(Command::ARG_LOCAL) {
+ ConfigLocation::Local
+ } else if let Some(path) = self.get_one::(Command::ARG_FILE) {
+ ConfigLocation::File(path.clone())
+ } else {
+ // Default fallback when no location is explicitly specified
+ ConfigLocation::Local
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::extensions::WithLocations;
+ use pretty_assertions::assert_eq;
+ use rstest::rstest;
+
+ fn build_command() -> Command {
+ Command::new("cmd").with_location_args(
+ "use global config",
+ "use local config",
+ "use custom config file",
+ )
+ }
+
+ fn get_matches(args: &[&str]) -> ArgMatches {
+ build_command()
+ .try_get_matches_from(std::iter::once("cmd").chain(args.iter().copied()))
+ .unwrap()
+ }
+
+ #[rstest]
+ fn returns_global_location() {
+ let matches = get_matches(&["--global"]);
+ assert_eq!(matches.get_config_location(), ConfigLocation::Global);
+ }
+
+ #[rstest]
+ fn returns_local_location() {
+ let matches = get_matches(&["--local"]);
+ assert_eq!(matches.get_config_location(), ConfigLocation::Local);
+ }
+
+ #[rstest]
+ fn returns_file_location() {
+ let matches = get_matches(&["--file", "custom.toml"]);
+ assert_eq!(
+ matches.get_config_location(),
+ ConfigLocation::File(PathBuf::from("custom.toml")),
+ );
+ }
+
+ #[rstest]
+ fn returns_local_as_default_when_nothing_given() {
+ let matches = get_matches(&[]);
+ assert_eq!(matches.get_config_location(), ConfigLocation::Local);
+ }
+}
diff --git a/cli/src/extensions/command.rs b/cli/src/extensions/command.rs
new file mode 100644
index 00000000..6e6946cb
--- /dev/null
+++ b/cli/src/extensions/command.rs
@@ -0,0 +1,17 @@
+mod with_command_and_plugin;
+mod with_file;
+mod with_key;
+mod with_locations;
+mod with_name;
+mod with_pattern;
+mod with_scope;
+mod with_verbose;
+
+pub use with_command_and_plugin::WithCommandPlugin;
+pub use with_file::WithFile;
+pub use with_key::WithKey;
+pub use with_locations::WithLocations;
+pub use with_name::WithName;
+pub use with_pattern::WithPattern;
+pub use with_scope::WithScope;
+pub use with_verbose::WithVerbose;
diff --git a/cli/src/extensions/command/with_command_and_plugin.rs b/cli/src/extensions/command/with_command_and_plugin.rs
new file mode 100644
index 00000000..f4e0327d
--- /dev/null
+++ b/cli/src/extensions/command/with_command_and_plugin.rs
@@ -0,0 +1,61 @@
+use clap::{Arg, Command, builder::PossibleValuesParser};
+use plugins::CommandType;
+use strum::VariantNames;
+
+/// Trait to add required `command` and `plugin` arguments to a Clap command.
+///
+/// This allows CLI commands to explicitly specify which command type and plugin
+/// they want to operate on.
+pub trait WithCommandPlugin {
+ /// Constant name of the CLI argument for the command.
+ const ARG_COMMAND: &'static str;
+
+ /// Constant name of the CLI argument for the plugin.
+ const ARG_PLUGIN: &'static str;
+
+ /// Extends a `Command` by adding the `command` and `plugin` positional arguments.
+ ///
+ /// # Arguments
+ ///
+ /// * `self` – The command being extended.
+ /// * `command_help` – Help message describing the purpose of the `command` argument.
+ /// * `plugin_help` – Help message describing the purpose of the `plugin` argument.
+ ///
+ /// # Returns
+ ///
+ /// The original [`Command`] with both `command` and `plugin` arguments appended.
+ fn with_command_and_plugin_arg(
+ self,
+ command_help: &'static str,
+ plugin_help: &'static str,
+ ) -> Self;
+}
+
+impl WithCommandPlugin for Command {
+ const ARG_COMMAND: &'static str = "command";
+
+ const ARG_PLUGIN: &'static str = "plugin";
+
+ fn with_command_and_plugin_arg(
+ self,
+ command_help: &'static str,
+ plugin_help: &'static str,
+ ) -> Self {
+ self.arg(
+ Arg::new(Self::ARG_COMMAND)
+ .value_name("COMMAND")
+ .index(1)
+ .required(true)
+ // Restricts values to the variants of `CommandType`
+ .value_parser(PossibleValuesParser::new(CommandType::VARIANTS))
+ .help(command_help),
+ )
+ .arg(
+ Arg::new(Self::ARG_PLUGIN)
+ .value_name("PLUGIN")
+ .index(2)
+ .required(true)
+ .help(plugin_help),
+ )
+ }
+}
diff --git a/cli/src/extensions/command/with_file.rs b/cli/src/extensions/command/with_file.rs
new file mode 100644
index 00000000..72bc96fc
--- /dev/null
+++ b/cli/src/extensions/command/with_file.rs
@@ -0,0 +1,89 @@
+use std::path::PathBuf;
+
+use clap::{Arg, Command, ValueHint};
+
+/// Trait to add an optional `file` argument to a Clap command.
+pub trait WithFile {
+ /// Constant name of the CLI argument for the key.
+ const ARG_FILE: &'static str;
+
+ /// Extends a `Command` by adding a `file` argument with the provided help text.
+ ///
+ /// # Arguments
+ ///
+ /// * `self` - The command being extended.
+ /// * `file_help` - Help message describing the purpose of the `file` argument.
+ ///
+ /// # Returns
+ ///
+ /// The original `Command` with the `file` argument appended.
+ fn with_file_arg(self, key_help: &'static str) -> Self;
+}
+
+impl WithFile for Command {
+ const ARG_FILE: &'static str = "file";
+
+ fn with_file_arg(self, file_help: &'static str) -> Self {
+ self.arg(
+ Arg::new(Self::ARG_FILE)
+ .short('f')
+ .long(Self::ARG_FILE)
+ .value_name("FILE")
+ .value_parser(clap::value_parser!(PathBuf))
+ .value_hint(ValueHint::FilePath)
+ .help(file_help),
+ )
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use clap::Command;
+ use pretty_assertions::assert_eq;
+ use rstest::{fixture, rstest};
+ use std::path::PathBuf;
+
+ #[fixture]
+ fn cmd() -> Command {
+ Command::new("cli").with_file_arg("help text")
+ }
+
+ #[rstest]
+ fn adds_argument_with_correct_properties(cmd: Command) {
+ let arg = cmd
+ .get_arguments()
+ .find(|a| a.get_id() == "file")
+ .expect("Argument should exist");
+
+ assert_eq!(arg.get_short(), Some('f'));
+ assert_eq!(arg.get_long(), Some("file"));
+ assert_eq!(
+ arg.get_value_names().unwrap().iter().next().unwrap(),
+ "FILE"
+ );
+ }
+
+ #[rstest]
+ #[case(vec!["cli", "-f", "path/to.x"], Some("path/to.x"))]
+ #[case(vec!["cli", "--file", "other/file.y"], Some("other/file.y"))]
+ #[case(vec!["cli"], None)]
+ fn yields_file_arg_as_pathbuf(
+ #[case] args: Vec<&str>,
+ #[case] expected: Option<&str>,
+ cmd: Command,
+ ) {
+ let matches = cmd.clone().try_get_matches_from(args).unwrap();
+ let got: Option<&PathBuf> = matches.get_one("file");
+
+ match expected {
+ Some(file) => {
+ let want = PathBuf::from(file);
+ assert_eq!(got.unwrap(), &want);
+ }
+ None => {
+ assert!(got.is_none());
+ }
+ }
+ }
+}
diff --git a/cli/src/extensions/command/with_key.rs b/cli/src/extensions/command/with_key.rs
new file mode 100644
index 00000000..a0a35dbb
--- /dev/null
+++ b/cli/src/extensions/command/with_key.rs
@@ -0,0 +1,76 @@
+use clap::{Arg, Command};
+
+/// Trait to add a required `key` argument to a Clap command.
+pub trait WithKey {
+ /// Constant name of the CLI argument for the key.
+ const ARG_KEY: &'static str;
+
+ /// Extends a `Command` by adding a positional `key` argument with the given help text.
+ ///
+ /// # Arguments
+ ///
+ /// * `self` - The command being extended.
+ /// * `key_help` - Help message describing the purpose of the `key` argument.
+ ///
+ /// # Returns
+ ///
+ /// The original `Command` with the `key` argument appended.
+ fn with_key_arg(self, key_help: &'static str) -> Self;
+}
+
+impl WithKey for Command {
+ const ARG_KEY: &'static str = "key";
+
+ fn with_key_arg(self, key_help: &'static str) -> Self {
+ self.arg(
+ Arg::new(Self::ARG_KEY)
+ .value_name("KEY")
+ .help(key_help)
+ .required(true)
+ .index(1),
+ )
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use clap::Command;
+ use pretty_assertions::assert_eq;
+ use rstest::{fixture, rstest};
+
+ #[fixture]
+ fn cmd() -> Command {
+ Command::new("cmd").with_key_arg("help text")
+ }
+
+ #[rstest]
+ fn adds_argument_with_correct_properties(cmd: Command) {
+ let arg = cmd
+ .get_positionals()
+ .find(|a| a.get_id() == "key")
+ .expect("Argument should exist");
+
+ assert!(arg.is_required_set());
+ assert_eq!(arg.get_index(), Some(1));
+ assert_eq!(arg.get_value_names().unwrap().iter().next().unwrap(), "KEY");
+ }
+
+ #[rstest]
+ #[case(vec!["cmd", "secret-key"], Some("secret-key"))]
+ #[case(vec!["cmd"], None)]
+ fn yields_key_arg(#[case] args: Vec<&str>, #[case] expected: Option<&str>, cmd: Command) {
+ let matches = cmd.clone().try_get_matches_from(args);
+
+ match expected {
+ Some(key) => {
+ let m = matches.unwrap();
+ let got: &String = m.get_one("key").expect("Should have value");
+ assert_eq!(got, key);
+ }
+ None => {
+ assert!(matches.is_err());
+ }
+ }
+ }
+}
diff --git a/cli/src/extensions/command/with_locations.rs b/cli/src/extensions/command/with_locations.rs
new file mode 100644
index 00000000..25f99015
--- /dev/null
+++ b/cli/src/extensions/command/with_locations.rs
@@ -0,0 +1,151 @@
+use std::path::PathBuf;
+
+use clap::{Arg, ArgAction, ArgGroup, Command, ValueHint};
+
+/// Trait for adding CLI flags or options to select configuration source locations.
+///
+/// Provides argument names and a convenience method to attach mutually-exclusive
+/// options for global, local, or explicit file configurations.
+pub trait WithLocations {
+ /// Argument name for the global location flag.
+ const ARG_GLOBAL: &'static str;
+
+ /// Argument name for the local location flag.
+ const ARG_LOCAL: &'static str;
+
+ /// Argument name for the custom location flag
+ const ARG_FILE: &'static str;
+
+ /// Extend a `Command` with location-selection arguments.
+ ///
+ /// # Arguments
+ ///
+ /// * `self` - The `Command` to augment.
+ /// * `global_help` - Help text for the global flag.
+ /// * `local_help` - Help text for the local flag.
+ /// * `file_help` - Help text for the file option.
+ ///
+ /// # Returns
+ ///
+ /// The updated `Command` with global, local, and file options,
+ /// constrained to be mutually exclusive, grouped under "location".
+ fn with_location_args(
+ self,
+ global_help: &'static str,
+ local_help: &'static str,
+ file_help: &'static str,
+ ) -> Self;
+}
+
+impl WithLocations for Command {
+ const ARG_GLOBAL: &'static str = "global";
+
+ const ARG_LOCAL: &'static str = "local";
+
+ const ARG_FILE: &'static str = "file";
+
+ fn with_location_args(
+ self,
+ global_help: &'static str,
+ local_help: &'static str,
+ file_help: &'static str,
+ ) -> Self {
+ self.arg(
+ Arg::new(Self::ARG_GLOBAL)
+ .short('g')
+ .long(Self::ARG_GLOBAL)
+ .help(global_help)
+ .action(ArgAction::SetTrue)
+ // Cannot be used with local or file
+ .conflicts_with_all([Self::ARG_LOCAL, Self::ARG_FILE]),
+ )
+ .arg(
+ Arg::new(Self::ARG_LOCAL)
+ .short('l')
+ .long(Self::ARG_LOCAL)
+ .help(local_help)
+ .action(ArgAction::SetTrue)
+ // Cannot be used with global or file
+ .conflicts_with_all([Self::ARG_GLOBAL, Self::ARG_FILE]),
+ )
+ .arg(
+ Arg::new(Self::ARG_FILE)
+ .short('f')
+ .long(Self::ARG_FILE)
+ .value_name("FILE")
+ .help(file_help)
+ .value_hint(ValueHint::FilePath)
+ .value_parser(clap::value_parser!(PathBuf))
+ // Cannot be used with global or local
+ .conflicts_with_all([Self::ARG_GLOBAL, Self::ARG_LOCAL]),
+ )
+ .group(
+ ArgGroup::new("location")
+ .args([Self::ARG_GLOBAL, Self::ARG_LOCAL, Self::ARG_FILE])
+ .required(false),
+ )
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use clap::error::ErrorKind;
+ use pretty_assertions::assert_eq;
+ use rstest::{fixture, rstest};
+
+ #[fixture]
+ fn cmd() -> Command {
+ Command::new("cmd").with_location_args("global help", "local help", "file help")
+ }
+
+ #[rstest]
+ fn test_global_flag(cmd: Command) {
+ let matches = cmd.try_get_matches_from(vec!["cmd", "--global"]).unwrap();
+ assert!(matches.get_flag("global"));
+ assert!(!matches.get_flag("local"));
+ assert!(matches.get_one::("file").is_none());
+ }
+
+ #[rstest]
+ fn test_local_flag(cmd: Command) {
+ let matches = cmd.try_get_matches_from(vec!["cmd", "-l"]).unwrap();
+ assert!(matches.get_flag("local"));
+ assert!(!matches.get_flag("global"));
+ assert!(matches.get_one::("file").is_none());
+ }
+
+ #[rstest]
+ fn test_file_option(cmd: Command) {
+ let path = "config.toml";
+ let matches = cmd
+ .try_get_matches_from(vec!["cmd", "--file", path])
+ .unwrap();
+ assert_eq!(
+ matches.get_one::("file"),
+ Some(&PathBuf::from(path))
+ );
+ assert!(!matches.get_flag("global"));
+ assert!(!matches.get_flag("local"));
+ }
+
+ #[rstest]
+ fn test_conflicting_flags_global_local(cmd: Command) {
+ let result = cmd.try_get_matches_from(vec!["cmd", "-g", "-l"]);
+ assert!(result.is_err_and(|e| e.kind() == ErrorKind::ArgumentConflict));
+ }
+
+ #[rstest]
+ fn test_conflicting_flags_global_file(cmd: Command) {
+ let result = cmd.try_get_matches_from(vec!["cmd", "--global", "--file", "conf.toml"]);
+ assert!(result.is_err_and(|e| e.kind() == ErrorKind::ArgumentConflict));
+ }
+
+ #[rstest]
+ fn test_no_flags(cmd: Command) {
+ let matches = cmd.try_get_matches_from(vec!["cmd"]).unwrap();
+ assert!(!matches.get_flag("global"));
+ assert!(!matches.get_flag("local"));
+ assert!(matches.get_one::("file").is_none());
+ }
+}
diff --git a/cli/src/extensions/command/with_name.rs b/cli/src/extensions/command/with_name.rs
new file mode 100644
index 00000000..ce4f6425
--- /dev/null
+++ b/cli/src/extensions/command/with_name.rs
@@ -0,0 +1,79 @@
+use clap::{Arg, Command};
+
+/// Trait to add a required `name` argument to a Clap command.
+pub trait WithName {
+ /// Constant name of the CLI argument for the name.
+ const ARG_NAME: &'static str;
+
+ /// Extends a `Command` by adding a positional `name` argument with the given help text.
+ ///
+ /// # Arguments
+ ///
+ /// * `self` - The command being extended.
+ /// * `key_help` - Help message describing the purpose of the `name` argument.
+ ///
+ /// # Returns
+ ///
+ /// The original `Command` with the `name` argument appended.
+ fn with_name_arg(self, name_help: &'static str) -> Self;
+}
+
+impl WithName for Command {
+ const ARG_NAME: &'static str = "name";
+
+ fn with_name_arg(self, name_help: &'static str) -> Self {
+ self.arg(
+ Arg::new(Self::ARG_NAME)
+ .value_name("NAME")
+ .index(1)
+ .required(true)
+ .help(name_help),
+ )
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use clap::Command;
+ use pretty_assertions::assert_eq;
+ use rstest::{fixture, rstest};
+
+ #[fixture]
+ fn cmd() -> Command {
+ Command::new("cmd").with_name_arg("help text")
+ }
+
+ #[rstest]
+ fn adds_argument_with_correct_properties(cmd: Command) {
+ let arg = cmd
+ .get_positionals()
+ .find(|a| a.get_id() == "name")
+ .expect("Argument should exist");
+
+ assert!(arg.is_required_set());
+ assert_eq!(arg.get_index(), Some(1));
+ assert_eq!(
+ arg.get_value_names().unwrap().iter().next().unwrap(),
+ "NAME"
+ );
+ }
+
+ #[rstest]
+ #[case(vec!["cmd", "origin"], Some("origin"))]
+ #[case(vec!["cmd"], None)]
+ fn yields_key_arg(#[case] args: Vec<&str>, #[case] expected: Option<&str>, cmd: Command) {
+ let matches = cmd.clone().try_get_matches_from(args);
+
+ match expected {
+ Some(key) => {
+ let m = matches.unwrap();
+ let got: &String = m.get_one("name").expect("Should have value");
+ assert_eq!(got, key);
+ }
+ None => {
+ assert!(matches.is_err());
+ }
+ }
+ }
+}
diff --git a/cli/src/extensions/command/with_pattern.rs b/cli/src/extensions/command/with_pattern.rs
new file mode 100644
index 00000000..42982b04
--- /dev/null
+++ b/cli/src/extensions/command/with_pattern.rs
@@ -0,0 +1,87 @@
+use clap::{Arg, Command};
+use globset::Glob;
+
+/// Trait to add a required `pattern` argument to a Clap command.
+pub trait WithPattern {
+ /// Constant name of the CLI argument for the pattern.
+ const ARG_PATTERN: &'static str;
+
+ /// Extends a `Command` by adding a positional `pattern` argument with the given help text.
+ /// Uses `Glob` parser to interpret pattern syntax, typically for matching file paths.
+ ///
+ /// # Arguments
+ ///
+ /// * `self` - The command being extended.
+ /// * `pattern_help` - Help message describing the purpose of the `pattern` argument.
+ ///
+ /// # Returns
+ ///
+ /// The original `Command` with the `pattern` argument appended.
+ fn with_pattern_arg(self, key_help: &'static str) -> Self;
+}
+
+impl WithPattern for Command {
+ const ARG_PATTERN: &'static str = "pattern";
+
+ fn with_pattern_arg(self, pattern_help: &'static str) -> Self {
+ self.arg(
+ Arg::new(Self::ARG_PATTERN)
+ .value_name("PATTERN")
+ .value_parser(clap::value_parser!(Glob))
+ .help(pattern_help)
+ .required(true)
+ .index(1),
+ )
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use clap::Command;
+ use pretty_assertions::assert_eq;
+ use rstest::{fixture, rstest};
+
+ #[fixture]
+ fn cmd() -> Command {
+ Command::new("cmd").with_pattern_arg("help text")
+ }
+
+ #[rstest]
+ fn adds_argument_with_correct_properties(cmd: Command) {
+ let arg = cmd
+ .get_positionals()
+ .find(|a| a.get_id() == "pattern")
+ .expect("Argument should exist");
+
+ assert!(arg.is_required_set());
+ assert_eq!(arg.get_index(), Some(1));
+ assert_eq!(
+ arg.get_value_names().unwrap().iter().next().unwrap(),
+ "PATTERN"
+ );
+ }
+
+ #[rstest]
+ #[case(vec!["cmd", "*.sh"], Some("*.sh"))]
+ #[case(vec!["cmd"], None)]
+ fn yields_pattern_arg_as_glob(
+ #[case] args: Vec<&str>,
+ #[case] expected: Option<&str>,
+ cmd: Command,
+ ) {
+ let matches = cmd.clone().try_get_matches_from(args);
+
+ match expected {
+ Some(pattern) => {
+ let m = matches.unwrap();
+ let got: &Glob = m.get_one("pattern").expect("Should have value");
+ let want = Glob::new(pattern).unwrap();
+ assert_eq!(got, &want);
+ }
+ None => {
+ assert!(matches.is_err());
+ }
+ }
+ }
+}
diff --git a/cli/src/extensions/command/with_scope.rs b/cli/src/extensions/command/with_scope.rs
new file mode 100644
index 00000000..097e384e
--- /dev/null
+++ b/cli/src/extensions/command/with_scope.rs
@@ -0,0 +1,90 @@
+use clap::{Arg, Command, builder::PossibleValuesParser};
+use plugins::ScopeType;
+use strum::VariantNames;
+
+/// Trait to add an optional `scope` argument to a Clap command.
+///
+/// This argument allows users to specify the scope in which the command should operate
+/// (e.g., local, global, etc.), using the variants defined in [`ScopeType`].
+pub trait WithScope {
+ /// Constant name of the CLI argument for the scope.
+ const ARG_SCOPE: &'static str;
+
+ /// Extends a `Command` by adding a `scope` argument with the given help text.
+ ///
+ /// # Arguments
+ ///
+ /// * `self` – The command being extended.
+ /// * `scope_help` – Help message describing the purpose of the `scope` argument.
+ ///
+ /// # Returns
+ ///
+ /// The original [`Command`] with the `scope` argument appended.
+ fn with_scope_arg(self, scope_help: &'static str) -> Self;
+}
+
+impl WithScope for Command {
+ const ARG_SCOPE: &'static str = "scope";
+
+ fn with_scope_arg(self, scope_help: &'static str) -> Self {
+ self.arg(
+ Arg::new(Self::ARG_SCOPE)
+ .short('s')
+ .long(Self::ARG_SCOPE)
+ .default_value("local")
+ // Uses the variants of `ScopeType` as allowed values
+ .value_parser(PossibleValuesParser::new(ScopeType::VARIANTS))
+ .help(scope_help),
+ )
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use clap::error::ErrorKind;
+ use pretty_assertions::assert_eq;
+ use rstest::{fixture, rstest};
+
+ #[fixture]
+ fn cmd() -> Command {
+ Command::new("cmd").with_scope_arg("scope help")
+ }
+
+ #[rstest]
+ fn test_short_scope_local(cmd: Command) {
+ let matches = cmd
+ .try_get_matches_from(vec!["cmd", "-s", "local"])
+ .unwrap();
+ assert_eq!(
+ matches.get_one::("scope"),
+ Some(&"local".to_string())
+ );
+ }
+
+ #[rstest]
+ fn test_long_scope_global(cmd: Command) {
+ let matches = cmd
+ .try_get_matches_from(vec!["cmd", "--scope", "global"])
+ .unwrap();
+ assert_eq!(
+ matches.get_one::("scope"),
+ Some(&"global".to_string())
+ );
+ }
+
+ #[rstest]
+ fn test_invalid_scope_value(cmd: Command) {
+ let result = cmd.try_get_matches_from(vec!["cmd", "--scope", "invalid"]);
+ assert!(result.is_err_and(|e| e.kind() == ErrorKind::InvalidValue));
+ }
+
+ #[rstest]
+ fn test_no_scope_provided(cmd: Command) {
+ let matches = cmd.try_get_matches_from(vec!["cmd"]).unwrap();
+ assert_eq!(
+ matches.get_one::("scope"),
+ Some(&"local".to_string())
+ );
+ }
+}
diff --git a/cli/src/extensions/command/with_verbose.rs b/cli/src/extensions/command/with_verbose.rs
new file mode 100644
index 00000000..8bde929a
--- /dev/null
+++ b/cli/src/extensions/command/with_verbose.rs
@@ -0,0 +1,33 @@
+use clap::{Arg, Command};
+
+/// Trait to add a `verbose` flag to a Clap command.
+pub trait WithVerbose {
+ /// Constant name of the CLI flag for the verbose.
+ const ARG_VERBOSE: &'static str;
+
+ /// Extends a `Command` by adding a `verbose` flag with the given help text.
+ ///
+ /// # Arguments
+ ///
+ /// * `self` - The command being extended.
+ /// * `key_help` - Help message describing the purpose of the `verbose` flag.
+ ///
+ /// # Returns
+ ///
+ /// The original `Command` with the `verbose` flag appended.
+ fn with_verbose_arg(self, verbose_help: &'static str) -> Self;
+}
+
+impl WithVerbose for Command {
+ const ARG_VERBOSE: &'static str = "verbose";
+
+ fn with_verbose_arg(self, verbose_help: &'static str) -> Self {
+ self.arg(
+ Arg::new(Self::ARG_VERBOSE)
+ .long(Self::ARG_VERBOSE)
+ .short('v')
+ .action(clap::ArgAction::SetTrue)
+ .help(verbose_help),
+ )
+ }
+}
diff --git a/cli/src/main.rs b/cli/src/main.rs
index e7a11a96..2d2c76ca 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -1,3 +1,26 @@
-fn main() {
- println!("Hello, world!");
+mod commands;
+mod extensions;
+mod meva_cli;
+
+use miette::Result;
+
+use crate::meva_cli::MevaCli;
+use commands::collect_commands;
+use engine::engine_container::MevaContainer;
+
+#[tokio::main]
+async fn main() -> Result<()> {
+ miette::set_panic_hook();
+
+ let container = MevaContainer;
+ let mut cli = MevaCli::new(container);
+
+ let commands = collect_commands();
+
+ for command in commands {
+ cli.add_command(command);
+ }
+
+ cli.run().await?;
+ Ok(())
}
diff --git a/cli/src/meva_cli.rs b/cli/src/meva_cli.rs
new file mode 100644
index 00000000..ed2444cb
--- /dev/null
+++ b/cli/src/meva_cli.rs
@@ -0,0 +1,94 @@
+use std::collections::HashMap;
+
+use clap::{Command, error::ErrorKind};
+use engine::engine_container::MevaContainer;
+use miette::{IntoDiagnostic, Result, WrapErr, miette};
+
+use crate::commands::MevaCommand;
+
+/// The main entry point for the Meva CLI application.
+///
+/// This struct orchestrates the entire command-line interface lifecycle.
+pub struct MevaCli {
+ /// Registry of available commands, mapped from command name to the command instance.
+ commands: HashMap<&'static str, Box>>,
+
+ /// Dependency injection container providing access to core engine services.
+ container: MevaContainer,
+}
+
+impl MevaCli {
+ /// Creates a new `MevaCli` instance with the provided dependency container.
+ ///
+ /// The instance starts with no registered commands. Use [`add_command`](Self::add_command) to populate it.
+ ///
+ /// # Arguments
+ /// * `container`: The initialized `MevaContainer` holding shared resources/services.
+ pub fn new(container: MevaContainer) -> Self {
+ Self {
+ commands: HashMap::new(),
+ container,
+ }
+ }
+
+ /// Registers a command with the CLI.
+ ///
+ /// The command is stored in an internal registry keyed by its name (as returned by `command.name()`).
+ ///
+ /// # Arguments
+ /// * `command`: A boxed instance implementing the `MevaCommand` trait.
+ pub fn add_command(&mut self, command: Box>) {
+ self.commands.insert(command.name(), command);
+ }
+
+ /// Constructs the `clap` argument parser configuration.
+ ///
+ /// This method iterates over all registered commands, adding them as subcommands
+ /// to the main application parser. It also configures global settings such as
+ /// the application name, version, and description.
+ ///
+ /// # Returns
+ /// A fully configured [`clap::Command`] builder ready for argument parsing.
+ fn build_cli(&self) -> Command {
+ let mut cli = Command::new("meva")
+ .about("Meva distributed version control system")
+ .version("1.0.0")
+ .subcommand_required(true)
+ .arg_required_else_help(true);
+
+ for command in self.commands.values() {
+ cli = cli.subcommand(command.build_command());
+ }
+
+ cli
+ }
+
+ /// Executes the CLI application.
+ pub async fn run(&self) -> Result<()> {
+ let matches = match self.build_cli().try_get_matches() {
+ Ok(m) => m,
+ Err(err) => {
+ return match err.kind() {
+ // DisplayHelp and DisplayVersion are not "errors" in the traditional sense,
+ // so we print the info and exit gracefully.
+ ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => {
+ err.print().into_diagnostic()?;
+ Ok(())
+ }
+ _ => Err(miette!("Failed to parse command-line arguments:\n{}", err)),
+ };
+ }
+ };
+
+ if let Some((name, sub_matches)) = matches.subcommand()
+ && let Some(cmd) = self.commands.get(name)
+ {
+ return cmd
+ .execute(sub_matches, &self.container)
+ .await
+ .wrap_err_with(|| format!("Error running `{name}` command"));
+ }
+
+ Ok(())
+ }
+}
diff --git a/docs/images/distributed-architecture.png b/docs/images/distributed-architecture.png
new file mode 100644
index 00000000..742b6a8c
Binary files /dev/null and b/docs/images/distributed-architecture.png differ
diff --git a/docs/images/feather.png b/docs/images/feather.png
new file mode 100644
index 00000000..80e11520
Binary files /dev/null and b/docs/images/feather.png differ
diff --git a/docs/images/gui/gui-branch.png b/docs/images/gui/gui-branch.png
new file mode 100644
index 00000000..d8bbadd6
Binary files /dev/null and b/docs/images/gui/gui-branch.png differ
diff --git a/docs/images/gui/gui-clone-repo.png b/docs/images/gui/gui-clone-repo.png
new file mode 100644
index 00000000..75bd33fa
Binary files /dev/null and b/docs/images/gui/gui-clone-repo.png differ
diff --git a/docs/images/gui/gui-commit-modal.png b/docs/images/gui/gui-commit-modal.png
new file mode 100644
index 00000000..11d066f9
Binary files /dev/null and b/docs/images/gui/gui-commit-modal.png differ
diff --git a/docs/images/gui/gui-config-error.png b/docs/images/gui/gui-config-error.png
new file mode 100644
index 00000000..4b4dbd63
Binary files /dev/null and b/docs/images/gui/gui-config-error.png differ
diff --git a/docs/images/gui/gui-conflict-editor.png b/docs/images/gui/gui-conflict-editor.png
new file mode 100644
index 00000000..e69048ee
Binary files /dev/null and b/docs/images/gui/gui-conflict-editor.png differ
diff --git a/docs/images/gui/gui-conflict-mark.png b/docs/images/gui/gui-conflict-mark.png
new file mode 100644
index 00000000..8f1c64b9
Binary files /dev/null and b/docs/images/gui/gui-conflict-mark.png differ
diff --git a/docs/images/gui/gui-conflict-menu.png b/docs/images/gui/gui-conflict-menu.png
new file mode 100644
index 00000000..8517582a
Binary files /dev/null and b/docs/images/gui/gui-conflict-menu.png differ
diff --git a/docs/images/gui/gui-conflict-unmerged.png b/docs/images/gui/gui-conflict-unmerged.png
new file mode 100644
index 00000000..54d73754
Binary files /dev/null and b/docs/images/gui/gui-conflict-unmerged.png differ
diff --git a/docs/images/gui/gui-context-menu.png b/docs/images/gui/gui-context-menu.png
new file mode 100644
index 00000000..92bd2580
Binary files /dev/null and b/docs/images/gui/gui-context-menu.png differ
diff --git a/docs/images/gui/gui-create-repo.png b/docs/images/gui/gui-create-repo.png
new file mode 100644
index 00000000..00efe31f
Binary files /dev/null and b/docs/images/gui/gui-create-repo.png differ
diff --git a/docs/images/gui/gui-dashboard.png b/docs/images/gui/gui-dashboard.png
new file mode 100644
index 00000000..a5c87ca7
Binary files /dev/null and b/docs/images/gui/gui-dashboard.png differ
diff --git a/docs/images/gui/gui-diff.png b/docs/images/gui/gui-diff.png
new file mode 100644
index 00000000..09e1cf2c
Binary files /dev/null and b/docs/images/gui/gui-diff.png differ
diff --git a/docs/images/gui/gui-history.png b/docs/images/gui/gui-history.png
new file mode 100644
index 00000000..173a0909
Binary files /dev/null and b/docs/images/gui/gui-history.png differ
diff --git a/docs/images/gui/gui-menu-file.png b/docs/images/gui/gui-menu-file.png
new file mode 100644
index 00000000..011d0eae
Binary files /dev/null and b/docs/images/gui/gui-menu-file.png differ
diff --git a/docs/images/gui/gui-open-error.png b/docs/images/gui/gui-open-error.png
new file mode 100644
index 00000000..03c411c0
Binary files /dev/null and b/docs/images/gui/gui-open-error.png differ
diff --git a/docs/images/gui/gui-plugin-edit.png b/docs/images/gui/gui-plugin-edit.png
new file mode 100644
index 00000000..6847beb4
Binary files /dev/null and b/docs/images/gui/gui-plugin-edit.png differ
diff --git a/docs/images/gui/gui-plugin-register.png b/docs/images/gui/gui-plugin-register.png
new file mode 100644
index 00000000..6442ae5f
Binary files /dev/null and b/docs/images/gui/gui-plugin-register.png differ
diff --git a/docs/images/gui/gui-plugins-list-disabled.png b/docs/images/gui/gui-plugins-list-disabled.png
new file mode 100644
index 00000000..6484898a
Binary files /dev/null and b/docs/images/gui/gui-plugins-list-disabled.png differ
diff --git a/docs/images/gui/gui-plugins-list-enabled.png b/docs/images/gui/gui-plugins-list-enabled.png
new file mode 100644
index 00000000..64f4ee6b
Binary files /dev/null and b/docs/images/gui/gui-plugins-list-enabled.png differ
diff --git a/docs/images/gui/gui-settings-view.png b/docs/images/gui/gui-settings-view.png
new file mode 100644
index 00000000..da63684d
Binary files /dev/null and b/docs/images/gui/gui-settings-view.png differ
diff --git a/docs/images/gui/gui-staging.png b/docs/images/gui/gui-staging.png
new file mode 100644
index 00000000..bf4a967b
Binary files /dev/null and b/docs/images/gui/gui-staging.png differ
diff --git a/docs/images/gui/gui-status-bar.png b/docs/images/gui/gui-status-bar.png
new file mode 100644
index 00000000..4d23efe4
Binary files /dev/null and b/docs/images/gui/gui-status-bar.png differ
diff --git a/docs/images/gui/gui-sync-actions.png b/docs/images/gui/gui-sync-actions.png
new file mode 100644
index 00000000..97f6cdda
Binary files /dev/null and b/docs/images/gui/gui-sync-actions.png differ
diff --git a/docs/images/receive-pack.png b/docs/images/receive-pack.png
new file mode 100644
index 00000000..7285452b
Binary files /dev/null and b/docs/images/receive-pack.png differ
diff --git a/docs/images/upload-pack.png b/docs/images/upload-pack.png
new file mode 100644
index 00000000..b6c973bc
Binary files /dev/null and b/docs/images/upload-pack.png differ
diff --git a/docs/plugins/examples/bash/infinite_counter.sh b/docs/plugins/examples/bash/infinite_counter.sh
new file mode 100644
index 00000000..77a53814
--- /dev/null
+++ b/docs/plugins/examples/bash/infinite_counter.sh
@@ -0,0 +1,34 @@
+#!/usr/bin/env bash
+
+# Description:
+# Example of Bash plugin which demonstrates:
+# - printing messages to standard output (stdout) every 100 ms,
+# - timeout behaviour.
+#
+# Invocation:
+# chmod +x infinite_counter.sh
+# ./infinite_counter.sh
+#
+# Expected behavior:
+# - stdout: 0, 1, 2, 3, ... (printed continuously),
+# - exit status: plugin will be terminated by the plugin runner when timeout is reached.
+#
+# Example configuration entry (JSON):
+# {
+# "name": "infinite_counter",
+# "description": "Demonstrates plugin runner timeout behavior",
+# "file": "infinite_counter.sh",
+# "event": "pre-execute",
+# "order": 1,
+# "timeout": 500,
+# "enabled": true,
+# "interpreter": "bash"
+# }
+
+i=0
+
+while true; do
+ echo "$i"
+ ((i++))
+ sleep 0.1
+done
\ No newline at end of file
diff --git a/docs/plugins/examples/bash/nonzero_exit.sh b/docs/plugins/examples/bash/nonzero_exit.sh
new file mode 100644
index 00000000..133e23c1
--- /dev/null
+++ b/docs/plugins/examples/bash/nonzero_exit.sh
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+
+# Description:
+# Example of Bash plugin which demonstrates:
+# - printing a message to standard output (stdout),
+# - printing a message to standard error (stderr),
+# - exiting with a non-zero status code (3).
+#
+# Invocation:
+# chmod +x nonzero_exit.sh
+# ./nonzero_exit.sh
+#
+# Expected behavior:
+# - stdout: "This is a message to stdout",
+# - stderr: "This is a message to stderr",
+# - exit status: 3.
+#
+# Example configuration entry (JSON):
+# {
+# "name": "nonzero_exit",
+# "description": "Demonstrates exiting with a non-zero status code",
+# "file": "nonzero_exit.sh",
+# "event": "pre-execute",
+# "order": 1,
+# "timeout": null,
+# "enabled": true,
+# "interpreter": "bash"
+# }
+
+echo "This is a message to stdout"
+echo "This is a message to stderr" >&2
+exit 3
\ No newline at end of file
diff --git a/docs/plugins/examples/bash/print_arg.sh b/docs/plugins/examples/bash/print_arg.sh
new file mode 100644
index 00000000..884e47d4
--- /dev/null
+++ b/docs/plugins/examples/bash/print_arg.sh
@@ -0,0 +1,40 @@
+#!/usr/bin/env bash
+
+# Description:
+# Example Bash plugin which demonstrates:
+# - reading a file specified as the first argument in the console,
+# - printing its content to standard output (stdout),
+# - handling file-not-found or read errors by printing to standard error (stderr).
+#
+# Invocation:
+# chmod +x print_arg.sh
+# ./print_arg.sh
+#
+# Expected behavior:
+# - stdout: content of the specified file,
+# - stderr: error messages if the file does not exist or cannot be read,
+# - exit status: 0 on success, 1 on error.
+#
+# Example configuration entry (JSON):
+# {
+# "name": "print_arg",
+# "description": null,
+# "file": "print_arg.sh",
+# "event": "pre-execute",
+# "order": 1,
+# "timeout": null,
+# "enabled": true,
+# "interpreter": "bash"
+# }
+
+filename="$1"
+
+if [ ! -f "$filename" ]; then
+ echo "Error: File '$filename' not found." >&2
+ exit 1
+fi
+
+cat "$filename" || {
+ echo "Error reading file '$filename'" >&2
+ exit 1
+}
\ No newline at end of file
diff --git a/docs/plugins/examples/bash/write_error.sh b/docs/plugins/examples/bash/write_error.sh
new file mode 100644
index 00000000..201cc388
--- /dev/null
+++ b/docs/plugins/examples/bash/write_error.sh
@@ -0,0 +1,61 @@
+#!/usr/bin/env bash
+
+# Description:
+# Example Bash plugin that writes a business logic error
+# to the "error" field of a JSON file representing plugin execution results using 'jq'.
+#
+# Dependencies:
+# Requires 'jq' to be installed (sudo apt install jq / brew install jq).
+#
+# Invocation:
+# chmod +x write_error.sh
+# ./write_error.sh
+#
+# Expected behavior:
+# - The "error" field in the JSON file will be updated.
+# - exit status: 1.
+#
+# Example configuration entry (JSON):
+# {
+# "name": "write_error",
+# "description": null,
+# "file": "write_error.sh",
+# "event": "pre-execute",
+# "order": 1,
+# "timeout": null,
+# "enabled": true,
+# "interpreter": "bash"
+# }
+
+filename="$1"
+
+if [ -z "$filename" ]; then
+ echo "Usage: $0 " >&2
+ exit 1
+fi
+
+if [ ! -f "$filename" ]; then
+ echo "Error: File '$filename' not found." >&2
+ exit 1
+fi
+
+if ! command -v jq &> /dev/null; then
+ echo "Error: 'jq' is not installed. Please install it to manipulate JSON files in Bash." >&2
+ exit 1
+fi
+
+error_payload='{
+ "code": "BUSINESS_LOGIC_ERROR",
+ "message": "A business rule was violated during plugin execution",
+ "details": "Optional additional context or stack trace"
+}'
+
+if jq --argjson err "$error_payload" '.error = $err' "$filename" > "${filename}.tmp"; then
+ mv "${filename}.tmp" "$filename"
+ echo "Updated error in $filename"
+ exit 1
+else
+ echo "Error parsing or writing JSON." >&2
+ rm -f "${filename}.tmp"
+ exit 1
+fi
\ No newline at end of file
diff --git a/docs/plugins/examples/js/infinite_counter.js b/docs/plugins/examples/js/infinite_counter.js
new file mode 100644
index 00000000..234918b6
--- /dev/null
+++ b/docs/plugins/examples/js/infinite_counter.js
@@ -0,0 +1,40 @@
+#!/usr/bin/env node
+/*
+Description:
+Example of Node.js plugin which demonstrates:
+- printing messages to standard output (stdout) every 100 ms,
+- timeout behaviour.
+
+Invocation:
+On Linux / macOS:
+ node infinite_counter.js
+ ./infinite_counter.js
+On Windows:
+ node infinite_counter.js
+
+Expected behavior:
+- stdout: 0, 1, 2, 3, ... (printed continuously),
+- exit status: plugin will be terminated by the plugin runner when timeout is reached.
+
+Example configuration entry (JSON):
+{
+ "name": "infinite_counter",
+ "description": "Demonstrates plugin runner timeout behavior",
+ "file": "infinite_counter.js",
+ "event": "pre-execute",
+ "order": 1,
+ "timeout": 500,
+ "enabled": true,
+ "interpreter": "node"
+}
+*/
+
+let i = 0;
+
+function loop() {
+ console.log(i);
+ i++;
+ setTimeout(loop, 100); // 100 ms
+}
+
+loop();
diff --git a/docs/plugins/examples/js/init_files.js b/docs/plugins/examples/js/init_files.js
new file mode 100644
index 00000000..0ecf22e4
--- /dev/null
+++ b/docs/plugins/examples/js/init_files.js
@@ -0,0 +1,236 @@
+#!/usr/bin/env node
+/*
+Description:
+Example of a Node.js plugin script which demonstrates:
+- creating README.md, LICENSE (MIT), and .mevaignore files,
+- handling missing fields in invocation input by writing an error object,
+- prompting the user for author name,
+- writing output or error messages in JSON to stdout/stderr.
+
+Invocation:
+On Linux / macOS:
+ node init_files.js /path/to/invocation_input.json
+On Windows:
+ node init_files.js /path/to/invocation_input.json
+
+Expected behavior:
+- if 'post-payload.repository_dir' exists and directory is valid:
+ -> README.md, LICENSE, and .mevaignore are created if not already present,
+ -> JSON with {"created": [...]} or friendly message is printed to stdout.
+- if required fields are missing:
+ -> JSON with {"error": {...}} is printed to stdout/stderr,
+ -> input JSON file is updated with 'error' field.
+
+Example configuration entry (JSON):
+{
+ "name": "init_files",
+ "description": "Creates README.md, LICENSE and .mevaignore in a newly initialized repository",
+ "file": "init_files.js",
+ "event": "post-execute",
+ "order": 1,
+ "timeout": null,
+ "enabled": true,
+ "interpreter": "node"
+}
+*/
+
+const fs = require("fs");
+const path = require("path");
+const readline = require("readline");
+
+function makeError(jsonPath, code, message, details = null) {
+ let payload = {};
+ try {
+ const fileData = fs.readFileSync(jsonPath, "utf-8");
+ payload = JSON.parse(fileData);
+ } catch {
+ // ignore JSON errors here
+ }
+ payload.error = { code, message, details };
+ try {
+ fs.writeFileSync(jsonPath, JSON.stringify(payload, null, 2), "utf-8");
+ } catch {
+ // ignore write errors here
+ }
+ console.error(JSON.stringify(payload.error));
+ process.exit(1);
+}
+
+function loadJson(jsonPath) {
+ try {
+ const data = fs.readFileSync(jsonPath, "utf-8");
+ return JSON.parse(data);
+ } catch (err) {
+ makeError(
+ jsonPath,
+ "JSON_PARSE_ERROR",
+ "Failed to parse input JSON",
+ err.message
+ );
+ }
+}
+
+function safeWrite(filePath, content) {
+ if (fs.existsSync(filePath)) return false;
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
+ fs.writeFileSync(filePath, content, "utf-8");
+ return true;
+}
+
+function prompt(question) {
+ const rl = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout,
+ });
+ return new Promise((resolve) =>
+ rl.question(question, (answer) => {
+ rl.close();
+ resolve(answer.trim());
+ })
+ );
+}
+
+async function main() {
+ const args = process.argv.slice(2);
+ const jsonPath = path.resolve(args[0]);
+ const payload = loadJson(jsonPath);
+
+ const postPayload = payload["post-payload"];
+ if (!postPayload || !postPayload.repository_dir) {
+ makeError(
+ jsonPath,
+ "MISSING_FIELD",
+ "post-payload.repository_dir is required"
+ );
+ }
+
+ const repoDir = path.resolve(postPayload.repository_dir);
+ if (!fs.existsSync(repoDir) || !fs.lstatSync(repoDir).isDirectory()) {
+ makeError(
+ jsonPath,
+ "DIR_NOT_FOUND",
+ `Repository directory does not exist: ${repoDir}`
+ );
+ }
+
+ const year = new Date().getFullYear();
+
+ console.log("Enter your full name for license copyright:");
+ const author = await prompt("Full name: ");
+ if (!author) {
+ makeError(
+ jsonPath,
+ "MISSING_AUTHOR",
+ "Author name for LICENSE is required."
+ );
+ }
+
+ const readmeContent = `# ${path.basename(repoDir)}
+
+A concise description of the project purpose and features.
+
+## Table of Contents
+- [Installation](#installation)
+- [Usage](#usage)
+- [Configuration](#configuration)
+- [Development](#development)
+- [Contributing](#contributing)
+- [License](#license)
+
+## Installation
+Steps to install the project locally:
+1. Clone the repository.
+2. Install dependencies.
+3. Run initial setup if needed.
+
+## Usage
+Examples of how to use this project:
+\`\`\`
+command-to-run --with options
+\`\`\`
+
+## Configuration
+List available configuration options and environment variables.
+
+## Development
+Instructions for contributors and developers:
+- Run tests: \`command-to-test\`
+- Run linting: \`command-to-lint\`
+- Build or compile: \`command-to-build\`
+
+## Contributing
+To contribute:
+1. Fork the repository.
+2. Create a feature branch.
+3. Make changes with clear commits.
+4. Submit a pull request after testing.
+
+## License
+This project is licensed under the MIT License. See the \`LICENSE\` file for details.
+`;
+
+ const mitLicense = `MIT License
+
+Copyright (c) ${year} ${author}
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+`;
+
+ const mevaignoreContent = `# VS Code / Editor artifacts
+.vscode/
+.idea/
+*.code-workspace
+.env
+
+# Python cache and artifacts
+__pycache__/
+.python-version
+
+# Build and distribution
+dist/
+build/
+
+# Dependency management
+.venv/
+venv/
+
+# Logs and temporary files
+*.log
+tmp/
+`;
+
+ try {
+ const created = [];
+ if (safeWrite(path.join(repoDir, "README.md"), readmeContent))
+ created.push("README.md");
+ if (safeWrite(path.join(repoDir, "LICENSE"), mitLicense))
+ created.push("LICENSE");
+ if (safeWrite(path.join(repoDir, ".mevaignore"), mevaignoreContent))
+ created.push(".mevaignore");
+
+ if (created.length) {
+ console.log("Files created:");
+ created.forEach((f) => console.log(`- ${f}`));
+ } else {
+ console.log("No files were created (all files already exist).");
+ }
+ process.exit(0);
+ } catch (err) {
+ makeError(jsonPath, "WRITE_ERROR", "Error writing files", err.message);
+ }
+}
+
+main();
diff --git a/docs/plugins/examples/js/nonzero_exit.js b/docs/plugins/examples/js/nonzero_exit.js
new file mode 100644
index 00000000..a4d3ad13
--- /dev/null
+++ b/docs/plugins/examples/js/nonzero_exit.js
@@ -0,0 +1,37 @@
+#!/usr/bin/env node
+/**
+Description:
+Example of Node.js plugin which demonstrates:
+- printing a message to standard output (stdout),
+- printing a message to standard error (stderr),
+- exiting with a non-zero status code (3).
+
+Invocation:
+On Linux / macOS:
+ node nonzero_exit.js
+ ./nonzero_exit.js
+On Windows:
+ node nonzero_exit.js
+
+Expected behavior:
+- stdout: "This is a message to stdout",
+- stderr: "This is a message to stderr",
+- exit status: 3.
+
+Example configuration entry (JSON):
+{
+ "name": "nonzero_exit",
+ "description": "Demonstrates exiting with a non-zero status code",
+ "file": "nonzero_exit.js",
+ "event": "pre-execute",
+ "order": 1,
+ "timeout": null,
+ "enabled": true,
+ "interpreter": "node"
+}
+*/
+
+console.log("This is a message to stdout");
+console.error("This is a message to stderr");
+
+process.exit(3);
diff --git a/docs/plugins/examples/js/print_arg.js b/docs/plugins/examples/js/print_arg.js
new file mode 100644
index 00000000..e4f5ca9f
--- /dev/null
+++ b/docs/plugins/examples/js/print_arg.js
@@ -0,0 +1,49 @@
+#!/usr/bin/env node
+/*
+Description:
+Example Node.js plugin which demonstrates:
+- reading a file specified as the first argument in the console,
+- printing its content to standard output (stdout),
+- handling file-not-found or read errors by printing to standard error (stderr).
+
+Invocation:
+On Linux / macOS:
+ node print_arg.js
+ ./print_arg.js
+On Windows:
+ node print_arg.js
+
+Expected behavior:
+- stdout: content of the specified file,
+- stderr: error messages if the file does not exist or cannot be read,
+- exit status: 0 on success, 1 on error.
+
+Example configuration entry (JSON):
+{
+ "name": "print_arg",
+ "description": null,
+ "file": "print_arg.js",
+ "event": "pre-execute",
+ "order": 1,
+ "timeout": null,
+ "enabled": true,
+ "interpreter": "node"
+}
+ */
+
+const fs = require("fs");
+
+const args = process.argv.slice(2);
+const filename = args[0];
+
+try {
+ const content = fs.readFileSync(filename, { encoding: "utf8" });
+ console.log(content);
+} catch (err) {
+ if (err.code === "ENOENT") {
+ console.error(`Error: File '${filename}' not found.`);
+ } else {
+ console.error(`Error reading file '${filename}': ${err.message}`);
+ }
+ process.exit(1);
+}
diff --git a/docs/plugins/examples/js/write_error.js b/docs/plugins/examples/js/write_error.js
new file mode 100644
index 00000000..7fe86f16
--- /dev/null
+++ b/docs/plugins/examples/js/write_error.js
@@ -0,0 +1,67 @@
+#!/usr/bin/env node
+/*
+Description:
+Example Node.js plugin that writes a business logic error
+to the "error" field of a JSON file representing plugin execution results.
+
+Invocation:
+On Linux / macOS:
+ node write_error.js
+ ./write_error.js
+On Windows:
+ node write_error.js
+
+Expected behavior:
+- The "error" field in the JSON file will be updated with a serialized InvocationError:
+ {
+ "code": "BUSINESS_LOGIC_ERROR",
+ "message": "A business rule was violated during plugin execution",
+ "details": "Optional additional context or stack trace"
+ },
+- exit status: 1 (indicating the plugin returned an error).
+
+Example configuration entry (JSON):
+{
+ "name": "write_error",
+ "description": null,
+ "file": "write_error.js",
+ "event": "pre-execute",
+ "order": 1,
+ "timeout": null,
+ "enabled": true,
+ "interpreter": "node"
+}
+ */
+
+const fs = require("fs");
+const path = require("path");
+
+const args = process.argv.slice(2);
+const filename = args[0];
+
+const invocationError = {
+ code: "BUSINESS_LOGIC_ERROR",
+ message: "A business rule was violated during plugin execution",
+ details: "Optional additional context or stack trace",
+};
+
+try {
+ const rawData = fs.readFileSync(filename, "utf8");
+ const data = JSON.parse(rawData);
+
+ data.error = invocationError;
+
+ fs.writeFileSync(filename, JSON.stringify(data, null, 2), "utf8");
+
+ console.log(`Updated error in ${filename}`);
+ process.exit(1);
+} catch (err) {
+ if (err.code === "ENOENT") {
+ console.error(`Error: File '${filename}' not found.`);
+ } else if (err.name === "SyntaxError") {
+ console.error(`Error parsing JSON: ${err.message}`);
+ } else {
+ console.error(`Unexpected error: ${err.message}`);
+ }
+ process.exit(1);
+}
diff --git a/docs/plugins/examples/powershell/infinite_counter.ps1 b/docs/plugins/examples/powershell/infinite_counter.ps1
new file mode 100644
index 00000000..bc4a8db3
--- /dev/null
+++ b/docs/plugins/examples/powershell/infinite_counter.ps1
@@ -0,0 +1,33 @@
+<#
+Description:
+Example of PowerShell plugin which demonstrates:
+- printing messages to standard output (stdout) every 100 ms,
+- timeout behaviour.
+
+Invocation:
+On Windows:
+ powershell infinite_counter.ps1
+
+Expected behavior:
+- stdout: 0, 1, 2, 3, ... (printed continuously),
+- exit status: plugin will be terminated by the plugin runner when timeout is reached.
+
+Example configuration entry (JSON):
+{
+ "name": "infinite_counter",
+ "description": "Demonstrates plugin runner timeout behavior",
+ "file": "infinite_counter.ps1",
+ "event": "pre-execute",
+ "order": 1,
+ "timeout": 500,
+ "enabled": true,
+ "interpreter": "powershell"
+}
+#>
+
+$i = 0
+while ($true) {
+ Write-Output $i
+ $i++
+ Start-Sleep -Milliseconds 100
+}
\ No newline at end of file
diff --git a/docs/plugins/examples/powershell/nonzero_exit.ps1 b/docs/plugins/examples/powershell/nonzero_exit.ps1
new file mode 100644
index 00000000..ca73a86e
--- /dev/null
+++ b/docs/plugins/examples/powershell/nonzero_exit.ps1
@@ -0,0 +1,33 @@
+<#
+Description:
+Example of PowerShell plugin which demonstrates:
+- printing a message to standard output (stdout),
+- printing a message to standard error (stderr),
+- exiting with a non-zero status code (3).
+
+Invocation:
+On Windows:
+ powershell nonzero_exit.ps1
+
+Expected behavior:
+- stdout: "This is a message to stdout",
+- stderr: "This is a message to stderr",
+- exit status: 3.
+
+Example configuration entry (JSON):
+{
+ "name": "nonzero_exit",
+ "description": "Demonstrates exiting with a non-zero status code",
+ "file": "nonzero_exit.ps1",
+ "event": "pre-execute",
+ "order": 1,
+ "timeout": null,
+ "enabled": true,
+ "interpreter": "powershell"
+}
+#>
+
+Write-Output "This is a message to stdout"
+Write-Error "This is a message to stderr"
+
+$host.SetShouldExit(3)
\ No newline at end of file
diff --git a/docs/plugins/examples/powershell/print_arg.ps1 b/docs/plugins/examples/powershell/print_arg.ps1
new file mode 100644
index 00000000..c45befae
--- /dev/null
+++ b/docs/plugins/examples/powershell/print_arg.ps1
@@ -0,0 +1,45 @@
+<#
+Description:
+Example Powershell plugin which demonstrates:
+- reading a file specified as the first argument in the console,
+- printing its content to standard output (stdout),
+- handling file-not-found or read errors by printing to standard error (stderr).
+
+Invocation:
+On Windows:
+ powershell print_arg.ps1
+
+Expected behavior:
+- stdout: content of the specified file,
+- stderr: error messages if the file does not exist or cannot be read,
+- exit status: 0 on success, 1 on error.
+
+Example configuration entry (JSON):
+{
+ "name": "print_arg",
+ "description": null,
+ "file": "print_arg.ps1",
+ "event": "pre-execute",
+ "order": 1,
+ "timeout": null,
+ "enabled": true,
+ "interpreter": "powershell"
+}
+#>
+
+param (
+ [Parameter(Mandatory = $true)]
+ [string]$filename
+)
+
+try {
+ Get-Content -Path $filename -ErrorAction Stop | ForEach-Object {
+ Write-Output $_
+ }
+}
+catch {
+ Write-Error "Error reading file '$filename': $_"
+ $host.SetShouldExit(1)
+}
+
+$host.SetShouldExit(0)
\ No newline at end of file
diff --git a/docs/plugins/examples/python/infinite_counter.py b/docs/plugins/examples/python/infinite_counter.py
new file mode 100644
index 00000000..8dcea94c
--- /dev/null
+++ b/docs/plugins/examples/python/infinite_counter.py
@@ -0,0 +1,38 @@
+#!/usr/bin/env python3
+"""
+Description:
+Example of Python plugin which demonstrates:
+- printing messages to standard output (stdout) every 100 ms,
+- timeout behaviour.
+
+Invocation:
+On Linux / macOS:
+ python3 infinite_counter.py
+ ./infinite_counter.py
+On Windows:
+ python infinite_counter.py
+
+Expected behavior:
+- stdout: 0, 1, 2, 3, ... (printed continuously),
+- exit status: plugin will be terminated by the plugin runner when timeout is reached.
+
+Example configuration entry (JSON):
+{
+ "name": "infinite_counter",
+ "description": "Demonstrates plugin runner timeout behavior",
+ "file": "infinite_counter.py",
+ "event": "pre-execute",
+ "order": 1,
+ "timeout": 500,
+ "enabled": true,
+ "interpreter": "python"
+}
+"""
+
+import time
+
+i = 0
+while True:
+ print(i, flush=True)
+ i += 1
+ time.sleep(0.1) # 100 ms
diff --git a/docs/plugins/examples/python/init_files.py b/docs/plugins/examples/python/init_files.py
new file mode 100644
index 00000000..6481eb90
--- /dev/null
+++ b/docs/plugins/examples/python/init_files.py
@@ -0,0 +1,184 @@
+#!/usr/bin/env python3
+"""
+Description:
+Example of Python plugin which demonstrates:
+- creating README.md, LICENSE (MIT) and .mevaignore files,
+- handling missing fields in InvocationInput by writing an error object.
+
+Invocation:
+On Linux / macOS:
+ python3 init_files.py /path/to/invocation_input.json
+ ./init_files.py /path/to/invocation_input.json
+On Windows:
+ python init_files.py /path/to/invocation_input.json
+
+Expected behavior:
+- if 'post-payload.repository_dir' exists and directory is valid:
+ -> README.md, LICENSE and .mevaignore are created if not already present,
+ -> JSON with {"created": [...]} is printed to stdout.
+- if required fields are missing:
+ -> JSON with {"error": {...}} is printed to stdout,
+ -> input JSON file is updated with 'error' field.
+
+Example configuration entry (JSON):
+{
+ "name": "init_files",
+ "description": "Creates README.md, LICENSE and .mevaignore in a newly initialized repository",
+ "file": "init_files.py",
+ "event": "post-execute",
+ "order": 1,
+ "timeout": null,
+ "enabled": true,
+ "interpreter": "python"
+}
+"""
+
+import sys
+import json
+from pathlib import Path
+from datetime import datetime
+from typing import Any, Dict, Optional, List
+
+def make_error(json_path: Path, code: str, message: str, details: Optional[str] = None):
+ try:
+ payload = json.loads(json_path.read_text(encoding="utf-8"))
+ except Exception:
+ payload = {}
+ payload["error"] = {
+ "code": code,
+ "message": message,
+ "details": details,
+ }
+ json_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
+ sys.exit(1)
+
+def load_json(p: Path) -> Dict[str, Any]:
+ try:
+ return json.loads(p.read_text(encoding="utf-8"))
+ except Exception as e:
+ make_error(p, "JSON_PARSE_ERROR", "Failed to parse input JSON", str(e))
+
+def safe_write(path: Path, content: str) -> bool:
+ if path.exists():
+ return False
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+ return True
+
+def main():
+ json_path = Path(sys.argv[1])
+ payload = load_json(json_path)
+
+ post = payload.get("post-payload")
+ if not post or "repository_dir" not in post:
+ make_error(json_path, "MISSING_FIELD", "post-payload.repository_dir is required")
+
+ repo_dir = Path(post["repository_dir"]).resolve()
+ if not repo_dir.exists():
+ make_error(json_path, "DIR_NOT_FOUND", f"Repository directory does not exist: {repo_dir}")
+
+ year = datetime.now().year
+
+ print("Enter your full name for license copyright:", flush=True)
+ author = input().strip()
+ if not author:
+ make_error(json_path, "MISSING_AUTHOR", "Author name for LICENSE is required.")
+
+ readme_content = (
+ "# " + repo_dir.name + "\n\n"
+ "A concise description of the project purpose and features.\n\n"
+ "## Table of Contents\n"
+ "- [Installation](#installation)\n"
+ "- [Usage](#usage)\n"
+ "- [Configuration](#configuration)\n"
+ "- [Development](#development)\n"
+ "- [Contributing](#contributing)\n"
+ "- [License](#license)\n\n"
+ "## Installation\n"
+ "Steps to install the project locally:\n"
+ "1. Clone the repository.\n"
+ "2. Install dependencies.\n"
+ "3. Run initial setup if needed.\n\n"
+ "## Usage\n"
+ "Examples of how to use this project:\n"
+ "```\n"
+ "command-to-run --with options\n"
+ "```\n\n"
+ "## Configuration\n"
+ "List available configuration options and environment variables.\n\n"
+ "## Development\n"
+ "Instructions for contributors and developers:\n"
+ "- Run tests: `command-to-test`\n"
+ "- Run linting: `command-to-lint`\n"
+ "- Build or compile: `command-to-build`\n\n"
+ "## Contributing\n"
+ "To contribute:\n"
+ "1. Fork the repository.\n"
+ "2. Create a feature branch.\n"
+ "3. Make changes with clear commits.\n"
+ "4. Submit a pull request after testing.\n\n"
+ "## License\n"
+ "This project is licensed under the MIT License. See the `LICENSE` file for details.\n"
+ )
+
+ mit_license = (
+ "MIT License\n\n"
+ f"Copyright (c) {year} {author}\n\n"
+ "Permission is hereby granted, free of charge, to any person obtaining a copy\n"
+ "of this software and associated documentation files (the \"Software\"), to deal\n"
+ "in the Software without restriction, including without limitation the rights\n"
+ "to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n"
+ "copies of the Software, and to permit persons to whom the Software is\n"
+ "furnished to do so, subject to the following conditions:\n\n"
+ "THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n"
+ "IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n"
+ "FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n"
+ "AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n"
+ "LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n"
+ "OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n"
+ "SOFTWARE.\n"
+ )
+
+ mevaignore_content = (
+ "# VS Code / Editor artifacts\n"
+ ".vscode/\n"
+ ".idea/\n"
+ "*.code-workspace\n"
+ ".env\n\n"
+ "# Python cache and artifacts\n"
+ "__pycache__/\n"
+ ".python-version\n\n"
+ "# Build and distribution\n"
+ "dist/\n"
+ "build/\n\n"
+ "# Dependency management\n"
+ ".venv/\n"
+ "venv/\n\n"
+ "# Logs and temporary files\n"
+ "*.log\n"
+ "tmp/\n"
+ )
+
+ try:
+ created: List[str] = []
+ if safe_write(repo_dir / "README.md", readme_content):
+ created.append("README.md")
+ if safe_write(repo_dir / "LICENSE", mit_license):
+ created.append("LICENSE")
+ if safe_write(repo_dir / ".mevaignore", mevaignore_content):
+ created.append(".mevaignore")
+
+ if created:
+ print("Files created:", flush=True)
+ for file in created:
+ print(f"- {file}", flush=True)
+ else:
+ print("No files were created (all files already exist).", flush=True)
+ sys.exit(0)
+
+ except Exception as e:
+ make_error(json_path, "WRITE_ERROR", "Error writing files", str(e))
+ sys.exit(1)
+
+if __name__ == "__main__":
+ main()
diff --git a/docs/plugins/examples/python/nonzero_exit.py b/docs/plugins/examples/python/nonzero_exit.py
new file mode 100644
index 00000000..39b5cb27
--- /dev/null
+++ b/docs/plugins/examples/python/nonzero_exit.py
@@ -0,0 +1,39 @@
+#!/usr/bin/env python3
+"""
+Description:
+Example of Node.js plugin which demonstrates:
+- printing a message to standard output (stdout),
+- printing a message to standard error (stderr),
+- exiting with a non-zero status code (3).
+
+Invocation:
+On Linux / macOS:
+ python3 nonzero_exit.py
+ ./nonzero_exit.py
+On Windows:
+ python nonzero_exit.py
+
+Expected behavior:
+- stdout: "This is a message to stdout",
+- stderr: "This is a message to stderr",
+- exit status: 3.
+
+Example configuration entry (JSON):
+{
+ "name": "nonzero_exit",
+ "description": "Demonstrates exiting with a non-zero status code",
+ "file": "nonzero_exit.py",
+ "event": "pre-execute",
+ "order": 1,
+ "timeout": null,
+ "enabled": true,
+ "interpreter": "python"
+}
+"""
+
+import sys
+
+print("This is a message to stdout", flush=True)
+print("This is a message to stderr", file=sys.stderr, flush=True)
+
+sys.exit(3)
\ No newline at end of file
diff --git a/docs/plugins/examples/python/print_arg.py b/docs/plugins/examples/python/print_arg.py
new file mode 100644
index 00000000..5f4c4e0b
--- /dev/null
+++ b/docs/plugins/examples/python/print_arg.py
@@ -0,0 +1,48 @@
+#!/usr/bin/env python3
+"""
+Description:
+Example Python plugin which demonstrates:
+- reading a file specified as the first argument in the console,
+- printing its content to standard output (stdout),
+- handling file-not-found or read errors by printing to standard error (stderr).
+
+Invocation:
+On Linux / macOS:
+ python3 print_arg.py
+ ./print_arg.py
+On Windows:
+ python print_arg.py
+
+Expected behavior:
+- stdout: content of the specified file,
+- stderr: error messages if the file does not exist or cannot be read,
+- exit status: 0 on success, 1 on error.
+
+Example configuration entry (JSON):
+{
+ "name": "print_arg",
+ "description": null,
+ "file": "print_arg.py",
+ "event": "pre-execute",
+ "order": 1,
+ "timeout": null,
+ "enabled": true,
+ "interpreter": "python"
+}
+"""
+
+import sys
+
+filename = sys.argv[1]
+
+try:
+ with open(filename, "r", encoding="utf-8") as f:
+ for line in f:
+ print(line, end="", flush=True)
+ print(flush=True)
+except FileNotFoundError:
+ print(f"Error: File '{filename}' not found.", file=sys.stderr, flush=True)
+ sys.exit(1)
+except Exception as e:
+ print(f"Error reading file '{filename}': {e}", file=sys.stderr, flush=True)
+ sys.exit(1)
\ No newline at end of file
diff --git a/docs/plugins/examples/python/write_error.py b/docs/plugins/examples/python/write_error.py
new file mode 100644
index 00000000..8a764b77
--- /dev/null
+++ b/docs/plugins/examples/python/write_error.py
@@ -0,0 +1,67 @@
+#!/usr/bin/env python3
+"""
+Description:
+Example Python plugin that writes a business logic error
+to the "error" field of a JSON file representing plugin execution results.
+
+Invocation:
+On Linux / macOS:
+ python3 write_error.py
+ ./write_error.py
+On Windows:
+ python write_error.py
+
+Expected behavior:
+- The "error" field in the JSON file will be updated with a serialized InvocationError:
+ {
+ "code": "BUSINESS_LOGIC_ERROR",
+ "message": "A business rule was violated during plugin execution",
+ "details": "Optional additional context or stack trace"
+ },
+- exit status: 1 (indicating the plugin returned an error).
+
+Example configuration entry (JSON):
+{
+ "name": "write_error",
+ "description": null,
+ "file": "write_error.py",
+ "event": "pre-execute",
+ "order": 1,
+ "timeout": null,
+ "enabled": true,
+ "interpreter": "python"
+}
+"""
+
+import sys
+import json
+
+filename = sys.argv[1]
+
+invocation_error = {
+ "code": "BUSINESS_LOGIC_ERROR",
+ "message": "A business rule was violated during plugin execution",
+ "details": "Optional additional context or stack trace"
+}
+
+try:
+ with open(filename, "r", encoding="utf-8") as f:
+ data = json.load(f)
+
+ data["error"] = invocation_error
+
+ with open(filename, "w", encoding="utf-8") as f:
+ json.dump(data, f, indent=2)
+
+ print(f"Updated error in {filename}", flush=True)
+ sys.exit(1)
+
+except FileNotFoundError:
+ print(f"Error: File '{filename}' not found.", file=sys.stderr, flush=True)
+ sys.exit(1)
+except json.JSONDecodeError as e:
+ print(f"Error parsing JSON: {e}", file=sys.stderr, flush=True)
+ sys.exit(1)
+except Exception as e:
+ print(f"Unexpected error: {e}", file=sys.stderr, flush=True)
+ sys.exit(1)
diff --git a/engine/Cargo.toml b/engine/Cargo.toml
index d29147f9..29c4dd21 100644
--- a/engine/Cargo.toml
+++ b/engine/Cargo.toml
@@ -6,6 +6,38 @@ authors.workspace = true
[dependencies]
shared = { path = "../shared" }
+plugins = { path = "../plugins" }
+tempfile.workspace = true
+thiserror.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+regex.workspace = true
+toml.workspace = true
+toml_edit = "0.23.2"
+dirs.workspace = true
+globset.workspace = true
+walkdir = "2.5.0"
+sha1 = "0.11.0-rc.0"
+hex = "0.4.3"
+chrono.workspace = true
+rayon = "1.11.0"
+flate2 = "1.1.2"
+bincode = "2.0.1"
+encoding_rs = "0.8.35"
+chardetng = "0.1.17"
+tree-ds = "0.2.0"
+similar.workspace = true
+owo-colors.workspace = true
+path-absolutize.workspace = true
+russh.workspace = true
+russh-keys.workspace = true
+cryptovec.workspace = true
+async-trait.workspace = true
+tokio.workspace = true
+url.workspace = true
+itertools.workspace = true
+strum.workspace = true
+strum_macros.workspace = true
[dev-dependencies]
rstest.workspace = true
diff --git a/engine/src/branch_manager.rs b/engine/src/branch_manager.rs
new file mode 100644
index 00000000..4250a7ec
--- /dev/null
+++ b/engine/src/branch_manager.rs
@@ -0,0 +1,189 @@
+mod branch;
+mod branch_info;
+mod remove_branches_result;
+
+pub mod meva_branch_manager;
+
+use crate::errors::EngineResult;
+use crate::objects::MevaCommit;
+
+pub use branch::{Branch, BranchType};
+pub use branch_info::BranchInfo;
+pub use meva_branch_manager::MevaBranchManager;
+pub use remove_branches_result::RemoveBranchesResult;
+
+/// Defines a common interface for managing commits within a branch.
+///
+/// This trait handles the lifecycle of branches within the repository, including:
+/// - **Commit Operations**: adding, amending, and retrieving commits.
+/// - **Branch Management**: creating, deleting, renaming, and listing branches.
+/// - **State Inspection**: identifying the current active branch.
+pub trait BranchManager: Send + Sync {
+ /// Retrieves the name of the currently active branch (pointed to by HEAD).
+ ///
+ /// # Returns
+ ///
+ /// * `Ok(Some(name))` - If HEAD points to a valid branch reference (e.g., "main").
+ /// * `Ok(None)` - If HEAD is in a "detached" state (points directly to a commit hash)
+ /// or if the repository is empty.
+ ///
+ /// # Errors
+ ///
+ /// Returns an [`EngineResult`] if the HEAD file cannot be read or parsed.
+ fn current_branch_name(&self) -> EngineResult