feat: add git commit subcommand
This commit is contained in:
parent
b1ed583a24
commit
c859c60c8d
11 changed files with 628 additions and 429 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
10
README.md
10
README.md
|
|
@ -1,4 +1,5 @@
|
||||||
# halig
|
# halig
|
||||||
|
|
||||||
[](https://git.roboces.dev/catalin/halig/actions)
|
[](https://git.roboces.dev/catalin/halig/actions)
|
||||||

|

|
||||||

|

|
||||||
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
0
halig/commands/git/__init__.py
Normal file
0
halig/commands/git/__init__.py
Normal file
35
halig/commands/git/commit.py
Normal file
35
halig/commands/git/commit.py
Normal 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)
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
0
tests/commands/test_git/__init__.py
Normal file
0
tests/commands/test_git/__init__.py
Normal file
59
tests/commands/test_git/test_commit.py
Normal file
59
tests/commands/test_git/test_commit.py
Normal 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]
|
||||||
Loading…
Add table
Add a link
Reference in a new issue