Skip to content
1,536 changes: 444 additions & 1,092 deletions demo/demo_python.ipynb

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions demo/hallmark-demo-clone/.hm
Submodule .hm added at e42561
1 change: 1 addition & 0 deletions demo/repo1/.hm
Submodule .hm added at 948485
33 changes: 26 additions & 7 deletions mod/hallmark/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from git.exc import GitError

from .downloader import DownloadError, download_remote_data
from .error import CloneError, DestinationExistsError
from .error import CloneError, DestinationExistsError, CheckoutError

from . import Repo # from "__init__.py"

Expand Down Expand Up @@ -96,6 +96,7 @@ def emit_section(title, entries, fg):
emit_section(
"Changes to be committed:",
[
("state", staged["state"]),
("new file", staged["added"]),
("modified", staged["modified"]),
("deleted", staged["deleted"]),
Expand All @@ -116,27 +117,34 @@ def emit_section(title, entries, fg):
for path in untracked:
click.echo(" " + click.style(path, fg="red"))

if not any([staged["added"], staged["modified"], staged["deleted"],
if not any([staged["state"], staged["added"], staged["modified"], staged["deleted"],
worktree["modified"], worktree["deleted"], untracked]):
click.echo("")
click.echo("nothing to commit, working tree clean")


@hallmark.command(short_help="Add files to hallmark index.")
@click.option(
"--regex",
"encoding",
is_flag=True,
default=False,
show_default=True,
help="Enable regex-based encoding rules from config.yml.")
@click.argument("inputs", nargs=-1, required=True)
@click.pass_obj
def add(repo, inputs):
def add(repo, encoding, inputs):
"""Add files to the hallmark index.

`hallmark add FORMAT` uses the branch format string workflow.
`hallmark add [--regex] FORMAT` uses the branch format string workflow.
`hallmark add "."` rebuilds the manifest from current files that match
the branch `fmt` in `config.yml`.
Explicit path inputs such as shell-expanded `*` are not supported yet
with the parameter-based manifest format.
"""
try:
if len(inputs) == 1:
pf = repo.add(inputs[0])
pf = repo.add(inputs[0], encoding)
else:
pf = repo.add_paths(list(inputs))
except (RuntimeError, ValueError, FileNotFoundError) as e:
Expand Down Expand Up @@ -205,6 +213,17 @@ def log(repo):
click.echo(history)


@hallmark.command(short_help="List hallmark branches.")
@click.pass_obj
def branch(repo):
"""List local hallmark branches."""
snapshot = repo.branches()
current = snapshot["current"]
for name in snapshot["names"]:
prefix = "*" if name == current else " "
click.echo(f"{prefix} {name}")


@hallmark.command(short_help="Switch to another branch.")
@click.argument("target_branch")
@click.pass_obj
Expand All @@ -219,7 +238,7 @@ def checkout(repo, target_branch):
try:
if repo.checkout(target_branch):
click.echo(f'Switched to branch "{target_branch}".')
except (GitError, RuntimeError, ValueError, FileNotFoundError) as e:
except (GitError, RuntimeError, ValueError, FileNotFoundError, CheckoutError) as e:
raise ClickException(str(e))


Expand All @@ -245,7 +264,7 @@ def clone(url, path, no_fetch_data, max_workers):
Supports concurrent downloads for efficient retrieval of large datasets.
"""
try:
repo = Repo.clone(url, path)
repo = Repo.clone(url, path, fetch_data=False)
click.echo(f'Successfully cloned to "{path}"')

if not no_fetch_data:
Expand Down
3 changes: 3 additions & 0 deletions mod/hallmark/error.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,6 @@ def from_git_command(

class DestinationExistsError(CloneError):
"""Raised when clone destination already exists."""

class CheckoutError(HallmarkError):
"""Raised when a hallmark checkout cannot proceed safely."""
101 changes: 85 additions & 16 deletions mod/hallmark/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from typing import Dict, List, Optional, Tuple, Union

from .dothm import Dothm
from .error import DestinationExistsError
from .error import CheckoutError, DestinationExistsError
from .objects import Objects
from .paraframe import ParaFrame
from .repo_config import branch_encodings, branch_fmt, path_from_row, row_to_path, \
Expand Down Expand Up @@ -94,7 +94,15 @@ def init(cls, path: Union[Path, str]) -> "Repo":
return cls(path)

@classmethod
def clone(cls, url: str, path: Union[Path, str]) -> "Repo":
def clone(
cls,
url: str,
path: Union[Path, str],
*,
fetch_data: bool = True,
max_workers: int = 4,
show_progress: bool = False,
) -> "Repo":
clone_path = Path(path)
if clone_path.exists():
raise DestinationExistsError(
Expand All @@ -110,7 +118,31 @@ def clone(cls, url: str, path: Union[Path, str]) -> "Repo":
if worktree_path:
Worktree.init(worktree_path)

return cls(path)
repo = cls(path)
repo.download_result = None

if fetch_data and worktree_path:
from .downloader import DownloadError, download_remote_data

result = download_remote_data(
repo,
worktree_path,
max_workers=max_workers,
show_progress=show_progress,
)
repo.download_result = result
if result["failed"]:
errors = result.get("errors", [])
details = "\n".join(f" - {error}" for error in errors[:5])
remaining = result["failed"] - len(errors[:5])
if remaining > 0:
details += f"\n - ... {remaining} more error(s)"
raise DownloadError(
f"Failed to download {result['failed']} file(s):\n"
f"{details}"
)

return repo

@staticmethod
def checksum(path: Path, chunk_size: int = 1024 * 1024) -> str:
Expand Down Expand Up @@ -147,6 +179,11 @@ def status(self) -> dict[str, object]:
head_state = load_head_state(self)
head_map = manifest_map(head_state)
staged_map = manifest_map(self.state)
state_changes = sorted({
diff.a_path or diff.b_path
for diff in self.dothm.index.diff("HEAD")
if diff.a_path or diff.b_path
})

staged_added = sorted(path for path in staged_map if path not in head_map)
staged_deleted = sorted(path for path in head_map if path not in staged_map)
Expand Down Expand Up @@ -180,6 +217,7 @@ def status(self) -> dict[str, object]:
return {
"branch": self.dothm.active_branch.name,
"staged": {
"state": state_changes,
"added": staged_added,
"modified": staged_modified,
"deleted": staged_deleted,
Expand Down Expand Up @@ -215,6 +253,11 @@ def add(self, fstr: str, encoding: bool = False) -> ParaFrame:
self.dothm.dump(self.state)
return pf.drop(columns=["sha1"], errors="ignore")

try:
previous_fmt = branch_fmt(self)
except RuntimeError:
previous_fmt = None

set_branch_fmt(self, fstr)

with chdir(self.worktree):
Expand All @@ -232,7 +275,11 @@ def add(self, fstr: str, encoding: bool = False) -> ParaFrame:
for path in pf["path"].astype(str)
]

self.state.update(manifest_frame_from_pf(pf, fstr))
manifest = manifest_frame_from_pf(pf, fstr)
if previous_fmt is None or previous_fmt != fstr:
self.state.replace(manifest)
else:
self.state.update(manifest)
self.dothm.dump(self.state)
return pf.drop(columns=["sha1"], errors="ignore")

Expand All @@ -253,31 +300,37 @@ def log(self) -> str:
return ""
return self.dothm.git.log()

def branches(self) -> dict[str, object]:
current = self.dothm.active_branch.name
names = sorted(head.name for head in self.dothm.heads)
return {"current": current, "names": names}

def checkout(self, target_branch: str) -> bool:
if not isinstance(target_branch, str) or not target_branch.strip():
raise ValueError("branch name must be a non-empty string")

if self.worktree is None:
raise RuntimeError("cannot checkout without a worktree")
raise CheckoutError("cannot checkout without a worktree")
ensure_clean_tracked_files(self)

existing = {head.name for head in self.dothm.heads}
new_branch = target_branch not in existing
current_tracked = tracked_paths(self)
target_state = load_branch_data(self, target_branch)
target_fmt = target_state.config["data"][0]["fmt"]
target_tracked = {
row_to_path(row, target_fmt)
for _, row in target_state.data.iterrows()
}

for _, row in target_state.data.iterrows():
rel_path = row_to_path(row, target_state.config["data"][0]["fmt"])
if rel_path not in current_tracked and (self.worktree / rel_path).exists():
raise RuntimeError(
f'target tracked path "{rel_path}" already exists '
"as an untracked file")

# remove current tracked files from worktree
for _, row in self.state.data.iterrows():
path = self.worktree / path_from_row(self, row)
if path.exists():
path.unlink()
rel_path = row_to_path(row, target_fmt)
target_path = self.worktree / rel_path
if rel_path not in current_tracked and target_path.exists():
if self.checksum(target_path) != row["sha1"]:
raise CheckoutError(
f'target tracked path "{rel_path}" already exists '
"as an untracked file")

# switch .hm branch
if new_branch:
Expand All @@ -288,10 +341,26 @@ def checkout(self, target_branch: str) -> bool:
# reload state from the new branch
self.state = self.dothm.load()

removed_paths = []
for rel_path in sorted(current_tracked - target_tracked, reverse=True):
path = self.worktree / rel_path
if path.exists():
path.unlink()
removed_paths.append(path)

# restore files from objects store via hardlinks
for _, row in self.state.data.iterrows():
self.objects.restore(row["sha1"], self.worktree / path_from_row(self, row))

for path in removed_paths:
parent = path.parent
while parent != self.worktree and parent.exists():
try:
parent.rmdir()
except OSError:
break
parent = parent.parent

return True

def add_worktree(self, target_branch: str) -> bool:
Expand Down
9 changes: 5 additions & 4 deletions mod/hallmark/repo_worktree.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from pathlib import Path

from .repo_config import branch_fmt, path_from_row
from .error import CheckoutError


def effective_cwd(repo) -> Path:
Expand Down Expand Up @@ -38,21 +39,21 @@ def tracked_paths(repo) -> set[Path]:

def ensure_clean_tracked_files(repo) -> None:
if repo.worktree is None:
raise RuntimeError("cannot checkout without a worktree")
raise CheckoutError("cannot checkout without a worktree")

for _, row in repo.state.data.iterrows():
rel_path = path_from_row(repo, row)
path = repo.worktree / rel_path
if not path.exists():
raise RuntimeError(
raise CheckoutError(
f'tracked file "{rel_path}" is missing; commit or \
restore it before checkout')
if repo.checksum(path) != row["sha1"]:
raise RuntimeError(
raise CheckoutError(
f'tracked file "{rel_path}" has uncommitted changes; \
commit them before checkout')

if repo.dothm.index.diff("HEAD"):
raise RuntimeError(
raise CheckoutError(
"you have uncommitted hallmark state changes — " \
"commit them before checkout")
Loading
Loading