diff --git a/README.md b/README.md index f7fccc8..92ed877 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,9 @@ python3 rvctl.py "$@" ./rvctl.py ``` -Ścieżki i domyślne ustawienia są trzymane w `workspace.json`. +Lokalne ścieżki i domyślne ustawienia są trzymane w `workspace.json`. +Publiczny opis wspólnego workspace, czyli serie, karty i wersje repozytoriów, +jest w osobnym repo `edu-workspace/workspace-info`. ## Co robi narzędzie @@ -40,9 +42,10 @@ python3 rvctl.py "$@" 2. Listowanie i pobieranie kart pracy - `rvctl` czyta serie, karty i zadania z workspace, pomaga wybrać materiał do - pracy oraz przygotowuje remotes potrzebne do repo odpowiedzi. Docelowy model - namespace `series`, `cards` i `tasks` jest w `doc/series.md`. + `rvctl` czyta serie i karty z `meta/workspace-info`, zadania z pobranych + kart, pomaga wybrać materiał do pracy oraz przygotowuje remotes potrzebne do + repo odpowiedzi. Docelowy model namespace `series`, `cards` i `tasks` jest w + `doc/series.md`. 3. Uruchamianie środowiska programistycznego w kontenerach @@ -108,6 +111,16 @@ Po tym kroku w workspace jest już repo launchera: └── rv-launcher ``` +Repo `workspace-info` trzymamy osobno od narzędzi, w katalogu `meta`: + +```text +~/dev/workspace/rv +├── meta +│ └── workspace-info +└── tools + └── rv-launcher +``` + Etap 3: wczytaj token z remota do lokalnego store: ```bash @@ -283,6 +296,18 @@ Po konfiguracji tokenów najpierw sprawdź stan store: ./rvctl tokens compare ``` +Następnie pobierz albo zaktualizuj publiczny manifest wspólnego workspace: + +```bash +./rvctl workspace sync +``` + +Manifest trafia do: + +```text +~/dev/workspace/rv/meta/workspace-info +``` + Następnie wylistuj dostępne serie. Jeżeli katalog `series` w workspace jeszcze nie istnieje, `rvctl` utworzy go automatycznie: @@ -293,6 +318,7 @@ nie istnieje, `rvctl` utworzy go automatycznie: Przykładowy wynik: ```text +fiz 3 0 inf 3 0 ``` @@ -319,6 +345,8 @@ Karty trafiają do `series` w workspace: ```text ~/dev/workspace/rv +├── meta +│ └── workspace-info ├── tools │ └── rv-launcher ├── tokens @@ -351,10 +379,10 @@ karcie z `workspace.json`, czyli tutaj na `inf bss`: Przykładowy wynik: ```text -inf bss task1_bss ~/dev/workspace/rv/series/inf/lab-rv32i-strlen-bss-data-stack/src/tasks/task1_bss.s -inf bss task2_data ~/dev/workspace/rv/series/inf/lab-rv32i-strlen-bss-data-stack/src/tasks/task2_data.s -inf bss task3_stack_unused ~/dev/workspace/rv/series/inf/lab-rv32i-strlen-bss-data-stack/src/tasks/task3_stack_unused.s -inf bss task4_stack_strlen ~/dev/workspace/rv/series/inf/lab-rv32i-strlen-bss-data-stack/src/tasks/task4_stack_strlen.s +inf bss task1_bss ~/dev/workspace/rv/series/inf/lab-rv32i-strlen-bss-data-stack/src/tasks/task1_bss.c +inf bss task2_data ~/dev/workspace/rv/series/inf/lab-rv32i-strlen-bss-data-stack/src/tasks/task2_data.c +inf bss task3_stack_unused ~/dev/workspace/rv/series/inf/lab-rv32i-strlen-bss-data-stack/src/tasks/task3_stack_unused.c +inf bss task4_stack_strlen ~/dev/workspace/rv/series/inf/lab-rv32i-strlen-bss-data-stack/src/tasks/task4_stack_strlen.c ``` Przełącz się na wybrane zadanie. `rvctl` bierze login ucznia ze store tokenów, diff --git a/doc/rvctl.md b/doc/rvctl.md index 979590c..7daa783 100644 --- a/doc/rvctl.md +++ b/doc/rvctl.md @@ -22,21 +22,24 @@ Typowy układ: ~/dev/edu/repos/rv/rv-launcher ~/dev/edu/repos/rv/rv32i-hazard3-env ~/dev/edu/repos/rv/series// +~/dev/workspace/rv/meta/workspace-info ~/dev/workspace/rv/tools/rv-launcher ~/dev/workspace/rv/tools/rv32i-hazard3-env ~/dev/workspace/rv/series// ``` -`rvctl` czyta karty z `series_root` w workspace. Tool repo -`rv32i-hazard3-env` wybiera najpierw z workspace, a potem z fallbacku -`~/dev/edu/repos/rv`, jeśli taki klon roboczy jeszcze nie istnieje. +`rvctl` czyta publiczny opis serii i kart z `workspace-info`, a pobrane karty +trzyma w `series_root` w workspace. Tool repo `rv32i-hazard3-env` wybiera +najpierw z workspace, a potem z fallbacku `~/dev/edu/repos/rv`, jeśli taki klon +roboczy jeszcze nie istnieje. -Karty pracy też są rozdzielone: +Karty pracy są rozdzielone: -- `original_series_root` wskazuje repo źródłowe kart, na przykład `~/dev/edu/repos/rv/series` -- `series_root` wskazuje klony testowe w workspace, na przykład `~/dev/workspace/rv/series` +- `workspace_info_path` wskazuje manifest, na przykład `~/dev/workspace/rv/meta/workspace-info/workspace.json` +- `series_root` wskazuje klony robocze w workspace, na przykład `~/dev/workspace/rv/series` +- `original_series_root` zostaje fallbackiem dla lokalnych repo źródłowych -Komendy launchera pracują na `series_root`, czyli na klonach testowych. +Komendy launchera pracują na `series_root`, czyli na klonach roboczych. ## Pobranie repo i przełączenie gałęzi @@ -203,6 +206,8 @@ Szczegółowy help parsera: Komendy: - `show-config` +- `workspace show` +- `workspace sync` - `list-series` - `list-cards [series]` - `series list` @@ -238,6 +243,9 @@ config_path... original_root... original_series_root... workspace_root... +workspace_info_path... +workspace_info_url... +workspace_info_status... series_root... socket_root... token_path... @@ -259,10 +267,28 @@ Przykład: ./rvctl show-config ``` +## `workspace show` + +Pokazuje lokalizację i stan publicznego manifestu wspólnego workspace. + +```bash +./rvctl workspace show +``` + +## `workspace sync` + +Klonuje albo aktualizuje repo `edu-workspace/workspace-info` do katalogu +`~/dev/workspace/rv/meta/workspace-info`. + +```bash +./rvctl workspace sync +./rvctl workspace sync --dry-run +``` + ## `series list` -Listuje serie dostępne w źródłach i workspace. Komenda tworzy `series_root`, -jeżeli katalog jeszcze nie istnieje. +Listuje serie dostępne w `workspace-info` i workspace. Komenda tworzy +`series_root`, jeżeli katalog jeszcze nie istnieje. Format wyjścia: diff --git a/doc/series.md b/doc/series.md index 29d01fe..a1b92b1 100644 --- a/doc/series.md +++ b/doc/series.md @@ -12,13 +12,15 @@ Komendy kart pracy dzielimy na trzy poziomy: - `series cards tasks` - operacje na zadaniach w ramach karty `rvctl` tworzy `series_root`, jeżeli katalog jeszcze nie istnieje. Lista serii -i kart może pochodzić z repo źródłowych, a `fetch` pobiera wybraną kartę do -workspace. +i kart pochodzi docelowo z publicznego manifestu +`~/dev/workspace/rv/meta/workspace-info/workspace.json`. Jeżeli manifestu nie +ma, narzędzie używa starego fallbacku przez `original_series_root`. ## Szybki przepływ ```bash ./rvctl tokens compare +./rvctl workspace sync ./rvctl series list ./rvctl series cards list inf ./rvctl series cards fetch inf bss @@ -32,8 +34,9 @@ Dla karty `bss` właściwym repo jest `lab-rv32i-strlen-bss-data-stack`. ### `series list` -Listuje dostępne serie. Jeżeli `~/dev/workspace/rv/series` nie istnieje, -komenda tworzy ten katalog. +Listuje dostępne serie z manifestu `workspace-info` oraz liczbę kart już +pobranych do lokalnego workspace. Jeżeli `~/dev/workspace/rv/series` nie +istnieje, komenda tworzy ten katalog. ```bash ./rvctl series list @@ -42,6 +45,7 @@ komenda tworzy ten katalog. Typowy wynik: ```text +fiz 3 0 inf 3 0 ``` @@ -147,10 +151,10 @@ Skrót dla domyślnej karty: Przykładowy wynik: ```text -inf bss task1_bss ~/dev/workspace/rv/series/inf/lab-rv32i-strlen-bss-data-stack/src/tasks/task1_bss.s -inf bss task2_data ~/dev/workspace/rv/series/inf/lab-rv32i-strlen-bss-data-stack/src/tasks/task2_data.s -inf bss task3_stack_unused ~/dev/workspace/rv/series/inf/lab-rv32i-strlen-bss-data-stack/src/tasks/task3_stack_unused.s -inf bss task4_stack_strlen ~/dev/workspace/rv/series/inf/lab-rv32i-strlen-bss-data-stack/src/tasks/task4_stack_strlen.s +inf bss task1_bss ~/dev/workspace/rv/series/inf/lab-rv32i-strlen-bss-data-stack/src/tasks/task1_bss.c +inf bss task2_data ~/dev/workspace/rv/series/inf/lab-rv32i-strlen-bss-data-stack/src/tasks/task2_data.c +inf bss task3_stack_unused ~/dev/workspace/rv/series/inf/lab-rv32i-strlen-bss-data-stack/src/tasks/task3_stack_unused.c +inf bss task4_stack_strlen ~/dev/workspace/rv/series/inf/lab-rv32i-strlen-bss-data-stack/src/tasks/task4_stack_strlen.c ``` ### `series cards tasks show SERIES CARD TASK` diff --git a/doc/workspace.md b/doc/workspace.md index b557ad7..03648df 100644 --- a/doc/workspace.md +++ b/doc/workspace.md @@ -10,4 +10,39 @@ Publiczny entrypoint launchera: ``` Implementacja CLI znajduje się w `rvctl.py`. Plik `workspace.json` przechowuje -ścieżki workspace, series, socketów, tokenów i fallback do repo źródłowych. +lokalne ścieżki workspace, series, socketów, tokenów i adres publicznego +manifestu. + +Publiczny manifest wspólnego workspace jest w repo: + +```text +edu-workspace/workspace-info +``` + +Lokalna kopia manifestu znajduje się w: + +```text +~/dev/workspace/rv/meta/workspace-info +``` + +Typowy układ: + +```text +~/dev/workspace/rv +├── meta +│ └── workspace-info +├── tools +│ └── rv-launcher +├── tokens +│ └── tokens.json +└── series +``` + +`workspace-info` opisuje serie, karty, repo źródłowe, repo odpowiedzi i branche. +Nie przechowuje tokenów ani lokalnych plików roboczych ucznia. + +Aktualizacja manifestu: + +```bash +./rvctl workspace sync +``` diff --git a/rvctl.py b/rvctl.py index 66d48d5..d402b1b 100755 --- a/rvctl.py +++ b/rvctl.py @@ -12,7 +12,7 @@ import re import shlex import subprocess import sys -from dataclasses import dataclass +from dataclasses import dataclass, replace from pathlib import Path from urllib import error as urlerror from urllib import request as urlrequest @@ -44,6 +44,9 @@ class WorkspaceConfig: original_root: Path original_series_root: Path workspace_root: Path + workspace_info_path: Path + workspace_info_url: str + workspace_info: dict | None series_root: Path socket_root: Path token_path: Path @@ -59,6 +62,21 @@ class TaskEntry: path: Path +@dataclass(frozen=True) +class CardInfo: + series_id: str + card_id: str + repo: str + branch: str + title: str + source_org: str + answer_org: str + source_remote: str + answer_remote: str + fallback_branch: str + source_path: Path | None = None + + SCOPE_FIELDS = [ ("a", "activitypub"), ("A", "admin"), @@ -102,6 +120,10 @@ def load_config(config_path: Path) -> WorkspaceConfig: series_root = expand_path(raw.get("series_root", str(workspace_root / "series")), base_dir) socket_root = expand_path(raw.get("socket_root", str(workspace_root / "sockets")), base_dir) token_path = expand_path(raw.get("token_file", str(workspace_root / "tokens" / "tokens.json")), base_dir) + workspace_info_path = expand_path( + raw.get("workspace_info_path", str(workspace_root / "meta" / "workspace-info" / "workspace.json")), + base_dir, + ) candidate_values = raw.get("tools_root_candidates") if not candidate_values: @@ -114,8 +136,10 @@ def load_config(config_path: Path) -> WorkspaceConfig: tools_root = next((path for path in tools_root_candidates if path.exists()), tools_root_candidates[0]) defaults = raw.get("defaults", {}) git_raw = raw.get("git", {}) + base_url = git_raw.get("base_url", "http://77.90.8.171:3001").rstrip("/") + workspace_info_url = raw.get("workspace_info_url", f"{base_url}/edu-workspace/workspace-info.git") git = GitConfig( - base_url=git_raw.get("base_url", "http://77.90.8.171:3001").rstrip("/"), + base_url=base_url, source_org=git_raw.get("source_org", "edu-inf"), answer_org=git_raw.get("answer_org", "c2025-1a-inf"), source_remote=git_raw.get("source_remote", "r1"), @@ -123,12 +147,16 @@ def load_config(config_path: Path) -> WorkspaceConfig: origin_remote=git_raw.get("origin_remote", "origin"), fallback_branch=git_raw.get("fallback_branch", "build"), ) + workspace_info = json.loads(workspace_info_path.read_text(encoding="utf-8")) if workspace_info_path.is_file() else None return WorkspaceConfig( config_path=config_path, original_root=original_root, original_series_root=original_series_root, workspace_root=workspace_root, + workspace_info_path=workspace_info_path, + workspace_info_url=workspace_info_url, + workspace_info=workspace_info, series_root=series_root, socket_root=socket_root, token_path=token_path, @@ -139,6 +167,131 @@ def load_config(config_path: Path) -> WorkspaceConfig: ) +def workspace_info_root(config: WorkspaceConfig) -> Path: + return config.workspace_info_path.parent + + +def read_workspace_info_file(config: WorkspaceConfig, raw_path: str) -> dict: + info_path = expand_path(raw_path, workspace_info_root(config)) + return json.loads(info_path.read_text(encoding="utf-8")) + + +def workspace_series_manifests(config: WorkspaceConfig) -> dict[str, dict]: + if not config.workspace_info: + return {} + + manifests: dict[str, dict] = {} + for entry in config.workspace_info.get("series", []): + if not isinstance(entry, dict): + continue + series_id = str(entry.get("id", "")).strip() + if not series_id: + continue + manifest = dict(entry) + file_name = entry.get("file") + if isinstance(file_name, str) and file_name: + manifest = read_workspace_info_file(config, file_name) + manifest.setdefault("id", series_id) + manifests[series_id] = manifest + return manifests + + +def workspace_series_manifest(config: WorkspaceConfig, series_name: str) -> dict | None: + return workspace_series_manifests(config).get(series_name) + + +def series_exists(config: WorkspaceConfig, series_name: str) -> bool: + if workspace_series_manifest(config, series_name) is not None: + return True + return (config.series_root / series_name).is_dir() or (config.original_series_root / series_name).is_dir() + + +def default_card_for_series(config: WorkspaceConfig, series_name: str) -> str | None: + manifest = workspace_series_manifest(config, series_name) + if manifest and isinstance(manifest.get("default_card"), str): + return manifest["default_card"] + return config.defaults.get("card") + + +def config_for_card(config: WorkspaceConfig, card_info: CardInfo) -> WorkspaceConfig: + return replace( + config, + git=replace( + config.git, + source_org=card_info.source_org, + answer_org=card_info.answer_org, + source_remote=card_info.source_remote, + answer_remote=card_info.answer_remote, + fallback_branch=card_info.fallback_branch, + ), + ) + + +def card_infos_from_manifest(config: WorkspaceConfig, series_name: str) -> list[CardInfo]: + manifest = workspace_series_manifest(config, series_name) + if not manifest: + return [] + + source_org = str(manifest.get("source_org", config.git.source_org)) + answer_org = str(manifest.get("answer_org", config.git.answer_org)) + source_remote = str(manifest.get("source_remote", config.git.source_remote)) + answer_remote = str(manifest.get("answer_remote", config.git.answer_remote)) + fallback_branch = str(manifest.get("fallback_branch", config.git.fallback_branch)) + cards = [] + for raw_card in manifest.get("cards", []): + if not isinstance(raw_card, dict): + continue + card_id = str(raw_card.get("id", "")).strip() + if not card_id: + continue + repo_name = str(raw_card.get("repo", card_id)).strip() + branch_name = str(raw_card.get("branch", fallback_branch)).strip() or fallback_branch + title = str(raw_card.get("title", "")).strip() + source_path = config.original_series_root / series_name / repo_name + cards.append( + CardInfo( + series_id=series_name, + card_id=card_id, + repo=repo_name, + branch=branch_name, + title=title, + source_org=source_org, + answer_org=answer_org, + source_remote=source_remote, + answer_remote=answer_remote, + fallback_branch=fallback_branch, + source_path=source_path if source_path.is_dir() else None, + ) + ) + return cards + + +def card_infos_from_source_dirs(config: WorkspaceConfig, series_name: str) -> list[CardInfo]: + return [ + CardInfo( + series_id=series_name, + card_id=card_label_from_repo_name(path.name), + repo=path.name, + branch=git_current_branch(config, path), + title=read_card_title(path), + source_org=config.git.source_org, + answer_org=config.git.answer_org, + source_remote=config.git.source_remote, + answer_remote=config.git.answer_remote, + fallback_branch=config.git.fallback_branch, + source_path=path, + ) + for path in source_card_directories(config, series_name) + ] + + +def source_card_infos(config: WorkspaceConfig, series_name: str) -> list[CardInfo]: + manifest_cards = card_infos_from_manifest(config, series_name) + if manifest_cards: + return manifest_cards + return card_infos_from_source_dirs(config, series_name) + + def series_directories(config: WorkspaceConfig) -> list[Path]: config.series_root.mkdir(parents=True, exist_ok=True) return sorted(path for path in config.series_root.iterdir() if path.is_dir()) @@ -170,7 +323,38 @@ def card_label_from_repo_name(repo_name: str) -> str: return repo_name +def card_info_matches(card_info: CardInfo, normalized: str) -> bool: + candidates = {card_info.card_id, card_info.repo, card_label_from_repo_name(card_info.repo)} + return normalized in candidates or any(normalized in candidate for candidate in candidates) + + +def resolve_card_info(config: WorkspaceConfig, series_name: str, card_name: str) -> CardInfo: + cards = source_card_infos(config, series_name) + if not cards: + source_hint = config.original_series_root / series_name + if config.workspace_info_path.is_file(): + source_hint = config.workspace_info_path + raise SystemExit(f"Missing source series: {source_hint}") + + normalized = slug_value(card_name, "card") + matches = [card_info for card_info in cards if card_info_matches(card_info, normalized)] + if not matches: + available = ", ".join(card.card_id for card in cards) + raise SystemExit(f"Missing card '{card_name}' in series '{series_name}'. Available: {available}") + if len(matches) > 1: + available = ", ".join(card.repo for card in matches) + raise SystemExit(f"Ambiguous card '{card_name}' in series '{series_name}'. Matches: {available}") + return matches[0] + + def resolve_source_card_path(config: WorkspaceConfig, series_name: str, card_name: str) -> Path: + manifest_card = resolve_card_info(config, series_name, card_name) + if manifest_card.source_path: + return manifest_card.source_path + return config.original_series_root / series_name / manifest_card.repo + + +def resolve_source_card_path_from_dirs(config: WorkspaceConfig, series_name: str, card_name: str) -> Path: cards = source_card_directories(config, series_name) if not cards: raise SystemExit(f"Missing source series: {config.original_series_root / series_name}") @@ -220,10 +404,11 @@ def resolve_selector( return series_value, card_value if series_arg and not card_arg: - if (config.series_root / series_arg).is_dir() or (config.original_series_root / series_arg).is_dir(): - if not default_card: + if series_exists(config, series_arg): + series_default_card = default_card_for_series(config, series_arg) + if not series_default_card: raise SystemExit("Missing default card in config.") - return series_arg, default_card + return series_arg, series_default_card if default_series and (config.series_root / default_series / series_arg).is_dir(): return default_series, series_arg if default_series: @@ -245,8 +430,8 @@ def resolve_card_path(config: WorkspaceConfig, series_name: str, card_name: str) card_path = config.series_root / series_name / card_name if card_path.is_dir(): return card_path - source_card_path = resolve_source_card_path(config, series_name, card_name) - resolved_path = workspace_card_path(config, series_name, source_card_path) + card_info = resolve_card_info(config, series_name, card_name) + resolved_path = workspace_card_path_for_info(config, card_info) if resolved_path.is_dir(): return resolved_path raise SystemExit(f"Missing card path: {resolved_path}") @@ -254,12 +439,13 @@ def resolve_card_path(config: WorkspaceConfig, series_name: str, card_name: str) def print_series(config: WorkspaceConfig) -> None: config.series_root.mkdir(parents=True, exist_ok=True) + manifest_by_name = workspace_series_manifests(config) source_by_name = {path.name: path for path in source_series_directories(config)} workspace_by_name = {path.name: path for path in series_directories(config)} - for series_name in sorted(set(source_by_name) | set(workspace_by_name)): - source_path = source_by_name.get(series_name) + for series_name in sorted(set(manifest_by_name) | set(source_by_name) | set(workspace_by_name)): + manifest = manifest_by_name.get(series_name) workspace_path = workspace_by_name.get(series_name) - source_count = len(source_card_directories(config, series_name)) if source_path else 0 + source_count = len(source_card_infos(config, series_name)) if (manifest or series_name in source_by_name) else 0 workspace_count = len(card_directories(workspace_path)) if workspace_path else 0 print(f"{series_name}\t{source_count}\t{workspace_count}\t{workspace_path or ''}") @@ -270,17 +456,21 @@ def print_cards(config: WorkspaceConfig, series_arg: str | None) -> None: raise SystemExit("Missing series name and no default series is configured.") config.series_root.mkdir(parents=True, exist_ok=True) - source_cards = {path.name: path for path in source_card_directories(config, series_name)} + source_cards = {card.repo: card for card in source_card_infos(config, series_name)} workspace_series_path = config.series_root / series_name workspace_cards = {path.name: path for path in card_directories(workspace_series_path)} if workspace_series_path.is_dir() else {} if not source_cards and not workspace_cards: raise SystemExit(f"Missing series: {series_name}") for repo_name in sorted(set(source_cards) | set(workspace_cards)): - source_path = source_cards.get(repo_name) + source_card = source_cards.get(repo_name) workspace_path = workspace_cards.get(repo_name) - title = read_card_title(workspace_path or source_path) if (workspace_path or source_path) else "" - label = card_label_from_repo_name(repo_name) + title = read_card_title(workspace_path) if workspace_path else "" + if not title and source_card: + title = source_card.title + if not title and source_card and source_card.source_path: + title = read_card_title(source_card.source_path) + label = source_card.card_id if source_card else card_label_from_repo_name(repo_name) status = "workspace" if workspace_path else "source" if title: print(f"{label}\t{repo_name}\t{status}\t{title}") @@ -2508,6 +2698,13 @@ def source_url_for_card(config: WorkspaceConfig, series_name: str, source_card_p return f"{config.git.base_url}/{config.git.source_org}/{source_card_path.name}.git" +def source_url_for_card_info(config: WorkspaceConfig, card_info: CardInfo) -> str: + card_config = config_for_card(config, card_info) + if card_info.source_path: + return source_url_for_card(card_config, card_info.series_id, card_info.source_path) + return f"{card_config.git.base_url}/{card_info.source_org}/{card_info.repo}.git" + + def ensure_answer_token_from_source( config: WorkspaceConfig, source_remote: str, @@ -2539,21 +2736,26 @@ def workspace_card_path(config: WorkspaceConfig, series_name: str, source_card_p return config.series_root / series_name / source_card_path.name +def workspace_card_path_for_info(config: WorkspaceConfig, card_info: CardInfo) -> Path: + return config.series_root / card_info.series_id / card_info.repo + + def resolve_workspace_card_path(config: WorkspaceConfig, series_name: str, card_name: str) -> Path: - source_card_path = resolve_source_card_path(config, series_name, card_name) - card_path = workspace_card_path(config, series_name, source_card_path) + card_info = resolve_card_info(config, series_name, card_name) + card_path = workspace_card_path_for_info(config, card_info) if not card_path.is_dir(): raise SystemExit(f"Missing workspace card: {card_path}. Run: ./rvctl series cards fetch {series_name} {card_name}") return card_path def fetch_card_to_workspace(config: WorkspaceConfig, series_name: str, card_name: str, dry_run: bool = False) -> dict[str, str]: - source_card_path = resolve_source_card_path(config, series_name, card_name) - repo_name = source_card_path.name - target_path = workspace_card_path(config, series_name, source_card_path) - source_url = source_url_for_card(config, series_name, source_card_path) - answer_url = answer_url_for_card(config, repo_name) - source_branch = git_current_branch(config, source_card_path) + card_info = resolve_card_info(config, series_name, card_name) + card_config = config_for_card(config, card_info) + repo_name = card_info.repo + target_path = workspace_card_path_for_info(config, card_info) + source_url = source_url_for_card_info(config, card_info) + answer_url = answer_url_for_card(card_config, repo_name) + source_branch = card_info.branch if not dry_run: target_path.parent.mkdir(parents=True, exist_ok=True) @@ -2563,15 +2765,15 @@ def fetch_card_to_workspace(config: WorkspaceConfig, series_name: str, card_name if not target_path.exists(): subprocess.run(["git", "clone", source_url, str(target_path)], check=True) cloned = True - origin_url = git_capture(target_path, ["remote", "get-url", config.git.origin_remote]) - if origin_url and git_capture(target_path, ["remote", "get-url", config.git.source_remote]) is None: + origin_url = git_capture(target_path, ["remote", "get-url", card_config.git.origin_remote]) + if origin_url and git_capture(target_path, ["remote", "get-url", card_config.git.source_remote]) is None: subprocess.run( - ["git", "-C", str(target_path), "remote", "rename", config.git.origin_remote, config.git.source_remote], + ["git", "-C", str(target_path), "remote", "rename", card_config.git.origin_remote, card_config.git.source_remote], check=True, ) - set_git_remote(target_path, config.git.source_remote, source_url) - subprocess.run(["git", "-C", str(target_path), "fetch", config.git.source_remote, source_branch], check=True) - current_branch = git_current_branch(config, target_path) + set_git_remote(target_path, card_config.git.source_remote, source_url) + subprocess.run(["git", "-C", str(target_path), "fetch", card_config.git.source_remote, source_branch], check=True) + current_branch = git_current_branch(card_config, target_path) if cloned or current_branch == source_branch: if current_branch != source_branch: local_branch_exists = subprocess.run( @@ -2584,15 +2786,15 @@ def fetch_card_to_workspace(config: WorkspaceConfig, series_name: str, card_name subprocess.run(["git", "-C", str(target_path), "switch", source_branch], check=True) else: subprocess.run( - ["git", "-C", str(target_path), "switch", "--track", "-c", source_branch, f"{config.git.source_remote}/{source_branch}"], + ["git", "-C", str(target_path), "switch", "--track", "-c", source_branch, f"{card_config.git.source_remote}/{source_branch}"], check=True, ) - subprocess.run(["git", "-C", str(target_path), "pull", "--ff-only", config.git.source_remote, source_branch], check=True) + subprocess.run(["git", "-C", str(target_path), "pull", "--ff-only", card_config.git.source_remote, source_branch], check=True) token_status, source_token = ensure_answer_token_from_source( - config, - config.git.source_remote, - config.git.answer_remote, + card_config, + card_config.git.source_remote, + card_config.git.answer_remote, answer_url, dry_run=dry_run, ) @@ -2603,21 +2805,21 @@ def fetch_card_to_workspace(config: WorkspaceConfig, series_name: str, card_name str(source_token.get("user", "")), str(source_token.get("value", "")), ) - answer_remote_status = set_git_remote(target_path, config.git.answer_remote, answer_remote_url) + answer_remote_status = set_git_remote(target_path, card_config.git.answer_remote, answer_remote_url) return { "series": series_name, - "card": card_label_from_repo_name(repo_name), + "card": card_info.card_id, "repo": repo_name, "source": source_url, "workspace": str(target_path), - "source_remote": config.git.source_remote, + "source_remote": card_config.git.source_remote, "source_branch": source_branch, - "answer_remote": config.git.answer_remote, - "answer_org": config.git.answer_org, + "answer_remote": card_config.git.answer_remote, + "answer_org": card_config.git.answer_org, "answer_repo": repo_name, "answer_url": answer_url, - "token": config.git.answer_remote, + "token": card_config.git.answer_remote, "token_status": token_status, "answer_remote_status": answer_remote_status, "status": "dry-run" if dry_run else "ready", @@ -2781,14 +2983,16 @@ def switch_card_task( branch_override: str | None = None, dry_run: bool = False, ) -> dict[str, str]: + card_info = resolve_card_info(config, series_name, card_name) + card_config = config_for_card(config, card_info) card_path = resolve_workspace_card_path(config, series_name, card_name) task_name = resolve_submission_task(config, card_path, task_selector) - source_url = discover_source_url(config, card_path, series_name, card_name) + source_url = discover_source_url(card_config, card_path, series_name, card_name) source_repo_name = repo_name_from_url(source_url) - answer_url = build_answer_url(config, source_repo_name) - student_user = store_user_for_submission(config, source_url, answer_url, required=branch_override is None) + answer_url = build_answer_url(card_config, source_repo_name) + student_user = store_user_for_submission(card_config, source_url, answer_url, required=branch_override is None) branch_name = branch_value(branch_override) if branch_override else submission_branch_name(student_user, task_name) - current_branch = git_current_branch(config, card_path) + current_branch = git_current_branch(card_config, card_path) branch_exists = subprocess.run( ["git", "-C", str(card_path), "rev-parse", "--verify", "--quiet", branch_name], stdout=subprocess.DEVNULL, @@ -2800,14 +3004,14 @@ def switch_card_task( answer_remote_status = "dry-run" if not dry_run: token_status, source_token = ensure_answer_token_from_source( - config, - config.git.source_remote, - config.git.answer_remote, + card_config, + card_config.git.source_remote, + card_config.git.answer_remote, answer_url, ) answer_remote_status = set_git_remote( card_path, - config.git.answer_remote, + card_config.git.answer_remote, credentialed_remote_url( answer_url, str(source_token.get("user", "")), @@ -2825,19 +3029,19 @@ def switch_card_task( else: subprocess.run(["git", "-C", str(card_path), "switch", "-c", branch_name], check=True) status = "created" - upstream_status = set_branch_upstream(card_path, branch_name, config.git.answer_remote, dry_run=dry_run) + upstream_status = set_branch_upstream(card_path, branch_name, card_config.git.answer_remote, dry_run=dry_run) return { "series": series_name, - "card": card_label_from_repo_name(card_path.name), + "card": card_info.card_id, "task": task_name, "student_user": student_user, "branch": branch_name, - "answer_remote": config.git.answer_remote, + "answer_remote": card_config.git.answer_remote, "answer_url": answer_url, "token_status": token_status, "answer_remote_status": answer_remote_status, - "upstream": f"{config.git.answer_remote}/{branch_name}", + "upstream": f"{card_config.git.answer_remote}/{branch_name}", "upstream_status": upstream_status, "workspace": str(card_path), "status": status, @@ -2848,29 +3052,32 @@ def print_series_details(config: WorkspaceConfig, series_name: str) -> None: config.series_root.mkdir(parents=True, exist_ok=True) source_path = config.original_series_root / series_name workspace_path = config.series_root / series_name - cards = source_card_directories(config, series_name) + cards = source_card_infos(config, series_name) if not cards and not workspace_path.is_dir(): raise SystemExit(f"Missing series: {series_name}") print(f"series\t{series_name}") print(f"source_path\t{source_path if source_path.is_dir() else ''}") + print(f"manifest\t{config.workspace_info_path if workspace_series_manifest(config, series_name) else ''}") print(f"workspace_path\t{workspace_path}") print(f"cards_count\t{len(cards)}") - print(f"cards\t{' '.join(card_label_from_repo_name(path.name) for path in cards)}") + print(f"cards\t{' '.join(card.card_id for card in cards)}") def print_card_details(config: WorkspaceConfig, series_name: str, card_name: str) -> None: - source_card_path = resolve_source_card_path(config, series_name, card_name) - target_path = workspace_card_path(config, series_name, source_card_path) - repo_name = source_card_path.name + card_info = resolve_card_info(config, series_name, card_name) + card_config = config_for_card(config, card_info) + target_path = workspace_card_path_for_info(config, card_info) + repo_name = card_info.repo print(f"series\t{series_name}") - print(f"card\t{card_label_from_repo_name(repo_name)}") + print(f"card\t{card_info.card_id}") print(f"repo\t{repo_name}") - print(f"source_path\t{source_card_path}") + print(f"source_path\t{card_info.source_path or ''}") print(f"workspace_path\t{target_path}") - print(f"source_remote\t{config.git.source_remote}") - print(f"source_url\t{source_url_for_card(config, series_name, source_card_path)}") - print(f"answer_remote\t{config.git.answer_remote}") - print(f"answer_url\t{answer_url_for_card(config, repo_name)}") + print(f"source_remote\t{card_config.git.source_remote}") + print(f"source_url\t{source_url_for_card_info(config, card_info)}") + print(f"source_branch\t{card_info.branch}") + print(f"answer_remote\t{card_config.git.answer_remote}") + print(f"answer_url\t{answer_url_for_card(card_config, repo_name)}") if target_path.is_dir(): print(f"tasks\t{' '.join(task_names(target_path))}") @@ -2883,8 +3090,8 @@ def run_series(config: WorkspaceConfig, args: argparse.Namespace) -> None: print_series_details(config, args.series) return if args.series_command == "fetch": - for source_card_path in source_card_directories(config, args.series): - result = fetch_card_to_workspace(config, args.series, source_card_path.name, dry_run=args.dry_run) + for card_info in source_card_infos(config, args.series): + result = fetch_card_to_workspace(config, args.series, card_info.card_id, dry_run=args.dry_run) print_key_values(result) print() return @@ -2963,63 +3170,65 @@ def run_tasks(config: WorkspaceConfig, args: argparse.Namespace) -> None: def print_submission_plan(config: WorkspaceConfig, args: argparse.Namespace) -> None: series_name, card_name = resolve_selector(config, args.series, args.card) + card_info = resolve_card_info(config, series_name, card_name) + card_config = config_for_card(config, card_info) selector = f"{series_name}/{card_name}" card_path = resolve_card_path(config, series_name, card_name) - sync_tokens_from_repo(config, card_path) + sync_tokens_from_repo(card_config, card_path) class_name = slug_value(args.class_name, "class") - source_url = args.source_url or discover_source_url(config, card_path, series_name, card_name) + source_url = args.source_url or discover_source_url(card_config, card_path, series_name, card_name) source_repo_name = repo_name_from_url(source_url) answer_repo_name = build_answer_repo_name(source_repo_name, class_name, args.date) - answer_url = build_answer_url(config, answer_repo_name) - source_branch = git_current_branch(config, card_path) + answer_url = build_answer_url(card_config, answer_repo_name) + source_branch = git_current_branch(card_config, card_path) task_name = resolve_submission_task(config, card_path, args.task) student_user = ( slug_value(args.nick, "student user") if args.nick - else store_user_for_submission(config, source_url, answer_url, required=args.branch is None) + else store_user_for_submission(card_config, source_url, answer_url, required=args.branch is None) ) branch_name = branch_value(args.branch) if args.branch else submission_branch_name(student_user, task_name) print(f"selector\t{selector}") print(f"card_path\t{card_path}") print(f"task\t{task_name}") - print(f"source_repo\t{config.git.source_org}/{source_repo_name}") - print(f"source_remote\t{config.git.source_remote}") + print(f"source_repo\t{card_config.git.source_org}/{source_repo_name}") + print(f"source_remote\t{card_config.git.source_remote}") print(f"source_url\t{source_url}") print(f"source_branch\t{source_branch}") - print(f"answer_remote\t{config.git.answer_remote}") - print(f"answer_repo\t{config.git.answer_org}/{answer_repo_name}") + print(f"answer_remote\t{card_config.git.answer_remote}") + print(f"answer_repo\t{card_config.git.answer_org}/{answer_repo_name}") print(f"answer_url\t{answer_url}") print(f"student_user\t{student_user}") print(f"student_branch\t{branch_name}") if args.apply: - source_status = set_git_remote(card_path, config.git.source_remote, source_url) + source_status = set_git_remote(card_path, card_config.git.source_remote, source_url) token_status, source_token = ensure_answer_token_from_source( - config, - config.git.source_remote, - config.git.answer_remote, + card_config, + card_config.git.source_remote, + card_config.git.answer_remote, answer_url, ) answer_status = set_git_remote( card_path, - config.git.answer_remote, + card_config.git.answer_remote, credentialed_remote_url( answer_url, str(source_token.get("user", "")), str(source_token.get("value", "")), ), ) - print(f"apply_{config.git.source_remote}\t{source_status}") - print(f"apply_token_{config.git.answer_remote}\t{token_status}") - print(f"apply_{config.git.answer_remote}\t{answer_status}") + print(f"apply_{card_config.git.source_remote}\t{source_status}") + print(f"apply_token_{card_config.git.answer_remote}\t{token_status}") + print(f"apply_{card_config.git.answer_remote}\t{answer_status}") print("commands") - print(f" git -C {shlex.quote(str(card_path))} remote add {config.git.source_remote} {shlex.quote(source_url)}") - print(f" git -C {shlex.quote(str(card_path))} remote add {config.git.answer_remote} {shlex.quote(answer_url)}") - print(f" git -C {shlex.quote(str(card_path))} fetch {config.git.source_remote} {shlex.quote(source_branch)}") + print(f" git -C {shlex.quote(str(card_path))} remote add {card_config.git.source_remote} {shlex.quote(source_url)}") + print(f" git -C {shlex.quote(str(card_path))} remote add {card_config.git.answer_remote} {shlex.quote(answer_url)}") + print(f" git -C {shlex.quote(str(card_path))} fetch {card_config.git.source_remote} {shlex.quote(source_branch)}") print(f" git -C {shlex.quote(str(card_path))} switch -c {shlex.quote(branch_name)}") - print(f" git -C {shlex.quote(str(card_path))} push -u {config.git.answer_remote} {shlex.quote(branch_name)}") + print(f" git -C {shlex.quote(str(card_path))} push -u {card_config.git.answer_remote} {shlex.quote(branch_name)}") def tmux_target_exists(session_name: str) -> bool: @@ -3107,11 +3316,55 @@ def run_tmux_container(config: WorkspaceConfig, args: argparse.Namespace) -> Non print(f"Attach with: tmux attach -t {session_name}") +def workspace_info_status(config: WorkspaceConfig) -> str: + info_root = workspace_info_root(config) + if (info_root / ".git").is_dir(): + return "git" + if config.workspace_info_path.is_file(): + return "file" + return "missing" + + +def run_workspace(config: WorkspaceConfig, args: argparse.Namespace) -> None: + if args.workspace_command == "show": + print(f"workspace_info_path\t{config.workspace_info_path}") + print(f"workspace_info_url\t{config.workspace_info_url}") + print(f"status\t{workspace_info_status(config)}") + if config.workspace_info: + print(f"workspace\t{config.workspace_info.get('workspace', '')}") + print(f"series\t{len(config.workspace_info.get('series', []))}") + return + + if args.workspace_command == "sync": + info_root = workspace_info_root(config) + if args.dry_run: + print(f"workspace_info_path\t{config.workspace_info_path}") + print(f"workspace_info_url\t{config.workspace_info_url}") + print(f"status\t{workspace_info_status(config)}") + print(f"command\t{'pull' if (info_root / '.git').is_dir() else 'clone'}") + return + info_root.parent.mkdir(parents=True, exist_ok=True) + if (info_root / ".git").is_dir(): + subprocess.run(["git", "-C", str(info_root), "pull", "--ff-only"], check=True) + elif info_root.exists(): + raise SystemExit(f"Workspace info path exists but is not a git repo: {info_root}") + else: + subprocess.run(["git", "clone", config.workspace_info_url, str(info_root)], check=True) + print(f"workspace_info_path\t{config.workspace_info_path}") + print(f"status\tready") + return + + raise SystemExit(f"Unsupported workspace command: {args.workspace_command}") + + def print_config(config: WorkspaceConfig) -> None: print(f"config_path\t{config.config_path}") print(f"original_root\t{config.original_root}") print(f"original_series_root\t{config.original_series_root}") print(f"workspace_root\t{config.workspace_root}") + print(f"workspace_info_path\t{config.workspace_info_path}") + print(f"workspace_info_url\t{config.workspace_info_url}") + print(f"workspace_info_status\t{workspace_info_status(config)}") print(f"series_root\t{config.series_root}") print(f"socket_root\t{config.socket_root}") print(f"token_path\t{config.token_path}") @@ -3192,6 +3445,7 @@ def print_main_overview(config_path: Path) -> None: ["command", "arguments", "purpose"], [ ["show-config", "", "show resolved paths and Git defaults"], + ["workspace", "show|sync", "show or update meta/workspace-info"], ["series", "list|cards|fetch|tasks", "work with series, cards and tasks"], ["tasks", "list|show|switch", "shortcut for tasks in the default card"], ["list-series", "", "compat alias for series list"], @@ -3207,11 +3461,12 @@ def print_main_overview(config_path: Path) -> None: ["step", "command", "result"], [ ["1", "./rvctl tokens compare", "compare repo remotes with tokens.json"], - ["2", "./rvctl series list", "choose a series"], - ["3", "./rvctl series cards list inf", "choose a card"], - ["4", "./rvctl series cards fetch inf bss", "fetch card and prepare r1a"], - ["5", "./rvctl tasks list", "list tasks from the default card"], - ["6", "./rvctl tasks switch 4", "switch to the answer branch for task 4"], + ["2", "./rvctl workspace sync", "clone or update workspace-info"], + ["3", "./rvctl series list", "choose a series"], + ["4", "./rvctl series cards list inf", "choose a card"], + ["5", "./rvctl series cards fetch inf bss", "fetch card and prepare r1a"], + ["6", "./rvctl tasks list", "list tasks from the default card"], + ["7", "./rvctl tasks switch 4", "switch to the answer branch for task 4"], ], ) print() @@ -3311,6 +3566,12 @@ def build_parser() -> argparse.ArgumentParser: cards_parser = subparsers.add_parser("list-cards", help="List cards in a series.") cards_parser.add_argument("series", nargs="?", help="Series id, for example 'inf'.") + workspace_parser = subparsers.add_parser("workspace", help="Manage workspace metadata manifest.") + workspace_subparsers = workspace_parser.add_subparsers(dest="workspace_command", required=True) + workspace_subparsers.add_parser("show", help="Show workspace-info configuration.") + workspace_sync_parser = workspace_subparsers.add_parser("sync", help="Clone or update workspace-info into meta/.") + workspace_sync_parser.add_argument("--dry-run", action="store_true", help="Print plan without cloning or pulling.") + tasks_parser = subparsers.add_parser("tasks", help="Shortcut for tasks in the default card.") tasks_subparsers = tasks_parser.add_subparsers(dest="tasks_command", required=True) @@ -3559,6 +3820,9 @@ def main() -> int: if args.command == "list-cards": print_cards(config, args.series) return 0 + if args.command == "workspace": + run_workspace(config, args) + return 0 if args.command == "tasks": run_tasks(config, args) return 0 diff --git a/workspace.json b/workspace.json index 406b47b..3ccf811 100644 --- a/workspace.json +++ b/workspace.json @@ -3,6 +3,8 @@ "original_root": "~/dev/edu/repos/rv", "original_series_root": "~/dev/edu/repos/rv/series", "workspace_root": "~/dev/workspace/rv", + "workspace_info_path": "~/dev/workspace/rv/meta/workspace-info/workspace.json", + "workspace_info_url": "http://77.90.8.171:3001/edu-workspace/workspace-info.git", "series_root": "~/dev/workspace/rv/series", "socket_root": "~/dev/workspace/rv/sockets", "token_file": "~/dev/workspace/rv/tokens/tokens.json",