Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 32 additions & 22 deletions src/codealmanac/services/repositories/identity.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,32 @@
from hashlib import sha256
from pathlib import Path

from codealmanac.core.errors import ValidationFailed
from codealmanac.core.paths import normalize_path
from codealmanac.core.slug import to_kebab_case
from codealmanac.services.repositories.models import RepositoryName


def repository_name_for(
root_path: Path,
requested_name: RepositoryName | None,
) -> str:
name = to_kebab_case(requested_name or root_path.name)
if not name:
raise ValidationFailed("could not derive a repository name; pass --name")
return name


def repository_id_for(root_path: Path) -> str:
digest = sha256(str(normalize_path(root_path)).encode("utf-8")).hexdigest()[:16]
return f"repo_{digest}"
from hashlib import sha256
from pathlib import Path

from codealmanac.core.errors import ValidationFailed
from codealmanac.core.paths import normalize_path
from codealmanac.core.slug import to_kebab_case
from codealmanac.services.repositories.models import RepositoryName


def repository_name_for(
root_path: Path,
requested_name: RepositoryName | None,
) -> str:
name = to_kebab_case(requested_name or root_path.name)
if not name:
raise ValidationFailed("could not derive a repository name; pass --name")
return name


def repository_id_for(root_path: Path) -> str:
digest = sha256(str(normalize_path(root_path)).encode("utf-8")).hexdigest()[:16]
return f"repo_{digest}"


def project_id_for(name: str) -> str:
digest = sha256(name.casefold().encode("utf-8")).hexdigest()[:16]
return f"proj_{digest}"


def workspace_id_for(root_path: Path) -> str:
digest = sha256(str(normalize_path(root_path)).encode("utf-8")).hexdigest()[:16]
return f"ws_{digest}"
20 changes: 20 additions & 0 deletions src/codealmanac/services/repositories/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,25 @@
]


class Project(CodeAlmanacModel):
project_id: str
name: RepositoryName
description: str = ""
created_at: datetime


class Workspace(CodeAlmanacModel):
workspace_id: str
project_id: str
root_path: Path
almanac_root: Path = Field(default=DEFAULT_ALMANAC_ROOT)
registered_at: datetime

@property
def almanac_path(self) -> Path:
return self.root_path / self.almanac_root


class Repository(CodeAlmanacModel):
repository_id: str
name: RepositoryName
Expand Down Expand Up @@ -85,3 +104,4 @@ class RegisteredRepository(CodeAlmanacModel):

class RegisteredRepositories(CodeAlmanacModel):
repositories: tuple[RegisteredRepository, ...]

37 changes: 29 additions & 8 deletions src/codealmanac/services/repositories/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,14 @@
from codealmanac.services.repositories.selection import (
contains_path,
entry_by_exact_path,
select_repository_record,
entry_by_name,
)
from codealmanac.services.repositories.state import repository_state
from codealmanac.services.repositories.store import RepositoryStore


class RepositoriesService:

def __init__(self, store: RepositoryStore):
self.store = store

Expand All @@ -60,10 +61,13 @@ def register(self, request: RegisterRepositoryRequest) -> Repository:
root_path,
request.name or (existing.name if existing is not None else None),
)
existing_by_name = entry_by_name(name, self.store.list())
description = (
request.description.strip()
or (existing.description if existing is not None else "")
or (existing_by_name.description if existing_by_name is not None else "")
)

repository = Repository(
repository_id=repository_id_for(root_path),
name=name,
Expand All @@ -89,12 +93,21 @@ def find_by_root_path(self, path: Path) -> Repository | None:
return None
return entry.to_repository()

def select_by_name(self, request: SelectRepositoryRequest) -> Repository:
def select_by_name(
self,
request: SelectRepositoryRequest,
preferred_cwd: Path | None = None,
) -> Repository:
entries = self.store.list()
selected = select_repository_record(request, entries)
if selected is not None:
return selected.to_repository()
raise NotFoundError("repository", request.name)
matches = [e for e in entries if e.name.casefold() == request.name.casefold()]
if not matches:
raise NotFoundError("repository", request.name)
if preferred_cwd is not None:
normalized_cwd = normalize_path(preferred_cwd)
for entry in matches:
if contains_path(entry.path, normalized_cwd):
return entry.to_repository()
return matches[0].to_repository()

def registered_repository_at(self, path: Path) -> Repository:
normalized = normalize_path(path)
Expand All @@ -110,7 +123,10 @@ def select_for_operation(
) -> Repository:
if repository_name is None:
return self.registered_repository_at(cwd)
return self.select_by_name(SelectRepositoryRequest(name=repository_name))
return self.select_by_name(
SelectRepositoryRequest(name=repository_name),
preferred_cwd=cwd,
)

def read_repository_at(self, path: Path) -> Repository:
registered = self.find_by_root_path(path)
Expand All @@ -126,14 +142,19 @@ def read_repository_at(self, path: Path) -> Repository:
)
raise NoRepositorySelected()


def select_for_read(
self,
cwd: Path,
repository_name: RepositoryName | None,
) -> Repository:
if repository_name is None:
return self.read_repository_at(cwd)
return self.select_by_name(SelectRepositoryRequest(name=repository_name))
return self.select_by_name(
SelectRepositoryRequest(name=repository_name),
preferred_cwd=cwd,
)


def validate_path(self, repository_id: str, path: Path) -> Path:
repository = self.get(repository_id)
Expand Down
41 changes: 39 additions & 2 deletions src/codealmanac/services/repositories/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from codealmanac.core.paths import normalize_path
from codealmanac.database.local import open_local_database
from codealmanac.services.repositories.identity import project_id_for
from codealmanac.services.repositories.models import Repository, RepositoryRecord
from codealmanac.services.repositories.records import (
repository_record_for,
Expand All @@ -17,7 +18,41 @@ def __init__(self, database_path: Path):

def remember(self, repository: Repository) -> RepositoryRecord:
record = repository_record_for(repository)
project_id = project_id_for(record.name)
with self.connect() as connection:
connection.execute(
"""
INSERT INTO projects (project_id, name, description, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(name) DO UPDATE SET
description = excluded.description
""",
(
project_id,
record.name,
record.description,
record.registered_at.isoformat(),
),
)
connection.execute(
"""
INSERT INTO workspaces (
workspace_id, project_id, root_path, almanac_root, registered_at
)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(workspace_id) DO UPDATE SET
root_path = excluded.root_path,
almanac_root = excluded.almanac_root
""",
(
f"ws_{record.repository_id}",
project_id,
record.path.as_posix(),
record.almanac_root.as_posix(),
record.registered_at.isoformat(),
),
)

connection.execute(
"""
INSERT INTO repositories (
Expand All @@ -29,18 +64,19 @@ def remember(self, repository: Repository) -> RepositoryRecord:
registered_at
)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(repository_id) DO UPDATE SET
ON CONFLICT(root_path) DO UPDATE SET
name = excluded.name,
description = excluded.description,
root_path = excluded.root_path,
almanac_root = excluded.almanac_root,
registered_at = repositories.registered_at
""",
repository_values(record),
)

connection.commit()
return record


def find_by_repository_id(self, repository_id: str) -> RepositoryRecord | None:
with self.connect() as connection:
row = connection.execute(
Expand Down Expand Up @@ -86,3 +122,4 @@ def list(self) -> list[RepositoryRecord]:

def connect(self):
return open_local_database(self.database_path, REPOSITORY_TABLES)

19 changes: 18 additions & 1 deletion src/codealmanac/services/repositories/tables.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,27 @@
REPOSITORY_TABLES = """
CREATE TABLE IF NOT EXISTS projects (
project_id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS workspaces (
workspace_id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(project_id),
root_path TEXT NOT NULL UNIQUE,
almanac_root TEXT NOT NULL,
registered_at TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS repositories (
repository_id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
root_path TEXT NOT NULL UNIQUE,
almanac_root TEXT NOT NULL,
registered_at TEXT NOT NULL
);

"""

44 changes: 44 additions & 0 deletions tests/test_repository_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,47 @@ def test_repository_store_updates_existing_repository(tmp_path: Path):
assert record.description == "Updated"
assert record.registered_at == first.registered_at
assert [item.repository_id for item in store.list()] == ["repo-1"]


def test_read_repository_at_registers_multiple_workspaces(tmp_path: Path):
from codealmanac.services.repositories.requests import (
RegisterRepositoryRequest,
)
from codealmanac.services.repositories.roots import (
ALMANAC_ROOT_MARKER_FILE,
ALMANAC_ROOT_MARKER_README,
)
from codealmanac.services.repositories.service import RepositoriesService

dir_a = tmp_path / "work" / "a" / "lmfellow"
dir_b = tmp_path / "work" / "b" / "lmfellow"
for d in (dir_a, dir_b):
almanac = d / "almanac"
almanac.mkdir(parents=True, exist_ok=True)
(almanac / ALMANAC_ROOT_MARKER_FILE).write_text(
"topics: []\n", encoding="utf-8"
)
(almanac / ALMANAC_ROOT_MARKER_README).write_text(
"# Test\n", encoding="utf-8"
)

service = RepositoriesService(RepositoryStore(tmp_path / "codealmanac.db"))
# Register dir_a
repo_a = service.register(RegisterRepositoryRequest(root_path=dir_a))
assert repo_a.name == "lmfellow"
assert repo_a.root_path == dir_a

# Auto-register dir_b via read_repository_at
repo_b = service.read_repository_at(dir_b)
assert repo_b.name == "lmfellow"
assert repo_b.root_path == dir_b

# Verify select_for_read resolves preferred checkout by CWD
selected_a = service.select_for_read(cwd=dir_a, repository_name="lmfellow")
assert selected_a.root_path == dir_a

selected_b = service.select_for_read(cwd=dir_b, repository_name="lmfellow")
assert selected_b.root_path == dir_b



Loading