Source code for hubvault.entry.formatters
"""
Human-readable CLI output helpers for :mod:`hubvault.entry`.
This module keeps git-like textual rendering logic separate from Click command
registration so commands stay focused on argument parsing and public API calls.
The helpers intentionally render familiar git-style summaries without
pretending that ``hubvault`` has a git workspace or index.
The module contains:
* :func:`short_oid` - Render a shortened object identifier
* :func:`format_status_output` - Render ``status`` output
* :func:`format_branch_output` - Render ``branch`` output
* :func:`format_log_output` - Render ``log`` output
* :func:`format_ls_tree_output` - Render ``ls-tree`` output
* :func:`format_merge_output` - Render ``merge`` output
* :func:`format_verify_output` - Render ``verify`` output
"""
from datetime import datetime
from typing import Dict, Optional, Sequence, Union
from ..models import GitCommitInfo, MergeConflict, MergeResult, RepoFile, RepoFolder, VerifyReport
[docs]
def short_oid(oid: Optional[str], length: int = 7) -> str:
"""
Return a shortened human-readable object identifier.
:param oid: Full object identifier, or ``None``
:type oid: Optional[str]
:param length: Number of hexadecimal characters to keep, defaults to ``7``
:type length: int, optional
:return: Short object identifier or ``"0" * length`` when ``oid`` is ``None``
:rtype: str
Example::
>>> short_oid("abcdef1234567890abcdef1234567890abcdef12")
'abcdef1'
"""
if oid is None:
return "0" * length
return oid.split(":", 1)[-1][:length]
[docs]
def format_status_output(branch: str, head: Optional[str], short: bool = False, show_branch: bool = False) -> str:
"""
Render repository status output in a git-like style.
:param branch: Branch name shown as the current CLI branch
:type branch: str
:param head: Current head commit ID, if any
:type head: Optional[str]
:param short: Whether short-format output is requested
:type short: bool, optional
:param show_branch: Whether branch information should be emitted in short mode
:type show_branch: bool, optional
:return: Formatted status text
:rtype: str
Example::
>>> format_status_output("main", None, short=True, show_branch=True)
'## No commits on main'
"""
if short:
if not show_branch:
return ""
if head is None:
return "## No commits on {branch}".format(branch=branch)
return "## {branch}".format(branch=branch)
lines = ["On branch {branch}".format(branch=branch), ""]
if head is None:
lines.extend(["No commits yet", "", "nothing to commit, repository clean"])
else:
lines.append("nothing to commit, repository clean")
return "\n".join(lines)
[docs]
def format_branch_output(
branch_names: Sequence[str],
current_branch: str,
commit_map: Optional[Dict[str, Optional[GitCommitInfo]]] = None,
verbose: bool = False,
) -> str:
"""
Render branch listings in a git-like style.
:param branch_names: Branch names to render
:type branch_names: Sequence[str]
:param current_branch: Branch name marked with ``*``
:type current_branch: str
:param commit_map: Optional mapping from branch name to newest commit entry
:type commit_map: Optional[Dict[str, Optional[GitCommitInfo]]]
:param verbose: Whether verbose listing is requested
:type verbose: bool, optional
:return: Formatted branch listing
:rtype: str
Example::
>>> format_branch_output(["dev", "main"], current_branch="main")
' dev\\n* main'
"""
lines = []
commit_map = commit_map or {}
for name in branch_names:
prefix = "*" if name == current_branch else " "
if verbose:
commit = commit_map.get(name)
if commit is None:
lines.append("{prefix} {name} (empty)".format(prefix=prefix, name=name))
else:
lines.append(
"{prefix} {name} {oid} {title}".format(
prefix=prefix,
name=name,
oid=short_oid(commit.commit_id),
title=commit.title,
)
)
else:
lines.append("{prefix} {name}".format(prefix=prefix, name=name))
return "\n".join(lines)
def _format_git_date(value: datetime) -> str:
return value.strftime("%a %b %d %H:%M:%S %Y +0000")
[docs]
def format_log_output(commits: Sequence[GitCommitInfo], oneline: bool = False) -> str:
"""
Render commit history in a git-like style.
:param commits: Commit entries to render
:type commits: Sequence[GitCommitInfo]
:param oneline: Whether to use the compact one-line format
:type oneline: bool, optional
:return: Formatted history text
:rtype: str
Example::
>>> commit = GitCommitInfo("abcdef1234567890abcdef1234567890abcdef12", [], datetime(2024, 1, 1), "seed", "", None, None)
>>> format_log_output([commit], oneline=True)
'abcdef1 seed'
"""
if oneline:
return "\n".join(
"{oid} {title}".format(oid=short_oid(item.commit_id), title=item.title)
for item in commits
)
blocks = []
for item in commits:
authors = ", ".join(item.authors) if item.authors else "Unknown"
block = [
"commit {commit_id}".format(commit_id=item.commit_id),
"Author: {authors}".format(authors=authors),
"Date: {date}".format(date=_format_git_date(item.created_at)),
"",
" {title}".format(title=item.title),
]
if item.message:
for line in item.message.splitlines():
block.append(" {line}".format(line=line))
blocks.append("\n".join(block))
return "\n\n".join(blocks)
[docs]
def format_ls_tree_output(entries: Sequence[Union[RepoFile, RepoFolder]]) -> str:
"""
Render tree entries in a git-like ``ls-tree`` style.
:param entries: Tree entries returned by the public API
:type entries: Sequence[Union[RepoFile, RepoFolder]]
:return: Formatted tree listing
:rtype: str
Example::
>>> format_ls_tree_output([RepoFolder("demo", "tree123")])
'040000 tree tree123\\tdemo'
"""
lines = []
for item in entries:
if isinstance(item, RepoFolder):
lines.append("040000 tree {oid}\t{path}".format(oid=item.tree_id, path=item.path))
else:
lines.append("100644 blob {oid}\t{path}".format(oid=item.blob_id, path=item.path))
return "\n".join(lines)
def _format_conflict(conflict: MergeConflict) -> str:
path = conflict.path if conflict.related_path is None else "{path} -> {related}".format(
path=conflict.path,
related=conflict.related_path,
)
return "CONFLICT ({kind}): {path}".format(kind=conflict.conflict_type, path=path)
[docs]
def format_merge_output(result: MergeResult) -> str:
"""
Render merge results in a git-like but hubvault-aware style.
:param result: Structured merge result returned by the public API
:type result: MergeResult
:return: Formatted merge text
:rtype: str
Example::
>>> formatter = format_merge_output # doctest: +SKIP
"""
if result.status == "already-up-to-date":
return "Already up to date."
if result.status == "fast-forward":
return "Updating {before}..{after}\nFast-forward".format(
before=short_oid(result.target_head_before),
after=short_oid(result.head_after),
)
if result.status == "merged":
commit_id = result.commit.oid if result.commit is not None else result.head_after
return "Merge made by the 'hubvault' strategy.\nCreated commit {oid}.".format(
oid=short_oid(commit_id),
)
lines = [_format_conflict(conflict) for conflict in result.conflicts]
lines.append("Automatic merge failed; no commit was created.")
return "\n".join(lines)
[docs]
def format_verify_output(report: VerifyReport, full: bool = False) -> str:
"""
Render verification results for the CLI.
:param report: Verification report returned by the public API
:type report: VerifyReport
:param full: Whether the report comes from ``full_verify()``
:type full: bool, optional
:return: Formatted verification output
:rtype: str
Example::
>>> format_verify_output(VerifyReport(True), full=False)
'Quick verification OK'
"""
title = "Full verification OK" if full else "Quick verification OK"
if not report.ok:
title = "Full verification failed" if full else "Quick verification failed"
lines = [title]
if report.checked_refs:
lines.append("Checked refs: {refs}".format(refs=", ".join(report.checked_refs)))
if report.warnings:
lines.append("Warnings:")
lines.extend("- {item}".format(item=item) for item in report.warnings)
if report.errors:
lines.append("Errors:")
lines.extend("- {item}".format(item=item) for item in report.errors)
return "\n".join(lines)