diff --git a/src/codealmanac/services/repositories/identity.py b/src/codealmanac/services/repositories/identity.py index 278b26b2..91089c8b 100644 --- a/src/codealmanac/services/repositories/identity.py +++ b/src/codealmanac/services/repositories/identity.py @@ -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}" \ No newline at end of file diff --git a/src/codealmanac/services/repositories/models.py b/src/codealmanac/services/repositories/models.py index 0e6b58aa..27ff13a3 100644 --- a/src/codealmanac/services/repositories/models.py +++ b/src/codealmanac/services/repositories/models.py @@ -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 @@ -85,3 +104,4 @@ class RegisteredRepository(CodeAlmanacModel): class RegisteredRepositories(CodeAlmanacModel): repositories: tuple[RegisteredRepository, ...] + diff --git a/src/codealmanac/services/repositories/service.py b/src/codealmanac/services/repositories/service.py index 3b494700..c82d33b6 100644 --- a/src/codealmanac/services/repositories/service.py +++ b/src/codealmanac/services/repositories/service.py @@ -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 @@ -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, @@ -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) @@ -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) @@ -126,6 +142,7 @@ def read_repository_at(self, path: Path) -> Repository: ) raise NoRepositorySelected() + def select_for_read( self, cwd: Path, @@ -133,7 +150,11 @@ def select_for_read( ) -> 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) diff --git a/src/codealmanac/services/repositories/store.py b/src/codealmanac/services/repositories/store.py index 081a8e00..6a6c69dd 100644 --- a/src/codealmanac/services/repositories/store.py +++ b/src/codealmanac/services/repositories/store.py @@ -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, @@ -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 ( @@ -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( @@ -86,3 +122,4 @@ def list(self) -> list[RepositoryRecord]: def connect(self): return open_local_database(self.database_path, REPOSITORY_TABLES) + diff --git a/src/codealmanac/services/repositories/tables.py b/src/codealmanac/services/repositories/tables.py index f77c62e3..170dd57b 100644 --- a/src/codealmanac/services/repositories/tables.py +++ b/src/codealmanac/services/repositories/tables.py @@ -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 ); + """ + diff --git a/tests/test_repository_store.py b/tests/test_repository_store.py index b6707745..7920586f 100644 --- a/tests/test_repository_store.py +++ b/tests/test_repository_store.py @@ -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 + + +