feat: add git commit subcommand
All checks were successful
checks / pre-commit (push) Successful in 4m29s
checks / tests-10 (push) Successful in 1m59s
checks / tests-11 (push) Successful in 2m8s
checks / tests-12 (push) Successful in 1m36s

This commit is contained in:
cătălin 2024-09-03 17:29:44 +02:00
commit c859c60c8d
No known key found for this signature in database
11 changed files with 628 additions and 429 deletions

View file

@ -19,7 +19,7 @@ repos:
args: [ --fix=lf ] args: [ --fix=lf ]
- repo: https://github.com/charliermarsh/ruff-pre-commit - repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.5.6 rev: v0.6.3
hooks: hooks:
- id: ruff - id: ruff
args: args:

View file

@ -1,4 +1,5 @@
# halig # halig
[![Build status](https://git.roboces.dev/catalin/halig/badges/workflows/ci.yaml/badge.svg)](https://git.roboces.dev/catalin/halig/actions) [![Build status](https://git.roboces.dev/catalin/halig/badges/workflows/ci.yaml/badge.svg)](https://git.roboces.dev/catalin/halig/actions)
![PyPI](https://img.shields.io/pypi/v/halig?logo=python) ![PyPI](https://img.shields.io/pypi/v/halig?logo=python)
![PyPI - License](https://img.shields.io/pypi/l/halig) ![PyPI - License](https://img.shields.io/pypi/l/halig)
@ -11,11 +12,10 @@
it encrypts the new contents into an [age](https://github.com/FiloSottile/age) file that it encrypts the new contents into an [age](https://github.com/FiloSottile/age) file that
you can store, _relatively_ safe, anywhere. you can store, _relatively_ safe, anywhere.
## Features ## Features
- Simple notebooks management with paths autocompletion - Simple notebooks management with paths autocompletion
- Passphrase-less, fully-encrypted notes, compatible with existing SSH keys - Passphrase-less, fully-encrypted notes, compatible with existing SSH keys
- No external `age` binary needed - No external `age` binary needed
- Almost all `age` advantages, like having multiple keys for encryption and decryption - Almost all `age` advantages, like having multiple keys for encryption and decryption
- Remote (HTTP) public keys import: e.g: github.com/\<username\>.keys - Remote (HTTP) public keys import: e.g: github.com/\<username\>.keys
@ -35,7 +35,7 @@ mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/halig"
cat << EOF > "${XDG_CONFIG_HOME:-$HOME/.config}/halig/halig.yml" cat << EOF > "${XDG_CONFIG_HOME:-$HOME/.config}/halig/halig.yml"
--- ---
notebooks_root_path: ~/Documents/Notebooks notebooks_root_path: ~/Documents/Notebooks
identity_paths: identity_paths:
- ~/.ssh/id_ed25519 - ~/.ssh/id_ed25519
recipient_paths: recipient_paths:
- ~/.ssh/id_ed25519.pub - ~/.ssh/id_ed25519.pub
@ -50,5 +50,7 @@ EOF
halig edit some_notebook # edit today's note relative to <notebooks_root_path>/some_notebook halig edit some_notebook # edit today's note relative to <notebooks_root_path>/some_notebook
halig edit some_notebook/foo # edit <notebooks_root_path>/some_notebook/foo.age halig edit some_notebook/foo # edit <notebooks_root_path>/some_notebook/foo.age
halig notebooks # list current notebooks halig notebooks # list current notebooks
halig git commit
halig git pull
halig git push
``` ```

View file

View file

@ -0,0 +1,35 @@
from pathlib import Path
from git import Repo
from rich import print
from halig.commands.base import BaseCommand
from halig.encryption import Encryptor
from halig.settings import Settings
class GitCommitCommand(BaseCommand):
@staticmethod
def __init_repo(repo_path: Path) -> Repo:
"""Check if `repo_path` is a git repo. If not, initialize it"""
if not (repo_path / ".git").is_dir():
print(f"[yellow] {repo_path} is not a git repo, initializing ...")
Repo.init(repo_path)
return Repo(repo_path)
return Repo(repo_path)
def __init__(self, settings: Settings, message: str | None = None):
super().__init__(settings)
self.settings = settings
self.encryptor = Encryptor(self.settings)
self.message = message or self.settings.default_commit_message
self.repo = self.__init_repo(self.settings.notebooks_root_path)
def run(self):
"""Add all .age files to git and commit them using gitpython"""
self.repo.index.add(
[str(path) for path in self.settings.notebooks_root_path.glob("**/*.age")]
)
self.repo.index.commit(self.message)

View file

@ -1,3 +1,5 @@
# APPS
GIT_HELP = "Git integration"
# COMMANDS # COMMANDS
COMMANDS_NOTEBOOKS_HELP = "List all notebooks and notes, tree-style" COMMANDS_NOTEBOOKS_HELP = "List all notebooks and notes, tree-style"
COMMANDS_EDIT_HELP = "Edit or add a note into a notebook" COMMANDS_EDIT_HELP = "Edit or add a note into a notebook"
@ -10,6 +12,7 @@ which are indexed into a SQLite FTS5 database located at `~/.cache/halig/halig.d
COMMANDS_REENCRYPT_HELP = """Reencrypt all available notes. This operation is useful COMMANDS_REENCRYPT_HELP = """Reencrypt all available notes. This operation is useful
when new public keys have been added to the config file and you want the notes when new public keys have been added to the config file and you want the notes
to be seen by the new pairing private keys""" to be seen by the new pairing private keys"""
COMMANDS_GIT_COMMIT_HELP = """Commit all .age files to git"""
# OPTIONS # OPTIONS
OPTION_CONFIG_HELP = "Configuration file. Must be YAML and schema compatible" OPTION_CONFIG_HELP = "Configuration file. Must be YAML and schema compatible"

View file

@ -9,6 +9,7 @@ from typer import Argument, Option, Typer
from halig import literals from halig import literals
from halig.__version__ import __version__ from halig.__version__ import __version__
from halig.commands.edit import EditCommand from halig.commands.edit import EditCommand
from halig.commands.git.commit import GitCommitCommand
from halig.commands.import_unencrypted import ImportCommand from halig.commands.import_unencrypted import ImportCommand
from halig.commands.notebooks import NotebooksCommand from halig.commands.notebooks import NotebooksCommand
from halig.commands.reencrypt import ReencryptCommand from halig.commands.reencrypt import ReencryptCommand
@ -17,7 +18,16 @@ from halig.commands.show import ShowCommand
from halig.settings import load_from_file from halig.settings import load_from_file
from halig.utils import capture from halig.utils import capture
app = Typer(pretty_exceptions_enable=False, pretty_exceptions_show_locals=False) git_app = Typer(
name="git",
help=literals.GIT_HELP,
pretty_exceptions_enable=False,
pretty_exceptions_show_locals=False,
)
app = Typer(
name="halig", pretty_exceptions_enable=False, pretty_exceptions_show_locals=False
)
app.add_typer(git_app)
config_option = Option(None, "--config", "-c", help=literals.OPTION_CONFIG_HELP) config_option = Option(None, "--config", "-c", help=literals.OPTION_CONFIG_HELP)
@ -125,8 +135,9 @@ def import_unencrypted(
def search( def search(
term: str, term: str,
index: bool = Option(False, help=literals.OPTION_INDEX_HELP), index: bool = Option(False, help=literals.OPTION_INDEX_HELP),
config: Optional[Path] = config_option, # noqa: UP007
): ):
settings = load_from_file() settings = load_from_file(config)
command = SearchCommand( command = SearchCommand(
term=term, term=term,
index=index, index=index,
@ -136,14 +147,24 @@ def search(
@app.command(help=literals.COMMANDS_REENCRYPT_HELP) @app.command(help=literals.COMMANDS_REENCRYPT_HELP)
def reencrypt(): def reencrypt(config: Path | None = config_option):
settings = load_from_file() settings = load_from_file(config)
command = ReencryptCommand( command = ReencryptCommand(
settings=settings, settings=settings,
) )
command.run() command.run()
@git_app.command(name="commit", help=literals.COMMANDS_GIT_COMMIT_HELP)
def git_commit(
message: str | None = None,
config: Path | None = config_option,
):
settings = load_from_file(config)
command = GitCommitCommand(settings=settings, message=message)
command.run()
@app.command(help=literals.COMMANDS_VERSION) @app.command(help=literals.COMMANDS_VERSION)
@capture @capture
def version(): def version():

View file

@ -47,6 +47,7 @@ class Settings(BaseSettings):
), ),
) )
remote_public_keys_timeout: float = 0.5 remote_public_keys_timeout: float = 0.5
default_commit_message: str = "Update notebooks"
@field_validator("identity_paths", "recipient_paths", mode="before") @field_validator("identity_paths", "recipient_paths", mode="before")
@classmethod @classmethod

875
pdm.lock generated

File diff suppressed because it is too large Load diff

View file

@ -4,16 +4,17 @@ authors = [
] ]
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"typer>=0.12", "typer>=0",
"rich>=13.3", "rich>=13.8",
"pydantic>=2.7", "pydantic>=2.8",
"pyyaml>=6.0", "pyyaml>=6.0",
"pyrage>=1.1", "pyrage>=1",
"httpx>=0.27", "httpx>=0",
"platformdirs>=4.2", "platformdirs>=4.2",
"pydantic-settings>=2.0", "pydantic-settings>=2",
"hishel>=0.0", "hishel>=0",
"whenever>=0.6.6", "whenever>=0.6.6",
"gitpython>=3.1.43",
] ]
name = "halig" name = "halig"
dynamic = ["version"] dynamic = ["version"]
@ -57,22 +58,22 @@ halig = "halig.main:app"
[project.optional-dependencies] [project.optional-dependencies]
testing = [ testing = [
"pytest>=7.2.2", "pytest>=7",
"pytest-cov>=4.0.0", "pytest-cov>=4",
"pyfakefs>=5.1.0", "pyfakefs>=5",
"pytest-clarity>=1.0.1", "pytest-clarity>=1",
"pytest-reportlog>=0.2.1", "pytest-reportlog>=0",
"pytest-duration-insights>=0.1.1", "pytest-duration-insights>=0",
"pytest-pretty>=1.1.1", "pytest-pretty>=1",
"pytest-mock>=3.10.0", "pytest-mock>=3",
"mock>=5.0.1", "mock>=5",
"faker>=28.1.0",
] ]
linting = [ linting = [
"black>=23.3.0", "ruff>=0.4",
"ruff>=0.1.0", "pyright>=1.1",
"pyright>=1.1.301", "mypy>=1.1",
"mypy>=1.1.1", "types-PyYAML>=6.0",
"types-PyYAML>=6.0.12.9",
] ]
docs = [ docs = [
"mkdocs-material>=9.1.5", "mkdocs-material>=9.1.5",

View file

View file

@ -0,0 +1,59 @@
import subprocess
import pytest
from halig.commands.git.commit import GitCommitCommand
from halig.settings import Settings
@pytest.fixture
def command(settings: Settings):
return GitCommitCommand(settings)
def test_repo_is_not_initialized(settings):
"""Given that settings.notebooks_root_path is not a git repo, assert that the command
initializes the repo upon instantiation"""
assert not (settings.notebooks_root_path / ".git").is_dir()
GitCommitCommand(settings)
assert (settings.notebooks_root_path / ".git").is_dir()
def test_repo_is_initialized(settings):
"""Manually initialize a repo in settings.notebooks_root_path and check that the command instantiation
is not reinitializing it"""
p = subprocess.Popen(["git", "init"], cwd=settings.notebooks_root_path)
p.wait()
assert (settings.notebooks_root_path / ".git").is_dir()
GitCommitCommand(settings)
assert (settings.notebooks_root_path / ".git").is_dir()
def test_run(settings, command, faker):
"""Create a bunch of .age and non-.age files and assert that all .age files are added to git and that the commit
message is set"""
for _ in range(10):
random_file = settings.notebooks_root_path / f"{faker.word()}.txt"
random_file.touch()
for _ in range(10):
random_age_file = settings.notebooks_root_path / f"{faker.word()}.age"
random_age_file.touch()
command.run()
assert settings.notebooks_root_path / ".git" / "index"
assert settings.notebooks_root_path / ".git" / "index" / "stage"
assert command.message in command.repo.git.log("--pretty=oneline").splitlines()[0]
assert "nothing added to commit but untracked files present (use \"git add\" to track)" in command.repo.git.status()
assert ".age" not in command.repo.git.status()
def test_custom_commit_message(settings, command, faker):
command.message = faker.word()
command.run()
assert command.message in command.repo.git.log("--pretty=oneline").splitlines()[0]