From 634d8311cc6f98326850a0f77e8fad3936984e6d Mon Sep 17 00:00:00 2001 From: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:07:09 +0530 Subject: [PATCH 1/3] `feat(repositories): model project and workspace identity for multi-checkout support (#47)` --- .../services/repositories/identity.py | 54 +++++++++++-------- .../services/repositories/models.py | 20 +++++++ .../services/repositories/service.py | 36 ++++++++++--- .../services/repositories/store.py | 39 +++++++++++++- .../services/repositories/tables.py | 19 ++++++- tests/test_repository_store.py | 34 ++++++++++++ 6 files changed, 170 insertions(+), 32 deletions(-) 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..d1ec2121 100644 --- a/src/codealmanac/services/repositories/service.py +++ b/src/codealmanac/services/repositories/service.py @@ -2,6 +2,7 @@ from pathlib import Path from codealmanac.core.errors import ( + AlreadyExists, NoRepositorySelected, NotFoundError, ValidationFailed, @@ -29,6 +30,7 @@ from codealmanac.services.repositories.selection import ( contains_path, entry_by_exact_path, + entry_by_name, select_repository_record, ) from codealmanac.services.repositories.state import repository_state @@ -60,10 +62,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 +94,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 +124,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 +143,7 @@ def read_repository_at(self, path: Path) -> Repository: ) raise NoRepositorySelected() + def select_for_read( self, cwd: Path, @@ -133,7 +151,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..f32dd209 100644 --- a/src/codealmanac/services/repositories/store.py +++ b/src/codealmanac/services/repositories/store.py @@ -2,7 +2,9 @@ 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, repository_record_from_row, @@ -17,7 +19,38 @@ 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 +62,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 +120,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..215bb82d 100644 --- a/tests/test_repository_store.py +++ b/tests/test_repository_store.py @@ -81,3 +81,37 @@ 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_under_same_project(tmp_path: Path): + from codealmanac.services.repositories.requests import RegisterRepositoryRequest, SelectRepositoryRequest + 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 + + From 3ee5d13244d9f929622d953cb91f02a2eb86e2eb Mon Sep 17 00:00:00 2001 From: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:14:11 +0530 Subject: [PATCH 2/3] fix --- .../services/repositories/service.py | 3 +-- .../services/repositories/store.py | 6 ++++-- tests/test_repository_store.py | 20 ++++++++++++++----- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/codealmanac/services/repositories/service.py b/src/codealmanac/services/repositories/service.py index d1ec2121..114903c9 100644 --- a/src/codealmanac/services/repositories/service.py +++ b/src/codealmanac/services/repositories/service.py @@ -2,7 +2,6 @@ from pathlib import Path from codealmanac.core.errors import ( - AlreadyExists, NoRepositorySelected, NotFoundError, ValidationFailed, @@ -31,12 +30,12 @@ contains_path, entry_by_exact_path, entry_by_name, - select_repository_record, ) 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 diff --git a/src/codealmanac/services/repositories/store.py b/src/codealmanac/services/repositories/store.py index f32dd209..6a6c69dd 100644 --- a/src/codealmanac/services/repositories/store.py +++ b/src/codealmanac/services/repositories/store.py @@ -4,7 +4,6 @@ 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, repository_record_from_row, @@ -37,7 +36,9 @@ def remember(self, repository: Repository) -> RepositoryRecord: ) connection.execute( """ - INSERT INTO workspaces (workspace_id, project_id, root_path, almanac_root, registered_at) + 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, @@ -51,6 +52,7 @@ def remember(self, repository: Repository) -> RepositoryRecord: record.registered_at.isoformat(), ), ) + connection.execute( """ INSERT INTO repositories ( diff --git a/tests/test_repository_store.py b/tests/test_repository_store.py index 215bb82d..7920586f 100644 --- a/tests/test_repository_store.py +++ b/tests/test_repository_store.py @@ -83,9 +83,14 @@ def test_repository_store_updates_existing_repository(tmp_path: Path): assert [item.repository_id for item in store.list()] == ["repo-1"] -def test_read_repository_at_registers_multiple_workspaces_under_same_project(tmp_path: Path): - from codealmanac.services.repositories.requests import RegisterRepositoryRequest, SelectRepositoryRequest - from codealmanac.services.repositories.roots import ALMANAC_ROOT_MARKER_FILE, ALMANAC_ROOT_MARKER_README +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" @@ -93,8 +98,12 @@ def test_read_repository_at_registers_multiple_workspaces_under_same_project(tmp 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") + (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 @@ -115,3 +124,4 @@ def test_read_repository_at_registers_multiple_workspaces_under_same_project(tmp assert selected_b.root_path == dir_b + From b008c82ad8d93a5755905920b086c867395bdddf Mon Sep 17 00:00:00 2001 From: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:16:20 +0530 Subject: [PATCH 3/3] Update service.py --- src/codealmanac/services/repositories/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/codealmanac/services/repositories/service.py b/src/codealmanac/services/repositories/service.py index 114903c9..c82d33b6 100644 --- a/src/codealmanac/services/repositories/service.py +++ b/src/codealmanac/services/repositories/service.py @@ -35,8 +35,8 @@ from codealmanac.services.repositories.store import RepositoryStore - class RepositoriesService: + def __init__(self, store: RepositoryStore): self.store = store